commit 0bc3f026708eb5c9240aec9c1e22df5b91b6cfb2 Author: Kaxi <1042864399@qq.com> Date: Tue Jun 2 23:14:41 2026 +0800 初始化仓库 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..1420c32 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: chg80333 +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: cg33 +thanks_dev: # Replace with a single thanks.dev username +custom: ['https://github.com/chenhg5/cc-connect/blob/main/docs/images/alipay.jpg?raw=true', 'https://raw.githubusercontent.com/chenhg5/cc-connect/refs/heads/main/docs/images/wechatpay.jpg'] # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..adc8e40 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,141 @@ +name: Bug Report +description: Report a bug to help us improve cc-connect +title: "[Bug] " +labels: ["bug"] + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to report a bug! Please fill out the form below to help us understand and reproduce the issue. + + - type: input + id: version + attributes: + label: cc-connect Version + description: Run `cc-connect --version` to get the version. + placeholder: "e.g. v0.3.0" + validations: + required: true + + - type: dropdown + id: os + attributes: + label: Operating System + options: + - macOS + - Linux (Ubuntu/Debian) + - Linux (Other) + - Windows (WSL) + - Other + validations: + required: true + + - type: dropdown + id: agent + attributes: + label: Agent Type + description: Which AI agent are you using? + options: + - Claude Code + - Codex (OpenAI) + - Cursor Agent + - Gemini CLI + - Other + validations: + required: true + + - type: dropdown + id: platform + attributes: + label: Platform + description: Which messaging platform(s) are involved? + multiple: true + options: + - Feishu (Lark) + - DingTalk + - Telegram + - Slack + - Discord + - LINE + - WeChat Work (企业微信) + - QQ (NapCat/OneBot) + - N/A + validations: + required: true + + - type: dropdown + id: install-method + attributes: + label: Installation Method + options: + - npm (npm install -g cc-connect) + - Binary download (GitHub Releases) + - Build from source + validations: + required: false + + - type: textarea + id: description + attributes: + label: Bug Description + description: A clear and concise description of what the bug is. + placeholder: Describe the bug... + validations: + required: true + + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. Configure config.toml with ... + 2. Run `cc-connect` + 3. Send message '...' in the chat + 4. See error ... + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen. + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened. + validations: + required: true + + - type: textarea + id: config + attributes: + label: Configuration (config.toml) + description: | + Please share the relevant parts of your config.toml (remove sensitive info like tokens/keys). + render: toml + validations: + required: false + + - type: textarea + id: logs + attributes: + label: Logs / Error Output + description: Paste any relevant logs or error messages here. + render: shell + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context, screenshots, or information about the problem. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d443464 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: Documentation + url: https://github.com/chenhg5/cc-connect#readme + about: Check the README and platform setup guides before opening an issue + - name: Discussions + url: https://github.com/chenhg5/cc-connect/discussions + about: Ask questions and share ideas in GitHub Discussions diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..eeb2966 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,74 @@ +name: Feature Request +description: Suggest a new feature or improvement for cc-connect +title: "[Feature] " +labels: ["enhancement"] + +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a feature! Please describe your idea clearly so we can evaluate and discuss it. + + - type: dropdown + id: area + attributes: + label: Feature Area + description: Which part of cc-connect does this relate to? + options: + - Core / Engine + - Agent (Claude Code, Codex, Cursor, Gemini, etc.) + - Platform (Feishu, DingTalk, Telegram, Slack, etc.) + - Session Management + - API Provider Management + - Voice / Speech-to-Text + - Image / Multimodal + - CLI / Commands + - Configuration + - Documentation + - Other + validations: + required: true + + - type: textarea + id: problem + attributes: + label: Problem or Motivation + description: | + Is your feature request related to a problem? Please describe. + A clear description of what the problem is, e.g. "I'm always frustrated when..." + placeholder: Describe the problem or motivation behind this feature... + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: Describe the solution you'd like. How should this feature work? + placeholder: Describe your ideal solution... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Add any other context, mockups, or references about the feature request here. + validations: + required: false + + - type: checkboxes + id: contribution + attributes: + label: Willingness to Contribute + description: Would you be willing to contribute this feature via a pull request? + options: + - label: I'd be willing to submit a PR for this feature diff --git a/.github/ISSUE_TEMPLATE/platform_agent_request.yml b/.github/ISSUE_TEMPLATE/platform_agent_request.yml new file mode 100644 index 0000000..d077160 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/platform_agent_request.yml @@ -0,0 +1,80 @@ +name: Platform / Agent Support Request +description: Request support for a new messaging platform or AI agent +title: "[Support Request] " +labels: ["new-integration"] + +body: + - type: markdown + attributes: + value: | + Want cc-connect to support a new messaging platform or AI coding agent? Let us know! + + - type: dropdown + id: type + attributes: + label: Request Type + options: + - New Platform (messaging app) + - New Agent (AI coding assistant) + validations: + required: true + + - type: input + id: name + attributes: + label: Platform / Agent Name + placeholder: "e.g. Microsoft Teams, WhatsApp, Aider, etc." + validations: + required: true + + - type: textarea + id: description + attributes: + label: Description + description: Brief description of the platform/agent and why you'd like it supported. + validations: + required: true + + - type: textarea + id: api-info + attributes: + label: API / SDK Information + description: | + Please share any relevant links to official documentation, bot APIs, SDKs, or developer resources. + placeholder: | + - Official docs: https://... + - Bot API: https://... + - SDK (Go/Python/Node): https://... + validations: + required: false + + - type: dropdown + id: connection + attributes: + label: Connection Type (for platforms) + description: If known, what type of connection does this platform support for bots? + options: + - WebSocket (no public IP needed) + - Long Polling (no public IP needed) + - Stream / SSE (no public IP needed) + - Webhook (public URL required) + - Unknown / Not sure + - N/A (agent request) + validations: + required: false + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context about why this integration would be valuable. + validations: + required: false + + - type: checkboxes + id: contribution + attributes: + label: Willingness to Contribute + description: Would you be willing to help implement this integration? + options: + - label: I'd be willing to submit a PR for this integration diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ddcf2ca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,181 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + release: + types: [published] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: web/pnpm-lock.yaml + + - name: Build web assets + working-directory: web + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Lint code + run: | + set -euo pipefail + go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 + LINT_BIN="$(go env GOPATH)/bin/golangci-lint" + + run_lint() { + "$LINT_BIN" run "$@" || { + code=$? + # exit code 5 = "no go files to analyze" (e.g. only non-Go files changed) — not a lint failure + [ "$code" -eq 5 ] && return 0 + return "$code" + } + } + + if [ "${{ github.event_name }}" = "pull_request" ]; then + git fetch --no-tags origin "${{ github.base_ref }}" + run_lint --new-from-rev "origin/${{ github.base_ref }}" ./... + elif [ "${{ github.event_name }}" = "push" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + run_lint --new-from-rev "${{ github.event.before }}" ./... + elif git rev-parse --verify HEAD^ >/dev/null 2>&1; then + run_lint --new-from-rev "$(git rev-parse HEAD^)" ./... + else + run_lint ./... + fi + + - name: Lint GitHub workflows + run: | + go install github.com/rhysd/actionlint/cmd/actionlint@v1.7.8 + "$(go env GOPATH)/bin/actionlint" -color + + unit-test: + runs-on: ubuntu-latest + needs: lint + + steps: + - uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: web/pnpm-lock.yaml + + - name: Build web assets + working-directory: web + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Download dependencies + run: go mod download + env: + GOPROXY: https://proxy.golang.org,direct + GOSUMDB: sum.golang.org + GONOSUMDB: "" + + - name: Verify module checksums + run: go mod verify + env: + GOPROXY: https://proxy.golang.org,direct + GOSUMDB: sum.golang.org + GONOSUMDB: "" + + - name: Build + run: go build ./... + + - name: Run tests + run: go test ./... -v -race + + - name: Run tests with coverage + run: go test ./... -coverprofile=coverage.out -covermode=atomic + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + if: github.event_name == 'push' + with: + files: ./coverage.out + fail_ci_if_error: false + + smoke-test: + runs-on: ubuntu-latest + needs: unit-test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Run smoke tests + run: go test -v -tags=smoke,no_web ./tests/e2e/... + + regression-test: + runs-on: ubuntu-latest + needs: smoke-test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Run regression tests + run: go test -v -tags=regression,no_web ./tests/e2e/... + + performance-test: + runs-on: ubuntu-latest + needs: regression-test + + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + cache: true + + - name: Run performance benchmarks + run: go test -bench=. -benchmem -tags=performance,no_web ./tests/performance/... diff --git a/.github/workflows/issue-reply.yml b/.github/workflows/issue-reply.yml new file mode 100644 index 0000000..9527f2d --- /dev/null +++ b/.github/workflows/issue-reply.yml @@ -0,0 +1,51 @@ +name: Issue Reply + +on: + issues: + types: [opened] + +jobs: + greet: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Greet new issue + uses: actions/github-script@v7 + with: + script: | + const issue = context.payload.issue; + const author = issue.user.login; + + // 检查是否是首次提 issue + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + creator: author, + state: 'all' + }); + + const isFirstIssue = issues.length === 1; + + let comment; + if (isFirstIssue) { + comment = [ + `👋 Hi @${author}! Thanks for opening your first issue here!`, + ``, + `We'll review it as soon as possible. While waiting, please make sure:`, + `- You've provided enough details about the issue`, + `- Any error messages are included in full`, + `- For feature requests, describe your use case`, + ``, + `Thanks for your feedback!`, + ].join('\n'); + } else { + comment = `👋 Thanks @${author} for opening this issue! We'll take a look soon.`; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: comment + }); diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..b1da623 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,44 @@ +name: Stale Issue Handler + +on: + schedule: + - cron: '0 0 * * *' # 每天运行一次 + workflow_dispatch: # 手动触发 + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v9 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + # Issue settings + stale-issue-message: | + ⚠️ This issue has been inactive for 30 days. + + If this is still relevant, please add a comment; otherwise it will be closed in 7 days. + close-issue-message: | + 🔒 This issue has been automatically closed due to inactivity. + + If this is still relevant, feel free to reopen or create a new issue. + stale-issue-label: 'stale' + days-before-issue-stale: 30 + days-before-issue-close: 7 + + # PR settings + stale-pr-message: | + ⚠️ This PR has been inactive for 60 days. + + If this change is still needed, please update the code or add a comment; otherwise it will be closed in 7 days. + close-pr-message: | + 🔒 This PR has been automatically closed due to inactivity. + stale-pr-label: 'stale' + days-before-pr-stale: 60 + days-before-pr-close: 7 + + # 豁免标签 + exempt-issue-labels: 'pinned,security,enhancement' + exempt-pr-labels: 'pinned,security' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd2b4e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,145 @@ +# ============================================================================ +# Binary outputs +# ============================================================================ +/cc-connect +cc-connect.bak-* +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +*.old + +# ============================================================================ +# Go workspace & tooling +# ============================================================================ +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool +coverage.html +coverage.txt +*.out + +# Go workspace files (go.work) +go.work +go.work.sum + +# Go module environment (local proxy settings) +go.env + +# Dependency directories +vendor/ + +# ============================================================================ +# Project-specific +# ============================================================================ +# Runtime configuration (contains secrets) +config.toml +config.*.toml +!config.example.toml +.env +*.local.toml + +# Instance lock files (created at startup, released on shutdown) +*.toml.lock + +# CC-Connect runtime state +.cc-connect/ + +# npm / pnpm (for npm package distribution and web admin) +.npmrc +package-lock.json +node_modules/ +.vite/ + +# ============================================================================ +# Build & distribution +# ============================================================================ +dist/ +build/ +bin/ +release/ +# Keep web/dist directory (for embedded admin UI) but ignore build artifacts +web/dist/* +!web/dist/.keep +# TypeScript incremental build cache — generated locally, should not be committed +web/tsconfig.tsbuildinfo + +# ============================================================================ +# Testing & temporary files +# ============================================================================ +test/ +scripts/ +tmp/ +temp/ +*.tmp +*.swp +*.swo +*~ +*.pid +*.log + +# ============================================================================ +# IDE & Editor +# ============================================================================ +.vscode/ +.idea/ +*.iml +*.ipr +*.iws +.vs/ +*.suo +*.user +*.userosscache +*.sln.docstates + +# Emacs +*~ +\#*\# +.\#* + +# Vim +*.swp +*.swo +*.swn + +# macOS +.DS_Store +.AppleDouble +.LSOverride + +# ============================================================================ +# Documentation (drafts) +# ============================================================================ +RELEASE.md +DRAFTS.md +NOTES.md + +# ============================================================================ +# System +# ============================================================================ +# Thumbs.db on Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# ============================================================================ +# AI agent planning artifacts +# ============================================================================ +.planning/ + +# ============================================================================ +# Security +# ============================================================================ +# Never commit secrets or credentials +*.key +*.pem +*.p12 +*.pfx +secrets/ +credentials/ + +.codex diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..18bb809 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,18 @@ +version: "2" + +run: + timeout: 5m + +linters: + enable: + - errcheck + - govet + - ineffassign + - staticcheck + +issues: + exclusions: + rules: + - path: _test\.go + linters: + - errcheck diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d4cc596 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,237 @@ +# CC-Connect Development Guide + +## Project Overview + +CC-Connect is a bridge that connects AI coding agents (Claude Code, Codex, Gemini CLI, Cursor, etc.) with messaging platforms (Feishu/Lark, Telegram, Discord, Slack, DingTalk, WeChat Work, QQ, LINE). Users interact with their coding agent through their preferred messaging app. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ cmd/cc-connect │ ← entry point, CLI, daemon +├─────────────────────────────────────────────────┤ +│ config/ │ ← TOML config parsing +├─────────────────────────────────────────────────┤ +│ core/ │ ← engine, interfaces, i18n, +│ │ cards, sessions, registry +├──────────────────────┬──────────────────────────┤ +│ agent/ │ platform/ │ +│ ├── claudecode/ │ ├── feishu/ │ +│ ├── codex/ │ ├── telegram/ │ +│ ├── cursor/ │ ├── discord/ │ +│ ├── gemini/ │ ├── slack/ │ +│ ├── iflow/ │ ├── dingtalk/ │ +│ ├── opencode/ │ ├── wecom/ │ +│ ├── acp/ │ ├── qq/ │ +│ └── qoder/ │ ├── qqbot/ │ +│ │ ├── line/ │ +│ │ └── weibo/ │ +├──────────────────────┴──────────────────────────┤ +│ daemon/ │ ← systemd/launchd service +└─────────────────────────────────────────────────┘ +``` + +### Key Design Principles + +**`core/` is the nucleus.** It defines all interfaces (`Platform`, `Agent`, `AgentSession`, etc.) and contains the `Engine` that orchestrates message flow. The core package must **never** import from `agent/` or `platform/`. + +**Plugin architecture via registries.** Agents and platforms register themselves through `core.RegisterAgent()` and `core.RegisterPlatform()` in their `init()` functions. The engine creates instances via `core.CreateAgent()` / `core.CreatePlatform()` using string names from config. + +**Dependency direction:** +``` +cmd/ → config/, core/, agent/*, platform/* +agent/* → core/ (never other agents or platforms) +platform/* → core/ (never other platforms or agents) +core/ → stdlib only (never agent/ or platform/) +``` + +### Core Interfaces + +- **`Platform`** — messaging platform adapter (Start, Reply, Send, Stop) +- **`Agent`** — AI coding agent adapter (StartSession, ListSessions, Stop) +- **`AgentSession`** — a running bidirectional session (Send, RespondPermission, Events) +- **`Engine`** — the central orchestrator that routes messages between platforms and agents + +Optional capability interfaces (implement only when needed): +- `CardSender` — rich card messages +- `InlineButtonSender` — inline keyboard buttons +- `ProviderSwitcher` — multi-model switching +- `DoctorChecker` — agent-specific health checks +- `AgentDoctorInfo` — CLI binary metadata for diagnostics + +## Development Rules + +### 1. No Hardcoding Platform or Agent Names in Core + +The `core/` package must remain agnostic. Never write `if p.Name() == "feishu"` or `CreateAgent("claudecode", ...)` in core. Use interfaces and capability checks instead: + +```go +// BAD — hardcodes platform knowledge in core +if p.Name() == "feishu" && supportsCards(p) { + +// GOOD — capability-based check +if supportsCards(p) { +``` + +```go +// BAD — hardcodes agent type +agent, _ := CreateAgent("claudecode", opts) + +// GOOD — derives from current agent +agent, _ := CreateAgent(e.agent.Name(), opts) +``` + +### 2. Prefer Interfaces Over Type Switches + +When behavior differs across platforms/agents, define an optional interface in core and let implementations opt in: + +```go +// In core/ +type AgentDoctorInfo interface { + CLIBinaryName() string + CLIDisplayName() string +} + +// In agent/claudecode/ +func (a *Agent) CLIBinaryName() string { return "claude" } +func (a *Agent) CLIDisplayName() string { return "Claude" } + +// In core/ — query via interface, fallback gracefully +if info, ok := agent.(AgentDoctorInfo); ok { + bin = info.CLIBinaryName() +} +``` + +### 3. Configuration Over Code + +- Features that may vary per deployment should be configurable in `config.toml` +- Use `map[string]any` options for agent/platform factories to stay flexible +- Add new config fields with sensible defaults so existing configs don't break + +### 4. High Cohesion, Low Coupling + +- Each `agent/X/` package is self-contained: it handles process lifecycle, output parsing, and session management for agent X +- Each `platform/X/` package is self-contained: it handles API connection, message receiving/sending, and card rendering for platform X +- Cross-cutting concerns (i18n, cards, streaming, rate limiting) live in `core/` + +### 5. Error Handling + +- Always wrap errors with context: `fmt.Errorf("feishu: reply card: %w", err)` +- Never silently swallow errors; at minimum log them with `slog.Error` / `slog.Warn` +- Use `slog` (structured logging) consistently; never `log.Printf` or `fmt.Printf` for runtime logs +- Redact tokens/secrets in error messages using `core.RedactToken()` + +### 6. Concurrency Safety + +- Agent sessions are accessed from multiple goroutines; protect shared state with `sync.Mutex` or `atomic` types +- Use `context.Context` for cancellation propagation +- Channels should have clear ownership; document who closes them +- Prefer `sync.Once` for one-time teardown (`pendingPermission.resolve()`) + +### 7. i18n + +All user-facing strings must go through `core/i18n.go`: +- Define a `MsgKey` constant +- Add translations for all supported languages (EN, ZH, ZH-TW, JA, ES) +- Use `e.i18n.T(MsgKey)` or `e.i18n.Tf(MsgKey, args...)` + +## Code Style + +- Follow standard Go conventions (`gofmt`, `go vet`) +- Use `strings.EqualFold` for case-insensitive comparisons +- Avoid `init()` for anything other than platform/agent registration +- Keep functions focused; extract helpers when a function exceeds ~80 lines +- Naming: `New()` for constructors, `Get/Set` for accessors, avoid stuttering (`feishu.FeishuPlatform` → `feishu.Platform`) + +## Testing + +### Requirements + +- All new features must include unit tests +- All bug fixes should include a regression test +- Tests must pass before committing: `go test ./...` + +### Running Tests + +```bash +# Full test suite +go test ./... + +# Specific package +go test ./core/ -v + +# Run specific test +go test ./core/ -run TestHandlePendingPermission -v + +# With race detector (CI) +go test -race ./... +``` + +### Test Patterns + +- Use stub types for `Platform` and `Agent` in core tests (see `core/engine_test.go`) +- Test card rendering by inspecting the returned `*Card` struct, not JSON +- For agent session tests, simulate event streams via channels + +## Selective Compilation + +Each agent and platform is imported via a separate `plugin_*.go` file with a +build tag (e.g. `//go:build !no_feishu`). By default **all** agents and +platforms are compiled in. + +### Include only specific agents/platforms + +```bash +# Only Claude Code agent + Feishu and Telegram platforms +make build AGENTS=claudecode PLATFORMS_INCLUDE=feishu,telegram + +# Multiple agents +make build AGENTS=claudecode,codex PLATFORMS_INCLUDE=feishu,telegram,discord +``` + +### Exclude specific agents/platforms + +```bash +# Exclude some platforms you don't need +make build EXCLUDE=discord,dingtalk,qq,qqbot,line +``` + +### Direct build tag usage (without Make) + +```bash +go build -tags 'no_discord no_dingtalk no_qq no_qqbot no_line' ./cmd/cc-connect +``` + +Available tags: `no_acp`, `no_claudecode`, `no_codex`, `no_copilot`, `no_cursor`, `no_gemini`, +`no_iflow`, `no_opencode`, `no_qoder`, `no_feishu`, `no_telegram`, +`no_discord`, `no_slack`, `no_dingtalk`, `no_wecom`, `no_weixin`, `no_qq`, `no_qqbot`, +`no_line`, `no_weibo`. + +## Pre-Commit Checklist + +1. **Build passes**: `go build ./...` +2. **Tests pass**: `go test ./...` +3. **No new hardcoded platform/agent names in core**: grep for platform names in `core/*.go` +4. **i18n complete**: all new user-facing strings have translations for all languages +5. **No secrets in code**: no API keys, tokens, or credentials in source files + +## Adding a New Platform + +1. Create `platform/newplatform/newplatform.go` +2. Implement `core.Platform` interface (and optional interfaces as needed) +3. Register in `init()`: `core.RegisterPlatform("newplatform", factory)` +4. Create `cmd/cc-connect/plugin_platform_newplatform.go` with `//go:build !no_newplatform` tag +5. Add `newplatform` to `ALL_PLATFORMS` in `Makefile` +6. Add config example in `config.example.toml` +7. Add unit tests + +## Adding a New Agent + +1. Create `agent/newagent/newagent.go` +2. Implement `core.Agent` and `core.AgentSession` interfaces +3. Register in `init()`: `core.RegisterAgent("newagent", factory)` +4. Create `cmd/cc-connect/plugin_agent_newagent.go` with `//go:build !no_newagent` tag +5. Add `newagent` to `ALL_AGENTS` in `Makefile` +6. Optionally implement `AgentDoctorInfo` for `cc-connect doctor` support +7. Add config example in `config.example.toml` +8. Add unit tests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7573172 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,683 @@ +# Changelog + +### New Features +- **QQ Bot inline keyboard**: add support for inline keyboard buttons and INTERACTION_CREATE events. Permission requests now render as clickable buttons instead of text replies. Requires enabling the INTERACTION capability (bit 26) in the QQ Open Platform bot settings. + +### ⚠️ QQ Bot Intent Configuration Change +The default intents for QQ Bot now include `INTERACTION_CREATE` (bit 26, value `1<<26`). If you previously set a custom `intents` value without this bit, inline keyboard buttons will not work — update your `intents` to include bit 26. If you use the default intents, no action is needed. See `config.example.toml` for the new `intents` option. + +## v1.3.3-beta.4 (2026-05-28) + +### New Features +- **`max_turn_time_mins`**: new config option — absolute wall-clock cap per agent turn that does NOT reset on tool-call events. Prevents long-running bash commands from permanently locking the session (#1091). Uses a two-phase shutdown: soft stop (10s grace) then force-kill. Session is preserved and resumed via `--resume` on the next message. + +### Fixed +- **Web console 404 regression**: `make release-all` did not depend on `make web`, so release binaries were built without frontend assets when `web/dist/` was empty (gitignored). All routes on the management port returned `404`. Fixed by adding `web` as a prerequisite of `release-all` (#1136) +- **Slack @mention without space**: `stripAppMentionText` only matched `"> "` (with trailing space), so `@Bot/command` (no space) was forwarded verbatim to Claude instead of being parsed as a command +- **DingTalk `msgtype="picture"` dropped**: image messages delivered as `"picture"` (instead of `"image"`) were silently dropped. Both types now route to the image handler (#1128) +- **Feishu `require_mention = false` ignored**: the platform read `group_reply_all` but users set `require_mention = false`; now both are treated as equivalent (#1141) +- **AskUserQuestion resolved with empty answer**: delivery receipts and read-notifications (empty messages) were accepted as valid answers to `AskUserQuestion`, resolving it within ~500ms before the user could respond. Empty/whitespace content is now rejected (#1086) + +## v1.3.3-beta.3 (2026-05-24) + +Beta release with blackbox testing infrastructure, cursor/opencode agent support, and bug fixes. + +### New Features +- **Blackbox testing framework**: Phase 1-2 blackbox testing with P0/P1/P2 coverage, config-switch, and NewEnvWithSetup infrastructure +- **Cursor/OpenCode agents**: add cursor and opencode agent support in blackbox tests + +### Fixed +- **Core italic wrapping**: restore italic wrapping on reply footer +- **Feishu footer asterisks**: strip asterisks from footer to prevent Feishu markdown italic rendering +- **Kimi session UUID**: capture session UUID from stderr instead of stdout +- **Codex stdio sentinel**: add stdio sentinel for Codex app_server backend +- **Windows cross-compile**: add missing `CheckLinger` stub to `daemon/windows.go` and `daemon/unsupported.go` so `make release-all` succeeds for all target platforms + +## v1.3.3-beta.2 (2026-05-09) + +Beta release with Slack Assistant API, DingTalk improvements, MAX platform webhook mode, and numerous platform fixes. No breaking changes. + +### New Features +- **Slack Assistant API**: support Slack Assistant API (Agent toggle) with natural on/off switching (#844) +- **DingTalk richText**: support richText message type for DingTalk platform (#828) +- **DingTalk image handling**: add DingTalk image message support (#828) +- **MAX webhook delivery mode**: add webhook delivery mode for MAX messenger platform with deployment docs (#818) +- **Claude Code env vars**: support project-level environment variables via `env` config section (#812) +- **display_mode enum**: add `display_mode` enum to replace boolean `quiet` config, with quiet/compact/normal/full options (#655) +- **Core reset_on_idle_mins default**: default to 30 minutes to prevent context drift (#494) +- **Claude Code custom system prompt**: add support for custom system prompt configuration via `system_prompt` option (#534) + +### Fixed +- **Bridge security**: require token when Bridge is enabled to prevent unauthorized access (#408) +- **Feishu recalled messages**: handle recalled messages gracefully (#841) +- **Feishu media download failure**: notify user when media download fails instead of silent drop (#815) +- **WeChat video messages**: send video files as proper video messages in WeChat (#813) +- **WeChat incomplete delivery**: notify user on incomplete message delivery and enhance retry logging (#771) +- **Telegram private topics**: preserve private topic session keys (#804) +- **Kimi session UUID**: capture session UUID from stderr instead of stdout (#766) +- **Codex app_server config**: app_server backend should honor model/effort/provider config + add stdio sentinel (#837) +- **Codex progress rendering**: render progress in rich Card 2.0 format (#838) +- **Core ellipsis events**: suppress ellipsis-only events and handle context indicator in footer +- **Core Markdown table**: render inline formatting inside GFM table cells (#675) +- **Feishu user id resolution**: guard user id resolution against edge cases +- **Feishu thread topics**: skip quote injection in thread-isolated topics (#767) +- **Config display mode**: honor project display mode setting +- **Daemon restart**: add --force flag to daemon restart command (#736) +- **AskUserQuestion**: use question text as answers key for proper answer routing (#822) + +## v1.3.3-beta.1 (2026-04-25) + +Beta release with new agents, new features, and broad platform fixes. No breaking changes. + +### New Features +- **Devin agent**: add Devin CLI as a first-class agent with full `/list`, `/mode`, and session management (#672) +- **`/ps` command** (replaces `/btw`): send a message to a busy session mid-turn; `/btw` kept as alias for backward compatibility (#620) +- **`!` shell shortcut**: use `!ls -la` as shorthand for `/shell ls -la`, with optional `--timeout` parameter (#658) +- **NO_REPLY suppression**: agents can return `NO_REPLY` to silently skip platform delivery, useful for cron/analysis tasks (#682) +- **Feishu shared WebSocket**: multiple projects sharing the same `app_id` now share one WebSocket connection with per-project `allow_chat` / `group_only` filtering (#613) +- **Message queue depth configurable**: new `[queue] max_depth` config option (default 5) (#690) +- **Claude Code opus[1m]**: add 1M-context Opus model option with shorthand descriptions (#660) +- **QQ Bot file send/receive**: full file attachment support with robustness checks (#685) +- **Bridge ImageSender/FileSender**: `cc-connect send --image/--file` now works through bridge protocol (#712) +- **Provider presets**: add NekoCode, VisionCoder, and AIHubMix to provider presets; add Trae CLI ACP and COCO ACP config examples (#739) + +### Fixed +- **OpenCode image handling**: inbound images from WeChat/WeCom are now correctly passed to OpenCode CLI via `--file` flags (#717) +- **Slack Markdown**: convert standard Markdown to Slack mrkdwn format (bold, italic, strike, links, headings) (#680) +- **QQ Bot reconnect**: cancel stale goroutines on WebSocket reconnect to prevent race conditions (#678) +- **Gemini multiline prompt**: pass prompt via stdin to preserve newlines (#695) +- **Telegram HTML fallback**: upgrade silent HTML parse failures to Warn-level logs (#674) +- **Telegram /skills**: show Telegram-safe skill command format (#571) +- **Feishu webhook mode**: skip bot open_id fetch in webhook mode for private deployments (#696) +- **Reply footer**: suppress footer when only workdir is known (#701) +- **Web UI add-platform**: fix "project not found" error when adding a new platform to an uncreated project + +### Contributors +Thanks to all contributors who made this release possible: +- @YoungShook — Devin agent integration, Telegram HTML fallback +- @Cigarrr — /ps command, NO_REPLY feature +- @vinnyxiong — Feishu shared WebSocket and allow_chat +- @happyTonakai — Shell `!` prefix and `--timeout` +- @AaronZ345 — Claude Code opus[1m] model +- @ferocknew — QQ Bot file support +- @soaringk — OpenCode image fix +- @Zx55 — Telegram /skills fix +- @zhaomoran — Feishu webhook mode fix +- @LyInfi — Reply footer suppression +- @meloalright — Trae/COCO ACP config examples + +## v1.3.2 (2026-04-21) + +Hotfix release: session filtering is now configurable and defaults to showing all sessions. + +### Fixed +- **`/list` shows all sessions by default**: the session filter introduced in v1.3.0 (which hid sessions not created by cc-connect) was accidentally merged and caused confusion. The filter is now **off by default** — `/list`, `/switch`, and `/delete` show all agent sessions regardless of origin. + +### Added +- **`filter_external_sessions` config option**: users who *do* want to hide externally-created sessions can set `filter_external_sessions = true` in `[[projects]]` to restore the old filtering behavior. +- **Comprehensive integration tests**: real-agent E2E tests for both Codex and Claude Code covering the full `/list` → `/new` → conversation → `/list` lifecycle with provider-based authentication (no env-var API keys required). Plus 9 adapter-level filter tests using real Codex/Claude Code session file fixtures. + +## v1.3.1 (2026-04-20) + +Patch release with critical bug fixes for session management, config preservation, and Weibo media support. + +### Fixed +- **Session visibility (`/list`)**: historical Codex sessions disappeared after upgrade due to `AgentSessionID` being cleared on `/new` or provider switch without preservation. Added `PastAgentSessionIDs` tracking with legacy data migration so existing sessions remain visible. +- **Session naming (`/new xxx`)**: custom session names from `/new` were not mapped to the agent session ID for agents where the ID is established asynchronously (Codex, Qoder, Kimi, etc.). Added name mapping to all `EventResult` and `EventText` handlers across interactive, relay, and drain paths. +- **Config comment preservation**: `/provider switch`, `/model`, `/lang`, display settings, and TTS changes now use surgical text-level editing instead of full TOML re-serialization, preserving all comments, unknown fields, and formatting. +- **Codex `codex_home` path**: session listing, history, and deletion now consistently use the configured `codex_home` instead of hardcoded `~/.codex`. +- **Feishu card callback hint**: log a reminder when interactive card mode is enabled but `card.action.trigger` may not be subscribed. + +### Added +- **Weibo image & file support**: send and receive images and files in Weibo DMs via base64 encoding within the WebSocket `send_message` payload. Implements `ImageSender` and `FileSender` interfaces. +- **Comprehensive session tests**: 12 new `SessionManager` unit tests covering `PastAgentSessionIDs`, legacy data migration, and version-based schema detection. 9 new `Engine` integration tests covering `/list` visibility across `/new`, provider switch, and real-world legacy data scenarios, plus end-to-end session name mapping tests for all three agent ID patterns (immediate, EventText, EventResult). +- **Config preservation tests**: 8 new tests verifying comment and field preservation for `SaveActiveProvider`, `SaveAgentModel`, `SaveProviderModel`, `SaveLanguage`, `SaveDisplayConfig`, `SaveTTSMode`, multi-project config, and global provider refs. + +## v1.3.0 (2026-04-19) + +First stable release of the 1.3 series. 555 commits since v1.2.1 with major new features, platform improvements, and broad community contributions. + +### Highlights + +- **Web Admin UI** — Full management dashboard embedded in the binary via `go:embed`. Project CRUD, session monitoring, cron editor, provider management, chat interface, and i18n (en/zh/zh-TW/ja/es). Use `cc-connect web` to open directly in the browser with auto-login. +- **Lifecycle Event Hooks** — New `[[hooks]]` config to trigger shell commands or HTTP webhooks on 7 event types: `message.received`, `message.sent`, `session.started`, `session.ended`, `cron.triggered`, `permission.requested`, `error`. Async by default, fail-open, non-blocking. +- **Skill Management** — New `/skills` page in the web UI with local skill browser (per-project, per-agent) and recommended skill presets fetched from remote. +- **Global Provider Management** — Add, edit, delete providers in the web UI; import from cc-switch config; per-agent-type provider presets with featured/star badges. + +### New Features +- `cc-connect web` CLI command: auto-configure web admin, open browser with token-based login +- Feishu: auto-resolve `@name` mentions to clickable at-tags (`resolve_mentions` config) +- Feishu: multi-level reply chain recognition; done-emoji reaction after streaming +- Feishu: configurable progress display styles (compact/card) +- Claude Code: support CLI wrappers via `cli_path`; `/effort` command for reasoning effort; `auto` permission mode; `disallowed_tools` config +- Codex: runtime reply footer; preserve workspace app-server options +- Kimi CLI: new agent support +- Pi: new agent support +- Discord: preserve table formatting; proxy support; `@everyone`/`@here` broadcast +- Telegram: forum topic support; markdown table monospace rendering; command menu adaptation +- WeCom: configurable `api_base_url` for private deployments; file receiving via HTTP callback +- Weixin (ilink): personal chat platform with CDN media, QR setup, image/file/audio send +- Config: support `${ENV_VAR}` placeholders in TOML values +- Core: `/workspace init` with local directory paths; `/dir` directory history; `agent-sid` command; auto-compress context on token threshold; outgoing rate limiting +- Daemon: preserve proxy env in systemd service + +### Bug Fixes +- Fix Windows cross-compilation (duplicate runas stub file) +- Fix web footer double 'v' prefix in version display +- Fix web modal overlay not covering full viewport (portal rendering) +- Fix provider preset cards: action buttons pinned to card bottom +- Fix web page content overlapping footer (global layout restructure) +- Fix Gemini image handling: save to workspace, prompt-based file references +- Fix Claude Code: unblock readLoop when child subprocesses hold stdout pipe +- Fix Codex: multiline prompt on resume; force-kill process group on stop +- Fix core: race condition during session cleanup; follow symlinked skill directories; persist agent_session_id; filter `/list` to cc-connect owned sessions +- Fix Feishu: slash commands in thread/reply context; user/chat name resolution in async goroutine +- Fix Telegram: UTF-8-safe command menu descriptions +- Fix TTS: don't send empty language_type to Qwen TTS API +- Fix config: `formatTOML` no longer strips user-set zero values +- Security: mask bridge token in `/api/v1/status`; path traversal protection for static files + +### Contributors + +Thanks to all contributors who made this release possible: + +- [@leoliang1997](https://github.com/leoliang1997) — Feishu card rendering, auto-resolve @mentions +- [@xukp20](https://github.com/xukp20) — Provider env handling, skill discovery, Codex options +- [@boyu-zhu](https://github.com/boyu-zhu) — Telegram markdown table rendering +- [@RukawaKaede](https://github.com/RukawaKaede) — Claude Code CLI wrapper support +- [@meishaoqing](https://github.com/meishaoqing) — Feishu multi-level reply chain +- [@Zx55](https://github.com/Zx55) — Telegram command menu, symlinked skill dirs +- [@leighstillard](https://github.com/leighstillard) — Claude Code `/effort` command +- [@ht290](https://github.com/ht290) — inject_sender display name +- [@Sentixxx](https://github.com/Sentixxx) — Claude Code readLoop subprocess fix +- [@bugwz](https://github.com/bugwz) — WeCom private deployment API base URL +- [@cold2600438-lgtm](https://github.com/cold2600438-lgtm) — Kimi CLI agent +- [@MeteorSkyOne](https://github.com/MeteorSkyOne) — Discord table formatting +- [@happyTonakai](https://github.com/happyTonakai) — Feishu done-emoji reaction +- [@xxb](https://github.com/xxb) — Codex reply footer, Discord session routing +- [@q107580018](https://github.com/q107580018) — Feishu delete/model card flows +- [@Cigarrr](https://github.com/Cigarrr) — Workspace binding parsing +- [@g1f9](https://github.com/g1f9) — Local directory workspace init +- [@0xsegfaulted](https://github.com/0xsegfaulted) — agent-sid command +- [@yzlu0917](https://github.com/yzlu0917) — Env var config placeholders +- [@sidney061212-ai](https://github.com/sidney061212-ai) — Agent session ID persistence +- [@zkunzhu](https://github.com/zkunzhu) — Daemon proxy env preservation +- [@Yuri0314](https://github.com/Yuri0314) — TTS language type fix + +## v1.2.2-beta.5 (2026-03-31) + +Beta release with embedded web admin, Discord proxy support, multimodal fixes, and major platform improvements. + +### New Features +- **Embedded Web Admin**: Web frontend is now compiled into the binary via `go:embed` — no separate `npm install` needed. Use `/web setup` to configure, or build with `no_web` tag to exclude. Binary size increases ~1MB (#356) +- **Web Admin Dashboard**: Full-featured management UI with project CRUD, session management, cron job editor, global settings, chat interface with bridge WebSocket, slash commands, and i18n (en/zh/zh-TW/ja/es) (#316) +- **Discord Proxy Support**: Discord platform now supports `proxy`, `proxy_username`, `proxy_password` options for HTTP API and WebSocket Gateway connections +- **Feishu Progress Styles**: Configurable progress display styles (compact/card) to reduce message spam +- **Claude Code Auto-Permission Mode**: New `auto` permission mode for Claude Code agent (#329) +- **WeCom File Receiving**: WeCom HTTP callback now supports receiving files and forwarding them to the agent (#330) +- **Outgoing Rate Limiting**: Per-platform outgoing message rate limiting +- **Telegram Forum Topics**: Migrated to `go-telegram/bot` library with forum topic support (#321) +- **Global Settings UI**: Expose global configurations (language, quiet, display, stream preview, rate limit, log) in the web admin + +### Bug Fixes +- **Gemini Image Handling**: Save attachments to workspace directory instead of `/tmp` so Gemini CLI tools can access them; use prompt-based file references instead of unsupported `--image` flag +- **Security**: Mask bridge token in `/api/v1/status` endpoint; add path traversal protection for static file serving +- **Codex**: Fix multiline prompt preservation on resume (#341); force kill session process group on stop (#340) +- **Session Recycling**: Wait for old session to close before creating new one (#352) +- **Discord**: Harden session routing and remove implicit continue bridge (#322); execute slash commands when defer fails (#300) +- **Slack**: Pass file uploads to agent (#296) +- **Telegram**: UTF-8-safe command menu descriptions (#301) +- **WeCom**: Strip @bot mentions from inbound text (#303) +- **Daemon**: macOS launchd do not respawn on clean exit (#304) +- **Core**: Route workspace model changes through session context (#339); outgoing rate limit refinements and i18n tightening +- **Config**: `formatTOML` no longer strips user-set zero values (e.g. `quiet = false`) + +### Improvements +- **CI**: Add Node.js setup for web frontend build in CI pipeline; use `no_web` tag for e2e/smoke tests +- **Tests**: Expanded coverage across agents, config, and core packages +- **Selective Compilation**: Added `no_web` build tag to exclude web assets from binary + +### Contributors + +Special thanks to all contributors who made this release possible: + +- **cg33** — Embedded web admin, Discord proxy, Gemini fix, security hardening +- **xxb** — Discord session routing fix, codex process kill, workspace reconnect (#322, #340, #315) +- **dev-null-sec** — Codex multiline prompt fix (#341) +- **xukp20** — Workspace model routing (#339) +- **zhengbuqian** — Telegram go-telegram/bot migration and forum topics (#321) +- **huangdijia** — Claude Code auto permission mode (#329) +- **buddhism5080** — Discord file sending (#307) + +## v1.2.2-beta.4 (2026-03-22) + +Beta release with Weixin (ilink) personal chat support, session/continue improvements, and platform fixes. + +### New Features +- **Weixin Personal (ilink)**: New platform with long-poll `getUpdates` / `sendMessage`, QR `weixin setup`, CDN decrypt for inbound media and `ImageSender`/`FileSender` outbound (#257) +- **Telegram**: Voice/audio reply support (#225) and async startup recovery +- **Discord**: `@everyone` / `@here` broadcast support (#132) +- **Cron**: Optional new session per run and per-job timeout (#236) +- **Claude Code**: `disallowed_tools` configuration option (#232) +- **Auto-Compress**: Compress context when estimated tokens exceed threshold (#231) +- **Continue / Sessions**: Fork session on `--continue` to avoid context contamination (#244); replace persisted `ContinueSession` sentinel with real agent session id; reserve CLI `--continue` bridge for real user traffic +- **Core**: `/dir` directory history; `/model` switching aligned with provider flow (#246) +- **Providers**: MiniMax M2.7 high-speed model added to example configs (#217) + +### Bug Fixes +- **Weixin**: Harden send path (empty body skip, response body cap, dedup keys, multi-voice segments); treat `sendMessage` JSON `ret != 0` as failure so quota/API errors surface correctly +- **Feishu**: Always reply to the original message; dispatch message handling asynchronously (#57) +- **Codex**: Mode switch and `--json` flag position fixes (#240, #239) +- **Multi-Workspace**: Workspace command prefix missing leading slash (#135) +- **Non-Claude Agents**: Ignore `ContinueSession` sentinel where inappropriate (#244 follow-up) +- **npm / Update**: Version sync after update; pre-release version comparison normalization + +### Improvements +- **Tests**: Expanded coverage across `config`, `core`, agents, and platforms +- **Logging / Errors**: Additional error logging in several code paths + +### Contributors + +Special thanks to all contributors who made this release possible: + +- **cg33** — Weixin ilink platform, setup CLI, and CDN media (#257) +- **Shawn** — Feishu async dispatch and reply-to-original fixes (#57) +- **quabug** — Discord broadcast and non-Claude ContinueSession handling (#132, #244) +- **huluma1314** — Auto-compress when token threshold exceeded (#231) +- **Leigh Stillard** — Fork session on `--continue` (#244) +- **Deeka Wong** — Telegram audio replies and core `/model` provider flow (#225, #246) +- **q107580018** — Telegram async startup recovery +- **just4zeroq** — Codex mode and JSON flag fixes (#240) +- **术士木星** — Cron session-per-run and job timeout (#236) +- **hushicai** — Claude `disallowed_tools` (#232) +- **Octopus** — MiniMax M2.7 high-speed in examples (#217) +- **alinnb** — `/dir` directory history +- **Claude** — Continue-session bridge fixes, auto-compress/cron edge cases, Weixin send hardening and API error handling, and broad test improvements + +## v1.2.2-beta.3 (2026-03-19) + +Beta release with major multi-user mode, improved workspace stability, and platform enhancements. + +### New Features +- **Multi-User Mode**: Per-user rate limits, role-based ACL (allow_from/admin_from), and audit logging +- **ImageSender**: Unified image sending support for 6 platforms (Feishu, Telegram, Discord, Slack, DingTalk, QQ) +- **MiniMax M2.7**: Upgraded default model from M2.5 to M2.7 for improved reasoning +- **/whoami Command**: Display user ID for allow_from/admin_from configuration +- **/btw Command**: Inject messages into busy sessions without interrupting +- **/dir Command**: Dynamic runtime work directory switching +- **Cron Muting**: Mute/unmute cron jobs with platform wrapper and UI integration +- **Interrupt Support**: Send interrupt signal to agent sessions (Ctrl+C equivalent) +- **CORS Support**: Cross-origin requests enabled for Bridge API +- **Message Queuing**: Queue messages when agent is busy instead of discarding +- **QQ Bot Markdown**: Full Markdown message support for QQ Bot + +### Bug Fixes +- **Workspace Session Persistence**: Sessions now persist to disk in multi-workspace mode +- **Race Conditions**: Multiple data race fixes (adminFrom, degraded field, userRolesMu) +- **Memory Leaks**: Fixed pendingAcks leak on WeCom WebSocket disconnect, goroutine leaks +- **i18n**: Complete translation coverage for error messages +- **Relay Timeout**: Return partial text after timeout instead of error +- **QQ Bot Reconnect**: Handle nil wsConn on failed reconnect + +### Improvements +- **Message Queue**: Extracted message queue handling into dedicated method +- **Cron UX**: Improved human-readable cron expressions +- **Slack**: Typing indicator, file download error handling, auth diagnostics +- **Provider Config**: `models` list for per-provider model selection via alias +- **Build**: Test infrastructure with P0/P1分层测试targets + +### Contributors + +Special thanks to all contributors who made this release possible: + +- **sean2077** - Multi-user mode, ACL, and audit logging +- **0xsegfaulted** - Multi-workspace fixes and interrupt support +- **octo-patch** - MiniMax M2.7 upgrade +- **windli2018** - Bridge CORS support +- **jenvan** - CORS fixes + +## v1.2.2-beta.2 (2026-03-16) + +Beta release with significant improvements to agent stability, platform onboarding, and user experience. + +### New Features +- **Feishu/Lark CLI Onboarding**: New `cc-connect feishu setup` command with QR code terminal display for quick bot configuration, supporting both new bot creation and existing bot binding +- **Pi Agent**: Added support for Pi coding agent with full session management and tool handling +- **Session TUI Browser**: New `cc-connect sessions` subcommand with terminal UI for browsing session history +- **Multi-Workspace Mode**: Channel-based workspace resolution with auto-binding by convention and interactive init flow +- **Design Documentation**: Added comprehensive design plans for multi-workspace and session resilience features +- **Slack Enhancements**: Typing indicator via emoji reactions, mrkdwn formatting guidance in system prompt +- **Session Resilience**: Automatic `--continue` on first connection, resume-failure fallback, and context usage indicators +- **Management API**: HTTP REST API endpoints for external management tools with WebSocket bridge support +- **Cron Setup Command**: `/cron setup` for easy cron job configuration with memory file integration + +### Bug Fixes +- **RateLimiter Goroutine Leak**: Fixed cleanup goroutine not stopped on replacement and engine shutdown +- **DrainEvents Infinite Loop**: Fixed infinite loop when channel is closed in `drainEvents` +- **InteractiveKey Consistency**: Fixed `executeCardAction` using wrong key for `interactiveStates` lookup in multi-workspace mode +- **Workspace Command Prefix**: Fixed missing leading slash in workspace command prefix check +- **Agent Session Close**: Always close events channel on session timeout to prevent goroutine leaks +- **Pi Agent Mutex**: Move thinking field read inside mutex in `StartSession` to prevent race condition +- **Session AgentID Protection**: Protect `Session.AgentSessionID` writes with mutex to prevent data races +- **Session Routing Race**: Prevent session routing race when `/new` runs during active turn +- **Discord Duplicate Messages**: Deduplicate gateway `MessageCreate` events causing duplicate responses +- **Codex JSON Lines**: Handle large stdout JSON lines without scanner buffer overflow +- **UTF-8 Safety**: Use rune-based splitting in `splitMessage` to prevent invalid UTF-8 sequences + +### Improvements +- **Gemini Display**: Enhanced tool display with diff syntax highlighting and improved Telegram markdown rendering +- **Thread Safety**: Added comprehensive thread-safe accessors for Session fields +- **Test Engine**: Thread safety improvements to test engine and fixed test assertions +- **Input Validation**: Consolidated interactive state cleanup and added input validation +- **i18n**: Updated rate limit messages to mention `/btw` command for adding context during processing + +### Contributors + +Special thanks to all contributors who made this release possible: + +- **kevinWangSheng** - Multiple critical bug fixes (RateLimiter, drainEvents, UTF-8 safety, session routing) +- **q107580018** - Feishu CLI onboarding with QR code integration +- **sean2077** - Session TUI browser and sessions management +- **quabug** - Pi agent implementation and Discord fixes +- **AtticusZeller** - Gemini tool display and Telegram markdown enhancements +- **leighstillard** - Multi-workspace design, session resilience, and Slack improvements +- **Shawn** - Thread safety fixes and test improvements +- **zhuguanqi** - Session management and data race fixes +- **Steve-Rye** - JSON lines handling improvements +- **Xihui He** - iFlow and agent enhancements +- **Mr.QiuW** - Various platform improvements + +## v1.2.2-beta.1 (2026-03-12) + +Beta release with major new features and security improvements. + +### New Features +- **`/usage` Command**: Add a built-in quota usage command with a generic agent usage-reporting interface; Codex now supports ChatGPT OAuth usage lookup via `~/.codex/auth.json` +- **Feishu Interactive Cards**: Beautiful card-based UI for slash commands (/help, /list, /status, etc.) with tabbed navigation and in-place updates +- **Lark Platform Support**: Added support for Lark (飞书国际版) with proper domain handling +- **Codex Reasoning Effort**: New `/reasoning` command to switch reasoning effort levels (low/medium/high) +- **Codex Model Cache Fallback**: `/model` command now falls back to local `~/.codex/models_cache.json` when API is unavailable +- **Gemini Timeout Config**: New `timeout_mins` option to configure per-turn timeout for Gemini agent +- **Batch Session Deletion**: `/delete` now supports comma lists, ranges, and mixed forms for batch deletion +- **TTS Support**: Text-to-speech with Qwen and OpenAI providers +- **Admin Privilege System**: Admin-only commands for privileged operations +- **iFlow Tool Timeout**: Configurable tool timeout and reset timer on partial completion +- **Card-based Permission Prompts**: Permission requests now use interactive cards with callback support +- **Shared Session Support**: Share sessions across all platforms with `share_session_in_channel` option + +### Bug Fixes +- **Security Hardening**: Socket permissions tightened (0600), token redaction in logs, warning for open `allow_from` +- **Slack @mention Support**: Fixed AppMentionEvent handling for channel @mentions +- **Update Fallback**: Self-update now falls back to .tar.gz/.zip archive when bare binary returns 404 +- **Skill Symlink**: Fixed skill directory scanning to follow symbolic links +- **QQBot Error Handling**: Added error logging for json.Unmarshal and WriteJSON calls +- **Claude Code Path**: Fixed underscore handling in findProjectDir path matching + +### Improvements +- **Daemon Config Flag**: Support daemon install with config file path +- **Message Tracing**: Added message tracing and threaded replies +- **Scanner Buffer**: Optimized scanner buffer sizes for large outputs + +## v1.2.1 (2026-03-09) + +Patch release with bug fixes and minor enhancements. + +### Bug Fixes +- **Engine: Idle Timer During Permission Wait** - Stop idle timer while waiting for user permission response to prevent session termination +- **Feishu: Nil Pointer Checks** - Add nil checks for `SenderId.OpenId` and `msg.Content` to prevent panics +- **Feishu: URL Validation** - Validate URLs before creating hyperlinks to prevent rejection of non-HTTP(S) URLs +- **Cron: Error Logging** - Log `json.Unmarshal` errors instead of silently ignoring when cron file is corrupted +- **Engine: Stale Event Prevention** - Add `drainEvents` utility to clear buffered events between turns + +### New Features +- **Bind Setup Command** - `/bind setup` writes relay instructions to memory file for better bot-to-bot relay configuration + +## v1.2.0 (2026-03-08) + +This is the first stable release of cc-connect 1.2.0, consolidating all beta changes and adding new features. + +### New Features (since beta.7) +- **Official QQ Bot Platform**: Native integration with Tencent's official QQ Bot Platform via WebSocket, supporting text, image, and document messages +- **iFlow CLI Agent**: Full support for iFlow CLI agent with interactive tool-call handling and mode switching +- **Shell Command Execution**: Custom commands can execute shell commands directly with `exec` field in config +- **Telegram Bot Menu**: Auto-register bot command menu on startup for better discoverability +- **DingTalk Reply Preprocessing**: Improved markdown content preprocessing for reply messages +- **Multi-Bot Relay Persistence**: Relay bindings now persist across restarts with improved binding messages + +### Improvements +- **Quiet Mode**: `/quiet` now supports both per-session and global scope modes +- **Compression Command**: Improved `/compress` command handling and code refactoring +- **i18n**: Added new message keys and improved command formatting + +### All 1.2.0 Highlights (from beta releases) +- **Bot-to-Bot Relay**: Forward messages between different messaging platforms +- **Streaming Preview**: Real-time message preview on Telegram, Discord, and Feishu +- **Typing Indicators**: Visual processing feedback on supported platforms +- **Session Search**: Search sessions by name, ID prefix, or summary +- **Custom Slash Commands**: Define reusable prompt templates +- **Agent Skills Discovery**: Auto-discover and invoke user-defined skills +- **Daemon Mode**: Run as background service with systemd/launchd support +- **Rate Limiting**: Per-session sliding-window rate limiter +- **Command Aliases**: Define shortcut aliases for commands +- **Self-Update**: In-place binary updates with auto-restart +- And many more improvements and bug fixes... + +## v1.2.0-beta.7 (2026-03-07) + +### New Features +- **Multi-Bot Relay Binding**: `/bind` now supports binding multiple bots in a group chat; use `/bind ` to add, `/bind -` to remove specific project +- **System-level Systemd**: Daemon mode now supports system-level systemd (`/etc/systemd/system/`) when running as root, useful for servers and containers +- **Config Example Command**: `cc-connect config-example` prints embedded config template for quick reference +- **Interactive Command Buttons**: `/lang`, `/model`, `/mode` commands now show interactive button menus for easy selection +- **Exec Commands**: Custom commands can execute shell commands directly with `exec` field in config +- **Configurable Idle Timeout**: Agent idle timeout can be configured via `idle_timeout_mins` in config + +### Improvements +- **Daemon Error Messages**: Improved systemd detection and error messages for WSL2, containers, and SSH environments +- **Codex CLI Visibility**: Patched codex session source to make CLI output visible + +### Bug Fixes +- **Streaming Preview**: Fixed stale preview messages when streaming degrades + +## v1.2.0-beta.6 (2026-03-06) + +### New Features +- **Bot-to-Bot Relay**: Forward messages between different messaging platforms via CLI (`cc-connect relay`) and internal API; enables cross-platform bot communication +- **Session Search**: Search sessions by name, ID prefix, or summary with `/search ` command +- **List Pagination**: `/list` now supports pagination with `--page` and `--page-size` flags for large session counts +- **Per-Platform Streaming Preview Control**: Configure streaming preview per platform via `streaming_preview` setting (Telegram, Discord, Feishu) +- **Silent Cron Mode**: Suppress cron job notification messages with `silent = true` in cron job config +- **Voice Qwen Mode**: Voice function now supports Qwen audio model for speech-to-text +- **Feishu Three-Tier Rendering**: Intelligent markdown rendering strategy — simple text uses plain messages, rich markdown uses Post, code blocks/tables use Card + +### Improvements +- **Status Display**: Improved `/status` command output with better formatting and Feishu message rendering fixes +- **Self-Update**: Auto-restart after update; added Gitee mirror support for Chinese users +- **Windows Self-Update**: Full Windows support for in-place binary updates +- **Message Splitting**: Improved boundary checks for cleaner message chunking +- **Platform Startup**: Better error handling and logging during platform initialization +- **Session Switch i18n**: Added translation for session switch success message + +### Bug Fixes +- **Idle Session Timeout**: Added timeout for unresponsive agent sessions to prevent hangs +- **Streaming Preview**: Removed `maxChars` check that caused premature preview termination +- **Message Deduplication**: Deduplicate messages by process start time to prevent duplicate processing + +## v1.2.0-beta.5 (2026-03-06) + +### New Features +- **Streaming Preview**: Real-time message preview that updates in-place as the agent streams output; supported on Telegram, Discord, and Feishu with configurable interval, min delta, and max length +- **Rate Limiting**: Per-session sliding-window rate limiter to prevent message flooding; configurable `max_messages` and `window_secs` +- **Typing Indicators**: Visual processing feedback — Telegram/Discord show native typing action, Feishu adds emoji reaction (auto-removed on completion) +- **Command Aliases**: Define shortcut aliases for commands (`[[aliases]]` in config.toml or `/alias add`); e.g. map "帮助" → "/help" +- **Banned Words Filter**: Block messages containing configured sensitive words (`banned_words` in config.toml) +- **Project-level Command Disabling**: Disable specific commands per project via `disabled_commands` config +- **Session Deletion**: Delete sessions with `/del` command +- **`/switch` Fuzzy Matching**: Switch sessions by name, ID prefix, or summary substring in addition to numeric index + +### Improvements +- **Streaming Preview + Tool Messages UX**: In non-quiet mode, when thinking/tool messages are sent, the streaming preview freezes and the final response is delivered as a new message at the bottom of the chat (instead of silently updating an older message above the tool messages) +- **Telegram Markdown→HTML**: Full Markdown-to-HTML conversion with proper escaping, placeholder-based tag nesting, and automatic fallback to plain text on parse errors +- **Discord Code-Fence-Aware Splitting**: Message chunking now respects code block boundaries, closing and re-opening fences across splits +- **Feishu Dual Rendering**: Simple markdown uses Post messages (normal font), code blocks/tables use Card messages (native rendering); matches Claude-to-IM's approach +- **Feishu Permission Interaction**: Confirmed WebSocket mode incompatibility with card button callbacks; uses text-based `/perm` commands (consistent with Claude-to-IM) +- **Session Creation & Naming**: Improved session naming with last user message as summary +- **Graceful Shutdown**: Improved context handling and lock release during shutdown +- **Unit Tests**: Added ~50 new test cases covering markdown conversion, message splitting, session management, and engine logic + +### Bug Fixes +- **Telegram HTML Crossed Tags**: Fixed `...` nesting issues by using placeholder-based formatting pipeline +- **Telegram HTML Attribute Escaping**: Fixed `"` in URLs breaking `` attributes (escape to `"`) +- **Telegram Duplicate Messages**: Fixed duplicate sends caused by streaming preview optimization skipping final HTML update +- **Streaming Preview Cursor**: Removed trailing `▍` cursor from final messages +- **Feishu Message Recall**: Unified preview and final message types to Card, eliminating unnecessary delete-and-resend +- **Feishu Reaction Cleanup**: Register empty handler for `im.message.reaction.deleted_v1` to suppress error logs +- **`fmt.Sprintf` Warnings**: Remove non-constant format strings flagged by `go vet` + +## v1.2.0-beta.2 (2026-03-01) + +### New Features +- **`/upgrade` Command**: Check for available updates (including beta) and self-update the binary in-place; queries both GitHub and Gitee releases +- **`/restart` Command**: Restart cc-connect service from chat with post-restart success notification +- **`/config reload` Command**: Hot-reload configuration (display, providers, commands) without restarting +- **`/name` Command**: Set custom display names for sessions (e.g. `/name my-feature`, `/name 3 bugfix`); names persist across restarts and show in `/list`, `/switch`, `/status` +- **Default Quiet Mode**: Configure `quiet = true` globally or per-project in config.toml to suppress thinking/tool progress by default; users can still toggle with `/quiet` +- **Command Prefix Matching**: Type shortened commands like `/pro l` for `/provider list`, `/sw 2` for `/switch 2`; works for all commands and subcommands +- **Numeric Session Switching**: `/list` shows numbered sessions; `/switch 3` switches by number instead of copying long IDs +- **Group Chat Mention Filtering**: Feishu, Discord, and Telegram bots now only respond to @mentions in group chats instead of all messages +- **Claude Code Router Support**: Integration with Claude Code Router for enhanced routing capabilities +- **Third-party Provider Proxy**: Local reverse proxy rewrites incompatible `thinking` parameters for third-party LLM providers (e.g. SiliconFlow) + +### Improvements +- **Session History for Claude Code**: `/history` now works after `/switch` by reading from agent JSONL files +- **List Summary**: `/list` now shows the most recent user message as summary instead of the first +- **Session Names in UI**: Custom session names display with 📌 prefix in `/list`, `/switch`, `/status` +- **API Server Shutdown**: Clean shutdown without "use of closed network connection" error +- **Agent Session Timeouts**: 8-second graceful shutdown timeout for all agent sessions with kill fallback +- **Feishu Rich Text**: Use Post (rich text) messages instead of Interactive Cards for normal font size + +### Bug Fixes +- **DingTalk Startup**: Fix false startup failure when stream client returns nil error +- **Deadlock on /new and /switch**: Release lock before async agent session close to prevent hangs +- **Provider Command**: Correctly list providers when no active provider is set +- **Unknown Command Handling**: Show i18n-friendly warning and fall through to agent for native commands + +### Security & Reliability +- **Race Condition Fixes**: `sync.Once` for channel close, mutex protection for concurrent fields, non-blocking event sends +- **Atomic File Writes**: Config, session, and cron files use temp+rename pattern +- **Message Deduplication**: Platform-level dedup for Feishu and DingTalk webhooks +- **HTTP Client Timeouts**: Shared 30s-timeout HTTP client for all outbound requests +- **Path Traversal Protection**: Validate command file paths +- **Sensitive Data Redaction**: Redact API keys and tokens in logs + +## v1.2.0-beta.1 (2026-03-01) + +### New Features +- **Custom Slash Commands**: Define reusable prompt templates as global slash commands (`[[commands]]` in config.toml or `/commands add`); supports positional parameters (`{{1}}`), rest parameters (`{{2*}}`), default values (`{{1:default}}`), and runtime add/del/list +- **Agent Skills Discovery**: Auto-discover and invoke user-defined skills from agent directories (e.g. `.claude/skills//SKILL.md`); list with `/skills`, invoke with `/ [args]`; supports all agents (Claude Code, Cursor, Gemini, Codex, Qoder) +- **`/config` Command**: View and modify runtime configuration (e.g. `thinking_max_len`, `tool_max_len`) from chat, with persistent save to `config.toml` +- **`/doctor` Command**: Run system diagnostics covering agent authentication, platform connectivity, system resources, dependencies, and network latency; fully i18n-supported +- **Discord Slash Commands**: Register native Discord Application Commands so typing `/` shows an autocomplete menu; supports per-guild instant registration via `guild_id` config +- **Daemon Mode**: Run cc-connect as a background service (`cc-connect daemon install/start/stop/status/logs`); supports systemd (Linux) and launchd (macOS) +- **Qoder CLI Agent**: Full support for the Qoder coding agent with streaming JSON, mode switching, and model selection +- **Telegram Proxy**: Support HTTP/SOCKS5 proxy for Telegram bot API connections +- **WeChat Work Proxy Auth**: Add `proxy_username` / `proxy_password` for authenticated forward proxies +- **i18n Expansion**: Add Traditional Chinese (zh-TW), Japanese (ja), and Spanish (es) language support +- **`--stdin` Support**: Read prompt from stdin for CLI usage (`echo "hello" | cc-connect send --stdin`) + +### Improvements +- **Slow Operation Monitoring**: Warn-level logs for slow platform send (>2s), agent start (>5s), agent close (>3s), agent send (>2s), and agent first event (>15s); turn completion logs now include `turn_duration` +- **`tool_max_len=0` Fix**: Remove hardcoded 200-char truncation in all agent sessions (Claude Code, Cursor, Codex, Gemini, Qoder), making the user-configurable `tool_max_len` setting authoritative +- **Cursor `/list` Improvements**: Parse binary blob structure to show accurate message counts and first user message summary + +### Bug Fixes +- **Telegram proxy**: Only override `http.Transport` when proxy is actually configured +- **Discord interaction fallback**: Gracefully fallback to channel messages when interaction token expires + +## v1.1.0 (2026-03-02) + +### New Features +- **`/compress` Command**: Compress/compact conversation context by forwarding native commands to agents (Claude Code `/compact`, Codex `/compact`, Gemini `/compress`); keeps long sessions manageable +- **Auto-Compress**: Added optional automatic context compression when estimated token usage exceeds a configurable threshold (`[projects.auto_compress]`). +- **Telegram Inline Buttons**: Permission prompts on Telegram now use clickable inline keyboard buttons (Allow / Deny / Allow All) instead of requiring text replies +- **`/model` Command**: View and switch AI models at runtime; supports numbered quick-select and custom model names. Fetches available models from provider API in real-time (Anthropic, OpenAI, Google), with built-in fallback list +- **`/memory` Command**: View and edit agent memory files (CLAUDE.md, AGENTS.md, GEMINI.md) directly from chat; supports both project-level and global-level (`/memory global`) +- **`/status` Command**: Display system status including project, agent, platforms, uptime, language, permission mode, session info, and cron job count + +### Improvements +- **Cron list display**: Multi-line card-style formatting with human-readable schedule translations and next execution time +- **Model switch resets session**: Switching model via `/model` now starts a fresh agent session instead of resuming the old one, preventing stale context from affecting the new model +- **Permission modes docs**: README now documents permission modes for all four agents (Claude Code, Codex, Cursor Agent, Gemini CLI) +- **Natural language scheduling docs**: INSTALL.md now explains how to enable cron job creation via natural language for non-Claude agents +- **README revamp**: Redesigned project header with architecture diagram, feature highlights, and multi-agent positioning + +### Bug Fixes +- **Gemini `/list` summary**: Fixed session list showing raw JSON (`{"dummy": true}`) instead of actual user message summary +- **GitHub Issue Templates**: Added structured templates for bug reports, feature requests, and platform/agent support requests + +## v1.1.0-beta.7 (2026-03-02) + +(see v1.1.0 above — beta.7 changes are included in the stable release) + +## v1.1.0-beta.6 (2026-02-28) + +### New Features +- **QQ Platform** (Beta): Support QQ messaging via OneBot v11 / NapCat WebSocket +- **Cron Scheduling**: Schedule recurring tasks via `/cron` command or CLI (`cc-connect cron add`), with JSON persistence and agent-aware session injection +- **Feishu Emoji Reaction**: Auto-add emoji reaction (default: "OnIt") on incoming messages to confirm receipt; configurable via `reaction_emoji` +- **Display Truncation Config**: New `[display]` config section to control thinking/tool message truncation (`thinking_max_len`, `tool_max_len`); set to 0 to disable truncation +- **`/version` Command**: Check current cc-connect version from within chat + +### Bug Fixes +- **Windows `/list` fix**: Claude Code sessions now discoverable on Windows despite drive letter colon in project key paths +- **CLAUDECODE env filter**: Prevent nested Claude Code session crash by filtering CLAUDECODE env var from subprocesses + +### Docs +- Clarified global config path `~/.cc-connect/config.toml` in INSTALL.md +- Fixed markdown image syntax in Chinese README + +## v1.1.0-beta.5 (2026-03-01) + +### New Features +- **Gemini CLI Agent**: Full support for `gemini` CLI with streaming JSON, mode switching, and provider management +- **Cursor Agent**: Integration with Cursor Agent CLI (`agent`) with mode and provider support + +## v1.1.0-beta.4 (2026-03-01) + +### Bug Fixes +- Fixed npm install: check binary version on install, replace outdated binary instead of skipping +- Added auto-reinstall logic for outdated binaries in `run.js` + +## v1.1.0-beta.3 (2026-03-01) + +### New Features +- **Voice Messages (STT)**: Transcribe voice messages to text via OpenAI Whisper, Groq Whisper, or SiliconFlow SenseVoice; requires `ffmpeg` +- **Image Support**: Handle image messages across platforms with multimodal content forwarding to agents +- **CLI Send**: `cc-connect send` command and internal Unix socket API for programmatic message sending +- **Message Dedup**: Prevent duplicate processing of WeChat Work messages + +## v1.1.0-beta.2 (2026-03-01) + +### New Features +- **Provider Management**: `/provider` command for runtime API provider switching; CLI `cc-connect provider add/list` +- **Configurable Data Dir**: Session data stored in `~/.cc-connect/` by default (configurable via `data_dir`) +- **Markdown Stripping**: Plain text fallback for platforms that don't support markdown (e.g. WeChat) + +## v1.1.0-beta.1 (2026-03-01) + +### New Features +- **Codex Agent**: OpenAI Codex CLI integration +- **Self-Update**: `cc-connect update` and `cc-connect check-update` commands +- **I18n**: Auto-detect language, `/lang` command to switch between English and Chinese +- **Session Persistence**: Sessions saved to disk as JSON, restored on restart + +## v1.0.1 (2026-02-28) + +- Bug fixes and stability improvements + +## v1.0.0 (2026-02-28) + +- Initial release +- Claude Code agent support +- Platforms: Feishu, DingTalk, Telegram, Slack, Discord, LINE, WeChat Work +- Commands: `/new`, `/list`, `/switch`, `/history`, `/quiet`, `/mode`, `/allow`, `/stop`, `/help` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0469017 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,237 @@ +# CC-Connect Development Guide + +## Project Overview + +CC-Connect is a bridge that connects AI coding agents (Claude Code, Codex, Gemini CLI, Cursor, etc.) with messaging platforms (Feishu/Lark, Telegram, Discord, Slack, DingTalk, WeChat Work, QQ, LINE). Users interact with their coding agent through their preferred messaging app. + +## Architecture + +``` +┌─────────────────────────────────────────────────┐ +│ cmd/cc-connect │ ← entry point, CLI, daemon +├─────────────────────────────────────────────────┤ +│ config/ │ ← TOML config parsing +├─────────────────────────────────────────────────┤ +│ core/ │ ← engine, interfaces, i18n, +│ │ cards, sessions, registry +├──────────────────────┬──────────────────────────┤ +│ agent/ │ platform/ │ +│ ├── claudecode/ │ ├── feishu/ │ +│ ├── codex/ │ ├── telegram/ │ +│ ├── cursor/ │ ├── discord/ │ +│ ├── gemini/ │ ├── slack/ │ +│ ├── iflow/ │ ├── dingtalk/ │ +│ ├── opencode/ │ ├── wecom/ │ +│ ├── acp/ │ ├── qq/ │ +│ └── qoder/ │ ├── qqbot/ │ +│ │ ├── line/ │ +│ │ └── weibo/ │ +├──────────────────────┴──────────────────────────┤ +│ daemon/ │ ← systemd/launchd service +└─────────────────────────────────────────────────┘ +``` + +### Key Design Principles + +**`core/` is the nucleus.** It defines all interfaces (`Platform`, `Agent`, `AgentSession`, etc.) and contains the `Engine` that orchestrates message flow. The core package must **never** import from `agent/` or `platform/`. + +**Plugin architecture via registries.** Agents and platforms register themselves through `core.RegisterAgent()` and `core.RegisterPlatform()` in their `init()` functions. The engine creates instances via `core.CreateAgent()` / `core.CreatePlatform()` using string names from config. + +**Dependency direction:** +``` +cmd/ → config/, core/, agent/*, platform/* +agent/* → core/ (never other agents or platforms) +platform/* → core/ (never other platforms or agents) +core/ → stdlib only (never agent/ or platform/) +``` + +### Core Interfaces + +- **`Platform`** — messaging platform adapter (Start, Reply, Send, Stop) +- **`Agent`** — AI coding agent adapter (StartSession, ListSessions, Stop) +- **`AgentSession`** — a running bidirectional session (Send, RespondPermission, Events) +- **`Engine`** — the central orchestrator that routes messages between platforms and agents + +Optional capability interfaces (implement only when needed): +- `CardSender` — rich card messages +- `InlineButtonSender` — inline keyboard buttons +- `ProviderSwitcher` — multi-model switching +- `DoctorChecker` — agent-specific health checks +- `AgentDoctorInfo` — CLI binary metadata for diagnostics + +## Development Rules + +### 1. No Hardcoding Platform or Agent Names in Core + +The `core/` package must remain agnostic. Never write `if p.Name() == "feishu"` or `CreateAgent("claudecode", ...)` in core. Use interfaces and capability checks instead: + +```go +// BAD — hardcodes platform knowledge in core +if p.Name() == "feishu" && supportsCards(p) { + +// GOOD — capability-based check +if supportsCards(p) { +``` + +```go +// BAD — hardcodes agent type +agent, _ := CreateAgent("claudecode", opts) + +// GOOD — derives from current agent +agent, _ := CreateAgent(e.agent.Name(), opts) +``` + +### 2. Prefer Interfaces Over Type Switches + +When behavior differs across platforms/agents, define an optional interface in core and let implementations opt in: + +```go +// In core/ +type AgentDoctorInfo interface { + CLIBinaryName() string + CLIDisplayName() string +} + +// In agent/claudecode/ +func (a *Agent) CLIBinaryName() string { return "claude" } +func (a *Agent) CLIDisplayName() string { return "Claude" } + +// In core/ — query via interface, fallback gracefully +if info, ok := agent.(AgentDoctorInfo); ok { + bin = info.CLIBinaryName() +} +``` + +### 3. Configuration Over Code + +- Features that may vary per deployment should be configurable in `config.toml` +- Use `map[string]any` options for agent/platform factories to stay flexible +- Add new config fields with sensible defaults so existing configs don't break + +### 4. High Cohesion, Low Coupling + +- Each `agent/X/` package is self-contained: it handles process lifecycle, output parsing, and session management for agent X +- Each `platform/X/` package is self-contained: it handles API connection, message receiving/sending, and card rendering for platform X +- Cross-cutting concerns (i18n, cards, streaming, rate limiting) live in `core/` + +### 5. Error Handling + +- Always wrap errors with context: `fmt.Errorf("feishu: reply card: %w", err)` +- Never silently swallow errors; at minimum log them with `slog.Error` / `slog.Warn` +- Use `slog` (structured logging) consistently; never `log.Printf` or `fmt.Printf` for runtime logs +- Redact tokens/secrets in error messages using `core.RedactToken()` + +### 6. Concurrency Safety + +- Agent sessions are accessed from multiple goroutines; protect shared state with `sync.Mutex` or `atomic` types +- Use `context.Context` for cancellation propagation +- Channels should have clear ownership; document who closes them +- Prefer `sync.Once` for one-time teardown (`pendingPermission.resolve()`) + +### 7. i18n + +All user-facing strings must go through `core/i18n.go`: +- Define a `MsgKey` constant +- Add translations for all supported languages (EN, ZH, ZH-TW, JA, ES) +- Use `e.i18n.T(MsgKey)` or `e.i18n.Tf(MsgKey, args...)` + +## Code Style + +- Follow standard Go conventions (`gofmt`, `go vet`) +- Use `strings.EqualFold` for case-insensitive comparisons +- Avoid `init()` for anything other than platform/agent registration +- Keep functions focused; extract helpers when a function exceeds ~80 lines +- Naming: `New()` for constructors, `Get/Set` for accessors, avoid stuttering (`feishu.FeishuPlatform` → `feishu.Platform`) + +## Testing + +### Requirements + +- All new features must include unit tests +- All bug fixes should include a regression test +- Tests must pass before committing: `go test ./...` + +### Running Tests + +```bash +# Full test suite +go test ./... + +# Specific package +go test ./core/ -v + +# Run specific test +go test ./core/ -run TestHandlePendingPermission -v + +# With race detector (CI) +go test -race ./... +``` + +### Test Patterns + +- Use stub types for `Platform` and `Agent` in core tests (see `core/engine_test.go`) +- Test card rendering by inspecting the returned `*Card` struct, not JSON +- For agent session tests, simulate event streams via channels + +## Selective Compilation + +Each agent and platform is imported via a separate `plugin_*.go` file with a +build tag (e.g. `//go:build !no_feishu`). By default **all** agents and +platforms are compiled in. + +### Include only specific agents/platforms + +```bash +# Only Claude Code agent + Feishu and Telegram platforms +make build AGENTS=claudecode PLATFORMS_INCLUDE=feishu,telegram + +# Multiple agents +make build AGENTS=claudecode,codex PLATFORMS_INCLUDE=feishu,telegram,discord +``` + +### Exclude specific agents/platforms + +```bash +# Exclude some platforms you don't need +make build EXCLUDE=discord,dingtalk,qq,qqbot,line +``` + +### Direct build tag usage (without Make) + +```bash +go build -tags 'no_discord no_dingtalk no_qq no_qqbot no_line' ./cmd/cc-connect +``` + +Available tags: `no_acp`, `no_claudecode`, `no_codex`, `no_cursor`, `no_gemini`, +`no_iflow`, `no_opencode`, `no_qoder`, `no_feishu`, `no_telegram`, +`no_discord`, `no_slack`, `no_dingtalk`, `no_wecom`, `no_weixin`, `no_qq`, `no_qqbot`, +`no_line`, `no_weibo`. + +## Pre-Commit Checklist + +1. **Build passes**: `go build ./...` +2. **Tests pass**: `go test ./...` +3. **No new hardcoded platform/agent names in core**: grep for platform names in `core/*.go` +4. **i18n complete**: all new user-facing strings have translations for all languages +5. **No secrets in code**: no API keys, tokens, or credentials in source files + +## Adding a New Platform + +1. Create `platform/newplatform/newplatform.go` +2. Implement `core.Platform` interface (and optional interfaces as needed) +3. Register in `init()`: `core.RegisterPlatform("newplatform", factory)` +4. Create `cmd/cc-connect/plugin_platform_newplatform.go` with `//go:build !no_newplatform` tag +5. Add `newplatform` to `ALL_PLATFORMS` in `Makefile` +6. Add config example in `config.example.toml` +7. Add unit tests + +## Adding a New Agent + +1. Create `agent/newagent/newagent.go` +2. Implement `core.Agent` and `core.AgentSession` interfaces +3. Register in `init()`: `core.RegisterAgent("newagent", factory)` +4. Create `cmd/cc-connect/plugin_agent_newagent.go` with `//go:build !no_newagent` tag +5. Add `newagent` to `ALL_AGENTS` in `Makefile` +6. Optionally implement `AgentDoctorInfo` for `cc-connect doctor` support +7. Add config example in `config.example.toml` +8. Add unit tests diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c1c5ff8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,111 @@ +# Contributing to cc-connect + +[中文](#为-cc-connect-做贡献) | [English](#contributing-to-cc-connect) + +Thank you for using cc-connect and for every issue, pull request, and piece of feedback that helps improve it. This guide turns the contributor welcome note from [#295](https://github.com/chenhg5/cc-connect/issues/295) into a permanent repo document. + +## Before You Open An Issue Or PR + +1. Search first. +Check [Issues](https://github.com/chenhg5/cc-connect/issues) and [Pull requests](https://github.com/chenhg5/cc-connect/pulls) for duplicates or related discussion before starting new work. + +2. Try the latest beta. +Many bugs are fixed in beta or pre-release builds before they reach stable. Please retry on the latest beta first when possible. + +## Writing A Helpful Issue + +Please include as much of the following as possible: + +- Version: `cc-connect --version`, npm tag, or release asset +- Environment: OS, installation method, agent type, and platform +- Reproduction steps: the smaller the repro, the better +- Expected behavior vs. actual behavior +- Logs or errors, with secrets redacted +- Optional analysis or a proposed fix + +We usually acknowledge new issues within about 1 to 2 business days. More complex bugs may take longer to investigate. + +## Pull Requests + +- Follow the repo guidance in [`CLAUDE.md`](./CLAUDE.md) and [`AGENTS.md`](./AGENTS.md). +- Run local validation before submitting. At minimum: + +```bash +go test ./... +``` + +- Call out breaking changes explicitly in the PR description. +- Update docs or examples when behavior or configuration changes. +- If you are fixing an issue, link it in the PR body with `Closes #` when appropriate. + +## Release Cadence + +- Beta / pre-release: roughly every 2 to 3 days +- Stable: roughly every 2 weeks + +Always treat the [GitHub Releases](https://github.com/chenhg5/cc-connect/releases) page as the source of truth. + +## Community + +- Discord: +- Telegram: +- X: +- WeChat: `@mongorz` (mention cc-connect when adding) + +Commercial support, custom work, or enterprise inquiries can go through the same channels. + +--- + +# 为 cc-connect 做贡献 + +感谢你使用 cc-connect,也感谢你通过 issue、PR 和反馈帮助项目持续改进。这份文档把 [#295](https://github.com/chenhg5/cc-connect/issues/295) 里的欢迎与参与指南正式沉淀到仓库中。 + +## 提交 Issue 或 PR 之前 + +1. 先搜索。 +先查看 [Issues](https://github.com/chenhg5/cc-connect/issues) 和 [Pull requests](https://github.com/chenhg5/cc-connect/pulls),避免重复劳动,也方便在已有讨论里继续跟进。 + +2. 先试最新 beta。 +很多问题会先在 beta / 预发布版本中修复。如果条件允许,建议先在最新 beta 上复现一次。 + +## 如何提交高质量 Issue + +建议尽量包含以下信息: + +- 版本号:`cc-connect --version`、npm 标签或 release 资源名 +- 环境:操作系统、安装方式、Agent 类型、平台类型 +- 复现步骤:越小越好 +- 预期行为和实际行为 +- 日志或报错,注意打码敏感信息 +- 可选的原因分析或修复思路 + +我们通常会在 1 到 2 个工作日内进行首次响应,复杂问题可能需要更长的排查时间。 + +## Pull Request + +- 请遵循仓库中的 [`CLAUDE.md`](./CLAUDE.md) 和 [`AGENTS.md`](./AGENTS.md)。 +- 提交前请先做本地验证,至少执行: + +```bash +go test ./... +``` + +- 如果包含 breaking change,请在 PR 描述中明确说明。 +- 如果改动影响行为或配置,请同步更新文档或示例。 +- 如果是在修复 issue,适合时请在 PR 描述中使用 `Closes #<编号>` 关联。 + +## 发版节奏 + +- Beta / 预发布:大约每 2 到 3 天一次 +- 稳定版:大约每 2 周一次 + +请以 [GitHub Releases](https://github.com/chenhg5/cc-connect/releases) 页面为准。 + +## 社区 + +- Discord: +- Telegram: +- X: +- 微信: `@mongorz`(添加时请备注 cc-connect) + +如果是商业合作、定制需求或企业支持,也可以通过以上渠道联系。 \ No newline at end of file diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..45e5f52 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,799 @@ +# cc-connect Installation & Configuration Guide + +> **This document is designed to be read by AI coding agents (Claude Code, Cursor, Gemini CLI, etc.) to help users install and configure cc-connect.** You can feed this file directly to your AI agent. + +## What is cc-connect? + +cc-connect bridges local AI coding assistants to messaging platforms (Feishu, DingTalk, Telegram, Slack, Discord, LINE, WeChat Work, QQ, **personal WeChat** via the **Weixin (ilink)** platform). + +GitHub: https://github.com/chenhg5/cc-connect + +**IMPORTANT: If you are an AI agent helping a user install cc-connect, use interactive tools (like AskUserQuestion) to guide them through configuration choices:** +- Agent selection (Claude Code, Cursor, Gemini, etc.) +- Platform selection (Feishu, Telegram, Discord, etc.) +- API keys and authentication tokens +- Project paths and preferences + +Don't guess values—always ask the user to choose via interactive prompts. + +## Step 1: Install cc-connect + +### Option A: npm (recommended for most users) + +```bash +npm install -g cc-connect +``` + +After installation, the `cc-connect` binary will be available globally. + + +### Option B: Homebrew (macOS / Linux) + +```bash +brew install cc-connect +``` + +### Option C: Download binary from GitHub Releases + +Go to https://github.com/chenhg5/cc-connect/releases and download the binary for your platform. + +Typical artifact names (check the release page for exact filenames): + +- Linux: `cc-connect--linux-amd64` (or `.tar.gz`) +- macOS: `cc-connect--darwin-amd64` / `arm64` +- Windows: `cc-connect--windows-amd64.exe` (or `.zip`) + +```bash +# Example for Linux amd64 (replace URL with the asset link from the release you chose): +curl -L -o cc-connect https://github.com/chenhg5/cc-connect/releases/latest/download/cc-connect-linux-amd64 +chmod +x cc-connect +sudo mv cc-connect /usr/local/bin/ +``` + +On macOS, you may need to remove the quarantine attribute: + +```bash +xattr -d com.apple.quarantine cc-connect +``` + +### Option D: Build from source + +Requires Go 1.22+. + +```bash +git clone https://github.com/chenhg5/cc-connect.git +cd cc-connect +make build +# Binary will be at ./cc-connect +``` + +## Step 2: Install your AI Agent + +cc-connect supports multiple local coding agents. Install at least one: + +```bash +# Claude Code +npm install -g @anthropic-ai/claude-code + +# Codex +npm install -g @openai/codex + +# Gemini CLI +npm install -g @google/gemini-cli + +# iFlow CLI +npm install -g @iflow-ai/iflow-cli + +# Qoder CLI +curl -fsSL https://qoder.com/install | bash +``` + +For **Cursor Agent** and **OpenCode**, follow their official install docs: +- Cursor Agent: https://docs.cursor.com/agent +- OpenCode: https://github.com/opencode-ai/opencode + +Verify your selected agent works: + +```bash +claude --version +codex --version +gemini --version +iflow --version +opencode --version +qodercli --version +``` + +## Step 3: Create config.toml + +> **💡 Recommended: Use the Web UI** — After installing, run `cc-connect web` to configure the web admin and open the dashboard in your browser. You can visually create projects, add platforms, manage API providers, and even chat with your agent directly from the browser — no need to edit TOML files by hand. **Note:** `cc-connect web` only configures and opens the browser — you still need to run `cc-connect` separately to start the service. + +If you prefer manual configuration, cc-connect looks for config in this order: +1. `-config ` flag (explicit) +2. `./config.toml` (current directory) +3. `~/.cc-connect/config.toml` (global, **recommended**) + +If no config file exists, running `cc-connect` will auto-create a starter template at `~/.cc-connect/config.toml`. + +**Manual config location:** + +```bash +mkdir -p ~/.cc-connect +# If you cloned the repo, copy the example: +cp config.example.toml ~/.cc-connect/config.toml +# Or just run cc-connect once — it will create a starter config automatically +``` + +You can also use a local config in the current directory: + +```bash +cp config.example.toml config.toml +``` + +The configuration has this structure: + +```toml +# Optional global settings +# language = "en" # "en", "zh", or "" (auto-detect) + +[log] +level = "info" # debug, info, warn, error + +# Each [[projects]] entry connects one code folder to one or more messaging platforms +[[projects]] +name = "my-project" + +[projects.agent] +type = "claudecode" # or "codex", "cursor", "gemini", "qoder", "opencode", "iflow" + +[projects.agent.options] +work_dir = "/absolute/path/to/your/project" +mode = "default" + +# --- Claude Code mode options --- +# "default", "acceptEdits" (alias: "edit"), "plan", "auto", "bypassPermissions" (alias: "yolo") +# allowed_tools = ["Read", "Grep", "Glob"] # optional: pre-approve specific tools + +# --- Codex mode options --- +# "suggest" (default), "auto-edit", "full-auto", "yolo" +# model = "o3" # optional: specify model + +# --- Qoder CLI mode options --- +# "default", "yolo" +# model = "auto" # "auto", "ultimate", "performance", "efficient", "lite" + +# --- iFlow CLI mode options --- +# "default", "auto-edit", "plan", "yolo" +# model = "Qwen3-Coder" # optional: specify model + +# Add one or more platform sections below +``` + +## Step 4: Configure a Messaging Platform + +Choose one or more platforms to connect. Each platform requires creating a bot/app on the platform's developer console and copying credentials into config.toml. + +--- + +### Feishu (Lark) — No public IP needed + +Connection: WebSocket long connection (SDK auto-negotiates) + +**CLI shortcut (recommended):** + +```bash +# Recommended: unified entry +cc-connect feishu setup --project my-project +cc-connect feishu setup --project my-project --app cli_xxx:sec_xxx + +# Force modes (usually unnecessary) +cc-connect feishu new --project my-project + +cc-connect feishu bind --project my-project --app cli_xxx:sec_xxx +``` + +Notes: +- `setup` is the unified entry: + - no credentials => same as `new` + - with `--app`/`--app-id` => same as `bind` +- `setup/new` prints a terminal QR code + URL for mobile scanning. +- If `--project` does not exist, cc-connect creates it automatically. +- This flow fills `app_id` / `app_secret`; in QR onboarding flow, Feishu usually pre-configures permissions and event subscriptions. +- Still verify app publish status and availability scope in Feishu Open Platform. + +**Setup steps:** +1. Go to https://open.feishu.cn → Console → Create Enterprise App +2. Enable **Bot** capability (App Capabilities → Bot) +3. Go to **Permissions** → add `im:message.receive_v1`, `im:message:send_as_bot` +4. Go to **Event Subscriptions** → select **WebSocket long connection mode** → add event `im.message.receive_v1` +5. Publish the app version +6. Copy App ID and App Secret + +**Config:** + +```toml +[[projects.platforms]] +type = "feishu" + +[projects.platforms.options] +app_id = "cli_xxxxxxxxxxxx" +app_secret = "xxxxxxxxxxxxxxxxxxxxxxxx" +``` + +**Detailed guide:** [docs/feishu.md](docs/feishu.md) + +--- + +### DingTalk — No public IP needed + +Connection: Stream mode (WebSocket) + +**Setup steps:** +1. Go to https://open-dev.dingtalk.com → Create App +2. Enable **Bot** capability, select **Stream mode** +3. Configure permissions for messaging +4. Copy Client ID (AppKey) and Client Secret (AppSecret) + +**Config:** + +```toml +[[projects.platforms]] +type = "dingtalk" + +[projects.platforms.options] +client_id = "dingxxxxxxxxxxxxxxxxx" +client_secret = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" +``` + +**Detailed guide:** [docs/dingtalk.md](docs/dingtalk.md) + +--- + +### Telegram — No public IP needed + +Connection: Long Polling + +**Setup steps:** +1. Message @BotFather on Telegram → send `/newbot` +2. Follow prompts to set bot name and username (must end with `bot`) +3. Copy the bot token + +**Config:** + +```toml +[[projects.platforms]] +type = "telegram" + +[projects.platforms.options] +token = "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz" +``` + +**Detailed guide:** [docs/telegram.md](docs/telegram.md) + +--- + +### Slack — No public IP needed + +Connection: Socket Mode (WebSocket) + +**Setup steps:** +1. Go to https://api.slack.com/apps → Create New App → From scratch +2. Enable **Socket Mode** (Settings → Socket Mode) → generate App-Level Token (`xapp-...`) +3. Subscribe to bot events: `message.im`, `app_mention` (Event Subscriptions) +4. Add Bot Token Scopes: `chat:write`, `im:history`, `im:read`, `im:write`, `app_mentions:read` +5. Install App to Workspace → copy Bot Token (`xoxb-...`) + +**Config:** + +```toml +[[projects.platforms]] +type = "slack" + +[projects.platforms.options] +bot_token = "xoxb-your-bot-token" +app_token = "xapp-your-app-level-token" +``` + +**Detailed guide:** [docs/slack.md](docs/slack.md) + +--- + +### Discord — No public IP needed + +Connection: Gateway WebSocket + +**Setup steps:** +1. Go to https://discord.com/developers/applications → New Application +2. Go to **Bot** → Add Bot → copy Token +3. Enable **Message Content Intent** (under Privileged Gateway Intents) +4. Go to **OAuth2** → URL Generator → select scope `bot` → select permissions `Send Messages`, `Read Message History` +5. Open the generated URL to invite bot to your server + +**Config:** + +```toml +[[projects.platforms]] +type = "discord" + +[projects.platforms.options] +token = "your-discord-bot-token" +``` + +**Detailed guide:** [docs/discord.md](docs/discord.md) + +--- + +### LINE — Requires public URL + +Connection: HTTP Webhook (you need ngrok, cloudflared, or a server with public IP) + +**Setup steps:** +1. Go to https://developers.line.biz/console/ → Create Messaging API channel +2. Copy Channel Secret and Channel Access Token (long-lived) +3. Set webhook URL in LINE console: `https://:/callback` +4. Expose local port using ngrok/cloudflared: `ngrok http 8080` or `cloudflared tunnel --url http://localhost:8080` + +**Config:** + +```toml +[[projects.platforms]] +type = "line" + +[projects.platforms.options] +channel_secret = "your-channel-secret" +channel_token = "your-channel-access-token" +port = "8080" +callback_path = "/callback" +``` + +--- + +### WeChat Work (企业微信) — Requires public URL + +Connection: HTTP Webhook (you need ngrok, cloudflared, or a server with public IP) + +**Setup steps:** +1. Log in to https://work.weixin.qq.com/wework_admin/frame +2. **App Management** → Create custom app → note AgentId and Secret +3. **My Enterprise** → note Corp ID +4. In the app → **Receive Messages** → Set API Receive: + - URL: `https://:/wecom/callback` + - Token: any random string + - EncodingAESKey: click "Random Generate" (43 chars) + - **Start cc-connect FIRST, then save** (to pass URL verification) +5. **Trusted IP** → add your server's outbound public IP +6. (Optional) **WeChat Plugin** → scan QR to link personal WeChat + +**Config:** + +```toml +[[projects.platforms]] +type = "wecom" + +[projects.platforms.options] +corp_id = "wwxxxxxxxxxxxxxxxxx" +corp_secret = "your-app-secret" +agent_id = "1000002" +callback_token = "your-callback-token" +callback_aes_key = "your-43-char-encoding-aes-key" +port = "8081" +callback_path = "/wecom/callback" +api_base_url = "https://qyapi.weixin.qq.com" # optional: override WeChat Work API base URL (for private deployments) +enable_markdown = false # true = Markdown messages (WeChat Work app only; personal WeChat shows "unsupported") +# proxy = "http://your-vps-ip:8888" # optional: forward proxy if your IP is dynamic +``` + +**Detailed guide:** [docs/wecom.md](docs/wecom.md) + +### Weixin (personal, ilink) — No public IP needed + +Personal WeChat uses Tencent’s **ilink bot HTTP API** (same family as OpenClaw `openclaw-weixin`). The recommended flow is CLI QR login, which writes `token` (and related fields) into `config.toml`. + +1. Run: + + ```bash + cc-connect weixin setup --project my-project + ``` + +2. Scan the QR code (or open the printed URL) in WeChat and confirm. + +3. Restart cc-connect, then send a message from WeChat once so `context_token` is cached. + +If you already have a Bearer token, use `cc-connect weixin bind --project my-project --token ''`. + +**Detailed guide (Chinese):** [docs/weixin.md](docs/weixin.md) + +### QQ (via NapCat / OneBot v11) — No public IP needed + +QQ integration requires a third-party OneBot v11 implementation (e.g., NapCat) as a bridge. + +1. Deploy NapCat (recommended via Docker): + ```bash + docker run -d --name napcat -e ACCOUNT= -p 3001:3001 -p 6099:6099 --restart unless-stopped mlikiowa/napcat-docker:latest + ``` +2. First launch: check `docker logs -f napcat` for a QR code, scan with QQ mobile app to log in +3. Open NapCat WebUI at `http://localhost:6099`, enable **Forward WebSocket** on port 3001 +4. Add to `config.toml`: + +```toml +[[projects.platforms]] +type = "qq" + +[projects.platforms.options] +ws_url = "ws://127.0.0.1:3001" # NapCat Forward WebSocket URL +token = "" # optional: access_token (must match NapCat config) +allow_from = "*" # allowed QQ user IDs: "12345,67890" or "*" for all +``` + +**Detailed guide:** [docs/qq.md](docs/qq.md) + +--- + +## Step 5: Run cc-connect + +**Open the Web UI (recommended):** + +```bash +cc-connect web # configure web admin & open browser (does NOT start cc-connect) +cc-connect # start the service +``` + +> **Note:** `cc-connect web` only configures the web admin and opens the dashboard in your browser — it does **not** start the cc-connect service itself. You still need to run `cc-connect` (or `cc-connect --config `) separately to actually start the bridge. Think of it as two steps: configure first, then run. + +**Important: If you are running inside a Claude Code session** (e.g., Claude Code helped you install and configure cc-connect), you must unset the `CLAUDECODE` environment variable before starting, otherwise Claude Code will refuse to launch as a subprocess: + +```bash +unset CLAUDECODE && cc-connect +``` + +Alternatively, open a **separate terminal** and run cc-connect there — this avoids the issue entirely. + +**Normal startup:** + +```bash +# Run with config.toml in current directory +cc-connect + +# Or specify config path +cc-connect -config /path/to/config.toml + +# Check version +cc-connect --version +``` + +You should see logs like: + +``` +level=INFO msg="platform started" project=my-project platform=feishu +level=INFO msg="engine started" project=my-project agent=claudecode platforms=1 +level=INFO msg="cc-connect is running" projects=1 +``` + +## Step 6: Chat Commands + +Once running, send messages to your bot on the configured platform. Available slash commands: + +``` +/new [name] — Start a new session +/list — List agent sessions +/switch — Resume an existing session +/current — Show current active session +/history [n] — Show last n messages (default 10) +/reasoning [level] — View/switch reasoning effort (Codex) +/mode [name] — View/switch permission mode (default/edit/plan/yolo) +/quiet — Toggle thinking/tool progress messages +/allow — Pre-allow a tool (next session) +/provider [...] — Manage API providers (list/add/remove/switch) +/stop — Stop current execution +/help — Show available commands +``` + +During a session, Claude may ask for tool permissions. Reply: +- `allow` or `允许` — approve this request +- `deny` or `拒绝` — reject this request +- `allow all` or `允许所有` — auto-approve all remaining requests this session + +## Step 7: Enable Natural Language Scheduling (Non-Claude-Code Agents) + +cc-connect supports scheduled tasks (cron jobs). You can always create them via slash commands (`/cron add ...`) or CLI (`cc-connect cron add ...`), but to let the agent **understand natural language** like "every day at 6am, summarize trending repos", the agent needs to know about cc-connect's cron CLI. + +**Claude Code** handles this automatically via `--append-system-prompt` — no extra setup needed. + +**For Codex, Cursor Agent, Qoder CLI, Gemini CLI, OpenCode, or iFlow CLI**, add the following instructions to the agent's project-level instruction file in your project's `work_dir`: + +| Agent | File to create/edit | +|-------|-------------------| +| Codex | `AGENTS.md` | +| Cursor Agent | `.cursorrules` | +| Qoder CLI | `AGENTS.md` | +| Gemini CLI | `GEMINI.md` | +| OpenCode | `OPENCODE.md` | +| iFlow CLI | `IFLOW.md` | + +**Content to add** (copy-paste into the file): + +```markdown +# cc-connect Integration + +This project is managed via cc-connect, a bridge to messaging platforms. + +## Scheduled tasks (cron) +When the user asks you to do something on a schedule (e.g. "every day at 6am", +"every Monday morning"), use the Bash/shell tool to run: + + cc-connect cron add --cron " " --prompt "" --desc "" + +Environment variables CC_PROJECT and CC_SESSION_KEY are already set — do NOT +specify --project or --session-key. + +Examples: + cc-connect cron add --cron "0 6 * * *" --prompt "Collect GitHub trending repos and send a summary" --desc "Daily GitHub Trending" + cc-connect cron add --cron "0 9 * * 1" --prompt "Generate a weekly project status report" --desc "Weekly Report" + +To list, edit, or delete cron jobs: + cc-connect cron list + cc-connect cron edit + cc-connect cron del + +Use `cron edit` to modify a single field instead of delete-and-recreate. +Common editable fields: cron_expr, prompt, exec, description, enabled (true/false), mute (true/false), timeout_mins (int). +Run `cc-connect cron edit --help` for the full field list. + +Examples: + cc-connect cron edit abc123 cron_expr "0 9 * * *" + cc-connect cron edit abc123 enabled false + cc-connect cron edit abc123 prompt "Updated daily summary task" + +## Send message to current chat +To proactively send a message back to the user's chat session (use --stdin heredoc for long/multi-line messages): + + cc-connect send --stdin <<'CCEOF' + your message here (any special characters are safe) + CCEOF + +For short single-line messages: + + cc-connect send -m "short message" +``` + +After adding this file, the agent will be able to translate natural language scheduling requests into `cc-connect cron add` commands automatically. + +> **Tip:** You may want to add `AGENTS.md` / `.cursorrules` / `GEMINI.md` to your `.gitignore` if you don't want cc-connect instructions committed to version control. + +## Multi-Project Setup + +A single cc-connect process can manage multiple projects. Each project has its own agent, work directory, and platforms: + +```toml +[[projects]] +name = "backend" + +[projects.agent] +type = "claudecode" + +[projects.agent.options] +work_dir = "/path/to/backend" +mode = "default" + +[[projects.platforms]] +type = "feishu" + +[projects.platforms.options] +app_id = "cli_xxx" +app_secret = "xxx" + +# Second project — using Codex +[[projects]] +name = "frontend" + +[projects.agent] +type = "codex" + +[projects.agent.options] +work_dir = "/path/to/frontend" +mode = "full-auto" + +[[projects.platforms]] +type = "telegram" + +[projects.platforms.options] +token = "xxx" + +# Third project — using Cursor Agent +[[projects]] +name = "design-system" + +[projects.agent] +type = "cursor" + +[projects.agent.options] +work_dir = "/path/to/design-system" +mode = "force" + +[[projects.platforms]] +type = "discord" + +[projects.platforms.options] +token = "xxx" + +# Fourth project — using Gemini CLI +[[projects]] +name = "my-gemini-project" + +[projects.agent] +type = "gemini" + +[projects.agent.options] +work_dir = "/path/to/gemini-project" +mode = "yolo" # "default" | "auto_edit" | "yolo" | "plan" + +[[projects.platforms]] +type = "slack" + +[projects.platforms.options] +bot_token = "xoxb-xxx" +app_token = "xapp-xxx" + +# Fifth project — using Qoder CLI +[[projects]] +name = "my-qoder-project" + +[projects.agent] +type = "qoder" + +[projects.agent.options] +work_dir = "/path/to/qoder-project" +mode = "default" # "default" | "yolo" +# model = "auto" # "auto" | "ultimate" | "performance" | "efficient" | "lite" + +[[projects.platforms]] +type = "telegram" + +[projects.platforms.options] +token = "xxx" + +# Sixth project — using iFlow CLI +[[projects]] +name = "my-iflow-project" + +[projects.agent] +type = "iflow" + +[projects.agent.options] +work_dir = "/path/to/iflow-project" +mode = "default" # "default" | "auto-edit" | "plan" | "yolo" +# model = "Qwen3-Coder" + +[[projects.platforms]] +type = "slack" + +[projects.platforms.options] +bot_token = "xoxb-xxx" +app_token = "xapp-xxx" +``` + +## Upgrade + +### Check current version + +```bash +cc-connect --version +``` + +### npm users + +```bash +npm update -g cc-connect +``` + +### Binary users + +Check the latest release at https://github.com/chenhg5/cc-connect/releases and compare with your local version. To upgrade: + +```bash +# Linux/macOS — replace with your platform suffix +curl -L -o /usr/local/bin/cc-connect https://github.com/chenhg5/cc-connect/releases/latest/download/cc-connect-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/') +chmod +x /usr/local/bin/cc-connect +``` + +### Source users + +```bash +cd cc-connect +git pull +make build +``` + +After upgrading, restart the running cc-connect process. + +## Step 8: Run as Background Service (Optional) + +You can run cc-connect as a daemon managed by the OS init system (Linux systemd user service, macOS launchd LaunchAgent, Windows Task Scheduler task). + +### Install the daemon + +```bash +cc-connect daemon install --config ~/.cc-connect/config.toml +``` + +You can also point the daemon at the directory that contains `config.toml`: + +```bash +cc-connect daemon install --work-dir ~/.cc-connect +``` + +Optional flags: `--config PATH`, `--log-file PATH`, `--log-max-size N` (MB), `--work-dir DIR`, `--force` (overwrite existing unit). `--config` points to a config file, while `--work-dir` points to the directory containing `config.toml`. + +### Linux systemd: Keep service running after SSH disconnect + +When installed as a user-level systemd service (non-root), cc-connect runs under `user@UID.service`. By default, systemd stops this service when your last login session ends (e.g., SSH disconnect). This is controlled by the "linger" setting. + +To keep cc-connect running persistently, enable linger for your user: + +```bash +sudo loginctl enable-linger $USER +``` + +After enabling linger, `user@UID.service` remains active even when you log out. The daemon install command will warn you if linger is not enabled. + +Alternatively, you can install as a system-level service (requires root): + +```bash +sudo cc-connect daemon install --config ~/.cc-connect/config.toml +``` + +System-level services are independent of login sessions. + +### Control the service + +```bash +cc-connect daemon start +cc-connect daemon stop +cc-connect daemon restart +cc-connect daemon status +``` + +### View logs + +```bash +cc-connect daemon logs # tail current log +cc-connect daemon logs -f # follow (like tail -f) +cc-connect daemon logs -n 100 # last 100 lines +cc-connect daemon logs --log-file /path/to/log # custom log file +``` + +Logs auto-rotate at the configured max size and keep one backup. + +On Windows, `daemon install` creates a native Task Scheduler task named `cc-connect`. +The task runs at user logon and is also started immediately after installation. The +installer writes a small PowerShell launcher under `~/.cc-connect` so the scheduled +task uses the selected config directory, log file, PATH, and proxy environment. + +### Uninstall + +```bash +cc-connect daemon uninstall +``` + +## Additional Features + +The following additional features are available: + +- **Codex Agent**: OpenAI Codex CLI integration (`codex exec --json`) +- **Cursor Agent**: Cursor Agent CLI integration (`agent --print --output-format stream-json`) +- **Gemini CLI**: Google Gemini CLI integration (`gemini -p --output-format stream-json`) +- **Qoder CLI**: Qoder CLI integration (`qodercli -p -f stream-json`) +- **OpenCode**: OpenCode CLI integration (`opencode run --format json`) +- **iFlow CLI**: iFlow CLI integration (`iflow -i -r -o`) +- **Voice Messages (STT)**: Speech-to-text via Whisper API (OpenAI / Groq / SiliconFlow). Requires `ffmpeg` and `[speech]` config. +- **Voice Reply (TTS)**: Text-to-speech via Qwen TTS / OpenAI TTS. Requires `ffmpeg` and `[tts]` config. +- **Image Messages**: Send images to Claude Code for multimodal analysis +- **API Provider Management**: Runtime switching between API providers via `/provider` command or CLI +- **CLI Send**: `cc-connect send` to inject messages into active sessions from external processes + +## Troubleshooting + +- **"session already in use"** — A previous Claude Code process may still be running. Use `/new` to start a fresh session. +- **No response from bot** — Check `cc-connect` logs. Set `level = "debug"` in `[log]` for verbose output. +- **WeChat Work can't send messages** — Ensure your outbound IP is in the Trusted IP whitelist. If using a proxy, check the proxy is reachable. +- **LINE/WeChat Work can't receive messages** — Ensure your webhook URL is publicly accessible (ngrok/cloudflared running). +- **macOS binary won't open** — Run `xattr -d com.apple.quarantine cc-connect` to remove quarantine flag. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a7074b2 --- /dev/null +++ b/Makefile @@ -0,0 +1,182 @@ +APP := cc-connect +MODULE := github.com/chenhg5/cc-connect +CMD := ./cmd/cc-connect +DIST := dist + +VERSION := v1.3.3-beta.4 +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "none") +BUILD_TIME := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ') + +LDFLAGS := -s -w \ + -X main.version=$(VERSION) \ + -X main.commit=$(COMMIT) \ + -X main.buildTime=$(BUILD_TIME) + +PLATFORMS := \ + linux/amd64 \ + linux/arm64 \ + darwin/amd64 \ + darwin/arm64 \ + windows/amd64 \ + windows/arm64 + +# --------------------------------------------------------------------------- +# Selective compilation via build tags. +# +# By default all agents and platforms are included. To build with only +# specific ones, set AGENTS and/or PLATFORMS_INCLUDE: +# +# make build AGENTS=claudecode PLATFORMS_INCLUDE=feishu,telegram +# +# You can also exclude specific ones: +# +# make build EXCLUDE=discord,dingtalk,qq,qqbot,line +# --------------------------------------------------------------------------- + +ALL_AGENTS := acp antigravity claudecode codex copilot cursor devin gemini iflow kimi opencode pi qoder tmux +ALL_PLATFORMS := feishu telegram discord slack dingtalk wecom weixin qq qqbot line weibo max +ALL_EXTRAS := web + +COMMA := , + +# Compute exclusion tags from AGENTS / PLATFORMS_INCLUDE / EXCLUDE variables +_EXCLUDE_TAGS := + +ifdef AGENTS + _WANTED_AGENTS := $(subst $(COMMA), ,$(AGENTS)) + _EXCLUDE_AGENTS := $(filter-out $(_WANTED_AGENTS),$(ALL_AGENTS)) + _EXCLUDE_TAGS += $(addprefix no_,$(_EXCLUDE_AGENTS)) +endif + +ifdef PLATFORMS_INCLUDE + _WANTED_PLATFORMS := $(subst $(COMMA), ,$(PLATFORMS_INCLUDE)) + _EXCLUDE_PLATFORMS := $(filter-out $(_WANTED_PLATFORMS),$(ALL_PLATFORMS)) + _EXCLUDE_TAGS += $(addprefix no_,$(_EXCLUDE_PLATFORMS)) +endif + +ifdef EXCLUDE + _EXCLUDE_TAGS += $(addprefix no_,$(subst $(COMMA), ,$(EXCLUDE))) +endif + +ifdef NO_WEB + _EXCLUDE_TAGS += no_web +endif + +_BUILD_TAGS := $(strip $(_EXCLUDE_TAGS)) +_TAGS_FLAG := $(if $(_BUILD_TAGS),-tags '$(_BUILD_TAGS)',) + +.PHONY: build run clean test test-fast test-full test-smoke test-e2e test-release test-release-local test-performance pre-test lint release release-all web + +web: + @if [ ! -d web/node_modules ]; then cd web && npm install; fi + cd web && npm run build + +build: web + go build $(_TAGS_FLAG) -ldflags "$(LDFLAGS)" -o $(APP) $(CMD) + +build-noweb: + go build $(_TAGS_FLAG) -tags 'no_web' -ldflags "$(LDFLAGS)" -o $(APP) $(CMD) + +run: build + ./$(APP) + +clean: + rm -f $(APP) + rm -rf $(DIST) + +# --------------------------------------------------------------------------- +# Testing targets. +# +# test-fast: Unit tests + smoke tests (< 2 min). Runs on every push. +# test-full: Full test suite including regression (< 10 min). PR requirement. +# test-smoke: Smoke tests only (< 1 min). Quick sanity check. +# test-e2e: E2E and regression tests only. +# test-release: Full + performance benchmarks. Before release. +# pre-test: Prerequisites (build + vet) before running tests. +# --------------------------------------------------------------------------- + +pre-test: + go build ./... + go vet ./... + +# Fast test: unit tests + smoke tests +test-fast: pre-test + go test -parallel=4 -race ./... + go test -parallel=4 -tags=smoke ./tests/e2e/... + +# Full test: unit + smoke + regression (PR requirement) +test-full: pre-test + go test -parallel=4 -race ./... + go test -parallel=4 -tags=smoke ./tests/e2e/... + go test -parallel=2 -tags=regression ./tests/e2e/... + +# Smoke tests only +test-smoke: pre-test + go test -v -tags=smoke ./tests/e2e/... + +# E2E/regression tests only +test-e2e: pre-test + go test -v -tags=regression ./tests/e2e/... + +# Performance benchmarks only +test-performance: pre-test + go test -bench=. -benchmem -tags=performance ./tests/performance/... + +# Release test: full + performance benchmarks +test-release: pre-test + go test -parallel=4 -race ./... + go test -parallel=4 -tags=smoke ./tests/e2e/... + go test -parallel=2 -tags=regression ./tests/e2e/... + go test -bench=. -benchmem -tags=performance ./tests/performance/... + +# Release-local gate: deterministic release checks that do not require real IM +# credentials, real provider accounts, or supervisor-managed services. +test-release-local: + go test ./tests/release_local/... + go test ./config + go test ./core -run 'TestEngineSendToSessionWithAttachments|TestProcessInteractiveEvents_SuppressesDuplicateSideChannelText|TestCmdList_AllSessionsVisibleAfterRepeatedNew|TestCmdList_SessionVisibleDuringAgentProcessing|TestEngine_Alias|TestEngine_BannedWords|TestEngine_DisabledCommands' + go test ./platform/feishu -run 'TestUserIDFromEventFallsBackToUserID|TestResolveUserNameSkipsInvalidLookupID|TestNew_CanDisableInteractiveCards' + +# Legacy: runs unit tests only +test: + go test -v ./... + +lint: + golangci-lint run ./... + +release-all: web clean + @mkdir -p $(DIST) + @$(foreach platform,$(PLATFORMS), \ + $(eval GOOS := $(word 1,$(subst /, ,$(platform)))) \ + $(eval GOARCH := $(word 2,$(subst /, ,$(platform)))) \ + $(eval EXT := $(if $(filter windows,$(GOOS)),.exe,)) \ + $(eval OUT := $(DIST)/$(APP)-$(VERSION)-$(GOOS)-$(GOARCH)$(EXT)) \ + echo "Building $(OUT)" && \ + GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 \ + go build $(_TAGS_FLAG) -ldflags "$(LDFLAGS)" -o $(OUT) $(CMD) && \ + ) true + @echo "Packaging archives..." + @cd $(DIST) && for f in $(APP)-*; do \ + case "$$f" in \ + *.tar.gz|*.zip) continue ;; \ + *.exe) zip "$${f%.exe}.zip" "$$f" ;; \ + *) tar czf "$$f.tar.gz" "$$f" ;; \ + esac; \ + done + @cd $(DIST) && sha256sum * > checksums.txt + @echo "Done. Binaries and archives in $(DIST)/" + +release: + @if [ -z "$(TARGET)" ]; then \ + echo "Usage: make release TARGET=linux/amd64"; \ + echo "Available: $(PLATFORMS)"; \ + exit 1; \ + fi + @mkdir -p $(DIST) + $(eval GOOS := $(word 1,$(subst /, ,$(TARGET)))) + $(eval GOARCH := $(word 2,$(subst /, ,$(TARGET)))) + $(eval EXT := $(if $(filter windows,$(GOOS)),.exe,)) + $(eval OUT := $(DIST)/$(APP)-$(VERSION)-$(GOOS)-$(GOARCH)$(EXT)) + GOOS=$(GOOS) GOARCH=$(GOARCH) CGO_ENABLED=0 \ + go build $(_TAGS_FLAG) -ldflags "$(LDFLAGS)" -o $(OUT) $(CMD) + @echo "Built: $(OUT)" diff --git a/README.md b/README.md new file mode 100644 index 0000000..96d1d7c --- /dev/null +++ b/README.md @@ -0,0 +1,615 @@ +

+ CC-Connect Banner +

+ +

+ + CI Status + + + Release + + + npm downloads + + + License + + + Go Report Card + +

+ +

+ + Discord + + + Telegram + +

+ +

+ English | 中文 +

+ +

+ + chenhg5/cc-connect | Trendshift + +

+ + +## ❤️ Sponsor + +> Want to appear here? Contact: chg80333@gmail.com | WeChat: mongorz + +
+Sponsors + +[![MiniMax](assets/banners/minimax-en.jpeg)](https://platform.minimax.io/subscribe/token-plan?code=lqYrKBvjke&source=link) + +MiniMax-M2.7 is a next-generation large language model designed for autonomous evolution and real-world productivity. Unlike traditional models, M2.7 actively participates in its own improvement through agent teams, dynamic tool use, and reinforcement learning loops. It delivers strong performance in software engineering (56.22% on SWE-Pro, 55.6% on VIBE-Pro, 57.0% on Terminal Bench 2) and excels in complex office workflows, achieving a leading 1495 ELO on GDPval-AA. With high-fidelity editing across Word, Excel, and PowerPoint, and a 97% adherence rate across 40+ complex skills, M2.7 sets a new standard for building AI-native workflows and organizations. + +[Click here](https://platform.minimax.io/subscribe/token-plan?code=lqYrKBvjke&source=link) to get an exclusive 12% off the MiniMax Token Plan + voucher for cc-connect users! + +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AIGoCodeThanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for cc-connect users: if you register via this link, you'll receive an extra 10% bonus credit on your first top-up!
AIHubMix AIHubMixThanks to AIHubMix for sponsoring this project! AIHubMix offers deep integration with 500+ global models including OpenAI, Claude, Gemini, Qwen, DeepSeek, Kimi. Unlimited concurrency, production-grade stability on Google Cloud. One API Key drives all your Agents with native OpenAI/Anthropic/Gemini format support — zero code changes. Pay-as-you-go pricing aligned with official providers, plus free models like coding-glm-5.1-free. Click here to sign up!
DMXAPIThanks to DMXAPI for sponsoring this project! DMXAPI provides global large model API services to 200+ enterprise users. One API key for all global models. Features include: instant invoicing, unlimited concurrency, starting from $0.15, 24/7 technical support. GPT/Claude/Gemini all at 32% off, domestic models 20-50% off, Claude Code exclusive models at 66% off! Register via this link.
APIKEY.FUNThanks to APIKEY.FUN for sponsoring this project! APIKEY.FUN is a professional enterprise-grade AI relay service, dedicated to providing stable, efficient, and cost-effective AI model API access for enterprises and individual developers. The platform supports Claude, OpenAI, Gemini and other mainstream models, with prices as low as 7% of official rates. Register via this link to enjoy an exclusive permanent 5% discount on all top-ups!
NekoCodeThanks to NekoCode for sponsoring this project! NekoCode provides reliable, stable, and efficient API relay services for Claude and CodeX with transparent pricing. Exclusive 10% discount for cc-connect users with promo code: CC-CONNECT. High-value, stable AI model access for developers. Register via this link.
ShengsuanyunThanks to Shengsuanyun for sponsoring this project! Shengsuanyun is a super factory dedicated to serving AI Native Teams, an industrial-grade AI task parallel execution platform, and a model marketplace that aggregates and supplies computing power from domestic and international LLM and image/video multimedia models such as Claude, Chatgpt, and Gemini. It guarantees no reverse engineering or data manipulation, boasts a 99.7% SLA availability across the entire site, and its monitoring interface is consistently green. Furthermore, it offers an enterprise-grade customized gateway for refined cost and access control, featuring intelligent routing, security protection, and BYOK enterprise-provided key hosting. The platform is billed on a pay-as-you-go basis and with a tokens plan (coming soon), and invoices are available. New users who register using this link will receive 10 yuan in model power and a 10% bonus on their first deposit.
AICodeMirrorThanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for CC users: register via this link to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!
CaMeLThanks to CaMeL for sponsoring this project! In-depth cooperation with major research institutes and supercomputing centers, self-developed high-stability high-efficiency cache scheduling solution. Exclusive for cc-connect users: New registration authentication immediately receive $10 credit upon registration. Register via this link.
AnyRoute.ioThanks to AnyRoute.io for sponsoring this project! AnyRoute.io is a reliable, stable, and efficient API relay platform integrating the latest Claude Code and Codex models. Transparent pricing with rates as low as 93% off official prices (just 0.7x), supports invoicing and enterprise-grade high-concurrency usage. Register via this link to get started.
aicanapi.comThanks to aicanapi.com for sponsoring this project! Aican API provides high-performance, low-latency, high-concurrency API services for enterprises and developers. Claude Code models at up to 84% off, other models at 80% off official price. Doubao Seedance 2 real-person generation service with queue-free access for faster responses. Choose Aican API for simpler, more efficient, and more cost-effective enterprise-grade AI services. Register via this link to get started.
PatewayThanks to Pateway for sponsoring this project! PatewayAI is a premium API relay service for serious AI developers, offering 100% official direct access to Claude and Codex models — no reverse engineering, no quality degradation. Transparent billing with token-level verification. Enterprise-grade concurrency, formal contracts and invoicing available. Register via this link to get $3 free trial credit, up to 40% off on top-ups, and referral rewards up to $150!
10点AIThanks to 10点AI for sponsoring this project! 10dian-AI Enterprise Platform is an AI API gateway for developers and enterprises, aggregating GPT, Claude, Gemini, DeepSeek and more. Optimized for production environments with stable high-concurrency operation, avoiding interface jitter and timeout issues. Affordable pricing, stable uptime, official guarantee. Register via this link to get ¥5 free credit!
SiliconFlowThanks to SiliconFlow for supporting this project! SiliconFlow is a high-performance AI infrastructure and model API platform, providing fast and reliable access to language, speech, image, and video models in one place. With pay-as-you-go billing, broad multimodal model support, high-speed inference, and enterprise-grade stability, SiliconFlow helps developers and teams build and scale AI applications more efficiently. Register via this link and complete real-name verification to receive ¥20 in bonus credit!
优云智算Thanks to 优云智算 for sponsoring this project! 优云智算 (UCloud AI Cloud Platform) provides stable and comprehensive domestic and international model APIs with just one key. Featuring high-value Coding Plan packages (monthly or per-use), plus stable official relay for overseas models. Supports Claude Code, Codex, and API calls. Enterprise features include high concurrency, 7x24 technical support, and self-service invoicing. Register via this link to receive ¥5 free platform credit!
DragonCodeThanks to DragonCode for supporting this project. DragonCode has prepared a special benefit for cc-connect users: register via this link to get started.
VisionCoderThanks to VisionCoder for supporting this project. VisionCoder Developer Platform is a reliable and efficient API relay service provider, offering access to mainstream AI models such as Claude Code, Codex, and Gemini. It helps developers and teams integrate AI capabilities more easily and improve productivity. VisionCoder is also offering our users a limited-time Token Plan promotion: buy 1 month and get 1 month free.
DDS HubThanks to DDS for sponsoring this project! DDS Hub is a reliable and high-performance Claude and CodeX API proxy service. We provides cost-effective domestic Claude direct acceleration services for both individual and enterprise users. We offer stable and low-latency Claude Max number pools, with full support for Claude Haiku, Opus, Sonnet, GPT 5.4 and other flagship models. Invoices are available for recharges of 1000 RMB or more. Enterprise customers can also enjoy customized grouping and dedicated technical support services. Exclusive benefit for CC connect users: Register via this link and enjoy an extra 10% credit on your first recharge (please contact the group admin to claim after recharging)!
Code0Thanks to Code0 for sponsoring this project! Code0 is an AI model aggregation API relay service for Chinese developers, compatible with OpenAI / Anthropic / Gemini protocols. One key for all mainstream models, stable support for Claude Code, Codex, Gemini CLI, cc-connect and more. Fixed exchange rate: ¥1.5 CNY = $1 USD API credit, transparent pricing, domestic direct connection, ready to use. Register via this link.
claudeapi.comThanks to claudeapi.com for sponsoring this project! claudeapi is a high-quality direct Claude connection service for mid-to-high-end users. It is fully integrated with Anthropic's official first-party Keys and AWS Bedrock official channels — no reverse engineering, no intelligence degradation, no stitching. It fully preserves the official capabilities, long context, and tool-calling performance of Opus / Sonnet / Haiku. Designed specifically for Claude Code power users, Agent developers, and enterprise teams, it focuses on out-of-the-box usability and enterprise-grade stability. Invoicing and team onboarding are supported. Register via this link.
+ +
+ +--- + +
+ +

+ Control your local AI agents from any chat app. Anywhere, anytime. +

+ +

+ cc-connect bridges AI agents running on your machine to the messaging platforms you already use.
+ Code review, research, automation, data analysis — anything an AI agent can do,
+ now accessible from your phone, tablet, or any device with a chat app. +

+ +

+ CC-Connect Architecture +

+ + +## 🆕 What’s New in v1.3.0 + +- **🌐 Web Admin UI (Recommended)** — Full management dashboard embedded in the binary — **no extra dependencies**. Create and edit projects, manage providers, monitor sessions, edit cron jobs, and **chat with your agent directly from the browser**. Supports 5 languages (en/zh/zh-TW/ja/es). We recommend managing cc-connect through the web UI instead of editing `config.toml` by hand. Run `cc-connect web` to configure and open the dashboard, then run `cc-connect` to start the service. +- **Lifecycle Event Hooks** — New `[[hooks]]` config triggers shell commands or HTTP webhooks on message, session, cron, permission, and error events. Async by default, fail-open. +- **Skill Management** — New `/skills` page with local skill browser and recommended presets. +- **Global Provider Management** — Add/edit/delete providers in the web UI; import from cc-switch config. +- **Personal WeChat** — Chat with your local agent from **Weixin (personal)** via ilink long-polling; QR `weixin setup`, CDN media, no public IP. *[Setup → `docs/weixin.md`](docs/weixin.md)* +- **Weibo DM** — Chat with your agent via **Weibo private messages** over WebSocket; no public IP needed, text streaming supported. +- **Feishu Enhancements** — Auto-resolve `@name` mentions, multi-level reply chain recognition, done-emoji reactions. +- **New Agents** — Kimi CLI and Pi agent support added. + + +## 🧩 Platform feature snapshot + +High-level view of what each **built-in platform** can do in cc-connect. + +**Legend** + +| Symbol | Meaning | +|--------|---------| +| ✅ | Works in **stable** cc-connect with typical configuration | +| ⚠️ | Partial, needs extra config (e.g. speech / ASR), or limited by the vendor app or API | +| ❌ | Not supported or not applicable in practice | + +† **QQ (NapCat / OneBot)** — unofficial self-hosted bridge; behaviour depends on your NapCat / network setup. + +| Capability | Feishu | WPS Xiezuo | DingTalk | Telegram | Slack | Discord | LINE | WeCom | Weibo | **Weixin**
*(personal)* | QQ† | QQ Bot | +|------------|:------:|:----------:|:--------:|:--------:|:-----:|:-------:|:----:|:-----:|:-----:|:-------------------------:|:---:|:------:| +| Text & slash commands | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Markdown / cards | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ✅ | ✅ | ✅ | +| Streaming / chunked replies | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Images & files | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅ | ✅ | ✅ | +| Voice / STT / TTS | ⚠️ | ❌ | ⚠️ | ✅ | ⚠️ | ⚠️ | ❌ | ⚠️ | ❌ | ✅ | ⚠️ | ⚠️ | +| Private (DM) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Group / channel | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅ | ✅ | ✅ | + +> **WeCom:** Webhook mode needs a **public URL**; long-connection / WS style setups often do not. +> **Voice row:** many platforms need `[speech]` / TTS providers enabled in `config.toml`; values are a best-effort summary. +> Per-platform setup: [Platform setup guides](#-platform-setup-guides) below. + + +## ✨ Why cc-connect? + +### 🤖 Universal Agent Support +**10+ AI Agents** — Claude Code, Codex, Cursor Agent, Kimi CLI, Qoder CLI, Gemini CLI, OpenCode, iFlow CLI, Pi, Devin, Copilot — plus any agent that supports the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/get-started/agents). Use whichever fits your workflow, or all of them at once. + +### 📱 Platform Flexibility +**12 Chat Platforms** — Feishu, WPS Xiezuo, DingTalk, Slack, Telegram, Discord, WeChat Work, Weibo, LINE, QQ, QQ Bot (Official), plus **Weixin (personal ilink)** for **personal WeChat**. Most platforms need **zero public IP**. + +### 🔄 Multi-Agent Orchestration +**Multi-Bot Relay** — Bind multiple bots in a group chat and let them communicate with each other. Ask Claude, get insights from Gemini — all in one conversation. + +### 🎮 Complete Chat Control +**Full Control from Chat** — Switch models (`/model`), tune reasoning (`/reasoning`), change permission modes (`/mode`), manage sessions, all via slash commands. + +**Directory Switching in Chat** — Change where the next session starts with `/dir ` (and `/cd ` as a compatibility alias), plus quick history jump via `/dir ` / `/dir -`. + +### 🧠 Persistent Memory +**Agent Memory** — Read and write agent instruction files (`/memory`) without touching the terminal. + +### ⏰ Intelligent Scheduling +**Scheduled Tasks** — Set up cron jobs in natural language. *"Every day at 6am, summarize GitHub trending"* just works. + +### 🎤 Multimodal Support +**Voice & Images** — Send voice messages or screenshots; cc-connect handles STT/TTS and multimodal forwarding. + +### 📦 Multi-Project Architecture +**Multi-Project** — One process, multiple projects, each with its own agent + platform combo. + +### 🌍 Multilingual Interface +**5 Languages** — Native support for English, Chinese (Simplified & Traditional), Japanese, and Spanish. Built-in i18n ensures everyone feels at home. + + +

+ 飞书 + Telegram + 微信 +

+

+ Left:Lark  |  Telegram  |  Right:Wechat +

+ + +## 🚀 Quick Start + +### 🤖 Install & Configure via AI Agent (Recommended) + +> **The easiest way** — Send this to Claude Code or any AI coding agent, and it will handle the entire installation and configuration for you: + +```bash +Follow https://raw.githubusercontent.com/chenhg5/cc-connect/refs/heads/main/INSTALL.md to install and configure cc-connect. +``` + + +### 📦 Manual Install + +**Via npm:** + +```bash +npm install -g cc-connect +``` + +**Via Homebrew (macOS / Linux):** + +```bash +brew install cc-connect +``` + +**Download binary from [GitHub Releases](https://github.com/chenhg5/cc-connect/releases):** + +```bash +# Linux amd64 - Stable +curl -L -o cc-connect https://github.com/chenhg5/cc-connect/releases/latest/download/cc-connect-linux-amd64 +chmod +x cc-connect +sudo mv cc-connect /usr/local/bin/ + +``` + +**Build from source (requires Go 1.22+):** + +```bash +git clone https://github.com/chenhg5/cc-connect.git +cd cc-connect +make build +``` + + +### ⚙️ Configure + +> **💡 Tip: Use the Web UI to configure** — After installing, run `cc-connect web` to configure the web admin and open the dashboard in your browser. You can visually create projects, add platforms, manage providers, and chat with your agent — no need to manually edit TOML files. **Note:** `cc-connect web` only configures and opens the browser — you still need to run `cc-connect` separately to start the service. + +If you prefer manual configuration: + +```bash +mkdir -p ~/.cc-connect +cp config.example.toml ~/.cc-connect/config.toml +vim ~/.cc-connect/config.toml +``` + +Set `admin_from = "alice,bob"` in a project to allow those user IDs to run privileged commands such as `/dir` and `/shell`. +When a user runs `/dir reset`, cc-connect restores the configured `work_dir` and clears the persisted override stored under `data_dir/projects/.state.json`. + + +### ▶️ Run + +```bash +./cc-connect +``` + + +### 🔄 Upgrade + +```bash +# npm +npm install -g cc-connect + +# Homebrew +brew upgrade cc-connect + +# Binary self-update +cc-connect update # Stable +cc-connect update --pre # Include pre-releases +``` + + +## 📊 Support Matrix + +| Component | Type | Status | +|-----------|------|--------| +| Agent | Claude Code | ✅ Supported | +| Agent | Codex (OpenAI) | ✅ Supported | +| Agent | Cursor Agent | ✅ Supported | +| Agent | Gemini CLI (Google) | ✅ Supported | +| Agent | Qoder CLI | ✅ Supported | +| Agent | OpenCode (Crush) | ✅ Supported | +| Agent | iFlow CLI | ✅ Supported | +| Agent | Kimi CLI (Moonshot) | ✅ Supported | +| Agent | Pi (Cursor Background Agent) | ✅ Supported | +| Agent | Copilot (GitHub) | ✅ Supported | +| Agent | ACP (Agent Client Protocol) | ✅ Any [ACP-compatible agent](https://agentclientprotocol.com/get-started/agents) | +| Agent | Devin (Cognition) | ✅ Supported (via ACP) | +| Agent | Goose (Block) | 🔜 Planned | +| Agent | Aider | 🔜 Planned | +| Platform | Feishu (Lark) | ✅ WebSocket — no public IP needed | +| Platform | DingTalk | ✅ Stream — no public IP needed | +| Platform | WPS Xiezuo | ✅ WebSocket — no public IP needed | +| Platform | Telegram | ✅ Long Polling — no public IP needed | +| Platform | Slack | ✅ Socket Mode — no public IP needed | +| Platform | Discord | ✅ Gateway — no public IP needed | +| Platform | Weibo | ✅ WebSocket — no public IP needed | +| Platform | LINE | ✅ Webhook — public URL required | +| Platform | WeChat Work | ✅ WebSocket / Webhook | +| Platform | Weixin (personal, ilink) | ✅— HTTP long polling — no public IP needed | +| Platform | QQ (NapCat/OneBot) | ✅ WebSocket | +| Platform | QQ Bot (Official) | ✅ WebSocket — no public IP needed | + + +## 📖 Platform Setup Guides + +| Platform | Guide | Connection | Public IP? | +|----------|-------|------------|------------| +| Feishu (Lark) | [docs/feishu.md](docs/feishu.md) | WebSocket | No | +| DingTalk | [docs/dingtalk.md](docs/dingtalk.md) | Stream | No | +| WPS Xiezuo | [docs/wps-xiezuo.md](docs/wps-xiezuo.md) | WebSocket | No | +| Telegram | [docs/telegram.md](docs/telegram.md) | Long Polling | No | +| Slack | [docs/slack.md](docs/slack.md) | Socket Mode | No | +| Discord | [docs/discord.md](docs/discord.md) | Gateway | No | +| Weibo | [docs/weibo.md](docs/weibo.md) | WebSocket | No | +| WeChat Work | [docs/wecom.md](docs/wecom.md) | WebSocket / Webhook | No (WS) / Yes (Webhook) | +| Weixin (personal) | [docs/weixin.md](docs/weixin.md) | HTTP long polling (ilink) | No | +| QQ / QQ Bot | [docs/qq.md](docs/qq.md) | WebSocket | No | + + +## 🎯 Key Features + +### 💬 Session Management + +``` +/new [name] Start a new session +/list List all sessions +/switch Switch session +/current Show current session +/dir [path|reset] Show, switch, or reset work directory +``` + +Project configs rotate to a fresh session automatically after long inactivity. This prevents "context drift" where stale chat history (failed commands, debugging noise) is repeatedly re-ingested via `--continue` and starts to dominate the model's attention. The previous session is preserved and remains accessible via `/list` and `/switch`. + +```toml +[[projects]] +reset_on_idle_mins = 30 # default when unset; set to 0 to disable +``` + +The default is **30 minutes** when unset. Set `reset_on_idle_mins = 0` to opt out and always continue the previous session. + +### 🛡️ OS-User Isolation (`run_as_user`) + +On Linux/macOS, a project can spawn its agent under a different Unix +user for OS-level file-system isolation from the supervisor user that +runs cc-connect. Currently supported by Claude Code. + +```toml +[[projects]] +name = "claude-sandboxed" +run_as_user = "partseeker-coder" +run_as_env = ["PGSSLROOTCERT"] +``` + +The target user needs passwordless sudo from the supervisor, no sudo +of its own, read+write on `work_dir`, and its own `~/.claude/settings.json` +with whatever credentials the agent uses. If you authenticate via +`claude.ai` OAuth, symlink the target user's `~/.claude/.credentials.json` +to the supervisor's copy so token refresh stays in sync — see the +[environment propagation checklist](./docs/usage.md#environment-propagation-what-moves-into-the-target-users-home) +for details. See +[`docs/usage.md`](./docs/usage.md#running-agents-as-a-different-unix-user-run_as_user) +for the full setup. + +Before starting cc-connect, audit the setup with: + +```bash +cc-connect doctor user-isolation +``` + +This runs three go/no-go preflight gates and an isolation probe that +reports what the target user can and cannot read. cc-connect refuses to +start if any gate fails or if the probe detects a cross-user leak. + +--- + +### 🔐 Permission Modes + +``` +/mode Show available modes +/mode yolo # Auto-approve all tools +/mode default # Ask for each tool +``` + + +### 🔄 Provider Management + +``` +/provider list List providers +/provider switch Switch API provider at runtime +``` + + +### 🤖 Model Selection + +``` +/model List available models (format: alias - model) +/model switch Switch to model by alias +``` + + +### 📂 Work Directory + +``` +/dir Show current work directory and history +/dir Switch to a path (relative or absolute) +/dir Switch from history +/dir - Switch to previous directory +/cd Compatibility alias for /dir +``` + + +### ⏰ Scheduled Tasks + +```bash +/cron add 0 6 * * * Summarize GitHub trending +``` + +### 📎 Agent Attachment Send-Back + +When an agent generates a local screenshot, chart, PDF, bundle, or other file, it can send that attachment back to the current chat. + +First release supports: +- Feishu +- Telegram + +If your agent does not natively inject the system prompt, run this once in chat after upgrading: + +```text +/bind setup +``` + +or: + +```text +/cron setup +``` + +This refreshes the cc-connect instructions in the project memory file so the agent knows how to send attachments back. + +You can control this feature globally in `config.toml`: + +```toml +attachment_send = "on" # default: "on"; set to "off" to block image/file send-back +``` + +This switch is independent from the agent's `/mode`. It only controls `cc-connect send --image/--file`. + +Examples: + +```bash +cc-connect send --image /absolute/path/to/chart.png +cc-connect send --file /absolute/path/to/report.pdf +cc-connect send --file /absolute/path/to/report.pdf --image /absolute/path/to/chart.png +``` + +Notes: +- Absolute paths are the safest option. +- `--image` and `--file` can both be repeated. +- `attachment_send = "off"` disables only attachment send-back; ordinary text replies still work. +- This command is for generated attachments, not ordinary text replies. + +📖 **Full documentation:** [docs/usage.md](docs/usage.md) + + +## 📚 Documentation + +- [Usage Guide](docs/usage.md) — Complete feature documentation +- [INSTALL.md](INSTALL.md) — AI-agent-friendly installation guide +- [config.example.toml](config.example.toml) — Configuration template +- [CONTRIBUTING.md](CONTRIBUTING.md) — How to report issues and contribute pull requests + + +## 👥 Community + +- [Discord](https://discord.gg/kHpwgaM4kq) +- [Telegram](https://t.me/+odGNDhCjbjdmMmZl) + + +## ☕ Support the Project + +If cc-connect has been helpful to you, consider buying us a coffee! Your support helps us: + +- 🛠️ Maintain and improve the project +- 📚 Write better documentation and tutorials +- 🐛 Fix bugs and add new features faster +- ☕ Keep the developers caffeinated + +### How to Donate + +**Buy Me a Coffee**: [https://buymeacoffee.com/cg33](https://buymeacoffee.com/cg33) + +**WeChat Pay / Alipay**: + +| WeChat Pay | Alipay | +|:----------:|:------:| +| WeChat Pay | Alipay | + +### Thank You, Donors! 🎉 + +We're grateful to everyone who has supported this project. Leave your GitHub username in the donation message if you'd like to be recognized here! + + +| Avatar | GitHub Username | Date | +|--------|-----------------|------| +| | [@thx0701](https://github.com/thx0701) | 2026-04-29 | + + +## 🤝 Commercial Cooperation + +We accept the following commercial collaborations: + +- **Enterprise Customization**: Custom deployment for internal AI tooling (Feishu, DingTalk, WeChat Work, Slack, etc.) +- **Technical Consulting**: AI agent integration and architecture design +- **Outsourcing Projects**: AI-related system development + +**Contact**: **Email**: chg80333@gmail.com | **WeChat**: mongorz | [Telegram](https://t.me/+odGNDhCjbjdmMmZl) | [Discord](https://discord.gg/kHpwgaM4kq) + + +## 🙏 Contributors + + + + + + +## ⭐ Star History + + + + + + Star History Chart + + + + +## 📄 License + +MIT License + + +

+ Built with ❤️ by the cc-connect community +

diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..40ed22c --- /dev/null +++ b/README.zh-CN.md @@ -0,0 +1,604 @@ +

+ CC-Connect Banner +

+ +

+ + CI Status + + + Release + + + npm downloads + + + License + + + Go Report Card + +

+ +

+ + Discord + + + Telegram + +

+ +

+ English | 中文 +

+ +

+ + chenhg5/cc-connect | Trendshift + +

+ + +## ❤️ 赞助 + +> 想在这里展示?联系:chg80333@gmail.com | 微信:mongorz + +
+赞助商 + +[![MiniMax](assets/banners/minimax-zh.jpeg)](https://platform.minimaxi.com/subscribe/token-plan?code=HAvthxk1tT&source=link) + +MiniMax M2.7 是 MiniMax 首个深度参与自我迭代的模型,可自主构建复杂 Agent Harness,并基于 Agent Teams、复杂 Skills、Tool Search Tool 等能力完成高复杂度生产力任务;其在软件工程、端到端项目交付及办公场景中表现优异,多项评测接近行业领先水平,同时具备稳定的复杂任务执行、环境交互能力以及良好的情商与身份保持能力。 + +[点击此处](https://platform.minimaxi.com/subscribe/token-plan?code=HAvthxk1tT&source=link)享 MiniMax Token Plan 专属 88 折优惠 + cc-connect 用户专属代金券! + +--- + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AIGoCode感谢 AIGoCode 对本项目的赞助!AIGoCode 是集 Claude Code、Codex、最新 Gemini 模型于一体的一站式平台,提供稳定高效、高性价比的 AI 编码服务。灵活订阅方案、零封号风险、无需 VPN 直连、响应速度极快。通过 此链接 注册,首充额外获得 10% 赠送额度!
AIHubMix AIHubMix感谢 AIHubMix 赞助本项目!500+ 模型一站式覆盖(Claude/GPT/Gemini/Qwen/DeepSeek/通义等),无限并发,谷歌云集群稳定运行。一个 API Key 驱动全部 Agent,支持 OpenAI/Anthropic/Gemini 三种原生格式,代码零改动迁移。按量计费对齐原厂,含免费模型。通过 此链接 注册。
DMXAPI感谢 DMXAPI(大模型API)赞助本项目!DMXAPI,一个 Key 用全球大模型。为 200+ 企业用户提供全球大模型 API 服务。充值即开票、当天开票、并发不限制、1元起充、7x24 在线技术辅导。GPT/Claude/Gemini 全部 6.8 折,国内模型 5~8 折,Claude Code 专属模型 3.4 折进行中!点击这里注册
APIKEY.FUN感谢 APIKEY.FUN 赞助本项目!APIKEY.FUN 是一家专业的企业级 AI 中转站,致力于为企业和个人开发者提供稳定、高效、低成本的 AI 模型 API 接入服务。平台支持 Claude、OpenAI、Gemini 等主流热门模型,价格低至官方原价的 7%。通过此链接注册,还可享受最高充值永久 95 折专属优惠!
NekoCode感谢 NekoCode 赞助本项目!NekoCode 提供 Claude 和 CodeX 的可靠稳定高效的 API 中转站,价格透明。NekoCode 为 CC-CONNECT 用户专属 9 折福利码:CC-CONNECT,为开发者提供高性价比稳定的 AI 模型接入服务。通过 此链接 注册。
胜算云感谢胜算云赞助了本项目!胜算云是专为 AI Native Teams 服务的超级工厂,工业级 AI 任务并行执行平台,模型商城集采直供聚合接入了 Claude、Chatgpt、Gemini 等海内外 LLM 及图片视频多媒体模型算力,绝无逆向掺水、全站模型 SLA 可用性高达 99.7%、监测接口日常全绿。更有企业级专属定制网关,实现团队精细化成本与权限管控,智能路由+安全防护+BYOK 企业自带密钥托管。平台按量及 tokens plan(即将上线)计费,可开票,使用此链接注册新用户可获 10 元模力及首充 10% 赠送。
AICodeMirror感谢 AICodeMirror 对本项目的赞助!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、24小时专属技术支持。Claude Code / Codex / Gemini 官方渠道价格仅为原价的 38% / 2% / 9%,充值还有额外折扣!AICodeMirror 为 CC 用户专属福利:通过 此链接 注册首充享受 20% 折扣,企业客户最高可享 25% 折扣!
CaMeL感谢 CaMeL 对本项目的赞助!携手各大科研院所、超算中心深度合作,自研高稳高效缓存调度方案。CC-Connect专享新人注册认证即送$10!通过 此链接 注册。
AnyRoute.io感谢 AnyRoute.io 对本项目的赞助!AnyRoute.io 是集 Claude Code、Codex 最新模型于一体、可靠稳定高效的 API 中转站,价格透明,最低低至官方 0.7 折,支持开票和企业高并发。通过 此链接 注册即可开始使用。
aicanapi.com感谢 aicanapi.com 对本项目的赞助!艾可API致力于为企业与开发者提供高性能、低延迟、可高并发承载的API接口服务。Claude Code 模型最低可达 1.6 折,其余模型普遍可享官方 2 折优惠,豆包 Seedance 2 真人生成服务支持免排队调用。选择艾可API,让企业级AI接口服务更简单、更高效、更具性价比。通过 此链接 注册即可开始使用。
Pateway感谢 Pateway 对本项目的赞助!PatewayAI 是面向重度 AI 开发者、专注官方直连的高品质模型 API 中转服务商。提供 Claude 全系列与 Codex 系列模型,100% 官方源直供,不掺假不注水。计费透明,Token 级账单可逐笔核验。支持企业级高并发,可签订正式合同并开具发票。通过 此链接 注册即送 $3 试用额度,充值低至 6 折,邀请好友双向赠送,邀请奖励可达 $150!
10点AI感谢 10点AI 对本项目的赞助!10dian-AI企业台是面向开发者与企业的 AI API 中转平台,聚合 GPT、Claude、Gemini、DeepSeek 等主流模型。针对生产环境专项优化,支持高并发稳定运行,有效规避接口抖动与超时问题。价格亲民性价比高、接口稳定不掉线、官方保真不参水。通过 此链接 注册即送 ¥5 余额!
SiliconFlow感谢 SiliconFlow 对本项目的支持!SiliconFlow 是高性能 AI 基础设施和模型 API 平台,提供语言、语音、图片、视频等多种模型的快速可靠访问。按量计费,多模态模型支持,高速推理,企业级稳定性,帮助开发者和团队更高效地构建和扩展 AI 应用。通过 此链接 注册并完成实名认证,即可获得 ¥20 bonus credits!
优云智算感谢优云智算赞助了本项目!优云智算是UCloud旗下AI云平台,提供稳定、全面的国内外模型API,仅一个key即可调用。主打包月、按次的高性价比国模Coding Plan套餐,同时提供官转稳定海外模型。支持接入 Claude Code、Codex 及 API 调用。支持企业高并发、7*24技术支持、自助开票。通过 此链接 注册的用户,可得免费5元平台体验金!
DragonCode感谢 DragonCode 对本项目的支持。DragonCode 为 cc-connect 用户准备了专属福利:通过 此链接 注册即可开始体验。
VisionCoder感谢 VisionCoder 对本项目的支持。VisionCoder 开发平台 是一个可靠高效的 API 中继服务提供商,提供 Claude Code、Codex、Gemini 等主流 AI 模型,帮助开发者和团队更轻松地集成 AI 功能,提升工作效率。VisionCoder 还为我们的用户提供 Token Plan 限时活动:购买 1 个月,赠送 1 个月。
DDS Hub感谢 DDS 赞助本项目!呆呆兽是一家专注 Claude 和 CodeX 的可靠高效 API 中转站,稳定运行、价格透明、开票便捷。为开发者提供高性价比的 AI 模型接入服务。通过 此链接 注册。
Code0感谢 Code0 对本项目的赞助!Code0 是面向中国开发者的 AI 模型聚合 API 中转服务,统一兼容 OpenAI / Anthropic / Gemini 三种协议格式,一个 Key 即可调用全量主流模型,稳定适配 Claude Code、Codex、Gemini CLI、cc-connect 等各类 Agent 工具。固定汇率计费:充值 1.5 元人民币 = 1 美元 API 额度,价格透明、国内直连、开箱即用。通过 此链接 注册。
claudeapi.com感谢 claudeapi.com 对本项目的赞助!claudeapi 是面向中高端用户的高质量直连 Claude 服务,完整接入 Anthropic 官方第一方 Keys 和 AWS Bedrock 官方渠道——无逆向工程、无智力降级、无拼接。完整保留 Opus / Sonnet / Haiku 的官方能力、长上下文和 Tool Calling 性能。专为 Claude Code 重度用户、Agent 开发者和企业团队设计,开箱即用、企业级稳定。支持开票和团队入驻。通过 此链接 注册。
+ +
+ +--- + +
+ +

+ 在任何聊天工具里,远程操控你的本地 AI Agent。随时随地,随心所欲。 +

+ +

+ cc-connect 把运行在你机器上的 AI Agent 桥接到你日常使用的即时通讯工具。
+ 代码审查、资料研究、自动化任务、数据分析 —— 只要 AI Agent 能做的事,
+ 都能通过手机、平板或任何有聊天应用的设备来完成。 +

+ +

+ CC-Connect 架构图 +

+ + +## 🆕 v1.3.0 更新了什么 + +- **🌐 Web 管理后台(推荐)** — 内置全功能可视化管理界面,**无需额外依赖**。支持项目增删改查、服务商管理、会话监控、定时任务编辑,还可以**直接在浏览器里和 Agent 对话**。支持 5 种语言 (en/zh/zh-TW/ja/es)。建议通过 Web UI 管理 cc-connect,无需手动编辑 `config.toml`。运行 `cc-connect web` 配置并打开管理后台,然后运行 `cc-connect` 启动服务。 +- **生命周期事件钩子** — 新增 `[[hooks]]` 配置,支持在消息收发、会话开始/结束、定时任务触发、权限请求、错误等事件时触发 Shell 命令或 HTTP Webhook。默认异步,失败不阻塞。 +- **技能管理** — 新增 `/skills` 页面,支持本地技能浏览和推荐预设。 +- **全局服务商管理** — 在 Web UI 中添加/编辑/删除 Provider,支持从 cc-switch 配置导入。 +- **个人微信** — 用 **微信个人号(ilink 长轮询)** 和本地 Agent 对话;支持扫码 `weixin setup`、CDN 收发图片/文件,**无需公网 IP**。*[接入说明 → `docs/weixin.md`](docs/weixin.md)* +- **微博私信** — 通过 **微博私信** 与 Agent 对话,WebSocket 连接,无需公网 IP,支持流式文本回复。 +- **飞书增强** — 自动解析 `@成员` 提及、多级回复链识别、完成 Emoji 反应。 +- **新增 Agent** — 支持 Kimi CLI 和 Pi agent。 + + +## 🧩 平台能力一览 + +内置各渠道在 cc-connect 里的大致能力对照,方便快速对比。 + +**图例** + +| 符号 | 含义 | +|------|------| +| ✅ | **稳定版** cc-connect + 常规配置下可用 | +| ⚠️ | 部分支持、需额外配置(如语音/STT)或受厂商接口 / 应用类型限制 | +| ❌ | 不支持或实际不可用 | + +† **QQ(NapCat / OneBot)** — 非官方自建桥接,体验依赖你的 NapCat 与网络环境。 + +| 能力 | 飞书 | WPS 协作 | 钉钉 | Telegram | Slack | Discord | LINE | 企业微信 | 微博 | **微信个人号**
(ilink) | QQ† | QQ 官方机器人 | +|------|:----:|:--------:|:----:|:--------:|:-----:|:-------:|:----:|:--------:|:----:|:--------------------------:|:---:|:------------:| +| 文本与斜杠命令 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Markdown / 卡片 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ✅ | ✅ | ✅ | +| 流式 / 分片回复 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 图片与文件 | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅ | ✅ | ✅ | +| 语音 / STT / TTS | ⚠️ | ❌ | ⚠️ | ✅ | ⚠️ | ⚠️ | ❌ | ⚠️ | ❌ | ✅ | ⚠️ | ⚠️ | +| 私聊 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| 群聊 / 频道 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️ | ✅ | ❌ | ✅ | ✅ | ✅ | + +> **企业微信:** Webhook 模式需要**公网 URL**;长连接等模式多数**不需要**。 +> **语音行:** 多数平台要在 `config.toml` 里配置 `[speech]` / TTS 等,表中为经验性归纳。 +> 分平台接入步骤见下文 [平台接入指南](#-平台接入指南)。 + + +## ✨ 为什么选择 cc-connect? + +### 🤖 通用 Agent 支持 +**10+ 大 AI Agent** — Claude Code、Codex、Cursor Agent、Kimi CLI、Qoder CLI、Gemini CLI、OpenCode、iFlow CLI、Pi、Devin、Copilot,还可通过 [Agent Client Protocol (ACP)](https://agentclientprotocol.com/get-started/agents) 接入更多 Agent。按需选用,或同时使用全部。 + +### 📱 平台灵活性 +**12 大聊天平台** — 飞书、WPS 协作、钉钉、Slack、Telegram、Discord、企业微信、微博、LINE、QQ、QQ 官方机器人,以及 **微信个人号(ilink)**。大部分平台**无需公网 IP**。 + +### 🔄 多 Agent 编排 +**多机器人中继** — 在群聊中绑定多个机器人,让它们相互协作。问 Claude,再听 Gemini 的见解 — 同一个对话搞定。 + +### 🎮 完整的聊天控制 +**聊天即控制** — 切换模型 (`/model`)、切换推理强度 (`/reasoning`)、切换权限模式 (`/mode`)、管理会话,全部通过斜杠命令完成。 + +**聊天切换工作目录** — 使用 `/dir <路径>` 切换下一次会话启动目录(`/cd <路径>` 为兼容别名),并支持 `/dir <序号>` / `/dir -` 快速在历史目录间跳转。 + +### 🧠 持久化记忆 +**Agent 记忆** — 在聊天中直接读写 Agent 指令文件 (`/memory`),无需回到终端。 + +### ⏰ 智能定时任务 +**定时任务** — 自然语言创建 cron 任务。"每天早上6点总结 GitHub trending" 即刻生效。 + +### 🎤 多模态支持 +**语音 & 图片** — 发语音或截图,cc-connect 自动处理 STT/TTS 和多模态转发。 + +### 📦 多项目架构 +**多项目管理** — 一个进程同时管理多个项目,各自独立的 Agent + 平台组合。 + +### 🌍 多语言界面 +**5 种语言** — 原生支持英语、中文(简体/繁体)、日语和西班牙语。内置 i18n 让每个人都能得心应手。 + + +

+ 飞书 + Telegram + 微信 +

+

+ 左:飞书  |  Telegram  |  右:微信 +

+ + +## 🚀 快速开始 + +### 🤖 通过 AI Agent 安装配置(推荐) + +> **最简单的方式** — 把这段话发给 Claude Code 或其他 AI 编码 Agent,它会帮你完成整个安装和配置过程: + +```bash +请参考 https://raw.githubusercontent.com/chenhg5/cc-connect/refs/heads/main/INSTALL.md 帮我安装和配置 cc-connect +``` + + +### 📦 手动安装 + +**通过 npm:** + +```bash +# npm install -g cc-connect +``` + +**通过 Homebrew(macOS / Linux):** + +```bash +brew install cc-connect +``` + +**从 [GitHub Releases](https://github.com/chenhg5/cc-connect/releases) 下载:** + +```bash +# Linux amd64 - 稳定版 +curl -L -o cc-connect https://github.com/chenhg5/cc-connect/releases/latest/download/cc-connect-linux-amd64 +chmod +x cc-connect +sudo mv cc-connect /usr/local/bin/ + +``` + +**从源码编译(需要 Go 1.22+):** + +```bash +git clone https://github.com/chenhg5/cc-connect.git +cd cc-connect +make build +``` + + +### ⚙️ 配置 + +> **💡 推荐使用 Web UI 配置** — 安装完成后,运行 `cc-connect web` 配置 Web 管理后台并在浏览器中打开。可以可视化创建项目、添加平台、管理服务商、直接和 Agent 聊天,无需手动编辑 TOML 文件。**注意:** `cc-connect web` 仅用于配置和打开浏览器,并不会启动 cc-connect 服务本身,你仍需单独运行 `cc-connect` 来启动。 + +如果你更喜欢手动配置: + +```bash +mkdir -p ~/.cc-connect +cp config.example.toml ~/.cc-connect/config.toml +vim ~/.cc-connect/config.toml +``` + +在项目配置里设置 `admin_from = "alice,bob"` 后,只有这些用户 ID 才能执行 `/dir`、`/shell` 等特权命令。 +执行 `/dir reset` 时,cc-connect 会恢复配置中的 `work_dir`,并清除保存在 `data_dir/projects/.state.json` 里的目录覆盖状态。 + + +### ▶️ 运行 + +```bash +./cc-connect +``` + + +### 🔄 升级 + +```bash +# npm +npm install -g cc-connect + +# Homebrew +brew upgrade cc-connect + +# 二进制自更新 +cc-connect update # 稳定版 +cc-connect update --pre # 含预发布版本 +``` + + +## 📊 支持状态 + +| 组件 | 类型 | 状态 | +|------|------|------| +| Agent | Claude Code | ✅ 已支持 | +| Agent | Codex (OpenAI) | ✅ 已支持 | +| Agent | Cursor Agent | ✅ 已支持 | +| Agent | Gemini CLI (Google) | ✅ 已支持 | +| Agent | Qoder CLI | ✅ 已支持 | +| Agent | OpenCode (Crush) | ✅ 已支持 | +| Agent | iFlow CLI | ✅ 已支持 | +| Agent | Kimi CLI (Moonshot) | ✅ 已支持 | +| Agent | Pi (Cursor Background Agent) | ✅ 已支持 | +| Agent | Copilot (GitHub) | ✅ 已支持 | +| Agent | ACP (Agent Client Protocol) | ✅ 支持任何 [ACP 兼容 Agent](https://agentclientprotocol.com/get-started/agents) | +| Agent | Devin (Cognition) | ✅ 已支持(通过 ACP)| +| Agent | Goose (Block) | 🔜 计划中 | +| Agent | Aider | 🔜 计划中 | +| Platform | 飞书 (Lark) | ✅ WebSocket — 无需公网 IP | +| Platform | 钉钉 | ✅ Stream — 无需公网 IP | +| Platform | WPS 协作 | ✅ WebSocket — 无需公网 IP | +| Platform | Telegram | ✅ Long Polling — 无需公网 IP | +| Platform | Slack | ✅ Socket Mode — 无需公网 IP | +| Platform | Discord | ✅ Gateway — 无需公网 IP | +| Platform | 微博 | ✅ WebSocket — 无需公网 IP | +| Platform | LINE | ✅ Webhook — 需要公网 URL | +| Platform | 企业微信 | ✅ WebSocket / Webhook | +| Platform | 微信个人号(ilink) | ✅— HTTP 长轮询 — 无需公网 IP | +| Platform | QQ (NapCat/OneBot) | ✅ WebSocket | +| Platform | QQ 官方机器人 | ✅ WebSocket — 无需公网 IP | + + +## 📖 平台接入指南 + +| 平台 | 指南 | 连接方式 | 需要公网 IP? | +|------|------|---------|-------------| +| 飞书 (Lark) | [docs/feishu.md](docs/feishu.md) | WebSocket | 不需要 | +| 钉钉 | [docs/dingtalk.md](docs/dingtalk.md) | Stream | 不需要 | +| WPS 协作 | [docs/wps-xiezuo.md](docs/wps-xiezuo.md) | WebSocket | 不需要 | +| Telegram | [docs/telegram.md](docs/telegram.md) | Long Polling | 不需要 | +| Slack | [docs/slack.md](docs/slack.md) | Socket Mode | 不需要 | +| Discord | [docs/discord.md](docs/discord.md) | Gateway | 不需要 | +| 微博 | [docs/weibo.md](docs/weibo.md) | WebSocket | 不需要 | +| 企业微信 | [docs/wecom.md](docs/wecom.md) | WebSocket / Webhook | 不需要 (WS) / 需要 (Webhook) | +| 微信个人号(ilink) | [docs/weixin.md](docs/weixin.md) | HTTP 长轮询(ilink) | 不需要 | +| QQ / QQ 机器人 | [docs/qq.md](docs/qq.md) | WebSocket | 不需要 | + + +## 🎯 核心功能 + +### 💬 会话管理 + +``` +/new [名称] 创建新会话 +/list 列出所有会话 +/switch 切换会话 +/current 查看当前会话 +/dir [路径|reset] 查看、切换或重置工作目录 +``` + +项目配置也可以开启“长时间空闲后自动切到新会话”: + +```toml +[[projects]] +reset_on_idle_mins = 60 +``` + + +### 🛡️ 系统用户隔离 (`run_as_user`) + +在 Linux/macOS 上,项目可以用另一个 Unix 用户身份启动 Agent,从而在操作系统层面实现文件系统隔离。目前 Claude Code 已支持。 + +```toml +[[projects]] +name = "claude-sandboxed" +run_as_user = "partseeker-coder" +run_as_env = ["PGSSLROOTCERT"] +``` + +目标用户需要:supervisor 对其配置免密 sudo、自身不拥有 sudo、对 `work_dir` 有读写权限、拥有自己的 `~/.claude/settings.json`。 +如果你通过 `claude.ai` OAuth 认证,请将目标用户的 `~/.claude/.credentials.json` 软链接到 supervisor 的副本以保持 token 同步 —— 详见[环境传播清单](./docs/usage.md#environment-propagation-what-moves-into-the-target-users-home)。 +完整设置说明见 [`docs/usage.md`](./docs/usage.md#running-agents-as-a-different-unix-user-run_as_user)。 + +启动 cc-connect 之前,可用以下命令审核配置: + +```bash +cc-connect doctor user-isolation +``` + +该命令会执行三项前置检查和一次隔离探测,报告目标用户能/不能读取的内容。如果任一检查失败或探测到跨用户泄漏,cc-connect 将拒绝启动。 + +--- + +### 🔐 权限模式 + +``` +/mode 查看可用模式 +/mode yolo # 自动批准所有工具 +/mode default # 每次工具调用前询问 +``` + + +### 🔄 Provider 管理 + +``` +/provider list 列出 Provider +/provider switch <名称> 运行时切换 API Provider +``` + + +### 🤖 模型选择 + +``` +/model 列出可用模型(格式:alias - model) +/model switch 按别名切换模型 +``` + + +### 📂 工作目录 + +``` +/dir 查看当前工作目录与历史 +/dir <路径> 切换到指定目录(相对或绝对路径) +/dir <序号> 按历史序号切换 +/dir - 返回上一个目录 +/cd <路径> `/dir <路径>` 的兼容别名 +``` + + +### ⏰ 定时任务 + +```bash +/cron add 0 6 * * * 帮我总结 GitHub trending +``` + +### 📎 Agent 回传图片和文件 + +当 Agent 在本地生成了截图、图表、PDF、日志包等文件时,可以主动把附件发回当前聊天。 + +首版支持: +- 飞书 +- Telegram + +如果当前 Agent 不是原生注入 system prompt 的类型,升级后请先在聊天里执行一次: + +```text +/bind setup +``` + +或: + +```text +/cron setup +``` + +这样会把最新的 cc-connect 指令写入项目记忆文件,Agent 才会知道如何回传附件。 + +你也可以在 `config.toml` 里全局控制这项能力: + +```toml +attachment_send = "on" # 默认 "on";设为 "off" 会禁用图片/文件回传 +``` + +这个开关与 agent 的 `/mode` 独立,只控制 `cc-connect send --image/--file` 这条附件回传路径。 + +回传方式: + +```bash +cc-connect send --image /absolute/path/to/chart.png +cc-connect send --file /absolute/path/to/report.pdf +cc-connect send --file /absolute/path/to/report.pdf --image /absolute/path/to/chart.png +``` + +要点: +- 使用绝对路径最稳妥。 +- `--image` 和 `--file` 都可以重复传多个。 +- `attachment_send = "off"` 只会关闭附件回传,普通文本回复仍然正常。 +- 这个命令是给“生成后的附件回传”用的,不是给普通文本回复用的。 + +📖 **完整文档:** [docs/usage.zh-CN.md](docs/usage.zh-CN.md) + + +## 📚 文档 + +- [使用指南](docs/usage.zh-CN.md) — 完整功能文档 +- [INSTALL.md](INSTALL.md) — AI Agent 友好的安装指南 +- [config.example.toml](config.example.toml) — 配置模板 +- [CONTRIBUTING.md](CONTRIBUTING.md) — Issue / PR 提交流程与贡献说明 + + +## 👥 社区 + +- [Discord](https://discord.gg/kHpwgaM4kq) +- [Telegram](https://t.me/+odGNDhCjbjdmMmZl) + + +## ☕ 支持项目 + +如果 cc-connect 对你有帮助,请考虑请我们喝杯咖啡!你的支持帮助我们: + +- 🛠️ 维护和改进项目 +- 📚 编写更好的文档和教程 +- 🐛 更快修复 bug 和添加新功能 +- ☕ 让开发者保持精力充沛 + +### 捐赠方式 + +**Buy Me a Coffee**:[https://buymeacoffee.com/cg33](https://buymeacoffee.com/cg33) + +**微信支付 / 支付宝**: + +| 微信支付 | 支付宝 | +|:----------:|:------:| +| 微信支付 | 支付宝 | + +### 感谢捐赠者!🎉 + +感谢每一位支持这个项目的朋友。捐赠时留言你的 GitHub 用户名,我们会在这里展示! + + +| 头像 | GitHub 用户名 | 日期 | +|------|-----------------|------| +| | [@thx0701](https://github.com/thx0701) | 2026-04-29 | + + +## 🤝 商业合作 + +我们接受以下商业合作: + +- **企业定制**:为企业定制内部 AI 工具入口(飞书、钉钉、企业微信、Slack 等) +- **技术咨询**:AI agent 集成方案设计与架构咨询 +- **外包项目**:AI 相关系统开发 + +**联系方式**:**邮箱**:chg80333@gmail.com | **微信**:mongorz | [Telegram](https://t.me/+odGNDhCjbjdmMmZl) | [Discord](https://discord.gg/kHpwgaM4kq) + + +## 🙏 贡献者 + + + + + + +## ⭐ Star History + + + + + + Star History Chart + + + + +## 📄 License + +MIT License + + +

+ 由 cc-connect 社区用 ❤️ 构建 +

diff --git a/agent/acp/agent.go b/agent/acp/agent.go new file mode 100644 index 0000000..6e60f0e --- /dev/null +++ b/agent/acp/agent.go @@ -0,0 +1,372 @@ +package acp + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("acp", New) +} + +// Agent runs an ACP (Agent Client Protocol) agent subprocess over stdio JSON-RPC. +type Agent struct { + workDir string + command string + args []string + staticEnv map[string]string + extraEnv []string + sessionEnv []string + authMethod string // optional, e.g. "cursor_login" for Cursor CLI (see authenticate RPC) + displayName string // optional, for doctor (default "ACP") + + // mode is the pending permission mode to apply to new sessions. + // When set, StartSession applies it via session/set_mode right after + // session/new. Empty means "use whatever the agent selects by default". + mode string + + // listUnsupported caches a negative result after we probe the agent + // for sessionCapabilities.list once. Eliminates spawn cost on + // subsequent `/ls` invocations against agents that don't implement + // session/list (e.g. some Copilot/OpenClaw builds). + listUnsupported atomic.Bool + + // modesCache holds the latest `modes` block we observed via + // session/new or session/load. It's populated by the session + // handshake so that future PermissionModes() calls can reflect the + // actual modes this specific ACP agent offers (rather than a + // hard-coded fallback that may not match). + modesMu sync.RWMutex + modesCache []core.PermissionModeInfo + modesCurrent string + + mu sync.RWMutex +} + +// sessionCallbacks lets a running acpSession report what it learned +// during the handshake back to its parent Agent. The session is owned +// by cc-connect's engine (not the agent), so without this the agent +// would never see availableModes / capability advertisements. +type sessionCallbacks interface { + reportModes(block acpModesBlock) + reportListSupported(supported bool) +} + +// Ensure *Agent satisfies sessionCallbacks at compile time. +var _ sessionCallbacks = (*Agent)(nil) + +// New builds an acp agent from project options. +// Required: options["command"] — executable name or path for the ACP agent. +// Optional: options["args"], options["env"], options["auth_method"], +// options["display_name"], options["mode"]. +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + cmdStr, _ := opts["command"].(string) + cmdStr = strings.TrimSpace(cmdStr) + if cmdStr == "" { + return nil, fmt.Errorf("acp: agent option \"command\" is required (path or name of the ACP agent binary)") + } + if _, err := exec.LookPath(cmdStr); err != nil { + return nil, fmt.Errorf("acp: command %q not found in PATH: %w", cmdStr, err) + } + + args := parseStringSlice(opts["args"]) + staticEnv := envMapFromOpts(opts) + extra := envPairsFromOpts(opts) + authMethod, _ := opts["auth_method"].(string) + authMethod = strings.TrimSpace(authMethod) + displayName, _ := opts["display_name"].(string) + displayName = strings.TrimSpace(displayName) + if displayName == "" { + displayName = "ACP" + } + mode, _ := opts["mode"].(string) + mode = strings.TrimSpace(mode) + + return &Agent{ + workDir: workDir, + command: cmdStr, + args: args, + staticEnv: staticEnv, + extraEnv: extra, + authMethod: authMethod, + displayName: displayName, + mode: mode, + }, nil +} + +func envMapFromOpts(opts map[string]any) map[string]string { + raw, ok := opts["env"] + if !ok || raw == nil { + return nil + } + switch m := raw.(type) { + case map[string]string: + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = v + } + return out + case map[string]any: + out := make(map[string]string, len(m)) + for k, v := range m { + out[k] = fmt.Sprint(v) + } + return out + default: + return nil + } +} + +func envPairsFromOpts(opts map[string]any) []string { + raw, ok := opts["env"] + if !ok || raw == nil { + return nil + } + switch m := raw.(type) { + case map[string]string: + var out []string + for k, v := range m { + out = append(out, k+"="+v) + } + return out + case map[string]any: + var out []string + for k, v := range m { + out = append(out, fmt.Sprintf("%s=%v", k, v)) + } + return out + default: + return nil + } +} + +func parseStringSlice(v any) []string { + switch x := v.(type) { + case nil: + return nil + case []string: + return append([]string(nil), x...) + case []any: + out := make([]string, 0, len(x)) + for _, e := range x { + switch t := e.(type) { + case string: + out = append(out, t) + default: + out = append(out, fmt.Sprint(t)) + } + } + return out + default: + return nil + } +} + +func (a *Agent) Name() string { return "acp" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + a.workDir = dir + a.mu.Unlock() + slog.Info("acp: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.workDir +} + +func (a *Agent) WorkspaceAgentOptions() map[string]any { + a.mu.RLock() + defer a.mu.RUnlock() + + opts := map[string]any{ + "command": a.command, + } + if len(a.args) > 0 { + opts["args"] = append([]string(nil), a.args...) + } + if len(a.staticEnv) > 0 { + env := make(map[string]string, len(a.staticEnv)) + for k, v := range a.staticEnv { + env[k] = v + } + opts["env"] = env + } + if a.authMethod != "" { + opts["auth_method"] = a.authMethod + } + if a.displayName != "" { + opts["display_name"] = a.displayName + } + return opts +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + a.sessionEnv = env + a.mu.Unlock() +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.RLock() + command := a.command + args := a.args + workDir := a.workDir + authMethod := a.authMethod + pendingMode := a.mode + extra := append([]string(nil), a.extraEnv...) + extra = append(extra, a.sessionEnv...) + a.mu.RUnlock() + + return newACPSession(ctx, acpSessionConfig{ + command: command, + args: args, + extraEnv: extra, + workDir: workDir, + resumeSessionID: sessionID, + authMethod: authMethod, + initialMode: pendingMode, + callbacks: a, + }) +} + +func (a *Agent) Stop() error { return nil } + +// -- AgentDoctorInfo -- + +func (a *Agent) CLIBinaryName() string { + a.mu.RLock() + cmd := a.command + a.mu.RUnlock() + return filepath.Base(cmd) +} + +func (a *Agent) CLIDisplayName() string { + a.mu.RLock() + n := a.displayName + a.mu.RUnlock() + if n == "" { + return "ACP" + } + return n +} + +// -- ModeSwitcher -- +// +// cc-connect's engine treats ModeSwitcher as the point of truth for +// both displaying `/mode` options and applying a mode selection. For +// the generic ACP adapter we keep the Key == ACP modeId so downstream +// `session/set_mode` calls don't need any translation. + +// SetMode stores a permission mode to apply to future sessions started +// via StartSession. If the caller-provided mode matches a known cached +// mode id (case-insensitive), it is normalised to that id. Otherwise +// it is stored as-is — some IM users may configure modes before the +// agent has started any session and thus advertised its mode list. +func (a *Agent) SetMode(mode string) { + normalised := mode + if m := a.matchModeID(mode); m != "" { + normalised = m + } + a.mu.Lock() + a.mode = normalised + a.mu.Unlock() + slog.Info("acp: mode changed for future sessions", "mode", normalised) +} + +// GetMode returns the mode cc-connect will treat as "current" when +// rendering the `/mode` picker or applying SetLiveMode. +// +// Precedence: the most recent explicit SetMode wins (that's the user's +// intent — `/mode plan` should immediately be reflected in the next +// `/mode` listing even before the session/set_mode RPC has returned). +// Only if no one has ever called SetMode for this Agent do we fall +// back to whatever the server advertised as currentModeId during the +// last handshake. +func (a *Agent) GetMode() string { + a.mu.RLock() + pending := a.mode + a.mu.RUnlock() + if pending != "" { + return pending + } + a.modesMu.RLock() + defer a.modesMu.RUnlock() + return a.modesCurrent +} + +// PermissionModes returns the modes this ACP agent offers. The list is +// populated from the latest `modes.availableModes` observed on +// session/new or session/load; before the first successful handshake +// it returns an empty slice, and the engine will hide the mode picker. +// +// ACP doesn't send per-mode Desc/NameZh, so Description (if the server +// sent one) maps to Desc for both locales. IM-side translators are +// free to map well-known ids to localised strings later. +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + a.modesMu.RLock() + defer a.modesMu.RUnlock() + out := make([]core.PermissionModeInfo, len(a.modesCache)) + copy(out, a.modesCache) + return out +} + +// matchModeID returns the canonical mode id for a user-typed string +// (case-insensitive match on id or display name). Empty string if no +// match or if we haven't observed modes yet. +func (a *Agent) matchModeID(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return "" + } + lower := strings.ToLower(input) + a.modesMu.RLock() + defer a.modesMu.RUnlock() + for _, m := range a.modesCache { + if strings.ToLower(m.Key) == lower || strings.ToLower(m.Name) == lower { + return m.Key + } + } + return "" +} + +// -- sessionCallbacks impl -- + +func (a *Agent) reportModes(block acpModesBlock) { + infos := make([]core.PermissionModeInfo, 0, len(block.AvailableModes)) + for _, m := range block.AvailableModes { + infos = append(infos, core.PermissionModeInfo{ + Key: m.ID, + Name: m.Name, + NameZh: m.Name, + Desc: m.Description, + DescZh: m.Description, + }) + } + a.modesMu.Lock() + a.modesCache = infos + a.modesCurrent = block.CurrentModeID + a.modesMu.Unlock() +} + +func (a *Agent) reportListSupported(supported bool) { + if !supported { + a.listUnsupported.Store(true) + } else { + a.listUnsupported.Store(false) + } +} diff --git a/agent/acp/agent_test.go b/agent/acp/agent_test.go new file mode 100644 index 0000000..245d4cb --- /dev/null +++ b/agent/acp/agent_test.go @@ -0,0 +1,72 @@ +package acp + +import ( + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestNew_DisplayNameDefault(t *testing.T) { + a, err := New(map[string]any{"command": "true"}) + if err != nil { + t.Fatal(err) + } + agent := a.(*Agent) + if got := agent.CLIDisplayName(); got != "ACP" { + t.Fatalf("CLIDisplayName = %q, want ACP", got) + } +} + +func TestNew_DisplayNameCustom(t *testing.T) { + a, err := New(map[string]any{ + "command": "true", + "display_name": "Copilot ACP", + }) + if err != nil { + t.Fatal(err) + } + agent := a.(*Agent) + if got := agent.CLIDisplayName(); got != "Copilot ACP" { + t.Fatalf("CLIDisplayName = %q, want Copilot ACP", got) + } +} + +func TestWorkspaceAgentOptions(t *testing.T) { + a, err := New(map[string]any{ + "command": "true", + "args": []any{"--acp", "--stdio"}, + "env": map[string]any{"FOO": "bar", "COPILOT_VALUE": "a=b"}, + "auth_method": "cursor_login", + "display_name": "Copilot ACP", + }) + if err != nil { + t.Fatal(err) + } + + agent := a.(*Agent) + agent.SetSessionEnv([]string{"SESSION_ONLY=1"}) + + snapshotter, ok := a.(core.WorkspaceAgentOptionSnapshotter) + if !ok { + t.Fatalf("agent does not implement WorkspaceAgentOptionSnapshotter") + } + opts := snapshotter.WorkspaceAgentOptions() + + if got, _ := opts["command"].(string); got != "true" { + t.Fatalf("command = %q, want true", got) + } + gotArgs, _ := opts["args"].([]string) + if len(gotArgs) != 2 || gotArgs[0] != "--acp" || gotArgs[1] != "--stdio" { + t.Fatalf("args = %#v, want [--acp --stdio]", gotArgs) + } + gotEnv, _ := opts["env"].(map[string]string) + if len(gotEnv) != 2 || gotEnv["FOO"] != "bar" || gotEnv["COPILOT_VALUE"] != "a=b" { + t.Fatalf("env = %#v, want config env only", gotEnv) + } + if got, _ := opts["auth_method"].(string); got != "cursor_login" { + t.Fatalf("auth_method = %q, want cursor_login", got) + } + if got, _ := opts["display_name"].(string); got != "Copilot ACP" { + t.Fatalf("display_name = %q, want Copilot ACP", got) + } +} diff --git a/agent/acp/cursor_integration_test.go b/agent/acp/cursor_integration_test.go new file mode 100644 index 0000000..3b44764 --- /dev/null +++ b/agent/acp/cursor_integration_test.go @@ -0,0 +1,47 @@ +package acp + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +// Exercises real Cursor CLI "agent acp" when installed (~/.local/bin/agent). +// Requires prior `agent login` (or CURSOR_API_KEY / CURSOR_AUTH_TOKEN). Skips if binary missing. +func TestCursorCLI_ACPHandshake(t *testing.T) { + if testing.Short() { + t.Skip("short mode") + } + if os.Getenv("CI") != "" { + t.Skip("skipping real Cursor CLI ACP handshake in CI (requires local agent and login)") + } + home, err := os.UserHomeDir() + if err != nil { + t.Skip("home dir:", err) + } + agentBin := filepath.Join(home, ".local/bin/agent") + if _, err := os.Stat(agentBin); err != nil { + t.Skip("Cursor agent not at", agentBin) + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + s, err := newACPSession(ctx, acpSessionConfig{ + command: agentBin, + args: []string{"acp"}, + workDir: t.TempDir(), + authMethod: "cursor_login", + }) + if err != nil { + t.Fatalf("handshake failed (is `agent login` done?): %v", err) + } + defer func() { _ = s.Close() }() + + if s.CurrentSessionID() == "" { + t.Fatal("empty ACP session id after handshake") + } + t.Logf("ACP session id: %s", s.CurrentSessionID()) +} diff --git a/agent/acp/list_sessions.go b/agent/acp/list_sessions.go new file mode 100644 index 0000000..313e2b3 --- /dev/null +++ b/agent/acp/list_sessions.go @@ -0,0 +1,266 @@ +package acp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// listSessionsProbeTimeout bounds how long we wait for a one-shot +// `session/list` round-trip before giving up. Keep this short — the +// whole point of the probe is that it's quick; if the ACP agent is +// slow we'd rather return nothing than block `/ls` in IM. +var listSessionsProbeTimeout = 15 * time.Second + +// acpModeInfo mirrors the ACP `modes.availableModes[]` shape sent by +// servers like `devin acp`, Cursor Agent, Copilot CLI, etc. +type acpModeInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` +} + +// acpModesBlock mirrors the `modes` object returned inside `session/new` +// and `session/load` responses. +type acpModesBlock struct { + CurrentModeID string `json:"currentModeId"` + AvailableModes []acpModeInfo `json:"availableModes"` +} + +// acpInitializeResult is the subset of `initialize` fields this package +// cares about. Additional vendor metadata is ignored. +type acpInitializeResult struct { + ProtocolVersion int `json:"protocolVersion"` + AgentCapabilities struct { + LoadSession bool `json:"loadSession"` + SessionCapabilities struct { + // ACP advertises capabilities as objects (possibly empty); + // treat "field present" as "supported" regardless of contents. + List json.RawMessage `json:"list,omitempty"` + } `json:"sessionCapabilities"` + } `json:"agentCapabilities"` +} + +// acpSessionListResult mirrors a `session/list` response. +type acpSessionListResult struct { + Sessions []acpSessionListEntry `json:"sessions"` +} + +type acpSessionListEntry struct { + SessionID string `json:"sessionId"` + Cwd string `json:"cwd"` + Title string `json:"title,omitempty"` + UpdatedAt string `json:"updatedAt,omitempty"` +} + +// probeSpawn launches ` `, sets up a JSON-RPC transport +// and starts its readLoop. The caller owns the returned `teardown` +// func and must invoke it to reap the child process. +func (a *Agent) probeSpawn(ctx context.Context, cwd string) (*transport, *bytes.Buffer, func(), error) { + cmd := exec.CommandContext(ctx, a.command, a.args...) + cmd.Dir = cwd + cmd.Env = core.MergeEnv(os.Environ(), a.extraEnv) + + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("acp: probe stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, nil, nil, fmt.Errorf("acp: probe stdout pipe: %w", err) + } + var stderrBuf bytes.Buffer + cmd.Stderr = io.MultiWriter(&stderrBuf) + + if err := cmd.Start(); err != nil { + return nil, nil, nil, fmt.Errorf("acp: probe start %s: %w", a.command, err) + } + + // The server-request handler needs to reference `tr` itself in order + // to respondError; declare via var so the closure captures the + // variable (which is assigned to a *transport below) rather than an + // uninitialised copy. + var tr *transport + tr = newTransport(stdout, stdin, + func(method string, _ json.RawMessage) { + slog.Debug("acp-probe: notification", "method", method) + }, + func(_ string, id json.RawMessage, _ json.RawMessage) { + _ = tr.respondError(id, -32601, "probe: method not implemented") + }, + ) + + readCtx, cancelRead := context.WithCancel(ctx) + go tr.readLoop(readCtx) + + teardown := func() { + cancelRead() + _ = stdin.Close() + done := make(chan struct{}) + go func() { + _ = cmd.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + <-done + } + } + return tr, &stderrBuf, teardown, nil +} + +// probeInitialize performs the ACP handshake on an already-spawned +// transport and returns the parsed initialize result. +func probeInitialize(ctx context.Context, tr *transport) (*acpInitializeResult, error) { + raw, err := tr.call(ctx, "initialize", map[string]any{ + "protocolVersion": 1, + "clientCapabilities": map[string]any{ + "fs": map[string]any{"readTextFile": false, "writeTextFile": false}, + "terminal": false, + }, + "clientInfo": map[string]any{"name": "cc-connect", "version": "1.0.0"}, + }) + if err != nil { + return nil, fmt.Errorf("acp: probe initialize: %w", err) + } + var res acpInitializeResult + if err := json.Unmarshal(raw, &res); err != nil { + return nil, fmt.Errorf("acp: probe parse initialize: %w", err) + } + return &res, nil +} + +// probeListSessions runs `session/list` on the given transport. +// Returns (nil, nil) if the agent refuses the call with +// method-not-found / invalid-request — callers interpret that as +// "unsupported" rather than "real error". +func probeListSessions(ctx context.Context, tr *transport, cwdFilter string) ([]acpSessionListEntry, error) { + params := map[string]any{} + if cwdFilter != "" { + params["cwd"] = cwdFilter + } + raw, err := tr.call(ctx, "session/list", params) + if err != nil { + if rpcErr, ok := err.(*rpcErrPayload); ok { + if rpcErr.Code == -32601 || rpcErr.Code == -32600 { + return nil, nil + } + } + return nil, err + } + var out acpSessionListResult + if err := json.Unmarshal(raw, &out); err != nil { + return nil, fmt.Errorf("acp: parse session/list: %w", err) + } + return out.Sessions, nil +} + +// ListSessions returns past sessions reported by the ACP agent, scoped +// to the agent's work_dir. If the agent does not advertise +// sessionCapabilities.list or the call soft-fails, returns nil. +// +// This runs a one-shot `` process that performs only +// initialize + session/list, so it does NOT allocate a real session on +// the backend (unlike session/new). Cost is roughly a single ACP +// handshake round-trip (~100-500ms for Devin). +func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) { + if a.listUnsupported.Load() { + // Already learned this agent doesn't support session/list; + // fast-path out to avoid respawning just to rediscover that. + return nil, nil + } + + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absWorkDir, err := filepath.Abs(workDir) + if err != nil { + absWorkDir = workDir + } + + probeCtx, cancel := context.WithTimeout(ctx, listSessionsProbeTimeout) + defer cancel() + + started := time.Now() + tr, stderrBuf, teardown, err := a.probeSpawn(probeCtx, absWorkDir) + if err != nil { + return nil, err + } + defer teardown() + + init, err := probeInitialize(probeCtx, tr) + if err != nil { + if msg := strings.TrimSpace(stderrBuf.String()); msg != "" { + return nil, fmt.Errorf("%w (stderr: %s)", err, truncateForLog(msg, 200)) + } + return nil, err + } + if len(init.AgentCapabilities.SessionCapabilities.List) == 0 { + slog.Info("acp: session/list unsupported by agent, caching for fast-path", "command", a.command) + a.listUnsupported.Store(true) + return nil, nil + } + + entries, err := probeListSessions(probeCtx, tr, absWorkDir) + if err != nil { + return nil, err + } + if entries == nil { + a.listUnsupported.Store(true) + return nil, nil + } + out := convertSessionList(entries, absWorkDir) + slog.Info("acp: session/list completed", + "total_entries", len(entries), + "matching_cwd", len(out), + "cwd", absWorkDir, + "elapsed", time.Since(started), + ) + return out, nil +} + +// convertSessionList maps ACP session/list entries to core.AgentSessionInfo. +// If `cwdFilter` is non-empty, entries whose cwd does not match are dropped; +// ACP servers SHOULD filter themselves when the request includes cwd, but +// we defend against servers that ignore the hint (see probe_caps.py output +// against devin acp: filter is respected there, but we still double-check). +func convertSessionList(entries []acpSessionListEntry, cwdFilter string) []core.AgentSessionInfo { + out := make([]core.AgentSessionInfo, 0, len(entries)) + for _, e := range entries { + if cwdFilter != "" && e.Cwd != "" && !strings.EqualFold(filepath.Clean(e.Cwd), filepath.Clean(cwdFilter)) { + continue + } + info := core.AgentSessionInfo{ + ID: e.SessionID, + Summary: strings.TrimSpace(e.Title), + } + if t, err := time.Parse(time.RFC3339, e.UpdatedAt); err == nil { + info.ModifiedAt = t + } else if t, err := time.Parse(time.RFC3339Nano, e.UpdatedAt); err == nil { + info.ModifiedAt = t + } + out = append(out, info) + } + return out +} + +func truncateForLog(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/agent/acp/mapping.go b/agent/acp/mapping.go new file mode 100644 index 0000000..dc71a93 --- /dev/null +++ b/agent/acp/mapping.go @@ -0,0 +1,311 @@ +package acp + +import ( + "encoding/json" + "strings" + + "github.com/chenhg5/cc-connect/core" +) + +// mapSessionUpdate turns one ACP session/update payload into zero or more core events. +func mapSessionUpdate(sessionID string, params json.RawMessage) []core.Event { + var wrap struct { + SessionID string `json:"sessionId"` + Update json.RawMessage `json:"update"` + } + if err := json.Unmarshal(params, &wrap); err != nil || len(wrap.Update) == 0 { + return nil + } + sid := wrap.SessionID + if sid == "" { + sid = sessionID + } + + var head struct { + SessionUpdate string `json:"sessionUpdate"` + } + if err := json.Unmarshal(wrap.Update, &head); err != nil { + return nil + } + + switch head.SessionUpdate { + case "agent_message_chunk": + return mapAgentMessageChunk(sid, wrap.Update) + case "tool_call": + return mapToolCall(sid, wrap.Update) + case "tool_call_update": + return mapToolCallUpdate(sid, wrap.Update) + case "plan": + return mapPlan(sid, wrap.Update) + case "user_message_chunk": + // History replay during session/load — suppress to avoid echoing user input. + return nil + default: + // Optional vendor / future ACP shapes — best-effort text extraction. + return mapSessionUpdateFallback(sid, head.SessionUpdate, wrap.Update) + } +} + +func mapAgentMessageChunk(sessionID string, update json.RawMessage) []core.Event { + var u struct { + Content struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if err := json.Unmarshal(update, &u); err != nil { + return nil + } + if u.Content.Text == "" { + return nil + } + return []core.Event{{ + Type: core.EventText, + Content: u.Content.Text, + SessionID: sessionID, + }} +} + +func mapToolCall(sessionID string, update json.RawMessage) []core.Event { + var u struct { + ToolCallID string `json:"toolCallId"` + Title string `json:"title"` + Kind string `json:"kind"` + Status string `json:"status"` + RawInput json.RawMessage `json:"rawInput"` + } + if err := json.Unmarshal(update, &u); err != nil { + return nil + } + toolName := u.Title + if toolName == "" { + toolName = u.Kind + } + if toolName == "" { + toolName = "tool" + } + toolInput := summarizeACPToolInput(u.Kind, u.RawInput) + if toolInput == "" { + toolInput = u.Title + } + return []core.Event{{ + Type: core.EventToolUse, + ToolName: toolName, + ToolInput: toolInput, + SessionID: sessionID, + }} +} + +func mapToolCallUpdate(sessionID string, update json.RawMessage) []core.Event { + var u struct { + Title string `json:"title"` + ToolCallID string `json:"toolCallId"` + Status string `json:"status"` + Content []struct { + Type string `json:"type"` + Content struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } `json:"content"` + } + if err := json.Unmarshal(update, &u); err != nil { + return nil + } + toolLabel := u.Title + if toolLabel == "" { + toolLabel = u.ToolCallID + } + if toolLabel == "" { + toolLabel = "tool" + } + body := extractToolCallContentText(u.Content) + st := strings.ToLower(strings.TrimSpace(u.Status)) + + switch { + case st == "completed" || st == "failed": + if body == "" && st == "completed" { + return nil + } + if st == "failed" && body == "" { + body = "(failed)" + } + return []core.Event{{ + Type: core.EventToolResult, + ToolName: toolLabel, + Content: truncateRunes(body, 800), + SessionID: sessionID, + }} + case st == "in_progress" || st == "pending": + // Stream intermediate tool output to IM (ACP allows content while not terminal). + if body == "" { + return nil + } + return []core.Event{{ + Type: core.EventToolResult, + ToolName: toolLabel, + Content: truncateRunes(body, 800), + SessionID: sessionID, + }} + default: + if body != "" { + return []core.Event{{ + Type: core.EventToolResult, + ToolName: toolLabel, + Content: truncateRunes(body, 800), + SessionID: sessionID, + }} + } + return nil + } +} + +func extractToolCallContentText(blocks []struct { + Type string `json:"type"` + Content struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` +}) string { + var b strings.Builder + for _, c := range blocks { + if c.Content.Text != "" { + if b.Len() > 0 { + b.WriteByte('\n') + } + b.WriteString(c.Content.Text) + } + } + return b.String() +} + +// mapSessionUpdateFallback handles unknown sessionUpdate values (vendor extensions +// that still carry human-readable text). Never guesses auth or tool semantics. +func mapSessionUpdateFallback(sessionID string, kind string, update json.RawMessage) []core.Event { + // Some agents may send reasoning as a dedicated discriminator; map to EventThinking. + switch strings.ToLower(kind) { + case "reasoning", "reasoning_chunk", "thinking", "agent_thinking_chunk": + var u struct { + Content struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + Text string `json:"text"` + } + if json.Unmarshal(update, &u) != nil { + return nil + } + t := u.Content.Text + if t == "" { + t = u.Text + } + if t == "" { + return nil + } + return []core.Event{{ + Type: core.EventThinking, + Content: t, + SessionID: sessionID, + }} + } + return nil +} + +func mapPlan(sessionID string, update json.RawMessage) []core.Event { + var u struct { + Entries []struct { + Content string `json:"content"` + Priority string `json:"priority"` + Status string `json:"status"` + } `json:"entries"` + } + if err := json.Unmarshal(update, &u); err != nil || len(u.Entries) == 0 { + return nil + } + var b strings.Builder + for i, e := range u.Entries { + if i > 0 { + b.WriteByte('\n') + } + line := e.Content + if e.Status != "" { + line = "[" + e.Status + "] " + line + } + b.WriteString(line) + } + return []core.Event{{ + Type: core.EventThinking, + Content: b.String(), + SessionID: sessionID, + }} +} + +func truncateRunes(s string, maxRunes int) string { + if maxRunes <= 0 { + return s + } + r := []rune(s) + if len(r) <= maxRunes { + return s + } + return string(r[:maxRunes]) + "..." +} + +// permissionOption matches ACP session/request_permission option entries. +type permissionOption struct { + OptionID string `json:"optionId"` + Name string `json:"name"` + Kind string `json:"kind"` +} + +func pickPermissionOptionID(allow bool, options []permissionOption) string { + if len(options) == 0 { + return "" + } + if allow { + for _, o := range options { + if strings.Contains(strings.ToLower(o.Kind), "allow") { + return o.OptionID + } + } + for _, o := range options { + if strings.Contains(strings.ToLower(o.Name), "allow") { + return o.OptionID + } + } + return options[0].OptionID + } + for _, o := range options { + if strings.Contains(strings.ToLower(o.Kind), "reject") || strings.Contains(strings.ToLower(o.Kind), "deny") { + return o.OptionID + } + } + for _, o := range options { + if strings.Contains(strings.ToLower(o.Name), "reject") || strings.Contains(strings.ToLower(o.Name), "deny") { + return o.OptionID + } + } + return options[len(options)-1].OptionID +} + +func buildPermissionResult(allow bool, optionID string) map[string]any { + if !allow { + if optionID == "" { + return map[string]any{ + "outcome": map[string]any{"outcome": "cancelled"}, + } + } + return map[string]any{ + "outcome": map[string]any{ + "outcome": "selected", + "optionId": optionID, + }, + } + } + return map[string]any{ + "outcome": map[string]any{ + "outcome": "selected", + "optionId": optionID, + }, + } +} diff --git a/agent/acp/mapping_test.go b/agent/acp/mapping_test.go new file mode 100644 index 0000000..4f8c4f7 --- /dev/null +++ b/agent/acp/mapping_test.go @@ -0,0 +1,170 @@ +package acp + +import ( + "encoding/json" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestMapSessionUpdate_agentMessageChunk(t *testing.T) { + params := json.RawMessage(`{ + "sessionId": "s1", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": {"type": "text", "text": "hello"} + } + }`) + evs := mapSessionUpdate("", params) + if len(evs) != 1 || evs[0].Type != core.EventText || evs[0].Content != "hello" { + t.Fatalf("got %+v", evs) + } +} + +func TestMapSessionUpdate_toolCallUpdate_inProgress(t *testing.T) { + params := json.RawMessage(`{ + "sessionId": "s1", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "c1", + "title": "Run", + "status": "in_progress", + "content": [ + {"type": "content", "content": {"type": "text", "text": "partial output"}} + ] + } + }`) + evs := mapSessionUpdate("", params) + if len(evs) != 1 || evs[0].Type != core.EventToolResult || evs[0].ToolName != "Run" { + t.Fatalf("got %+v", evs) + } +} + +func TestMapSessionUpdate_reasoningChunk(t *testing.T) { + params := json.RawMessage(`{ + "sessionId": "s1", + "update": { + "sessionUpdate": "reasoning_chunk", + "content": {"type": "text", "text": "step 1"} + } + }`) + evs := mapSessionUpdate("", params) + if len(evs) != 1 || evs[0].Type != core.EventThinking || evs[0].Content != "step 1" { + t.Fatalf("got %+v", evs) + } +} + +func TestMapSessionUpdate_toolCall(t *testing.T) { + params := json.RawMessage(`{ + "sessionId": "s1", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "c1", + "title": "Read file", + "kind": "read", + "status": "pending" + } + }`) + evs := mapSessionUpdate("", params) + if len(evs) != 1 || evs[0].Type != core.EventToolUse || evs[0].ToolName != "Read file" { + t.Fatalf("got %+v", evs) + } +} + +func TestPickPermissionOptionID(t *testing.T) { + opts := []permissionOption{ + {OptionID: "a", Kind: "allow_once"}, + {OptionID: "r", Kind: "reject_once"}, + } + if pickPermissionOptionID(true, opts) != "a" { + t.Fatal("allow") + } + if pickPermissionOptionID(false, opts) != "r" { + t.Fatal("deny") + } +} + +func TestBuildPermissionResult(t *testing.T) { + allow := buildPermissionResult(true, "opt1") + if allow["outcome"].(map[string]any)["optionId"] != "opt1" { + t.Fatalf("%v", allow) + } + denySel := buildPermissionResult(false, "rej") + if denySel["outcome"].(map[string]any)["optionId"] != "rej" { + t.Fatalf("%v", denySel) + } + cancel := buildPermissionResult(false, "") + if cancel["outcome"].(map[string]any)["outcome"] != "cancelled" { + t.Fatalf("%v", cancel) + } +} + +func TestMapSessionUpdate_toolCall_withRawInput(t *testing.T) { + params := json.RawMessage(`{ + "sessionId": "s1", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "c1", + "title": "Bash", + "kind": "bash", + "status": "pending", + "rawInput": {"command": "ls -la /tmp"} + } + }`) + evs := mapSessionUpdate("", params) + if len(evs) != 1 { + t.Fatalf("expected 1 event, got %d", len(evs)) + } + if evs[0].Type != core.EventToolUse { + t.Fatalf("expected EventToolUse, got %s", evs[0].Type) + } + if evs[0].ToolInput != "ls -la /tmp" { + t.Fatalf("expected tool input 'ls -la /tmp', got %q", evs[0].ToolInput) + } +} + +func TestSummarizeACPToolInput(t *testing.T) { + tests := []struct { + name string + kind string + raw string + want string + }{ + { + name: "bash command", + kind: "bash", + raw: `{"command": "echo hello"}`, + want: "echo hello", + }, + { + name: "read file", + kind: "read", + raw: `{"file_path": "/tmp/test.txt"}`, + want: "/tmp/test.txt", + }, + { + name: "empty raw", + kind: "bash", + raw: "", + want: "", + }, + { + name: "unknown kind falls back to formatted JSON", + kind: "unknown_tool", + raw: `{"key": "value"}`, + want: "{\n \"key\": \"value\"\n}", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var raw json.RawMessage + if tt.raw != "" { + raw = json.RawMessage(tt.raw) + } + got := summarizeACPToolInput(tt.kind, raw) + if got != tt.want { + t.Errorf("summarizeACPToolInput(%q, %s) = %q, want %q", tt.kind, tt.raw, got, tt.want) + } + }) + } +} diff --git a/agent/acp/rpc.go b/agent/acp/rpc.go new file mode 100644 index 0000000..38394d5 --- /dev/null +++ b/agent/acp/rpc.go @@ -0,0 +1,228 @@ +package acp + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "sync" + "sync/atomic" +) + +type rpcOutcome struct { + result json.RawMessage + err *rpcErrPayload +} + +type rpcErrPayload struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (e *rpcErrPayload) Error() string { + if e == nil { + return "json-rpc error" + } + return fmt.Sprintf("json-rpc %d: %s", e.Code, e.Message) +} + +type rpcNotifyHandler func(method string, params json.RawMessage) +type rpcRequestHandler func(method string, id json.RawMessage, params json.RawMessage) + +// transport implements newline-delimited JSON-RPC 2.0 over a pair of streams. +type transport struct { + in *bufio.Reader + out io.Writer + mu sync.Mutex + enc *json.Encoder + + nextID atomic.Int64 + + pendingMu sync.Mutex + pending map[string]chan rpcOutcome + + onNotif rpcNotifyHandler + onReq rpcRequestHandler +} + +func newTransport(in io.Reader, out io.Writer, onNotif rpcNotifyHandler, onReq rpcRequestHandler) *transport { + return &transport{ + in: bufio.NewReader(in), + out: out, + enc: json.NewEncoder(out), + pending: make(map[string]chan rpcOutcome), + onNotif: onNotif, + onReq: onReq, + } +} + +func (t *transport) readLoop(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + } + line, err := t.readLine() + if err != nil { + if err != io.EOF { + slog.Debug("acp: read error", "error", err) + } + t.cancelAll(fmt.Errorf("acp: read closed: %w", err)) + return + } + if len(bytes.TrimSpace(line)) == 0 { + continue + } + t.dispatchLine(line) + } +} + +func (t *transport) readLine() ([]byte, error) { + line, err := t.in.ReadBytes('\n') + if err != nil { + return line, err + } + return bytes.TrimSuffix(line, []byte("\r")), nil +} + +func (t *transport) dispatchLine(line []byte) { + var env struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error *rpcErrPayload `json:"error"` + } + if err := json.Unmarshal(line, &env); err != nil { + slog.Debug("acp: skip non-json line", "line", string(line)) + return + } + if env.Method != "" { + if isJSONRPCIDNullOrAbsent(env.ID) { + if t.onNotif != nil { + t.onNotif(env.Method, env.Params) + } + return + } + if t.onReq != nil { + t.onReq(env.Method, env.ID, env.Params) + } + return + } + if !isJSONRPCIDNullOrAbsent(env.ID) { + t.completePending(env.ID, env.Result, env.Error) + } +} + +func isJSONRPCIDNullOrAbsent(id json.RawMessage) bool { + if len(id) == 0 { + return true + } + return bytes.Equal(bytes.TrimSpace(id), []byte("null")) +} + +func jsonIDKey(id json.RawMessage) string { + id = bytes.TrimSpace(id) + var n json.Number + if json.Unmarshal(id, &n) == nil { + return string(n) + } + var s string + if json.Unmarshal(id, &s) == nil { + return s + } + return string(id) +} + +func (t *transport) completePending(id json.RawMessage, result json.RawMessage, rpcErr *rpcErrPayload) { + key := jsonIDKey(id) + t.pendingMu.Lock() + ch, ok := t.pending[key] + delete(t.pending, key) + t.pendingMu.Unlock() + if !ok { + slog.Debug("acp: unmatched rpc response", "id", key) + return + } + select { + case ch <- rpcOutcome{result: result, err: rpcErr}: + default: + } +} + +func (t *transport) cancelAll(err error) { + t.pendingMu.Lock() + defer t.pendingMu.Unlock() + msg := err.Error() + for k, ch := range t.pending { + select { + case ch <- rpcOutcome{err: &rpcErrPayload{Code: -32000, Message: msg}}: + default: + } + delete(t.pending, k) + } +} + +func (t *transport) call(ctx context.Context, method string, params any) (json.RawMessage, error) { + id := t.nextID.Add(1) + key := fmt.Sprintf("%d", id) + ch := make(chan rpcOutcome, 1) + t.pendingMu.Lock() + t.pending[key] = ch + t.pendingMu.Unlock() + + req := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + if err := t.writeJSON(req); err != nil { + t.pendingMu.Lock() + delete(t.pending, key) + t.pendingMu.Unlock() + return nil, fmt.Errorf("acp: write %s: %w", method, err) + } + select { + case <-ctx.Done(): + t.pendingMu.Lock() + delete(t.pending, key) + t.pendingMu.Unlock() + return nil, ctx.Err() + case out := <-ch: + if out.err != nil { + return nil, out.err + } + return out.result, nil + } +} + +func (t *transport) writeJSON(v any) error { + t.mu.Lock() + defer t.mu.Unlock() + return t.enc.Encode(v) +} + +type rpcResponseMsg struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcErrPayload `json:"error,omitempty"` +} + +func (t *transport) respondSuccess(id json.RawMessage, result any) error { + return t.writeJSON(rpcResponseMsg{JSONRPC: "2.0", ID: id, Result: result}) +} + +func (t *transport) respondError(id json.RawMessage, code int, message string) error { + return t.writeJSON(rpcResponseMsg{ + JSONRPC: "2.0", + ID: id, + Error: &rpcErrPayload{Code: code, Message: message}, + }) +} diff --git a/agent/acp/rpc_test.go b/agent/acp/rpc_test.go new file mode 100644 index 0000000..90491ff --- /dev/null +++ b/agent/acp/rpc_test.go @@ -0,0 +1,67 @@ +package acp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "testing" +) + +func TestTransportCallRoundTrip(t *testing.T) { + rResp, wMockResp := io.Pipe() + rReq, wTrOut := io.Pipe() + + tr := newTransport(rResp, wTrOut, nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go tr.readLoop(ctx) + + go func() { + defer wMockResp.Close() + sc := bufio.NewScanner(rReq) + for sc.Scan() { + var req map[string]any + if err := json.Unmarshal(sc.Bytes(), &req); err != nil { + continue + } + id := req["id"] + line := fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"result":{"protocolVersion":1}}`+"\n", id) + if _, err := io.WriteString(wMockResp, line); err != nil { + return + } + } + }() + + res, err := tr.call(ctx, "initialize", map[string]any{"protocolVersion": 1}) + if err != nil { + t.Fatal(err) + } + var got struct { + ProtocolVersion int `json:"protocolVersion"` + } + if err := json.Unmarshal(res, &got); err != nil { + t.Fatal(err) + } + if got.ProtocolVersion != 1 { + t.Fatalf("protocolVersion = %d", got.ProtocolVersion) + } + cancel() + _ = wTrOut.Close() +} + +func TestJSONIDKey(t *testing.T) { + if jsonIDKey(json.RawMessage(`42`)) != "42" { + t.Fatalf("numeric id") + } + if jsonIDKey(json.RawMessage(`"x"`)) != "x" { + t.Fatalf("string id") + } + if !isJSONRPCIDNullOrAbsent(json.RawMessage(`null`)) { + t.Fatalf("null id") + } + if !isJSONRPCIDNullOrAbsent(json.RawMessage(nil)) { + t.Fatalf("absent id") + } +} diff --git a/agent/acp/session.go b/agent/acp/session.go new file mode 100644 index 0000000..a6c66fc --- /dev/null +++ b/agent/acp/session.go @@ -0,0 +1,762 @@ +package acp + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// toolInputCacheMaxEntries caps toolInputByID growth; beyond this we evict +// roughly half the map (iteration order is arbitrary) to bound memory. +const toolInputCacheMaxEntries = 1000 + +type acpSession struct { + workDir string + events chan core.Event + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + + cmd *exec.Cmd + tr *transport + + acpSessMu sync.RWMutex + acpSessID string + + sendMu sync.Mutex + + permMu sync.Mutex + permByID map[string]permState + + toolInputMu sync.Mutex + toolInputByID map[string]string // toolCallId -> summarized tool input + + // modesMu guards availableModes and currentMode. Both fields are + // populated on handshake (session/new or session/load response) and + // updated whenever SetLiveMode succeeds or the server announces a + // mode change via session/update. + modesMu sync.RWMutex + availableModes []acpModeInfo + currentMode string + + callbacks sessionCallbacks // may be nil (tests, integration harness) +} + +type permState struct { + RPCID json.RawMessage + Options []permissionOption +} + +// acpSessionConfig bundles the inputs newACPSession needs. It's a +// struct rather than a long positional argument list because we keep +// adding optional knobs (initialMode, callbacks) and would otherwise +// break every call site each time. +type acpSessionConfig struct { + command string + args []string + extraEnv []string + workDir string + resumeSessionID string + authMethod string + initialMode string // if non-empty, applied via session/set_mode after session/new + callbacks sessionCallbacks // may be nil +} + +func newACPSession(ctx context.Context, cfg acpSessionConfig) (*acpSession, error) { + absWorkDir, err := filepath.Abs(cfg.workDir) + if err != nil { + absWorkDir = cfg.workDir + } + + sessionCtx, cancel := context.WithCancel(ctx) + s := &acpSession{ + workDir: absWorkDir, + events: make(chan core.Event, 128), + ctx: sessionCtx, + cancel: cancel, + permByID: make(map[string]permState), + toolInputByID: make(map[string]string), + acpSessID: cfg.resumeSessionID, + callbacks: cfg.callbacks, + } + s.alive.Store(true) + + cmd := exec.CommandContext(sessionCtx, cfg.command, cfg.args...) + cmd.Dir = absWorkDir + cmd.Env = core.MergeEnv(os.Environ(), cfg.extraEnv) + + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("acp: stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("acp: stdout pipe: %w", err) + } + var stderrBuf bytes.Buffer + cmd.Stderr = io.MultiWriter(&stderrBuf, os.Stderr) + + s.cmd = cmd + s.tr = newTransport(stdout, stdin, s.onNotification, s.onServerRequest) + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("acp: start %s: %w", cfg.command, err) + } + + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.tr.readLoop(sessionCtx) + waitErr := cmd.Wait() + if waitErr != nil { + msg := stderrBuf.String() + if msg != "" { + slog.Error("acp: process exited", "error", waitErr, "stderr", msg) + s.emit(core.Event{Type: core.EventError, Error: fmt.Errorf("%s", strings.TrimSpace(msg))}) + } else { + slog.Debug("acp: process exited", "error", waitErr) + } + } + s.alive.Store(false) + }() + + if err := s.handshake(cfg.resumeSessionID, cfg.authMethod); err != nil { + _ = s.Close() + return nil, err + } + + // Apply the agent-level mode preference now that we have a session + // id. If set_mode fails (e.g. modeId unknown to this backend) we + // log and carry on with whatever mode the server defaulted to — + // the alternative would be to reject the session entirely, which + // is worse UX for a non-critical control. + if strings.TrimSpace(cfg.initialMode) != "" { + if ok := s.SetLiveMode(cfg.initialMode); !ok { + slog.Warn("acp: initial mode could not be applied", + "mode", cfg.initialMode, + "session_id", s.currentACPSessionID(), + ) + } + } + + return s, nil +} + +// handshake runs initialize → optional authenticate → session/load or +// session/new, and caches any modes the server advertises so +// SetLiveMode / PermissionModes can answer correctly. +func (s *acpSession) handshake(resumeSessionID string, authMethod string) error { + initParams := map[string]any{ + "protocolVersion": 1, + "clientCapabilities": map[string]any{ + "fs": map[string]any{ + "readTextFile": false, + "writeTextFile": false, + }, + "terminal": false, + }, + "clientInfo": map[string]any{ + "name": "cc-connect", + "version": "1.0.0", + }, + } + res, err := s.tr.call(s.ctx, "initialize", initParams) + if err != nil { + return fmt.Errorf("acp: initialize: %w", err) + } + + var initOut acpInitializeResult + if err := json.Unmarshal(res, &initOut); err != nil { + return fmt.Errorf("acp: parse initialize result: %w", err) + } + listSupported := len(initOut.AgentCapabilities.SessionCapabilities.List) > 0 + slog.Debug("acp: initialized", + "protocol", initOut.ProtocolVersion, + "load_session", initOut.AgentCapabilities.LoadSession, + "list_sessions", listSupported, + ) + if s.callbacks != nil { + s.callbacks.reportListSupported(listSupported) + } + + if strings.TrimSpace(authMethod) != "" { + if _, err := s.tr.call(s.ctx, "authenticate", map[string]any{ + "methodId": authMethod, + }); err != nil { + return fmt.Errorf("acp: authenticate (%s): %w", authMethod, err) + } + slog.Debug("acp: authenticated", "method_id", authMethod) + } + + wantResume := resumeSessionID != "" && resumeSessionID != core.ContinueSession + if wantResume && initOut.AgentCapabilities.LoadSession { + loadParams := map[string]any{ + "sessionId": resumeSessionID, + "cwd": s.workDir, + "mcpServers": []any{}, + } + loadRes, err := s.tr.call(s.ctx, "session/load", loadParams) + if err != nil { + slog.Warn("acp: session/load failed, starting new session", "error", err) + } else { + var lr struct { + SessionID string `json:"sessionId"` + Modes *acpModesBlock `json:"modes"` + } + if json.Unmarshal(loadRes, &lr) == nil && lr.SessionID != "" { + s.setACPSessionID(lr.SessionID) + s.absorbModes(lr.Modes) + return nil + } + } + } + + newParams := map[string]any{ + "cwd": s.workDir, + "mcpServers": []any{}, + } + newRes, err := s.tr.call(s.ctx, "session/new", newParams) + if err != nil { + return fmt.Errorf("acp: session/new: %w", err) + } + var sn struct { + SessionID string `json:"sessionId"` + Modes *acpModesBlock `json:"modes"` + } + if err := json.Unmarshal(newRes, &sn); err != nil { + return fmt.Errorf("acp: parse session/new: %w", err) + } + if sn.SessionID == "" { + return fmt.Errorf("acp: session/new: empty sessionId") + } + s.setACPSessionID(sn.SessionID) + s.absorbModes(sn.Modes) + return nil +} + +// absorbModes copies a modes block into the session's cache and fans +// it out to the parent agent callbacks (if any). Both the session and +// the agent need the information: the session uses it to validate +// SetLiveMode inputs; the agent uses it to render `/mode` menus in IM. +func (s *acpSession) absorbModes(block *acpModesBlock) { + if block == nil || len(block.AvailableModes) == 0 { + return + } + s.modesMu.Lock() + s.availableModes = append(s.availableModes[:0], block.AvailableModes...) + if block.CurrentModeID != "" { + s.currentMode = block.CurrentModeID + } + s.modesMu.Unlock() + if s.callbacks != nil { + s.callbacks.reportModes(*block) + } +} + +func (s *acpSession) setACPSessionID(id string) { + s.acpSessMu.Lock() + s.acpSessID = id + s.acpSessMu.Unlock() +} + +func (s *acpSession) currentACPSessionID() string { + s.acpSessMu.RLock() + defer s.acpSessMu.RUnlock() + return s.acpSessID +} + +// CurrentMode returns the ACP modeId most recently applied or reported +// for this session. Empty when the server never sent a modes block. +func (s *acpSession) CurrentMode() string { + s.modesMu.RLock() + defer s.modesMu.RUnlock() + return s.currentMode +} + +// SetLiveMode applies a permission mode change to the running session +// via `session/set_mode`. Returns true on success, false if the mode +// is unknown / the call errors / the session is closed. +// +// This is the implementation of core.LiveModeSwitcher for ACP +// sessions; the engine invokes it when the user runs `/mode `, +// `/plan`, `/bypass`, etc. while a session is active. +// +// Client-side validation is important because at least one ACP server +// (devin acp in 2026.4.9) silently accepts unknown modeIds without +// any error, so a server-only check would let typos go undetected. +func (s *acpSession) SetLiveMode(mode string) bool { + if !s.alive.Load() { + return false + } + sid := s.currentACPSessionID() + if sid == "" { + return false + } + modeID := s.matchAvailableMode(mode) + if modeID == "" { + slog.Debug("acp: SetLiveMode rejected unknown mode", + "mode", mode, + "session_id", sid, + ) + return false + } + if _, err := s.tr.call(s.ctx, "session/set_mode", map[string]any{ + "sessionId": sid, + "modeId": modeID, + }); err != nil { + slog.Warn("acp: session/set_mode failed", + "mode", modeID, + "session_id", sid, + "error", err, + ) + return false + } + s.modesMu.Lock() + s.currentMode = modeID + s.modesMu.Unlock() + if s.callbacks != nil { + // Re-publish current modeId so Agent.GetMode stays in sync. + s.modesMu.RLock() + available := append([]acpModeInfo(nil), s.availableModes...) + s.modesMu.RUnlock() + s.callbacks.reportModes(acpModesBlock{ + CurrentModeID: modeID, + AvailableModes: available, + }) + } + slog.Info("acp: live mode applied", "mode", modeID, "session_id", sid) + return true +} + +// matchAvailableMode resolves a user-typed mode string to a known ACP +// modeId from the cached availableModes list. Matching is case- +// insensitive on both id and display name to accommodate IM input. +// Returns "" if nothing matches or if modes are unknown (first session +// hasn't handshaked yet). +func (s *acpSession) matchAvailableMode(input string) string { + input = strings.TrimSpace(input) + if input == "" { + return "" + } + lower := strings.ToLower(input) + s.modesMu.RLock() + defer s.modesMu.RUnlock() + for _, m := range s.availableModes { + if strings.ToLower(m.ID) == lower || strings.ToLower(m.Name) == lower { + return m.ID + } + } + return "" +} + +func (s *acpSession) onNotification(method string, params json.RawMessage) { + if method != "session/update" { + slog.Debug("acp: notification", "method", method) + return + } + s.cacheToolCallInput(params) + s.maybeAbsorbCurrentModeUpdate(params) + sid := s.currentACPSessionID() + for _, ev := range mapSessionUpdate(sid, params) { + s.emit(ev) + } +} + +// maybeAbsorbCurrentModeUpdate watches session/update notifications +// for `current_mode_update` (server-driven mode switch, e.g. when the +// user toggles modes via the Windsurf/IDE UI while cc-connect is +// connected). Keeping currentMode in sync here means the IM `/mode` +// indicator reflects the true server state rather than the last +// client-initiated value. +func (s *acpSession) maybeAbsorbCurrentModeUpdate(params json.RawMessage) { + var wrap struct { + Update json.RawMessage `json:"update"` + } + if json.Unmarshal(params, &wrap) != nil || len(wrap.Update) == 0 { + return + } + var head struct { + Kind string `json:"sessionUpdate"` + CurrentModeID string `json:"currentModeId"` + } + if json.Unmarshal(wrap.Update, &head) != nil { + return + } + if head.Kind != "current_mode_update" || head.CurrentModeID == "" { + return + } + s.modesMu.Lock() + s.currentMode = head.CurrentModeID + available := append([]acpModeInfo(nil), s.availableModes...) + s.modesMu.Unlock() + if s.callbacks != nil { + s.callbacks.reportModes(acpModesBlock{ + CurrentModeID: head.CurrentModeID, + AvailableModes: available, + }) + } +} + +// cacheToolCallInput extracts and caches rawInput from tool_call and tool_call_update +// session updates so that handlePermissionRequest can look it up by toolCallId. +// OpenCode ACP bug (#7370): rawInput is empty in tool_call and request_permission, +// but populated in tool_call_update. We cache from both sources. +func (s *acpSession) evictToolInputCacheIfNeededLocked() { + if len(s.toolInputByID) < toolInputCacheMaxEntries { + return + } + target := toolInputCacheMaxEntries / 2 + for k := range s.toolInputByID { + if len(s.toolInputByID) <= target { + break + } + delete(s.toolInputByID, k) + } +} + +func (s *acpSession) cacheToolCallInput(params json.RawMessage) { + var wrap struct { + Update json.RawMessage `json:"update"` + } + if json.Unmarshal(params, &wrap) != nil || len(wrap.Update) == 0 { + return + } + var head struct { + SessionUpdate string `json:"sessionUpdate"` + } + if json.Unmarshal(wrap.Update, &head) != nil { + return + } + switch head.SessionUpdate { + case "tool_call": + var tc struct { + ToolCallID string `json:"toolCallId"` + Kind string `json:"kind"` + RawInput json.RawMessage `json:"rawInput"` + } + if json.Unmarshal(wrap.Update, &tc) != nil || tc.ToolCallID == "" || len(tc.RawInput) == 0 { + return + } + s.toolInputMu.Lock() + s.evictToolInputCacheIfNeededLocked() + input := summarizeACPToolInput(tc.Kind, tc.RawInput) + s.toolInputByID[tc.ToolCallID] = input + s.toolInputMu.Unlock() + slog.Info("acp: cached tool_call input", "toolCallId", tc.ToolCallID, "kind", tc.Kind, "input", input) + case "tool_call_update": + var tc struct { + ToolCallID string `json:"toolCallId"` + RawInput json.RawMessage `json:"rawInput"` + } + if json.Unmarshal(wrap.Update, &tc) != nil || tc.ToolCallID == "" || len(tc.RawInput) == 0 { + return + } + input := summarizeACPToolInput("", tc.RawInput) + if input == "" { + return + } + s.toolInputMu.Lock() + s.evictToolInputCacheIfNeededLocked() + s.toolInputByID[tc.ToolCallID] = input + s.toolInputMu.Unlock() + slog.Info("acp: cached tool_call_update input", "toolCallId", tc.ToolCallID, "input", input) + } +} + +func (s *acpSession) onServerRequest(method string, id json.RawMessage, params json.RawMessage) { + switch method { + case "session/request_permission": + s.handlePermissionRequest(id, params) + case "cursor/ask_question", "cursor/create_plan", "cursor/update_todos", "cursor/task", "cursor/generate_image": + // Cursor CLI extensions — acknowledge so tool flows do not block; IM UX is limited for these. + slog.Debug("acp: cursor extension request (no-op ack)", "method", method) + _ = s.tr.respondSuccess(id, map[string]any{}) + default: + if strings.HasPrefix(method, "cursor/") { + slog.Debug("acp: unknown cursor extension, ack empty", "method", method) + _ = s.tr.respondSuccess(id, map[string]any{}) + return + } + slog.Info("acp: unhandled server request", "method", method) + _ = s.tr.respondError(id, -32601, "method not implemented") + } +} + +func (s *acpSession) handlePermissionRequest(id json.RawMessage, params json.RawMessage) { + var p struct { + SessionID string `json:"sessionId"` + ToolCall struct { + ToolCallID string `json:"toolCallId"` + Title string `json:"title"` + Kind string `json:"kind"` + RawInput json.RawMessage `json:"rawInput"` + } `json:"toolCall"` + Options []permissionOption `json:"options"` + } + if err := json.Unmarshal(params, &p); err != nil { + _ = s.tr.respondError(id, -32602, "invalid params") + return + } + slog.Debug("acp: permission request raw params", "params", string(params)) + reqKey := jsonIDKey(id) + toolName := p.ToolCall.Title + if toolName == "" { + toolName = p.ToolCall.Kind + } + if toolName == "" { + toolName = "permission" + } + + s.permMu.Lock() + s.permByID[reqKey] = permState{RPCID: id, Options: p.Options} + s.permMu.Unlock() + + rawTool := map[string]any{} + _ = json.Unmarshal(params, &rawTool) + + // OpenCode ACP bug (#7370): rawInput in request_permission is always {}, + // but tool_call_update (which arrives right after) has the real input. + // Emit in a goroutine so we don't block the read loop, and wait briefly + // for tool_call_update to populate the cache. + s.wg.Add(1) + go func() { + defer s.wg.Done() + for i := 0; i < 10; i++ { + s.toolInputMu.Lock() + toolInput := s.toolInputByID[p.ToolCall.ToolCallID] + s.toolInputMu.Unlock() + if toolInput != "" { + break + } + select { + case <-s.ctx.Done(): + return + case <-time.After(50 * time.Millisecond): + } + } + s.toolInputMu.Lock() + toolInput := s.toolInputByID[p.ToolCall.ToolCallID] + s.toolInputMu.Unlock() + if toolInput == "" { + toolInput = summarizeACPToolInput(p.ToolCall.Kind, p.ToolCall.RawInput) + } + if toolInput == "" { + toolInput = p.ToolCall.Title + } + if toolInput == "" { + toolInput = p.ToolCall.ToolCallID + } + + slog.Info("acp: permission request", "request_id", reqKey, "tool", toolName, "input", toolInput) + s.emit(core.Event{ + Type: core.EventPermissionRequest, + RequestID: reqKey, + ToolName: toolName, + ToolInput: toolInput, + ToolInputRaw: rawTool, + SessionID: s.currentACPSessionID(), + }) + }() +} + +func (s *acpSession) emit(ev core.Event) { + if ev.SessionID == "" { + ev.SessionID = s.currentACPSessionID() + } + select { + case s.events <- ev: + case <-s.ctx.Done(): + } +} + +func (s *acpSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if !s.alive.Load() { + return fmt.Errorf("acp: session closed") + } + + s.sendMu.Lock() + defer s.sendMu.Unlock() + + filePaths := core.SaveFilesToDisk(s.workDir, files) + prompt = core.AppendFileRefs(prompt, filePaths) + if len(images) > 0 { + prompt = s.appendImageRefs(prompt, images) + } + + sid := s.currentACPSessionID() + if sid == "" { + return fmt.Errorf("acp: no agent session id") + } + + promptBlocks := []any{ + map[string]any{"type": "text", "text": prompt}, + } + params := map[string]any{ + "sessionId": sid, + "prompt": promptBlocks, + } + + _, err := s.tr.call(s.ctx, "session/prompt", params) + if err != nil { + s.emit(core.Event{Type: core.EventError, Error: err}) + return fmt.Errorf("acp: session/prompt: %w", err) + } + + // Text was streamed via session/update; engine aggregates EventText. + s.emit(core.Event{ + Type: core.EventResult, + SessionID: sid, + Done: true, + }) + return nil +} + +func (s *acpSession) appendImageRefs(prompt string, images []core.ImageAttachment) string { + attachDir := filepath.Join(s.workDir, ".cc-connect", "attachments") + if err := os.MkdirAll(attachDir, 0o755); err != nil { + slog.Warn("acp: mkdir attachments failed", "error", err) + return prompt + } + var paths []string + for i, img := range images { + ext := ".bin" + switch img.MimeType { + case "image/jpeg": + ext = ".jpg" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + case "image/png", "": + ext = ".png" + } + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(attachDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o644); err != nil { + slog.Error("acp: save image failed", "error", err) + continue + } + paths = append(paths, fpath) + } + if len(paths) == 0 { + return prompt + } + if prompt == "" { + prompt = "User sent image(s)." + } + return prompt + "\n\n(Image files saved locally: " + strings.Join(paths, ", ") + ")" +} + +func (s *acpSession) RespondPermission(requestID string, result core.PermissionResult) error { + if !s.alive.Load() { + return fmt.Errorf("acp: session closed") + } + + s.permMu.Lock() + st, ok := s.permByID[requestID] + if ok { + delete(s.permByID, requestID) + } + s.permMu.Unlock() + if !ok { + return fmt.Errorf("acp: unknown permission request %q", requestID) + } + + allow := strings.EqualFold(result.Behavior, "allow") + optID := pickPermissionOptionID(allow, st.Options) + if allow && optID == "" { + slog.Warn("acp: allow requested but agent sent no options", "request_id", requestID) + return s.tr.respondError(st.RPCID, -32603, "no permission options from agent") + } + res := buildPermissionResult(allow, optID) + + slog.Debug("acp: permission response", "request_id", requestID, "allow", allow, "option_id", optID) + return s.tr.respondSuccess(st.RPCID, res) +} + +func (s *acpSession) Events() <-chan core.Event { + return s.events +} + +func (s *acpSession) CurrentSessionID() string { + return s.currentACPSessionID() +} + +func (s *acpSession) Alive() bool { + return s.alive.Load() +} + +func (s *acpSession) Close() error { + s.alive.Store(false) + s.cancel() + if s.cmd != nil && s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + select { + case <-done: + close(s.events) + case <-time.After(8 * time.Second): + slog.Warn("acp: close timed out waiting for I/O loop") + } + return nil +} + +// summarizeACPToolInput extracts a human-readable summary from ACP tool rawInput. +func summarizeACPToolInput(kind string, raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var m map[string]any + if json.Unmarshal(raw, &m) != nil { + return string(raw) + } + if len(m) == 0 { + return "" + } + switch strings.ToLower(kind) { + case "bash", "shell", "terminal", "execute": + if cmd, ok := m["command"].(string); ok { + if desc, ok := m["description"].(string); ok && desc != "" { + return "# " + desc + "\n" + cmd + } + return cmd + } + case "read", "write", "edit": + if fp, ok := m["file_path"].(string); ok { + return fp + } + if fp, ok := m["path"].(string); ok { + return fp + } + } + // Fallback: try extracting command with description before formatting JSON. + if cmd, ok := m["command"].(string); ok { + if desc, ok := m["description"].(string); ok && desc != "" { + return "# " + desc + "\n" + cmd + } + return cmd + } + b, _ := json.MarshalIndent(m, "", " ") + return string(b) +} diff --git a/agent/acp/session_mode_test.go b/agent/acp/session_mode_test.go new file mode 100644 index 0000000..af1207a --- /dev/null +++ b/agent/acp/session_mode_test.go @@ -0,0 +1,505 @@ +package acp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// --- Agent: mode cache & SetMode/GetMode --------------------------- + +func TestAgent_PermissionModes_emptyBeforeFirstHandshake(t *testing.T) { + a := &Agent{} + if got := a.PermissionModes(); len(got) != 0 { + t.Fatalf("want empty modes before first handshake, got %v", got) + } + if got := a.GetMode(); got != "" { + t.Fatalf("want empty mode before handshake, got %q", got) + } +} + +func TestAgent_reportModes_populatesCache(t *testing.T) { + a := &Agent{} + a.reportModes(acpModesBlock{ + CurrentModeID: "plan", + AvailableModes: []acpModeInfo{ + {ID: "normal", Name: "Code", Description: "Write and edit code"}, + {ID: "plan", Name: "Plan", Description: "Plan changes"}, + {ID: "bypass", Name: "Bypass Permissions"}, + }, + }) + + modes := a.PermissionModes() + if len(modes) != 3 { + t.Fatalf("got %d modes, want 3", len(modes)) + } + if modes[0].Key != "normal" || modes[0].Name != "Code" || modes[0].Desc != "Write and edit code" { + t.Fatalf("modes[0] = %+v", modes[0]) + } + if modes[0].NameZh != "Code" || modes[0].DescZh != "Write and edit code" { + t.Fatalf("zh fallback missing on modes[0] = %+v", modes[0]) + } + // Nobody has called SetMode, so GetMode falls back to the + // server-reported currentModeId. + if got := a.GetMode(); got != "plan" { + t.Fatalf("GetMode = %q, want plan (fallback to server currentModeId when no explicit SetMode)", got) + } +} + +// Regression: after `/mode plan`, cc-connect's engine calls SetMode("plan") +// then reads back GetMode() to decide what to display and apply via +// SetLiveMode. The pending SetMode MUST win over the previously-cached +// currentModeId, otherwise /mode reports the wrong mode name and the +// live switch goes to the old mode. +func TestAgent_GetMode_pendingWinsOverCachedCurrent(t *testing.T) { + a := &Agent{} + // Simulate a first session handshake which reported current=normal. + a.reportModes(acpModesBlock{ + CurrentModeID: "normal", + AvailableModes: []acpModeInfo{ + {ID: "normal", Name: "Code"}, + {ID: "plan", Name: "Plan"}, + }, + }) + if got := a.GetMode(); got != "normal" { + t.Fatalf("pre-SetMode GetMode = %q, want normal", got) + } + + a.SetMode("plan") + if got := a.GetMode(); got != "plan" { + t.Fatalf("post-SetMode GetMode = %q, want plan (pending takes precedence)", got) + } +} + +func TestAgent_SetMode_normalisesAgainstCache(t *testing.T) { + a := &Agent{} + a.reportModes(acpModesBlock{ + CurrentModeID: "normal", + AvailableModes: []acpModeInfo{ + {ID: "normal", Name: "Code"}, + {ID: "accept-edits", Name: "Accept Edits"}, + }, + }) + + // Case-insensitive match on id + a.SetMode("Normal") + a.mu.RLock() + if a.mode != "normal" { + t.Fatalf("pending mode = %q, want normal", a.mode) + } + a.mu.RUnlock() + + // Case-insensitive match on display name → canonical id + a.SetMode("accept edits") + a.mu.RLock() + gotPending := a.mode + a.mu.RUnlock() + if gotPending != "accept-edits" { + t.Fatalf("pending mode = %q, want accept-edits (normalised via case-insensitive display-name match)", gotPending) + } + + // Unknown input → stored as-is so a later StartSession can try it + // (at which point session/set_mode will soft-fail loudly). + a.SetMode("totally-unknown") + a.mu.RLock() + gotPending = a.mode + a.mu.RUnlock() + if gotPending != "totally-unknown" { + t.Fatalf("pending mode = %q, want totally-unknown (passthrough)", gotPending) + } +} + +func TestAgent_GetMode_fallbackToPendingWhenNoSession(t *testing.T) { + a := &Agent{mode: "plan"} + if got := a.GetMode(); got != "plan" { + t.Fatalf("GetMode = %q, want plan (pending, no handshake yet)", got) + } +} + +// --- session/list parsing ------------------------------------------ + +func TestConvertSessionList_cwdFilter(t *testing.T) { + entries := []acpSessionListEntry{ + {SessionID: "a", Cwd: "/tmp/proj1", Title: "First", UpdatedAt: "2026-04-18T16:15:29+00:00"}, + {SessionID: "b", Cwd: "/tmp/proj2", Title: "Second", UpdatedAt: "2026-04-18T16:10:29+00:00"}, + // Entry without cwd passes through regardless of filter + {SessionID: "c", Cwd: "", Title: "Third", UpdatedAt: "2026-04-18T16:05:29+00:00"}, + } + + got := convertSessionList(entries, "/tmp/proj1") + if len(got) != 2 { + t.Fatalf("got %d, want 2 (proj1 + cwd-less)", len(got)) + } + if got[0].ID != "a" || got[0].Summary != "First" { + t.Fatalf("entry[0] = %+v", got[0]) + } + if got[1].ID != "c" { + t.Fatalf("entry[1] = %+v, want passthrough of cwd-less entry", got[1]) + } + if got[0].ModifiedAt.IsZero() { + t.Fatalf("ModifiedAt not parsed: %+v", got[0]) + } +} + +func TestConvertSessionList_noCwdFilter(t *testing.T) { + entries := []acpSessionListEntry{ + {SessionID: "a", Cwd: "/tmp/proj1"}, + {SessionID: "b", Cwd: "/tmp/proj2"}, + } + got := convertSessionList(entries, "") + if len(got) != 2 { + t.Fatalf("got %d, want 2 when no filter", len(got)) + } +} + +func TestConvertSessionList_pathCleanAndCaseInsensitive(t *testing.T) { + entries := []acpSessionListEntry{ + {SessionID: "a", Cwd: "/Users/Foo/Proj"}, + {SessionID: "b", Cwd: "/users/foo/proj"}, // case-insensitive match expected on case-insensitive FS + {SessionID: "c", Cwd: "/Users/Foo/Proj/sub"}, + } + // filter that includes trailing separator to verify Clean + got := convertSessionList(entries, "/Users/Foo/Proj/") + if len(got) != 2 { + t.Fatalf("got %d, want 2 (case-insensitive + Clean match on first two)", len(got)) + } +} + +// Verifies probeListSessions swallows -32601 (method not found) and +// surfaces other errors. +func TestProbeListSessions_softFailsOnMethodNotFound(t *testing.T) { + rResp, wResp := io.Pipe() + rReq, wReq := io.Pipe() + + tr := newTransport(rResp, wReq, nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go tr.readLoop(ctx) + + // Mock server: respond -32601 for session/list. + go func() { + defer wResp.Close() + sc := bufio.NewScanner(rReq) + for sc.Scan() { + var req map[string]any + if err := json.Unmarshal(sc.Bytes(), &req); err != nil { + continue + } + id := req["id"] + line := fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"error":{"code":-32601,"message":"method not found"}}`+"\n", id) + _, _ = io.WriteString(wResp, line) + } + }() + + entries, err := probeListSessions(ctx, tr, "") + if err != nil { + t.Fatalf("want nil error on method-not-found, got %v", err) + } + if entries != nil { + t.Fatalf("want nil entries on method-not-found, got %v", entries) + } +} + +func TestProbeListSessions_propagatesHardError(t *testing.T) { + rResp, wResp := io.Pipe() + rReq, wReq := io.Pipe() + + tr := newTransport(rResp, wReq, nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go tr.readLoop(ctx) + + go func() { + defer wResp.Close() + sc := bufio.NewScanner(rReq) + for sc.Scan() { + var req map[string]any + if err := json.Unmarshal(sc.Bytes(), &req); err != nil { + continue + } + id := req["id"] + line := fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"error":{"code":-32000,"message":"boom"}}`+"\n", id) + _, _ = io.WriteString(wResp, line) + } + }() + + entries, err := probeListSessions(ctx, tr, "") + if err == nil { + t.Fatalf("want error on hard failure, got entries=%v", entries) + } + if !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected wrapped error to contain 'boom', got %v", err) + } +} + +func TestProbeListSessions_parsesSessions(t *testing.T) { + rResp, wResp := io.Pipe() + rReq, wReq := io.Pipe() + + tr := newTransport(rResp, wReq, nil, nil) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go tr.readLoop(ctx) + + go func() { + defer wResp.Close() + sc := bufio.NewScanner(rReq) + for sc.Scan() { + var req map[string]any + if err := json.Unmarshal(sc.Bytes(), &req); err != nil { + continue + } + id := req["id"] + line := fmt.Sprintf(`{"jsonrpc":"2.0","id":%v,"result":{"sessions":[{"sessionId":"s1","cwd":"/tmp","title":"hi","updatedAt":"2026-04-18T16:15:29+00:00"}]}}`+"\n", id) + _, _ = io.WriteString(wResp, line) + } + }() + + entries, err := probeListSessions(ctx, tr, "/tmp") + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].SessionID != "s1" || entries[0].Title != "hi" { + t.Fatalf("unexpected entries: %+v", entries) + } +} + +// --- session: SetLiveMode + callbacks ------------------------------ + +// fakeCallbacks captures reportModes / reportListSupported invocations +// so tests can assert on them deterministically. +type fakeCallbacks struct { + mu sync.Mutex + modes []acpModesBlock + listCalls []bool +} + +func (f *fakeCallbacks) reportModes(b acpModesBlock) { + f.mu.Lock() + f.modes = append(f.modes, b) + f.mu.Unlock() +} +func (f *fakeCallbacks) reportListSupported(supported bool) { + f.mu.Lock() + f.listCalls = append(f.listCalls, supported) + f.mu.Unlock() +} +func (f *fakeCallbacks) lastModes() (acpModesBlock, bool) { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.modes) == 0 { + return acpModesBlock{}, false + } + return f.modes[len(f.modes)-1], true +} + +// newTestSession builds an acpSession with a pipe-backed transport +// (no real subprocess). The second return value is a writer the test +// uses to inject server-side RPC responses. +func newTestSession(t *testing.T, cb sessionCallbacks) (*acpSession, *io.PipeWriter, *io.PipeReader) { + t.Helper() + rResp, wResp := io.Pipe() // server → client + rReq, wReq := io.Pipe() // client → server + + s := &acpSession{ + workDir: t.TempDir(), + events: make(chan core.Event, 32), + permByID: make(map[string]permState), + toolInputByID: make(map[string]string), + callbacks: cb, + } + s.ctx, s.cancel = context.WithCancel(context.Background()) + s.alive.Store(true) + s.acpSessID = "test-session-id" + s.tr = newTransport(rResp, wReq, s.onNotification, s.onServerRequest) + go s.tr.readLoop(s.ctx) + + t.Cleanup(func() { + s.cancel() + wResp.Close() + rReq.Close() + }) + return s, wResp, rReq +} + +func TestSession_SetLiveMode_success(t *testing.T) { + cb := &fakeCallbacks{} + s, wResp, rReq := newTestSession(t, cb) + + // Pre-populate availableModes so SetLiveMode validates OK. + s.absorbModes(&acpModesBlock{ + CurrentModeID: "normal", + AvailableModes: []acpModeInfo{ + {ID: "normal", Name: "Code"}, + {ID: "plan", Name: "Plan"}, + }, + }) + + // Mock server: read one request, verify it, respond success. + done := make(chan struct{}) + go func() { + defer close(done) + sc := bufio.NewScanner(rReq) + for sc.Scan() { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + SessionID string `json:"sessionId"` + ModeID string `json:"modeId"` + } `json:"params"` + } + if err := json.Unmarshal(sc.Bytes(), &req); err != nil { + continue + } + if req.Method != "session/set_mode" { + continue + } + if req.Params.SessionID != "test-session-id" || req.Params.ModeID != "plan" { + _, _ = fmt.Fprintf(wResp, `{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"bad params"}}`+"\n", req.ID) + return + } + _, _ = fmt.Fprintf(wResp, `{"jsonrpc":"2.0","id":%s,"result":{}}`+"\n", req.ID) + return + } + }() + + if !s.SetLiveMode("plan") { + t.Fatal("SetLiveMode returned false for valid mode") + } + <-done + + if got := s.CurrentMode(); got != "plan" { + t.Fatalf("CurrentMode = %q, want plan", got) + } + + // Callback should have been re-fired with currentModeId=plan. + time.Sleep(10 * time.Millisecond) // small grace for goroutine + last, ok := cb.lastModes() + if !ok { + t.Fatalf("expected callback invocation after successful set_mode") + } + if last.CurrentModeID != "plan" { + t.Fatalf("callback currentModeId = %q, want plan", last.CurrentModeID) + } +} + +func TestSession_SetLiveMode_rejectsUnknownMode(t *testing.T) { + cb := &fakeCallbacks{} + s, wResp, rReq := newTestSession(t, cb) + _ = wResp + _ = rReq + + s.absorbModes(&acpModesBlock{ + CurrentModeID: "normal", + AvailableModes: []acpModeInfo{ + {ID: "normal", Name: "Code"}, + }, + }) + + if s.SetLiveMode("plan") { + t.Fatal("SetLiveMode should refuse unknown mode without making RPC") + } + // currentMode unchanged. + if got := s.CurrentMode(); got != "normal" { + t.Fatalf("CurrentMode drifted: %q", got) + } +} + +func TestSession_SetLiveMode_caseInsensitive(t *testing.T) { + cb := &fakeCallbacks{} + s, wResp, rReq := newTestSession(t, cb) + + s.absorbModes(&acpModesBlock{ + AvailableModes: []acpModeInfo{ + {ID: "accept-edits", Name: "Accept Edits"}, + }, + }) + + // Mock server: unconditionally OK. + go func() { + sc := bufio.NewScanner(rReq) + for sc.Scan() { + var env struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params struct { + ModeID string `json:"modeId"` + } `json:"params"` + } + if err := json.Unmarshal(sc.Bytes(), &env); err != nil { + continue + } + if env.Method != "session/set_mode" { + continue + } + if env.Params.ModeID != "accept-edits" { + // Test asserts canonicalisation happened before RPC. + _, _ = fmt.Fprintf(wResp, `{"jsonrpc":"2.0","id":%s,"error":{"code":-32602,"message":"wrong id %q"}}`+"\n", env.ID, env.Params.ModeID) + return + } + _, _ = fmt.Fprintf(wResp, `{"jsonrpc":"2.0","id":%s,"result":{}}`+"\n", env.ID) + return + } + }() + + // User types "ACCEPT EDITS" with wrong case + if !s.SetLiveMode("ACCEPT EDITS") { + t.Fatal("SetLiveMode should accept case-variant of display name and canonicalise to id") + } +} + +func TestSession_absorbModes_reportsViaCallback(t *testing.T) { + cb := &fakeCallbacks{} + s, _, _ := newTestSession(t, cb) + + s.absorbModes(&acpModesBlock{ + CurrentModeID: "plan", + AvailableModes: []acpModeInfo{ + {ID: "normal"}, + {ID: "plan"}, + }, + }) + + got, ok := cb.lastModes() + if !ok { + t.Fatal("expected callback") + } + if got.CurrentModeID != "plan" || len(got.AvailableModes) != 2 { + t.Fatalf("unexpected callback block: %+v", got) + } +} + +func TestSession_maybeAbsorbCurrentModeUpdate(t *testing.T) { + cb := &fakeCallbacks{} + s, _, _ := newTestSession(t, cb) + s.absorbModes(&acpModesBlock{ + AvailableModes: []acpModeInfo{{ID: "normal"}, {ID: "plan"}}, + }) + + // Simulate a server-sent current_mode_update notification + params := json.RawMessage(`{ + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "current_mode_update", + "currentModeId": "plan" + } + }`) + s.maybeAbsorbCurrentModeUpdate(params) + + if got := s.CurrentMode(); got != "plan" { + t.Fatalf("currentMode = %q, want plan", got) + } + last, ok := cb.lastModes() + if !ok || last.CurrentModeID != "plan" { + t.Fatalf("callback should have been fired with currentModeId=plan, got %+v ok=%v", last, ok) + } +} diff --git a/agent/antigravity/antigravity.go b/agent/antigravity/antigravity.go new file mode 100644 index 0000000..e982c4e --- /dev/null +++ b/agent/antigravity/antigravity.go @@ -0,0 +1,566 @@ +package antigravity + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("antigravity", New) +} + +// Agent drives the Antigravity CLI (agy) in headless mode. +// +// Modes (maps to agy approval and sandbox flags): +// - "default": standard approval mode (prompt for each tool use) +// - "yolo": auto-approve all tools (--dangerously-skip-permissions) +// - "plan": read-only plan mode with terminal sandbox constraints (--sandbox) +type Agent struct { + workDir string + model string + mode string + cmd string // CLI binary name, default "agy" + timeout time.Duration + providers []core.ProviderConfig + activeIdx int + sessionEnv []string + mu sync.RWMutex +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + cmd, _ := opts["cmd"].(string) + if cmd == "" { + cmd = "agy" + } + + var timeoutMins int64 + switch v := opts["timeout_mins"].(type) { + case int64: + timeoutMins = v + case int: + timeoutMins = int64(v) + case float64: + timeoutMins = int64(v) + default: + if v != nil { + slog.Debug("antigravity: timeout_mins has unexpected type", "type", fmt.Sprintf("%T", v)) + } + } + var timeout time.Duration + if timeoutMins > 0 { + timeout = time.Duration(timeoutMins) * time.Minute + } + + if _, err := exec.LookPath(cmd); err != nil { + return nil, fmt.Errorf("antigravity: %q CLI not found in PATH, install from: https://antigravity.google/docs/cli-overview", cmd) + } + + return &Agent{ + workDir: workDir, + model: model, + mode: mode, + cmd: cmd, + timeout: timeout, + activeIdx: -1, + }, nil +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "yolo", "auto", "force", "bypasspermissions": + return "yolo" + case "plan", "sandbox": + return "plan" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "antigravity" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("antigravity: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("antigravity: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { + if models := a.configuredModels(); len(models) > 0 { + return models + } + if models := a.fetchModelsFromAPI(ctx); len(models) > 0 { + return models + } + return []core.ModelOption{ + {Name: "gemini-3.1-pro-preview", Desc: "Gemini 3.1 Pro Preview"}, + {Name: "gemini-3-flash-preview", Desc: "Gemini 3 Flash Preview"}, + {Name: "gemini-2.5-pro", Desc: "Gemini 2.5 Pro"}, + {Name: "gemini-2.5-flash", Desc: "Gemini 2.5 Flash"}, + } +} + +func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption { + apiKey := os.Getenv("GEMINI_API_KEY") + if apiKey == "" { + apiKey = os.Getenv("GOOGLE_API_KEY") + } + if apiKey == "" { + return nil + } + + url := "https://generativelanguage.googleapis.com/v1beta/models?key=" + apiKey + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Debug("antigravity: failed to fetch models", "error", err) + return nil + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK { + return nil + } + + var result struct { + Models []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil + } + + var models []core.ModelOption + for _, m := range result.Models { + id := strings.TrimPrefix(m.Name, "models/") + if !strings.HasPrefix(id, "gemini-") { + continue + } + models = append(models, core.ModelOption{Name: id, Desc: m.DisplayName}) + } + sort.Slice(models, func(i, j int) bool { return models[i].Name > models[j].Name }) + return models +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + model := a.model + mode := a.mode + cmd := a.cmd + workDir := a.workDir + timeout := a.timeout + extraEnv := a.providerEnvLocked() + extraEnv = append(extraEnv, a.sessionEnv...) + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + if m := a.providers[a.activeIdx].Model; m != "" { + model = m + } + } + a.mu.Unlock() + + return newAntigravitySession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout) +} + +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + return listAntigravitySessions(a.workDir) +} + +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("antigravity: cannot determine home dir: %w", err) + } + chatsDir := filepath.Join(homeDir, ".gemini", "tmp", antigravityProjectSlug(a.workDir), "chats") + entries, err := os.ReadDir(chatsDir) + if err != nil { + return fmt.Errorf("session file not found: %s", sessionID) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + fpath := filepath.Join(chatsDir, entry.Name()) + file, err := os.Open(fpath) + if err != nil { + continue + } + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + if scanner.Scan() { + var sf struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal([]byte(scanner.Text()), &sf) == nil && sf.SessionID == sessionID { + _ = file.Close() + return os.Remove(fpath) + } + } + if err := scanner.Err(); err != nil { + _ = file.Close() + continue + } + _ = file.Close() + } + return fmt.Errorf("session file not found: %s", sessionID) +} + +func (a *Agent) Stop() error { return nil } + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("antigravity: mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Prompt for approval on each tool use", DescZh: "每次工具调用都需要确认"}, + {Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"}, + {Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only plan mode in sandbox", DescZh: "只读沙箱规划模式"}, + } +} + +func (a *Agent) CommandDirs() []string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + dirs := []string{filepath.Join(absDir, ".gemini", "commands")} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".gemini", "commands")) + } + return dirs +} + +func (a *Agent) SkillDirs() []string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + dirs := []string{filepath.Join(absDir, ".gemini", "skills")} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".gemini", "skills")) + } + return dirs +} + +func (a *Agent) CompressCommand() string { return "" } + +func (a *Agent) ProjectMemoryFile() string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + return filepath.Join(absDir, "GEMINI.md") +} + +func (a *Agent) GlobalMemoryFile() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".gemini", "GEMINI.md") +} + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.activeIdx = -1 + slog.Info("antigravity: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("antigravity: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + var env []string + if p.APIKey != "" { + env = append(env, "GEMINI_API_KEY="+p.APIKey) + } + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +func antigravityProjectSlug(workDir string) string { + abs, err := filepath.Abs(workDir) + if err != nil { + abs = workDir + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return slugify(filepath.Base(abs)) + } + + data, err := os.ReadFile(filepath.Join(homeDir, ".gemini", "projects.json")) + if err == nil { + var registry struct { + Projects map[string]string `json:"projects"` + } + if json.Unmarshal(data, ®istry) == nil { + normalized := filepath.Clean(abs) + if slug, ok := registry.Projects[normalized]; ok { + return slug + } + } + } + + return slugify(filepath.Base(abs)) +} + +func slugify(s string) string { + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + result := b.String() + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + result = strings.Trim(result, "-") + if result == "" { + result = "project" + } + return result +} + +type sessionFile struct { + SessionID string `json:"sessionId"` + ProjectHash string `json:"projectHash"` + StartTime time.Time `json:"startTime"` + LastUpdated time.Time `json:"lastUpdated"` + Kind string `json:"kind"` +} + +type sessionMessagePart struct { + Text string `json:"text"` +} + +type sessionLine struct { + Type string `json:"type"` // "user", "assistant" + Content []sessionMessagePart `json:"content"` + Set *struct { + LastUpdated time.Time `json:"lastUpdated"` + } `json:"$set"` +} + +func listAntigravitySessions(workDir string) ([]core.AgentSessionInfo, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("antigravity: cannot determine home dir: %w", err) + } + + slug := antigravityProjectSlug(workDir) + chatsDir := filepath.Join(homeDir, ".gemini", "tmp", slug, "chats") + + entries, err := os.ReadDir(chatsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("antigravity: read chats dir: %w", err) + } + + var sessions []core.AgentSessionInfo + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + + fpath := filepath.Join(chatsDir, entry.Name()) + file, err := os.Open(fpath) + if err != nil { + continue + } + + var sf sessionFile + var summary string + msgCount := 0 + hasUserMsg := false + + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + lineNum := 0 + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + if lineNum == 0 { + if err := json.Unmarshal([]byte(line), &sf); err != nil || sf.SessionID == "" { + break + } + lineNum++ + continue + } + + var sl sessionLine + if json.Unmarshal([]byte(line), &sl) == nil { + if sl.Set != nil && !sl.Set.LastUpdated.IsZero() { + sf.LastUpdated = sl.Set.LastUpdated + } else if sl.Type != "" { + msgCount++ + if sl.Type == "user" { + hasUserMsg = true + if summary == "" && len(sl.Content) > 0 { + text := strings.TrimSpace(sl.Content[0].Text) + for _, chunk := range strings.Split(text, "\n") { + chunk = strings.TrimSpace(chunk) + if chunk != "" { + summary = chunk + break + } + } + } + } + } + } + lineNum++ + } + if err := scanner.Err(); err != nil { + _ = file.Close() + continue + } + _ = file.Close() + + if sf.SessionID == "" || sf.Kind == "subagent" || !hasUserMsg { + continue + } + + if summary == "" { + summary = sf.SessionID + } + if utf8.RuneCountInString(summary) > 60 { + summary = string([]rune(summary)[:60]) + "..." + } + + modTime := sf.LastUpdated + if modTime.IsZero() { + modTime = sf.StartTime + } + + sessions = append(sessions, core.AgentSessionInfo{ + ID: sf.SessionID, + Summary: summary, + MessageCount: msgCount, + ModifiedAt: modTime, + }) + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + + return sessions, nil +} diff --git a/agent/antigravity/antigravity_model_test.go b/agent/antigravity/antigravity_model_test.go new file mode 100644 index 0000000..51516ba --- /dev/null +++ b/agent/antigravity/antigravity_model_test.go @@ -0,0 +1,57 @@ +package antigravity + +import ( + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestConfiguredModels_BoundaryConditions(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "first"}}}, + {Models: []core.ModelOption{{Name: "second"}}}, + }, + } + + tests := []struct { + name string + activeIdx int + wantNil bool + wantName string + }{ + {name: "negative index", activeIdx: -1, wantNil: true}, + {name: "out of range", activeIdx: 2, wantNil: true}, + {name: "valid index", activeIdx: 1, wantName: "second"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a.activeIdx = tt.activeIdx + got := a.configuredModels() + if tt.wantNil { + if got != nil { + t.Fatalf("configuredModels() = %v, want nil", got) + } + return + } + if len(got) != 1 || got[0].Name != tt.wantName { + t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName) + } + }) + } +} + +func TestGetModel_PrefersActiveProviderModel(t *testing.T) { + a := &Agent{ + model: "gemini-2.5-flash", + providers: []core.ProviderConfig{ + {Name: "google", Model: "gemini-2.5-pro"}, + }, + activeIdx: 0, + } + + if got := a.GetModel(); got != "gemini-2.5-pro" { + t.Fatalf("GetModel() = %q, want gemini-2.5-pro", got) + } +} diff --git a/agent/antigravity/session.go b/agent/antigravity/session.go new file mode 100644 index 0000000..24d85e0 --- /dev/null +++ b/agent/antigravity/session.go @@ -0,0 +1,475 @@ +package antigravity + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// antigravitySession manages multi-turn conversations with the Antigravity CLI (agy). +type antigravitySession struct { + cmd string + workDir string + model string + mode string + timeout time.Duration + extraEnv []string + events chan core.Event + stdin io.WriteCloser + stdinMu sync.Mutex + closeOnce sync.Once + permReqID atomic.Value // stores string + chatID atomic.Value // stores string + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool +} + +var permissionPromptPattern = regexp.MustCompile(`(?is)(allow|approve|permission).{0,400}(\(y/n\)|\(y\/n\)|\(y\/N\)|\(Y\/n\)|\[y\/n\]|\[y\/N\]|\[Y\/n\]|yes\/no)`) + +func newAntigravitySession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*antigravitySession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + as := &antigravitySession{ + cmd: cmd, + workDir: workDir, + model: model, + mode: mode, + timeout: timeout, + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: sessionCtx, + cancel: cancel, + } + as.alive.Store(true) + + if resumeID != "" && resumeID != core.ContinueSession { + as.chatID.Store(resumeID) + } + + return as, nil +} + +func (as *antigravitySession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if !as.alive.Load() { + return fmt.Errorf("session is closed") + } + + // Capture existing chat logs so we can identify a new session on first turn + preEntries := make(map[string]bool) + homeDir, err := os.UserHomeDir() + if err == nil { + slug := antigravityProjectSlug(as.workDir) + chatsDir := filepath.Join(homeDir, ".gemini", "tmp", slug, "chats") + if entries, err := os.ReadDir(chatsDir); err == nil { + for _, entry := range entries { + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".jsonl") { + preEntries[entry.Name()] = true + } + } + } + } + + // Save images and files into the workspace + attachDir := filepath.Join(as.workDir, ".cc-connect", "attachments") + if (len(images) > 0 || len(files) > 0) && os.MkdirAll(attachDir, 0o755) != nil { + attachDir = os.TempDir() + } + + var imageRefs []string + for i, img := range images { + ext := ".png" + switch img.MimeType { + case "image/jpeg": + ext = ".jpg" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + } + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(attachDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o600); err == nil { + imageRefs = append(imageRefs, fpath) + } + } + + var fileRefs []string + for i, f := range files { + fname := filepath.Base(f.FileName) + if fname == "" || fname == "." || fname == ".." { + fname = fmt.Sprintf("file_%d_%d", time.Now().UnixMilli(), i) + } + fpath := filepath.Join(attachDir, fname) + if err := os.WriteFile(fpath, f.Data, 0o600); err == nil { + fileRefs = append(fileRefs, fpath) + } + } + + chatID := as.CurrentSessionID() + isResume := chatID != "" + + // Attach image and file references to prompt + fullPrompt := prompt + if len(imageRefs) > 0 { + if fullPrompt == "" { + fullPrompt = "Please analyze the attached image(s)." + } + fullPrompt += "\n\n[Attached images saved at: " + strings.Join(imageRefs, ", ") + "]" + } + if len(fileRefs) > 0 { + if fullPrompt == "" { + fullPrompt = "Please analyze the attached file(s)." + } + fullPrompt += "\n\n[Attached files saved at: " + strings.Join(fileRefs, ", ") + "]" + } + args := buildAntigravityArgs(chatID, isResume, as.mode, fullPrompt) + if strings.TrimSpace(as.model) != "" { + slog.Warn("antigravitySession: model is configured but ignored because agy does not support --model yet", "model", as.model) + } + + var ctx context.Context + var cancel context.CancelFunc + if as.timeout > 0 { + ctx, cancel = context.WithTimeout(as.ctx, as.timeout) + } else { + ctx, cancel = context.WithCancel(as.ctx) + } + + started := false + defer func() { + if !started { + cancel() + } + }() + + slog.Debug("antigravitySession: launching", "resume", isResume, "args", core.RedactArgs(args)) + cmd := exec.CommandContext(ctx, as.cmd, args...) + cmd.WaitDelay = 1 * time.Second + cmd.Dir = as.workDir + env := os.Environ() + if len(as.extraEnv) > 0 { + env = core.MergeEnv(env, as.extraEnv) + } + cmd.Env = env + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("antigravitySession: stdout pipe: %w", err) + } + var stdin io.WriteCloser + if usesInteractivePermission(as.mode) { + stdin, err = cmd.StdinPipe() + if err != nil { + return fmt.Errorf("antigravitySession: stdin pipe: %w", err) + } + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + return fmt.Errorf("antigravitySession: start: %w", err) + } + as.stdinMu.Lock() + as.stdin = stdin + as.stdinMu.Unlock() + + started = true + as.wg.Add(1) + go func() { + defer cancel() + as.readLoop(ctx, cmd, stdout, &stderrBuf, append(imageRefs, fileRefs...), preEntries, time.Now()) + }() + + return nil +} + +func buildAntigravityArgs(chatID string, isResume bool, mode, fullPrompt string) []string { + // Keep "-p " at the very end because agy consumes the immediate next arg. + args := make([]string, 0, 10) + if isResume { + args = append(args, "--conversation", chatID) + } + switch mode { + case "yolo": + args = append(args, "--dangerously-skip-permissions") + case "plan": + args = append(args, "--sandbox") + } + args = append(args, "-p", fullPrompt) + return args +} + +func usesInteractivePermission(mode string) bool { + return strings.EqualFold(strings.TrimSpace(mode), "default") +} + +func (as *antigravitySession) readLoop(ctx context.Context, cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer, tempFiles []string, preEntries map[string]bool, sendStartedAt time.Time) { + defer as.wg.Done() + defer func() { + for _, f := range tempFiles { + _ = os.Remove(f) + } + + // Detect conversation ID if this was the first turn of a fresh session + if as.CurrentSessionID() == "" { + var sid string + for attempt := 0; attempt < 15; attempt++ { + sid = as.detectNewSessionID(preEntries, sendStartedAt) + if sid != "" { + break + } + time.Sleep(20 * time.Millisecond) + } + if sid != "" { + as.chatID.Store(sid) + slog.Debug("antigravitySession: detected session ID", "session_id", sid) + // Emit an EventText carrying the session ID back to core + select { + case as.events <- core.Event{Type: core.EventText, SessionID: sid}: + case <-as.ctx.Done(): + } + } + } + + err := cmd.Wait() + sid := as.CurrentSessionID() + if err != nil { + stderrMsg := strings.TrimSpace(stderrBuf.String()) + if stderrMsg != "" { + slog.Error("antigravitySession: process failed", "error", err, "stderr", stderrMsg) + select { + case as.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}: + case <-as.ctx.Done(): + } + } + } + + // Finalize turn + select { + case as.events <- core.Event{Type: core.EventResult, SessionID: sid, Done: true}: + case <-as.ctx.Done(): + } + }() + + go func() { + <-ctx.Done() + _ = stdout.Close() + }() + + reader := bufio.NewReader(stdout) + buf := make([]byte, 1024) + permWindow := "" + + for { + n, err := reader.Read(buf) + if n > 0 { + text := string(buf[:n]) + permWindow += text + if len(permWindow) > 4096 { + permWindow = permWindow[len(permWindow)-4096:] + } + if pending, _ := as.permReqID.Load().(string); pending == "" { + if prompt, ok := extractPermissionPrompt(permWindow); ok { + requestID := fmt.Sprintf("agy-perm-%d", time.Now().UnixNano()) + as.permReqID.Store(requestID) + select { + case as.events <- core.Event{ + Type: core.EventPermissionRequest, + RequestID: requestID, + ToolName: "terminal_permission", + ToolInput: prompt, + ToolInputRaw: map[string]any{"prompt": prompt}, + }: + case <-as.ctx.Done(): + return + } + } + } + select { + case as.events <- core.Event{Type: core.EventText, Content: text}: + case <-as.ctx.Done(): + return + } + } + if err != nil { + if err != io.EOF && !strings.Contains(err.Error(), "file already closed") { + slog.Error("antigravitySession: read error", "error", err) + select { + case as.events <- core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}: + case <-as.ctx.Done(): + } + } + return + } + } +} + +func (as *antigravitySession) detectNewSessionID(preEntries map[string]bool, sendStartedAt time.Time) string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + slug := antigravityProjectSlug(as.workDir) + chatsDir := filepath.Join(homeDir, ".gemini", "tmp", slug, "chats") + + entries, err := os.ReadDir(chatsDir) + if err != nil { + return "" + } + + type candidate struct { + sessionID string + modTime time.Time + diff time.Duration + } + var candidates []candidate + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".jsonl") { + continue + } + if preEntries[entry.Name()] { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + mod := info.ModTime() + if !sendStartedAt.IsZero() && mod.Before(sendStartedAt.Add(-2*time.Second)) { + continue + } + + fpath := filepath.Join(chatsDir, entry.Name()) + file, err := os.Open(fpath) + if err != nil { + continue + } + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + if scanner.Scan() { + var sf struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal([]byte(scanner.Text()), &sf) == nil && sf.SessionID != "" { + diff := time.Duration(0) + if !sendStartedAt.IsZero() { + if mod.After(sendStartedAt) { + diff = mod.Sub(sendStartedAt) + } else { + diff = sendStartedAt.Sub(mod) + } + } + candidates = append(candidates, candidate{ + sessionID: sf.SessionID, + modTime: mod, + diff: diff, + }) + } + } + _ = file.Close() + } + if len(candidates) == 0 { + return "" + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].diff == candidates[j].diff { + return candidates[i].modTime.After(candidates[j].modTime) + } + return candidates[i].diff < candidates[j].diff + }) + return candidates[0].sessionID +} + +func extractPermissionPrompt(text string) (string, bool) { + loc := permissionPromptPattern.FindStringIndex(text) + if loc == nil { + return "", false + } + prompt := strings.TrimSpace(text[loc[0]:loc[1]]) + if prompt == "" { + return "", false + } + return prompt, true +} + +func (as *antigravitySession) RespondPermission(requestID string, result core.PermissionResult) error { + if !as.alive.Load() { + return fmt.Errorf("session is closed") + } + if pending, _ := as.permReqID.Load().(string); pending != "" && requestID != "" && requestID != pending { + return fmt.Errorf("permission request mismatch: got %q, pending %q", requestID, pending) + } + as.stdinMu.Lock() + defer as.stdinMu.Unlock() + if as.stdin == nil { + return fmt.Errorf("stdin is not available") + } + // agy permission prompts accept terminal-style responses. + // Keep this conservative until agy exposes a structured permission protocol. + reply := "y\n" + if strings.EqualFold(result.Behavior, "deny") { + reply = "n\n" + } + _, err := io.WriteString(as.stdin, reply) + if err != nil { + return fmt.Errorf("write permission response: %w", err) + } + as.permReqID.Store("") + return nil +} + +func (as *antigravitySession) Events() <-chan core.Event { + return as.events +} + +func (as *antigravitySession) CurrentSessionID() string { + v, _ := as.chatID.Load().(string) + return v +} + +func (as *antigravitySession) Alive() bool { + return as.alive.Load() +} + +func (as *antigravitySession) Close() error { + as.alive.Store(false) + as.cancel() + as.stdinMu.Lock() + if as.stdin != nil { + _ = as.stdin.Close() + as.stdin = nil + } + as.stdinMu.Unlock() + done := make(chan struct{}) + go func() { + as.wg.Wait() + as.closeOnce.Do(func() { + close(as.events) + }) + close(done) + }() + select { + case <-done: + case <-time.After(8 * time.Second): + slog.Warn("antigravitySession: close timed out") + } + return nil +} diff --git a/agent/antigravity/session_test.go b/agent/antigravity/session_test.go new file mode 100644 index 0000000..f92cde5 --- /dev/null +++ b/agent/antigravity/session_test.go @@ -0,0 +1,167 @@ +package antigravity + +import ( + "context" + "io" + "os" + "strings" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestSlugify(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"cc-connect", "cc-connect"}, + {"Daily", "daily"}, + {"My Project", "my-project"}, + {"hello_world", "hello-world"}, + {"Test.123", "test-123"}, + {"---weird---", "weird"}, + {"", "project"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := slugify(tt.input) + if got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestNormalizeMode(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"default", "default"}, + {"yolo", "yolo"}, + {"auto", "yolo"}, + {"force", "yolo"}, + {"plan", "plan"}, + {"sandbox", "plan"}, + {"invalid", "default"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizeMode(tt.input) + if got != tt.want { + t.Errorf("normalizeMode(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSession_ContinueSessionTreatedAsFresh(t *testing.T) { + s, err := newAntigravitySession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0) + if err != nil { + t.Fatalf("newAntigravitySession: %v", err) + } + defer func() { _ = s.Close() }() + + if got := s.CurrentSessionID(); got != "" { + t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got) + } +} + +func TestBuildAntigravityArgs_PromptAtEnd(t *testing.T) { + args := buildAntigravityArgs("sid-1", true, "plan", "What is 1+1?") + if len(args) < 2 { + t.Fatalf("args too short: %v", args) + } + if args[len(args)-2] != "-p" || args[len(args)-1] != "What is 1+1?" { + t.Fatalf("expected prompt to be final '-p ', got: %v", args) + } + if !contains(args, "--sandbox") { + t.Fatalf("expected --sandbox in args, got: %v", args) + } + if contains(args, "-m") || contains(args, "--model") { + t.Fatalf("did not expect model flags in args, got: %v", args) + } +} + +func TestUsesInteractivePermission(t *testing.T) { + if !usesInteractivePermission("default") { + t.Fatal("default mode should use interactive permission stdin") + } + if usesInteractivePermission("yolo") { + t.Fatal("yolo mode should not use interactive permission stdin") + } + if usesInteractivePermission("plan") { + t.Fatal("plan mode should not use interactive permission stdin") + } +} + +func TestRespondPermission_WritesTerminalAnswer(t *testing.T) { + s, err := newAntigravitySession(context.Background(), "echo", "/tmp", "", "default", "", nil, 0) + if err != nil { + t.Fatalf("newAntigravitySession: %v", err) + } + defer func() { _ = s.Close() }() + + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + defer func() { _ = r.Close() }() + defer func() { _ = w.Close() }() + s.stdin = w + + s.permReqID.Store("req") + if err := s.RespondPermission("req", core.PermissionResult{Behavior: "allow"}); err != nil { + t.Fatalf("RespondPermission allow: %v", err) + } + buf := make([]byte, 8) + n, err := r.Read(buf) + if err != nil && err != io.EOF { + t.Fatalf("read allow response: %v", err) + } + if got := string(buf[:n]); got != "y\n" { + t.Fatalf("allow response = %q, want %q", got, "y\n") + } + + s.permReqID.Store("req") + if err := s.RespondPermission("req", core.PermissionResult{Behavior: "deny"}); err != nil { + t.Fatalf("RespondPermission deny: %v", err) + } + n, err = r.Read(buf) + if err != nil && err != io.EOF { + t.Fatalf("read deny response: %v", err) + } + if got := string(buf[:n]); got != "n\n" { + t.Fatalf("deny response = %q, want %q", got, "n\n") + } +} + +func TestExtractPermissionPrompt(t *testing.T) { + text := "Tool wants to run command. Allow this action? (y/N)" + got, ok := extractPermissionPrompt(text) + if !ok { + t.Fatalf("expected permission prompt to be detected") + } + if got == "" { + t.Fatalf("detected prompt should not be empty") + } +} + +func TestExtractPermissionPrompt_SplitChunksDetectedInWindow(t *testing.T) { + part1 := "Tool wants to run command. Allow this" + part2 := " action? (y/N)" + got, ok := extractPermissionPrompt(part1 + part2) + if !ok || got == "" { + t.Fatalf("expected split prompt to be detected, got ok=%v prompt=%q", ok, got) + } +} + +func contains(xs []string, want string) bool { + for _, x := range xs { + if strings.TrimSpace(x) == want { + return true + } + } + return false +} diff --git a/agent/claudecode/claude_usage.go b/agent/claudecode/claude_usage.go new file mode 100644 index 0000000..f16bd40 --- /dev/null +++ b/agent/claudecode/claude_usage.go @@ -0,0 +1,663 @@ +package claudecode + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "regexp" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" + "github.com/creack/pty" +) + +const ( + claudeUsageSessionWindowSeconds = 5 * 60 * 60 + claudeUsageWeekWindowSeconds = 7 * 24 * 60 * 60 + claudeUsagePollInterval = 100 * time.Millisecond + claudeUsageStableFor = 450 * time.Millisecond + claudeUsageActionGap = 250 * time.Millisecond +) + +var ( + claudeUsagePercentRe = regexp.MustCompile(`(?i)\b(\d{1,3})\s*%\s*used\b`) + claudeUsageResetLineRe = regexp.MustCompile(`(?i)^resets\s+(.+?)\s*$`) + claudeUsageParenTZRe = regexp.MustCompile(`^(.*?)\s*\(([^()]+)\)\s*$`) + claudeUsageWhitespaceRe = regexp.MustCompile(`[ \t]+`) + claudeUsageRuleLineRe = regexp.MustCompile(`^[\p{Zs}\-─━_=]{4,}$`) +) + +type claudeUsageProbeState struct { + promptResponses int + sentWake bool + sentUsage bool + sentEnterRetry bool + sentUsageRetry bool + lastActionAt time.Time + usageSentAt time.Time +} + +func (a *Agent) GetUsage(ctx context.Context) (*core.UsageReport, error) { + if _, err := exec.LookPath("claude"); err != nil { + return nil, fmt.Errorf("claudecode: 'claude' CLI not found in PATH") + } + + screen, err := a.runClaudeUsageProbe(ctx) + if err != nil { + return nil, err + } + return parseClaudeUsageReport(screen, time.Now()) +} + +func (a *Agent) runClaudeUsageProbe(ctx context.Context) (string, error) { + probeCtx, cancel := context.WithCancel(ctx) + defer cancel() + + workDir, err := os.MkdirTemp("", "cc-connect-claude-usage-*") + if err != nil { + return "", fmt.Errorf("claudecode: create usage temp dir: %w", err) + } + defer os.RemoveAll(workDir) + + args := []string{ + "--tools", "", + "--permission-mode", "plan", + "--no-chrome", + } + cmd := exec.CommandContext(probeCtx, "claude", args...) + cmd.Dir = workDir + + env := filterEnv(os.Environ(), "CLAUDECODE") + env = append(env, "DISABLE_TELEMETRY=true") + env = append(env, "DISABLE_COST_WARNINGS=true") + if extra := a.usageProbeEnv(); len(extra) > 0 { + env = core.MergeEnv(env, extra) + } + cmd.Env = env + + var stderr bytes.Buffer + cmd.Stderr = &stderr + + ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 40, Cols: 120}) + if err != nil { + return "", fmt.Errorf("claudecode: start claude usage probe: %w", err) + } + + var waitErr error + processDone := make(chan struct{}) + go func() { + waitErr = cmd.Wait() + close(processDone) + }() + + terminal := newClaudeUsageTerminal() + readDone := make(chan error, 1) + go func() { + buf := make([]byte, 4096) + for { + n, err := ptmx.Read(buf) + if n > 0 { + terminal.Write(buf[:n]) + } + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) { + readDone <- nil + } else { + readDone <- err + } + return + } + } + }() + + defer func() { + if err := ptmx.Close(); err != nil { + slog.Warn("claudeSession: close ptmx", "error", err) + } + cancel() + // Wait for reader goroutine to finish so it is never leaked. + <-readDone + select { + case <-processDone: + case <-time.After(2 * time.Second): + if cmd.Process != nil { + if err := cmd.Process.Kill(); err != nil { + slog.Warn("claudeSession: kill process", "error", err) + } + } + <-processDone + } + }() + + ticker := time.NewTicker(claudeUsagePollInterval) + defer ticker.Stop() + + var ( + state claudeUsageProbeState + lastScreen string + lastChange = time.Now() + usageScreen string + ) + + for { + select { + case <-probeCtx.Done(): + if usageScreen != "" { + return usageScreen, nil + } + if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil { + return "", screenErr + } + return "", fmt.Errorf("claudecode: timed out waiting for Claude Code /usage panel: %w", probeCtx.Err()) + case err := <-readDone: + if err != nil { + return "", fmt.Errorf("claudecode: read Claude Code /usage output: %w", err) + } + case <-processDone: + if usageScreen != "" { + return usageScreen, nil + } + if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil { + return "", screenErr + } + if waitErr != nil { + return "", fmt.Errorf("claudecode: Claude Code exited before /usage rendered: %w", waitErr) + } + return "", fmt.Errorf("claudecode: Claude Code exited before /usage rendered") + case <-ticker.C: + screen := normalizeClaudeUsageText(terminal.String()) + if screen != lastScreen { + lastScreen = screen + lastChange = time.Now() + } + if usageReady(lastScreen) { + if usageScreen == "" { + usageScreen = lastScreen + } + } + if screenErr := detectClaudeUsageOutputError(lastScreen, stderr.String()); screenErr != nil { + return "", screenErr + } + if usageScreen != "" && time.Since(lastChange) >= claudeUsageStableFor { + return usageScreen, nil + } + if action := nextClaudeUsageProbeAction(lastScreen, &state, time.Now()); action != "" { + if _, err := io.WriteString(ptmx, action); err != nil { + return "", fmt.Errorf("claudecode: write Claude Code /usage probe input: %w", err) + } + } + } + } +} + +func (a *Agent) usageProbeEnv() []string { + a.mu.Lock() + defer a.mu.Unlock() + return a.runtimeEnvLocked() +} + +func nextClaudeUsageProbeAction(screen string, state *claudeUsageProbeState, now time.Time) string { + if now.Sub(state.lastActionAt) < claudeUsageActionGap { + return "" + } + + if action := promptActionForScreen(screen); action != "" && state.promptResponses < 6 { + state.promptResponses++ + state.lastActionAt = now + return action + } + + if usageReady(screen) { + return "" + } + + if !state.sentWake { + state.sentWake = true + state.lastActionAt = now + return "\r" + } + + if !state.sentUsage { + state.sentUsage = true + state.usageSentAt = now + state.lastActionAt = now + return "/usage\r" + } + + if !state.sentEnterRetry && now.Sub(state.usageSentAt) >= 900*time.Millisecond { + state.sentEnterRetry = true + state.lastActionAt = now + return "\r" + } + + if !state.sentUsageRetry && now.Sub(state.usageSentAt) >= 1500*time.Millisecond { + state.sentUsageRetry = true + state.lastActionAt = now + return "/usage\r" + } + + return "" +} + +func promptActionForScreen(screen string) string { + lower := strings.ToLower(screen) + if lower == "" { + return "" + } + if strings.Contains(lower, "quick safety check") || strings.Contains(lower, "yes, i trust this folder") { + return "\r" + } + if (strings.Contains(lower, "telemetry") || strings.Contains(lower, "help improve") || strings.Contains(lower, "usage data")) && + (strings.Contains(lower, "2. no") || strings.Contains(lower, "2. disable") || strings.Contains(lower, "2. don't")) { + return "\x1b[B\r" + } + if strings.Contains(lower, "enter to confirm") && !usageReady(lower) { + return "\r" + } + return "" +} + +func usageReady(screen string) bool { + lower := strings.ToLower(screen) + return strings.Contains(lower, "current session") && + strings.Contains(lower, "current week") && + strings.Contains(lower, "resets") && + claudeUsagePercentRe.MatchString(screen) +} + +func normalizeClaudeUsageText(raw string) string { + raw = strings.ReplaceAll(raw, "\r", "\n") + lines := strings.Split(raw, "\n") + + out := make([]string, 0, len(lines)) + lastBlank := false + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + if !lastBlank { + out = append(out, "") + lastBlank = true + } + continue + } + line = claudeUsageWhitespaceRe.ReplaceAllString(line, " ") + if claudeUsageRuleLineRe.MatchString(line) { + continue + } + out = append(out, line) + lastBlank = false + } + return strings.TrimSpace(strings.Join(out, "\n")) +} + +func parseClaudeUsageReport(text string, now time.Time) (*core.UsageReport, error) { + text = normalizeClaudeUsageText(text) + if text == "" { + return nil, fmt.Errorf("claudecode: Claude Code /usage produced empty output") + } + if err := detectClaudeUsageOutputError(text, ""); err != nil { + return nil, err + } + + report := &core.UsageReport{ + Provider: "claudecode", + } + + lines := strings.Split(text, "\n") + session, err := parseClaudeUsageWindow(lines, "Current session", claudeUsageSessionWindowSeconds, now) + if err != nil { + return nil, err + } + week, err := parseClaudeUsageWindow(lines, "Current week", claudeUsageWeekWindowSeconds, now) + if err != nil { + return nil, err + } + + report.Buckets = []core.UsageBucket{{ + Name: "Usage", + Allowed: true, + Windows: []core.UsageWindow{session, week}, + }} + return report, nil +} + +func parseClaudeUsageWindow(lines []string, header string, windowSeconds int, now time.Time) (core.UsageWindow, error) { + start := -1 + headerLower := strings.ToLower(header) + for i, line := range lines { + lower := strings.ToLower(strings.TrimSpace(line)) + if strings.HasPrefix(lower, headerLower) { + start = i + break + } + } + if start < 0 { + return core.UsageWindow{}, fmt.Errorf("claudecode: missing %s block in Claude Code /usage output", header) + } + + var ( + usedPercent *int + resetRaw string + ) + for i := start + 1; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + lower := strings.ToLower(line) + if i > start+1 && (strings.HasPrefix(lower, "current ") || strings.HasPrefix(lower, "extra usage")) { + break + } + if usedPercent == nil { + if m := claudeUsagePercentRe.FindStringSubmatch(line); len(m) == 2 { + v, _ := strconv.Atoi(m[1]) + usedPercent = &v + continue + } + } + if resetRaw == "" { + if m := claudeUsageResetLineRe.FindStringSubmatch(line); len(m) == 2 { + resetRaw = strings.TrimSpace(m[1]) + } + } + } + + if usedPercent == nil { + return core.UsageWindow{}, fmt.Errorf("claudecode: missing usage percentage in %s block", header) + } + + window := core.UsageWindow{ + Name: header, + UsedPercent: *usedPercent, + WindowSeconds: windowSeconds, + } + + if resetRaw != "" { + resetAt, err := parseClaudeUsageResetTime(resetRaw, now) + if err == nil { + resetAfter := int(resetAt.Sub(now).Round(time.Second).Seconds()) + if resetAfter < 0 { + resetAfter = 0 + } + window.ResetAfterSeconds = resetAfter + window.ResetAtUnix = resetAt.Unix() + } + } + + return window, nil +} + +func parseClaudeUsageResetTime(raw string, now time.Time) (time.Time, error) { + label := strings.TrimSpace(raw) + loc := now.Location() + if m := claudeUsageParenTZRe.FindStringSubmatch(label); len(m) == 3 { + label = strings.TrimSpace(m[1]) + tzName := strings.TrimSpace(m[2]) + tzLoc, err := time.LoadLocation(tzName) + if err != nil { + return time.Time{}, fmt.Errorf("unknown timezone %q", tzName) + } + loc = tzLoc + } + + nowInLoc := now.In(loc) + for _, layout := range []string{"3:04pm", "3pm"} { + parsed, err := time.ParseInLocation(layout, label, loc) + if err != nil { + continue + } + resetAt := time.Date(nowInLoc.Year(), nowInLoc.Month(), nowInLoc.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc) + if !resetAt.After(nowInLoc) { + resetAt = resetAt.Add(24 * time.Hour) + } + return resetAt, nil + } + + for _, layout := range []string{"Jan 2, 3:04pm", "Jan 2, 3pm"} { + parsed, err := time.ParseInLocation(layout, label, loc) + if err != nil { + continue + } + resetAt := time.Date(nowInLoc.Year(), parsed.Month(), parsed.Day(), parsed.Hour(), parsed.Minute(), 0, 0, loc) + if !resetAt.After(nowInLoc) { + resetAt = resetAt.AddDate(1, 0, 0) + } + return resetAt, nil + } + + return time.Time{}, fmt.Errorf("unsupported reset time %q", raw) +} + +func detectClaudeUsageOutputError(screen, stderr string) error { + joined := strings.ToLower(strings.TrimSpace(screen + "\n" + stderr)) + switch { + case joined == "": + return nil + case strings.Contains(joined, "unknown command") && strings.Contains(joined, "/usage"): + return fmt.Errorf("claudecode: this Claude Code version does not support /usage; please upgrade Claude Code") + case strings.Contains(joined, "auth login") || strings.Contains(joined, "not logged in") || strings.Contains(joined, "sign in"): + return fmt.Errorf("claudecode: Claude Code is not logged in for /usage; run `claude auth login`") + case strings.Contains(joined, "/usage") && (strings.Contains(joined, "not available") || strings.Contains(joined, "not supported") || strings.Contains(joined, "subscription")): + return fmt.Errorf("claudecode: current Claude account does not support /usage") + default: + return nil + } +} + +type claudeUsageTerminal struct { + mu sync.RWMutex + lines [][]rune + row int + col int +} + +func newClaudeUsageTerminal() *claudeUsageTerminal { + return &claudeUsageTerminal{ + lines: [][]rune{nil}, + } +} + +func (t *claudeUsageTerminal) Write(p []byte) { + t.mu.Lock() + defer t.mu.Unlock() + + for i := 0; i < len(p); { + switch p[i] { + case 0x1b: + next := t.consumeEscape(p[i:]) + if next <= 0 { + i++ + } else { + i += next + } + case '\r': + t.col = 0 + i++ + case '\n': + t.row++ + t.ensureRow(t.row) + i++ + case '\b': + if t.col > 0 { + t.col-- + } + i++ + case '\t': + advance := 4 - (t.col % 4) + for j := 0; j < advance; j++ { + t.writeRune(' ') + } + i++ + default: + if p[i] < 0x20 || p[i] == 0x7f { + i++ + continue + } + r, size := utf8.DecodeRune(p[i:]) + if r == utf8.RuneError && size == 1 { + i++ + continue + } + t.writeRune(r) + i += size + } + } +} + +func (t *claudeUsageTerminal) String() string { + t.mu.RLock() + defer t.mu.RUnlock() + + lines := make([]string, 0, len(t.lines)) + for _, line := range t.lines { + lines = append(lines, strings.TrimRight(string(line), " ")) + } + return strings.Join(lines, "\n") +} + +func (t *claudeUsageTerminal) consumeEscape(p []byte) int { + if len(p) < 2 { + return len(p) + } + switch p[1] { + case '[': + for i := 2; i < len(p); i++ { + if p[i] >= '@' && p[i] <= '~' { + t.applyCSI(string(p[2:i]), p[i]) + return i + 1 + } + } + return len(p) + case ']': + for i := 2; i < len(p); i++ { + if p[i] == '\a' { + return i + 1 + } + if p[i] == 0x1b && i+1 < len(p) && p[i+1] == '\\' { + return i + 2 + } + } + return len(p) + default: + return 2 + } +} + +func (t *claudeUsageTerminal) applyCSI(params string, final byte) { + switch final { + case 'A', 'B', 'C', 'D': + n := parseCSIInt(params, 1) + switch final { + case 'A': + t.row -= n + if t.row < 0 { + t.row = 0 + } + case 'B': + t.row += n + t.ensureRow(t.row) + case 'C': + t.col += n + case 'D': + t.col -= n + if t.col < 0 { + t.col = 0 + } + } + case 'H', 'f': + row, col := parseCSICursor(params) + if row < 0 { + row = 0 + } + if col < 0 { + col = 0 + } + t.row, t.col = row, col + t.ensureRow(t.row) + case 'J': + if params == "2" || params == "" { + t.lines = [][]rune{nil} + t.row, t.col = 0, 0 + } + case 'K': + t.ensureRow(t.row) + switch params { + case "", "0": + if t.col < len(t.lines[t.row]) { + t.lines[t.row] = t.lines[t.row][:t.col] + } + case "2": + t.lines[t.row] = nil + t.col = 0 + } + default: + } +} + +func (t *claudeUsageTerminal) writeRune(r rune) { + if t.row >= maxTerminalRows || t.col >= maxTerminalCols { + return + } + t.ensureCell(t.row, t.col) + if t.row < len(t.lines) && t.col < len(t.lines[t.row]) { + t.lines[t.row][t.col] = r + } + t.col++ +} + +const maxTerminalRows = 500 +const maxTerminalCols = 500 + +func (t *claudeUsageTerminal) ensureRow(row int) { + if row >= maxTerminalRows { + return + } + for len(t.lines) <= row { + t.lines = append(t.lines, nil) + } +} + +func (t *claudeUsageTerminal) ensureCell(row, col int) { + if row >= maxTerminalRows || col >= maxTerminalCols { + return + } + t.ensureRow(row) + for len(t.lines[row]) <= col { + t.lines[row] = append(t.lines[row], ' ') + } +} + +func parseCSIInt(raw string, fallback int) int { + raw = strings.TrimPrefix(raw, "?") + if raw == "" { + return fallback + } + n, err := strconv.Atoi(raw) + if err != nil || n <= 0 { + return fallback + } + return n +} + +func parseCSICursor(raw string) (int, int) { + raw = strings.TrimPrefix(raw, "?") + if raw == "" { + return 0, 0 + } + parts := strings.Split(raw, ";") + row := parseCSIInt(parts[0], 1) - 1 + col := 0 + if len(parts) > 1 { + col = parseCSIInt(parts[1], 1) - 1 + } + return row, col +} diff --git a/agent/claudecode/claude_usage_test.go b/agent/claudecode/claude_usage_test.go new file mode 100644 index 0000000..9d9f518 --- /dev/null +++ b/agent/claudecode/claude_usage_test.go @@ -0,0 +1,271 @@ +package claudecode + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func TestSanitizeClaudeUsageOutput_RendersCursorMoves(t *testing.T) { + raw := "" + + "\x1b[?2026h\rSettings:\x1b[1CStatus\x1b[1CConfig\x1b[1CUsage (tab to cycle)\r\n" + + "\x1b]8;;https://code.claude.com/docs/en/security\aSecurity guide\x1b]8;;\a\r\n" + + "Current session\r\n" + + "\x1b[32m1% used\x1b[0m\r\n" + + terminal := newClaudeUsageTerminal() + terminal.Write([]byte(raw)) + got := normalizeClaudeUsageText(terminal.String()) + if strings.Contains(got, "\x1b") { + t.Fatalf("sanitizeClaudeUsageOutput still contains escape codes: %q", got) + } + if !strings.Contains(got, "Settings: Status Config Usage (tab to cycle)") { + t.Fatalf("sanitized output missing spaced header: %q", got) + } + if !strings.Contains(got, "Security guide") { + t.Fatalf("sanitized output missing link text: %q", got) + } +} + +func TestParseClaudeUsageReport_Success(t *testing.T) { + loc := mustLoadLocation(t, "Asia/Seoul") + now := time.Date(2026, time.December, 22, 12, 0, 0, 0, loc) + text := strings.TrimSpace(` +Current session +1% used +Resets 3:59pm (Asia/Seoul) + +Current week (all models) +2% used +Resets Dec 23, 4:59pm (Asia/Seoul) + +Extra usage +Extra usage not enabled +`) + + report, err := parseClaudeUsageReport(text, now) + if err != nil { + t.Fatalf("parseClaudeUsageReport returned error: %v", err) + } + if report.Provider != "claudecode" { + t.Fatalf("Provider = %q, want claudecode", report.Provider) + } + if len(report.Buckets) != 1 { + t.Fatalf("Buckets = %d, want 1", len(report.Buckets)) + } + if len(report.Buckets[0].Windows) != 2 { + t.Fatalf("Windows = %d, want 2", len(report.Buckets[0].Windows)) + } + + session := report.Buckets[0].Windows[0] + if session.Name != "Current session" { + t.Fatalf("session.Name = %q", session.Name) + } + if session.WindowSeconds != 18000 { + t.Fatalf("session.WindowSeconds = %d, want 18000", session.WindowSeconds) + } + if session.UsedPercent != 1 { + t.Fatalf("session.UsedPercent = %d, want 1", session.UsedPercent) + } + if session.ResetAfterSeconds != 14340 { + t.Fatalf("session.ResetAfterSeconds = %d, want 14340", session.ResetAfterSeconds) + } + + week := report.Buckets[0].Windows[1] + if week.Name != "Current week" { + t.Fatalf("week.Name = %q", week.Name) + } + if week.WindowSeconds != 604800 { + t.Fatalf("week.WindowSeconds = %d, want 604800", week.WindowSeconds) + } + if week.UsedPercent != 2 { + t.Fatalf("week.UsedPercent = %d, want 2", week.UsedPercent) + } + if week.ResetAfterSeconds != 104340 { + t.Fatalf("week.ResetAfterSeconds = %d, want 104340", week.ResetAfterSeconds) + } +} + +func TestParseClaudeUsageReport_MissingOptionalFields(t *testing.T) { + loc := mustLoadLocation(t, "Asia/Seoul") + now := time.Date(2026, time.December, 22, 12, 0, 0, 0, loc) + text := strings.TrimSpace(` +Current session +15% used +Resets 3:00pm (Asia/Seoul) + +Current week +47% used +Resets Dec 23, 11:30am (Asia/Seoul) +`) + + report, err := parseClaudeUsageReport(text, now) + if err != nil { + t.Fatalf("parseClaudeUsageReport returned error: %v", err) + } + if report.Email != "" { + t.Fatalf("Email = %q, want empty", report.Email) + } + if report.AccountID != "" { + t.Fatalf("AccountID = %q, want empty", report.AccountID) + } + if report.UserID != "" { + t.Fatalf("UserID = %q, want empty", report.UserID) + } + if report.Plan != "" { + t.Fatalf("Plan = %q, want empty", report.Plan) + } + if len(report.Buckets) != 1 || len(report.Buckets[0].Windows) != 2 { + t.Fatalf("unexpected bucket layout: %+v", report.Buckets) + } +} + +func TestParseClaudeUsageReport_UpgradeRequired(t *testing.T) { + text := "Unknown command: /usage" + _, err := parseClaudeUsageReport(text, time.Now()) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "upgrade") { + t.Fatalf("err = %v, want upgrade guidance", err) + } +} + +func TestParseClaudeUsageReport_LoginRequired(t *testing.T) { + text := "Please run `claude auth login` to continue." + _, err := parseClaudeUsageReport(text, time.Now()) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "login") { + t.Fatalf("err = %v, want login guidance", err) + } +} + +func TestParseClaudeUsageReport_MissingWindowFields(t *testing.T) { + loc := mustLoadLocation(t, "Asia/Seoul") + now := time.Date(2026, time.December, 22, 12, 0, 0, 0, loc) + text := strings.TrimSpace(` +Current session +Resets 3:00pm (Asia/Seoul) + +Current week +47% used +Resets Dec 23, 11:30am (Asia/Seoul) +`) + + _, err := parseClaudeUsageReport(text, now) + if err == nil || !strings.Contains(strings.ToLower(err.Error()), "current session") { + t.Fatalf("err = %v, want current session parse error", err) + } +} + +func TestParseClaudeUsageReport_UnknownResetTimeDoesNotFail(t *testing.T) { + loc := mustLoadLocation(t, "America/Los_Angeles") + now := time.Date(2026, time.March, 30, 11, 0, 0, 0, loc) + text := strings.TrimSpace(` +Current session +14% used +Resets later today somehow + +Current week +33% used +Resets Apr 2, 4pm (America/Los_Angeles) +`) + + report, err := parseClaudeUsageReport(text, now) + if err != nil { + t.Fatalf("parseClaudeUsageReport returned error: %v", err) + } + if len(report.Buckets) != 1 || len(report.Buckets[0].Windows) != 2 { + t.Fatalf("unexpected bucket layout: %+v", report.Buckets) + } + session := report.Buckets[0].Windows[0] + if session.UsedPercent != 14 { + t.Fatalf("session.UsedPercent = %d, want 14", session.UsedPercent) + } + if session.ResetAfterSeconds != 0 || session.ResetAtUnix != 0 { + t.Fatalf("session reset should be unknown, got %+v", session) + } +} + +func TestParseClaudeUsageReport_MissingResetTimeDoesNotFail(t *testing.T) { + loc := mustLoadLocation(t, "America/Los_Angeles") + now := time.Date(2026, time.March, 30, 11, 0, 0, 0, loc) + text := strings.TrimSpace(` +Current session +14% used + +Current week +33% used +Resets Apr 2, 4pm (America/Los_Angeles) +`) + + report, err := parseClaudeUsageReport(text, now) + if err != nil { + t.Fatalf("parseClaudeUsageReport returned error: %v", err) + } + session := report.Buckets[0].Windows[0] + if session.UsedPercent != 14 { + t.Fatalf("session.UsedPercent = %d, want 14", session.UsedPercent) + } + if session.ResetAfterSeconds != 0 || session.ResetAtUnix != 0 { + t.Fatalf("session reset should be absent, got %+v", session) + } +} + +func TestParseClaudeUsageResetTime_AllowsWholeHourWithTimezone(t *testing.T) { + loc := mustLoadLocation(t, "America/Los_Angeles") + now := time.Date(2026, time.March, 30, 11, 0, 0, 0, loc) + + resetAt, err := parseClaudeUsageResetTime("2pm (America/Los_Angeles)", now) + if err != nil { + t.Fatalf("parseClaudeUsageResetTime returned error: %v", err) + } + + want := time.Date(2026, time.March, 30, 14, 0, 0, 0, loc) + if !resetAt.Equal(want) { + t.Fatalf("resetAt = %v, want %v", resetAt, want) + } +} + +func TestParseClaudeUsageResetTime_AllowsMonthDayWholeHour(t *testing.T) { + loc := mustLoadLocation(t, "America/Los_Angeles") + now := time.Date(2026, time.March, 30, 11, 0, 0, 0, loc) + + resetAt, err := parseClaudeUsageResetTime("Apr 2, 4pm (America/Los_Angeles)", now) + if err != nil { + t.Fatalf("parseClaudeUsageResetTime returned error: %v", err) + } + + want := time.Date(2026, time.April, 2, 16, 0, 0, 0, loc) + if !resetAt.Equal(want) { + t.Fatalf("resetAt = %v, want %v", resetAt, want) + } +} + +func TestAgentGetUsageSmoke(t *testing.T) { + if os.Getenv("CC_CONNECT_SMOKE_CLAUDE_USAGE") == "" { + t.Skip("set CC_CONNECT_SMOKE_CLAUDE_USAGE=1 to run") + } + if _, err := os.Stat("/usr/bin/env"); err != nil { + t.Skipf("environment not suitable: %v", err) + } + + a := &Agent{workDir: ".", mode: "plan"} + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + report, err := a.GetUsage(ctx) + if err != nil { + t.Fatalf("GetUsage returned error: %v", err) + } + if report == nil || len(report.Buckets) == 0 { + t.Fatalf("unexpected empty report: %+v", report) + } +} + +func mustLoadLocation(t *testing.T, name string) *time.Location { + t.Helper() + loc, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("LoadLocation(%q): %v", name, err) + } + return loc +} diff --git a/agent/claudecode/claudecode.go b/agent/claudecode/claudecode.go new file mode 100644 index 0000000..f85e540 --- /dev/null +++ b/agent/claudecode/claudecode.go @@ -0,0 +1,1412 @@ +package claudecode + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("claudecode", New) +} + +// Agent drives Claude Code CLI using --input-format stream-json +// and --permission-prompt-tool stdio for bidirectional communication. +// +// Permission modes (maps to Claude's --permission-mode): +// - "default": every tool call requires user approval +// - "acceptEdits": auto-approve file edit tools, ask for others +// - "plan": plan only, no execution until approved +// - "auto": Claude's automatic permission classifier +// - "bypassPermissions": auto-approve everything (alias: yolo) +type Agent struct { + workDir string + cliBin string // CLI binary name or path (default: "claude") + cliExtraArgs []string // extra args parsed from cli_path (e.g. ["code", "-t", "foo"]) + configEnv []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls + cliArgsFlag string // if set, claude args are passed as a single string via this flag (e.g. "-a") + model string + reasoningEffort string // "low" | "medium" | "high" | "max" + mode string // "default" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | "dontAsk" + allowedTools []string + disallowedTools []string + maxContextTokens int // optional: passed as --max-context-tokens when > 0 + providers []core.ProviderConfig + activeIdx int // -1 = no provider set + sessionEnv []string + routerURL string // Claude Code Router URL (e.g., "http://127.0.0.1:3456") + routerAPIKey string // Claude Code Router API key (optional) + systemPrompt string // Custom system prompt to pass to Claude CLI + + providerProxy *core.ProviderProxy // local proxy for third-party providers + proxyLocalURL string // local URL of the proxy + platformPrompt string // platform-specific formatting instructions + + // spawnOpts controls OS-user isolation via run_as_user. Zero value + // means legacy spawn as the supervisor user. See core/runas.go. + spawnOpts core.SpawnOptions + + mu sync.RWMutex +} + +var claudeProviderManagedEnvVars = map[string]struct{}{ + "CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST": {}, + "CLAUDE_CODE_USE_BEDROCK": {}, + "CLAUDE_CODE_USE_VERTEX": {}, + "CLAUDE_CODE_USE_FOUNDRY": {}, + "ANTHROPIC_BASE_URL": {}, + "ANTHROPIC_BEDROCK_BASE_URL": {}, + "ANTHROPIC_VERTEX_BASE_URL": {}, + "ANTHROPIC_FOUNDRY_BASE_URL": {}, + "ANTHROPIC_FOUNDRY_RESOURCE": {}, + "ANTHROPIC_VERTEX_PROJECT_ID": {}, + "CLOUD_ML_REGION": {}, + "ANTHROPIC_API_KEY": {}, + "ANTHROPIC_AUTH_TOKEN": {}, + "CLAUDE_CODE_OAUTH_TOKEN": {}, + "AWS_BEARER_TOKEN_BEDROCK": {}, + "ANTHROPIC_FOUNDRY_API_KEY": {}, + "CLAUDE_CODE_SKIP_BEDROCK_AUTH": {}, + "CLAUDE_CODE_SKIP_VERTEX_AUTH": {}, + "CLAUDE_CODE_SKIP_FOUNDRY_AUTH": {}, + "ANTHROPIC_MODEL": {}, + "ANTHROPIC_DEFAULT_HAIKU_MODEL": {}, + "ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION": {}, + "ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME": {}, + "ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES": {}, + "ANTHROPIC_DEFAULT_OPUS_MODEL": {}, + "ANTHROPIC_DEFAULT_OPUS_MODEL_DESCRIPTION": {}, + "ANTHROPIC_DEFAULT_OPUS_MODEL_NAME": {}, + "ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES": {}, + + // Provider-specific base URL env vars for thinking rewrite proxy routing. + // These are set by cc-connect when thinking override is needed for + // Bedrock/Vertex/Foundry providers that don't use base_url config. + "ANTHROPIC_BEDROCK_PROXY_BASE_URL": {}, + "ANTHROPIC_VERTEX_PROXY_BASE_URL": {}, + "ANTHROPIC_FOUNDRY_PROXY_BASE_URL": {}, + "ANTHROPIC_DEFAULT_SONNET_MODEL": {}, + "ANTHROPIC_DEFAULT_SONNET_MODEL_DESCRIPTION": {}, + "ANTHROPIC_DEFAULT_SONNET_MODEL_NAME": {}, + "ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES": {}, + "ANTHROPIC_SMALL_FAST_MODEL": {}, + "ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION": {}, + "CLAUDE_CODE_SUBAGENT_MODEL": {}, +} + +var claudeProviderManagedEnvPrefixes = []string{ + "VERTEX_REGION_CLAUDE_", +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + cliBin := "claude" + var cliExtraArgs []string + if cliPath, _ := opts["cli_path"].(string); cliPath != "" { + // NOTE: paths containing spaces are not supported because Fields + // splits on whitespace. Use a symlink or wrapper script instead. + parts := strings.Fields(cliPath) + cliBin = parts[0] + if len(parts) > 1 { + cliExtraArgs = parts[1:] + } + } + cliArgsFlag, _ := opts["cli_args_flag"].(string) + model, _ := opts["model"].(string) + reasoningEffort, _ := opts["reasoning_effort"].(string) + mode, _ := opts["mode"].(string) + mode = normalizePermissionMode(mode) + systemPrompt, _ := opts["system_prompt"].(string) + + var allowedTools []string + if tools, ok := opts["allowed_tools"].([]any); ok { + for _, t := range tools { + if s, ok := t.(string); ok { + allowedTools = append(allowedTools, s) + } + } + } + + var disallowedTools []string + if tools, ok := opts["disallowed_tools"].([]any); ok { + for _, t := range tools { + if s, ok := t.(string); ok { + disallowedTools = append(disallowedTools, s) + } + } + } + + maxContextTokens := 0 + switch v := opts["max_context_tokens"].(type) { + case int: + if v > 0 { + maxContextTokens = v + } + case int64: + if v > 0 { + maxContextTokens = int(v) + } + case float64: + if v > 0 { + maxContextTokens = int(v) + } + } + + // Claude Code Router support + routerURL, _ := opts["router_url"].(string) + routerAPIKey, _ := opts["router_api_key"].(string) + + // run_as_user: optional OS-user isolation. Injected into opts from + // the project-level config field by cmd/cc-connect/main.go. + spawnOpts := core.SpawnOptions{} + spawnOpts.RunAsUser, _ = opts["run_as_user"].(string) + if env, ok := opts["run_as_env"].([]any); ok { + for _, v := range env { + if s, ok := v.(string); ok { + spawnOpts.EnvAllowlist = append(spawnOpts.EnvAllowlist, s) + } + } + } else if env, ok := opts["run_as_env"].([]string); ok { + spawnOpts.EnvAllowlist = append(spawnOpts.EnvAllowlist, env...) + } + + // When run_as_user is set, the target user's PATH is what matters; + // skip the supervisor-side LookPath check and let spawn fail loudly + // at runtime if the target doesn't have claude installed. + if !spawnOpts.IsolationMode() { + if _, err := exec.LookPath(cliBin); err != nil { + return nil, fmt.Errorf("claudecode: %q CLI not found in PATH, please install it first", cliBin) + } + } + + // Parse project-level env from opts["env"] (set via [projects.agent.options.env] in config.toml). + // Stored separately from runtime sessionEnv so SetSessionEnv calls cannot overwrite it. + var configEnv []string + if envMap, ok := opts["env"].(map[string]string); ok { + for k, v := range envMap { + configEnv = append(configEnv, k+"="+v) + } + } else if envMap, ok := opts["env"].(map[string]any); ok { + for k, v := range envMap { + if s, ok := v.(string); ok { + configEnv = append(configEnv, k+"="+s) + } + } + } + + return &Agent{ + workDir: workDir, + cliBin: cliBin, + cliExtraArgs: cliExtraArgs, + cliArgsFlag: cliArgsFlag, + model: model, + reasoningEffort: normalizeEffort(reasoningEffort), + mode: mode, + systemPrompt: systemPrompt, + allowedTools: allowedTools, + disallowedTools: disallowedTools, + maxContextTokens: maxContextTokens, + configEnv: configEnv, + activeIdx: -1, + routerURL: routerURL, + routerAPIKey: routerAPIKey, + spawnOpts: spawnOpts, + }, nil +} + +// normalizeEffort maps user-friendly aliases to Claude CLI --effort values. +func normalizeEffort(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return "" + case "low": + return "low" + case "medium", "med": + return "medium" + case "high": + return "high" + case "max": + return "max" + default: + return "" + } +} + +// normalizePermissionMode maps user-friendly aliases to Claude CLI values. +func normalizePermissionMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "acceptedits", "accept-edits", "accept_edits", "edit": + return "acceptEdits" + case "plan": + return "plan" + case "auto": + return "auto" + case "bypasspermissions", "bypass-permissions", "bypass_permissions", + "yolo": + return "bypassPermissions" + case "dontask", "dont-ask", "dont_ask": + return "dontAsk" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "claudecode" } +func (a *Agent) CLIBinaryName() string { return a.cliBin } +func (a *Agent) CLIDisplayName() string { return "Claude" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("claudecode: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("claudecode: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) SetReasoningEffort(effort string) { + a.mu.Lock() + defer a.mu.Unlock() + a.reasoningEffort = normalizeEffort(effort) + slog.Info("claudecode: reasoning effort changed", "effort", a.reasoningEffort) +} + +func (a *Agent) GetReasoningEffort() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.reasoningEffort +} + +func (a *Agent) AvailableReasoningEfforts() []string { + return []string{"low", "medium", "high", "max"} +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { + if models := a.configuredModels(); len(models) > 0 { + return models + } + if models := a.fetchModelsFromAPI(ctx); len(models) > 0 { + return models + } + return []core.ModelOption{ + {Name: "sonnet", Desc: "Claude Sonnet (balanced)"}, + {Name: "opus", Desc: "Claude Opus (most capable)"}, + {Name: "opus[1m]", Desc: "Claude Opus (1M context)"}, + {Name: "haiku", Desc: "Claude Haiku (fastest)"}, + } +} + +func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption { + a.mu.Lock() + apiKey := "" + baseURL := "" + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + apiKey = a.providers[a.activeIdx].APIKey + baseURL = a.providers[a.activeIdx].BaseURL + } + a.mu.Unlock() + + if apiKey == "" { + apiKey = os.Getenv("ANTHROPIC_API_KEY") + } + if apiKey == "" { + return nil + } + if baseURL == "" { + baseURL = os.Getenv("ANTHROPIC_BASE_URL") + } + if baseURL == "" { + baseURL = "https://api.anthropic.com" + } + baseURL = strings.TrimRight(baseURL, "/") + + req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/v1/models", nil) + if err != nil { + return nil + } + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Debug("claudecode: failed to fetch models", "error", err) + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil + } + + var result struct { + Data []struct { + ID string `json:"id"` + DisplayName string `json:"display_name"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil + } + + var models []core.ModelOption + for _, m := range result.Data { + models = append(models, core.ModelOption{Name: m.ID, Desc: m.DisplayName}) + } + return models +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) SetPlatformPrompt(prompt string) { + a.mu.Lock() + defer a.mu.Unlock() + a.platformPrompt = prompt +} + +// StartSession creates a persistent interactive Claude Code session. +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + tools := make([]string, len(a.allowedTools)) + copy(tools, a.allowedTools) + disTools := make([]string, len(a.disallowedTools)) + copy(disTools, a.disallowedTools) + maxTok := a.maxContextTokens + model := a.model + effort := a.reasoningEffort + workDir := a.workDir + mode := a.mode + extraEnv := a.runtimeEnvLocked() + + activeIdx := a.activeIdx + var activeProviderName string + if activeIdx >= 0 && activeIdx < len(a.providers) { + activeProviderName = a.providers[activeIdx].Name + if m := a.providers[activeIdx].Model; m != "" { + model = m + } + } + slog.Debug("claudecode: StartSession provider state", + "activeIdx", activeIdx, + "activeProvider", activeProviderName, + "model", model, + "sessionID", sessionID, + "providerCount", len(a.providers)) + platformPrompt := a.platformPrompt + systemPrompt := a.systemPrompt + // When router_url is set, --verbose conflicts with --output-format stream-json + // (verbose emits non-JSON text to stdout that corrupts the JSON stream). + disableVerbose := a.routerURL != "" + a.mu.Unlock() + + return newClaudeSession(ctx, workDir, a.cliBin, a.cliExtraArgs, a.cliArgsFlag, model, effort, sessionID, mode, systemPrompt, tools, disTools, extraEnv, platformPrompt, disableVerbose, a.spawnOpts, maxTok) +} + +func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("claudecode: cannot determine home dir: %w", err) + } + + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absWorkDir, err := filepath.Abs(workDir) + if err != nil { + return nil, fmt.Errorf("claudecode: resolve work_dir: %w", err) + } + + projectDir := findProjectDir(homeDir, absWorkDir) + if projectDir == "" { + return nil, nil + } + + entries, err := os.ReadDir(projectDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("claudecode: read project dir: %w", err) + } + + var sessions []core.AgentSessionInfo + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") { + continue + } + + sessionID := strings.TrimSuffix(name, ".jsonl") + info, err := entry.Info() + if err != nil { + continue + } + + summary, msgCount := scanSessionMeta(filepath.Join(projectDir, name)) + + sessions = append(sessions, core.AgentSessionInfo{ + ID: sessionID, + Summary: summary, + MessageCount: msgCount, + ModifiedAt: info.ModTime(), + }) + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + + return sessions, nil +} + +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("claudecode: cannot determine home dir: %w", err) + } + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absWorkDir, err := filepath.Abs(workDir) + if err != nil { + return fmt.Errorf("claudecode: resolve work_dir: %w", err) + } + projectDir := findProjectDir(homeDir, absWorkDir) + if projectDir == "" { + return fmt.Errorf("session not found") + } + path := filepath.Join(projectDir, sessionID+".jsonl") + if _, err := os.Stat(path); os.IsNotExist(err) { + return fmt.Errorf("session file not found: %s", sessionID) + } + return os.Remove(path) +} + +// extractStringContent attempts to extract a plain string from a json.RawMessage. +// Returns empty string if the raw message is not a JSON string. +func extractStringContent(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err != nil { + return "" + } + return s +} + +func scanSessionMeta(path string) (string, int) { + f, err := os.Open(path) + if err != nil { + return "", 0 + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + + var summary string + var count int + + for scanner.Scan() { + var entry struct { + Type string `json:"type"` + Message struct { + Content json.RawMessage `json:"content"` + } `json:"message"` + } + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + if entry.Type == "user" || entry.Type == "assistant" { + count++ + if entry.Type == "user" { + if s := extractStringContent(entry.Message.Content); s != "" { + summary = s + } + } + } + } + summary = stripXMLTags(summary) + summary = strings.TrimSpace(summary) + if utf8.RuneCountInString(summary) > 40 { + summary = string([]rune(summary)[:40]) + "..." + } + return summary, count +} + +var xmlTagRe = regexp.MustCompile(`<[^>]+>`) + +func stripXMLTags(s string) string { + return xmlTagRe.ReplaceAllString(s, "") +} + +// GetSessionHistory reads the Claude Code JSONL transcript and returns user/assistant messages. +func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, err + } + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absWorkDir, _ := filepath.Abs(workDir) + projectDir := findProjectDir(homeDir, absWorkDir) + if projectDir == "" { + return nil, fmt.Errorf("claudecode: project dir not found") + } + + path := filepath.Join(projectDir, sessionID+".jsonl") + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("claudecode: open session file: %w", err) + } + defer f.Close() + + var entries []core.HistoryEntry + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + + for scanner.Scan() { + var raw struct { + Type string `json:"type"` + Timestamp string `json:"timestamp"` + Message struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"message"` + } + if json.Unmarshal(scanner.Bytes(), &raw) != nil { + continue + } + if raw.Type != "user" && raw.Type != "assistant" { + continue + } + + ts, _ := time.Parse(time.RFC3339Nano, raw.Timestamp) + text := extractTextContent(raw.Message.Content) + if text == "" { + continue + } + + entries = append(entries, core.HistoryEntry{ + Role: raw.Type, + Content: text, + Timestamp: ts, + }) + } + + if limit > 0 && len(entries) > limit { + entries = entries[len(entries)-limit:] + } + return entries, nil +} + +// extractTextContent extracts readable text from Claude Code message content. +// Content can be a plain string or an array of content blocks. +func extractTextContent(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + + // Try plain string first + var s string + if json.Unmarshal(raw, &s) == nil { + return s + } + + // Try array of content blocks + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + Thinking string `json:"thinking"` + } + if json.Unmarshal(raw, &blocks) != nil { + return "" + } + + for _, b := range blocks { + if b.Type == "text" && b.Text != "" { + return b.Text + } + } + return "" +} + +func (a *Agent) Stop() error { return nil } + +// SetMode changes the permission mode for future sessions. +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizePermissionMode(mode) + slog.Info("claudecode: permission mode changed", "mode", a.mode) +} + +// GetMode returns the current permission mode. +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +// GetRunAsUser returns the target user for OS-isolation spawning, or "" +// if no isolation is configured. Set at construction from the project-level +// run_as_user field (injected into opts by cmd/cc-connect/main.go). +// +// This accessor exists specifically so multi-workspace mode can propagate +// run_as_user from the parent (project-level) agent into per-workspace +// agent instances created lazily by core.Engine.getOrCreateWorkspaceAgent. +// Without this, workspace agents are constructed with a fresh opts map +// that never contained run_as_user, silently dropping back to the legacy +// supervisor-user spawn path — which is exactly the leak cc-connect#496 +// is designed to prevent. +func (a *Agent) GetRunAsUser() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.spawnOpts.RunAsUser +} + +// GetRunAsEnv returns the user-configured env allowlist extension (the +// run_as_env project field), which is merged with core.DefaultEnvAllowlist +// at spawn time. Returns nil if no extension is configured. +// +// Used by the multi-workspace propagation path alongside GetRunAsUser. +func (a *Agent) GetRunAsEnv() []string { + a.mu.Lock() + defer a.mu.Unlock() + if len(a.spawnOpts.EnvAllowlist) == 0 { + return nil + } + out := make([]string, len(a.spawnOpts.EnvAllowlist)) + copy(out, a.spawnOpts.EnvAllowlist) + return out +} + +// WorkspaceAgentOptions returns a snapshot of user-configured options that +// must propagate to per-workspace agent instances created lazily by +// core.Engine.getOrCreateWorkspaceAgent. Without this snapshot, the engine +// constructs workspace agents from a fresh opts map and silently drops +// every claudecode field except mode/model — so cli_path, allowed_tools, +// and friends would only take effect on the project-level agent. +// +// Runtime-only state (providers, sessionEnv, providerProxy, platformPrompt) +// is intentionally omitted: providers are rewired separately by the engine +// after construction; the rest is per-session and recomputed. +// +// configEnv IS included because it comes from the static config file and must +// propagate to every workspace agent. sessionEnv is excluded (runtime-only). +// +// run_as_user / run_as_env are also omitted because the engine has its own +// dedicated propagation path via GetRunAsUser/GetRunAsEnv (see cc-connect#496). +func (a *Agent) WorkspaceAgentOptions() map[string]any { + a.mu.RLock() + defer a.mu.RUnlock() + + opts := map[string]any{ + "mode": a.mode, + } + if len(a.configEnv) > 0 { + envMap := make(map[string]string, len(a.configEnv)) + for _, kv := range a.configEnv { + k, v, _ := strings.Cut(kv, "=") + envMap[k] = v + } + opts["env"] = envMap + } + if cliPath := snapshotCLIPath(a.cliBin, a.cliExtraArgs); cliPath != "" { + opts["cli_path"] = cliPath + } + if a.cliArgsFlag != "" { + opts["cli_args_flag"] = a.cliArgsFlag + } + if a.model != "" { + opts["model"] = a.model + } + if a.reasoningEffort != "" { + opts["reasoning_effort"] = a.reasoningEffort + } + if len(a.allowedTools) > 0 { + opts["allowed_tools"] = stringsToAny(a.allowedTools) + } + if len(a.disallowedTools) > 0 { + opts["disallowed_tools"] = stringsToAny(a.disallowedTools) + } + if a.maxContextTokens > 0 { + opts["max_context_tokens"] = a.maxContextTokens + } + if a.routerURL != "" { + opts["router_url"] = a.routerURL + } + if a.routerAPIKey != "" { + opts["router_api_key"] = a.routerAPIKey + } + return opts +} + +// snapshotCLIPath rebuilds the cli_path opts string from cliBin and the +// extra-args tail captured at construction. Returns "" when only the +// default "claude" binary is in use, so we don't pollute the workspace +// opts with a redundant default. +func snapshotCLIPath(cliBin string, cliExtraArgs []string) string { + // Normalise empty to the default binary so we can reason about extra args. + if cliBin == "" { + cliBin = "claude" + } + if cliBin == "claude" && len(cliExtraArgs) == 0 { + return "" // default binary, no extra args — no need to persist + } + if len(cliExtraArgs) == 0 { + return cliBin + } + return cliBin + " " + strings.Join(cliExtraArgs, " ") +} + +// stringsToAny copies a []string into a fresh []any so it round-trips +// through New()'s opts["..."].([]any) type assertion. +func stringsToAny(in []string) []any { + out := make([]any, len(in)) + for i, s := range in { + out[i] = s + } + return out +} + +// PermissionModes returns all supported permission modes. +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"}, + {Key: "acceptEdits", Name: "Accept Edits", NameZh: "接受编辑", Desc: "Auto-approve file edits, ask for others", DescZh: "自动允许文件编辑,其他需确认"}, + {Key: "plan", Name: "Plan Mode", NameZh: "计划模式", Desc: "Plan only, no execution until approved", DescZh: "只做规划不执行,审批后再执行"}, + {Key: "auto", Name: "Auto", NameZh: "自动模式", Desc: "Claude decides when to ask for permission", DescZh: "由 Claude 自动判断何时需要确认"}, + {Key: "bypassPermissions", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Auto-approve everything", DescZh: "全部自动通过"}, + {Key: "dontAsk", Name: "Don't Ask", NameZh: "静默拒绝", Desc: "Auto-deny tools unless pre-approved via allowed_tools or settings.json allow rules", DescZh: "未预授权的工具自动拒绝,不弹确认"}, + } +} + +// AddAllowedTools adds tools to the pre-allowed list (takes effect on next session). +func (a *Agent) AddAllowedTools(tools ...string) error { + a.mu.Lock() + defer a.mu.Unlock() + + existing := make(map[string]bool) + for _, t := range a.allowedTools { + existing[t] = true + } + for _, tool := range tools { + if !existing[tool] { + a.allowedTools = append(a.allowedTools, tool) + existing[tool] = true + } + } + slog.Info("claudecode: updated allowed tools", "tools", tools, "total", len(a.allowedTools)) + return nil +} + +// GetAllowedTools returns the current list of pre-allowed tools. +func (a *Agent) GetAllowedTools() []string { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]string, len(a.allowedTools)) + copy(result, a.allowedTools) + return result +} + +// GetDisallowedTools returns the current list of disallowed tools. +func (a *Agent) GetDisallowedTools() []string { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]string, len(a.disallowedTools)) + copy(result, a.disallowedTools) + return result +} + +// ── CommandProvider implementation ──────────────────────────── + +func (a *Agent) CommandDirs() []string { + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + dirs := []string{filepath.Join(absDir, ".claude", "commands")} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".claude", "commands")) + } + return dirs +} + +// ── SkillProvider implementation ────────────────────────────── + +func (a *Agent) SkillDirs() []string { + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + return appendProjectClaudeSkillDirs(absDir, claudeConfigHomeDir()) +} + +// ── ContextCompressor implementation ────────────────────────── + +func (a *Agent) CompressCommand() string { return "/compact" } + +func claudeConfigHomeDir() string { + if dir := strings.TrimSpace(os.Getenv("CLAUDE_CONFIG_DIR")); dir != "" { + return dir + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".claude") +} + +func appendProjectClaudeSkillDirs(workDir, configHome string) []string { + home, _ := os.UserHomeDir() + projectDirs := walkUpClaudeSkillDirs(workDir, home) + if configHome == "" { + return projectDirs + } + return uniqueSkillDirs(append(projectDirs, filepath.Join(configHome, "skills"))) +} + +func walkUpClaudeSkillDirs(workDir, home string) []string { + current := filepath.Clean(workDir) + home = filepath.Clean(home) + stopAt := findGitRoot(current) + + var dirs []string + for { + if home != "" && samePath(current, home) { + break + } + dirs = append(dirs, filepath.Join(current, ".claude", "skills")) + if stopAt != "" && samePath(current, stopAt) { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return uniqueSkillDirs(dirs) +} + +func findGitRoot(start string) string { + current := filepath.Clean(start) + for { + gitPath := filepath.Join(current, ".git") + if _, err := os.Stat(gitPath); err == nil { + return current + } + parent := filepath.Dir(current) + if parent == current { + return "" + } + current = parent + } +} + +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + return filepath.Clean(a) == filepath.Clean(b) +} + +func uniqueSkillDirs(paths []string) []string { + seen := make(map[string]struct{}, len(paths)) + out := make([]string, 0, len(paths)) + for _, path := range paths { + if path == "" { + continue + } + clean := filepath.Clean(path) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + out = append(out, clean) + } + return out +} + +// ── MemoryFileProvider implementation ───────────────────────── + +func (a *Agent) ProjectMemoryFile() string { + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + return filepath.Join(absDir, "CLAUDE.md") +} + +func (a *Agent) GlobalMemoryFile() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".claude", "CLAUDE.md") +} + +func (a *Agent) HasSystemPromptSupport() bool { return true } + +// ── ProviderSwitcher implementation ────────────────────────── + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + a.stopProviderProxyLocked() + if name == "" { + a.activeIdx = -1 + slog.Info("claudecode: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("claudecode: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +// providerEnvLocked returns env vars for the active provider. Caller must hold mu. +// +// When a custom base_url is configured: +// 1. We use ANTHROPIC_AUTH_TOKEN (Bearer) instead of ANTHROPIC_API_KEY +// (x-api-key). Claude Code validates API keys against api.anthropic.com +// which hangs for third-party endpoints; Bearer auth skips that check. +// 2. If the provider sets thinking (e.g. "disabled"), a local reverse proxy +// rewrites the thinking parameter for compatibility with providers that +// don't support adaptive thinking. +// +// For env-only providers (Bedrock, Vertex, Foundry) that don't set base_url +// but use CLAUDE_CODE_USE_BEDROCK/VERTEX/FOUNDRY env vars, the thinking +// rewrite proxy routes via ANTHROPIC_*_BASE_URL override env vars. +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + a.stopProviderProxyLocked() + return nil + } + p := a.providers[a.activeIdx] + var env []string + + if p.BaseURL != "" { + if p.Thinking != "" { + if err := a.ensureProviderProxyLocked(p.BaseURL, p.Thinking); err != nil { + slog.Error("providerproxy: failed to start", "error", err) + env = append(env, "ANTHROPIC_BASE_URL="+p.BaseURL) + } else { + env = append(env, "ANTHROPIC_BASE_URL="+a.proxyLocalURL) + env = append(env, "NO_PROXY=127.0.0.1") + } + } else { + a.stopProviderProxyLocked() + env = append(env, "ANTHROPIC_BASE_URL="+p.BaseURL) + } + if p.APIKey != "" { + env = append(env, "ANTHROPIC_AUTH_TOKEN="+p.APIKey) + env = append(env, "ANTHROPIC_API_KEY=") + } + if p.Model != "" { + env = append(env, "ANTHROPIC_MODEL="+p.Model) + } + } else { + // Check for env-only providers (Bedrock, Vertex, Foundry) that need thinking rewrite. + if p.Thinking != "" { + providerType := detectEnvOnlyProviderType(p.Env) + if providerType != "" { + targetURL := getDefaultEndpointForProviderType(providerType) + if targetURL != "" { + if err := a.ensureProviderProxyLocked(targetURL, p.Thinking); err != nil { + slog.Error("providerproxy: failed to start for "+providerType, "error", err) + a.stopProviderProxyLocked() + } else { + // Route the provider-specific requests through our proxy. + baseURLEnvVar := getBaseURLEnvVarForProviderType(providerType) + env = append(env, baseURLEnvVar+"="+a.proxyLocalURL) + env = append(env, "NO_PROXY=127.0.0.1") + slog.Info("claudecode: thinking rewrite proxy enabled for "+providerType, + "target", targetURL, "local", a.proxyLocalURL, "thinking", p.Thinking) + } + } else { + a.stopProviderProxyLocked() + } + } else { + a.stopProviderProxyLocked() + } + } else { + a.stopProviderProxyLocked() + } + if p.APIKey != "" { + env = append(env, "ANTHROPIC_API_KEY="+p.APIKey) + } + } + + for k, v := range p.Env { + env = append(env, k+"="+v) + } + slog.Debug("claudecode: providerEnv", + "provider", p.Name, + "model", p.Model, + "env", core.RedactEnv(env)) + return env +} + +func (a *Agent) runtimeEnvLocked() []string { + // configEnv (from config.toml [env]) is lower priority than provider keys or + // session-injected vars, but must survive SetSessionEnv calls (which only + // overwrite sessionEnv). Prepend it so later entries win on conflict. + env := append([]string(nil), a.configEnv...) + env = append(env, a.providerEnvLocked()...) + env = append(env, a.sessionEnv...) + + if a.routerURL != "" { + env = append(env, "ANTHROPIC_BASE_URL="+a.routerURL) + env = append(env, "NO_PROXY=127.0.0.1") + env = append(env, "DISABLE_TELEMETRY=true") + env = append(env, "DISABLE_COST_WARNINGS=true") + } + if a.routerAPIKey != "" { + env = append(env, "ANTHROPIC_API_KEY="+a.routerAPIKey) + } + + if !claudeEnvManagesProviderRouting(env) { + return env + } + return core.MergeEnv(env, []string{"CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST=1"}) +} + +func claudeEnvManagesProviderRouting(env []string) bool { + for _, entry := range env { + key, _, ok := strings.Cut(entry, "=") + if !ok { + continue + } + upper := strings.ToUpper(strings.TrimSpace(key)) + if _, ok := claudeProviderManagedEnvVars[upper]; ok { + return true + } + for _, prefix := range claudeProviderManagedEnvPrefixes { + if strings.HasPrefix(upper, prefix) { + return true + } + } + } + return false +} + +func (a *Agent) ensureProviderProxyLocked(targetURL, thinkingOverride string) error { + if a.providerProxy != nil && a.proxyLocalURL != "" { + return nil + } + a.stopProviderProxyLocked() + proxy, localURL, err := core.NewProviderProxy(targetURL, thinkingOverride) + if err != nil { + return err + } + a.providerProxy = proxy + a.proxyLocalURL = localURL + return nil +} + +func (a *Agent) stopProviderProxyLocked() { + if a.providerProxy != nil { + a.providerProxy.Close() + a.providerProxy = nil + a.proxyLocalURL = "" + } +} + +// detectEnvOnlyProviderType checks if the provider uses Bedrock, Vertex, or Foundry +// via environment variables (without base_url). Returns "bedrock", "vertex", "foundry", +// or empty string if not detected. +func detectEnvOnlyProviderType(env map[string]string) string { + if env == nil { + return "" + } + if env["CLAUDE_CODE_USE_BEDROCK"] == "1" { + return "bedrock" + } + if env["CLAUDE_CODE_USE_VERTEX"] == "1" { + return "vertex" + } + if env["CLAUDE_CODE_USE_FOUNDRY"] == "1" { + return "foundry" + } + return "" +} + +// getDefaultEndpointForProviderType returns the default API endpoint for Bedrock/Vertex/Foundry. +// Used as the proxy target when thinking rewrite is needed for env-only providers. +func getDefaultEndpointForProviderType(providerType string) string { + switch providerType { + case "bedrock": + // Bedrock cross-region inference endpoint; works with AWS SDK auth. + // User can override region via AWS_REGION or CLOUD_ML_REGION env var. + return "https://bedrock-runtime.us-east-1.amazonaws.com" + case "vertex": + // Vertex AI endpoint; requires CLOUD_ML_REGION env var for region. + return "https://us-east1-aiplatform.googleapis.com" + case "foundry": + // Anthropic Foundry internal endpoint (rarely used externally). + return "https://api.anthropic.com" + default: + return "" + } +} + +// getBaseURLEnvVarForProviderType returns the environment variable name that +// Claude Code uses to override the base URL for Bedrock/Vertex/Foundry providers. +func getBaseURLEnvVarForProviderType(providerType string) string { + switch providerType { + case "bedrock": + return "ANTHROPIC_BEDROCK_BASE_URL" + case "vertex": + return "ANTHROPIC_VERTEX_BASE_URL" + case "foundry": + return "ANTHROPIC_FOUNDRY_BASE_URL" + default: + return "" + } +} + +// summarizeInput produces a short human-readable description of tool input. +func summarizeInput(tool string, input any) string { + m, ok := input.(map[string]any) + if !ok { + return "" + } + + switch tool { + case "Read", "Edit", "Write": + if fp, ok := m["file_path"].(string); ok { + return fp + } + case "Bash": + if cmd, ok := m["command"].(string); ok { + return cmd + } + case "Grep": + if p, ok := m["pattern"].(string); ok { + return p + } + case "Glob": + if p, ok := m["pattern"].(string); ok { + return p + } + if p, ok := m["glob_pattern"].(string); ok { + return p + } + } + + b, err := json.Marshal(m) + if err != nil { + return "" + } + return string(b) +} + +// parseUserQuestions extracts structured questions from AskUserQuestion input. +func parseUserQuestions(input map[string]any) []core.UserQuestion { + questionsRaw, ok := input["questions"].([]any) + if !ok || len(questionsRaw) == 0 { + return nil + } + var questions []core.UserQuestion + for _, qRaw := range questionsRaw { + qMap, ok := qRaw.(map[string]any) + if !ok { + continue + } + q := core.UserQuestion{ + Question: strVal(qMap, "question"), + Header: strVal(qMap, "header"), + MultiSelect: boolVal(qMap, "multiSelect"), + } + if optsRaw, ok := qMap["options"].([]any); ok { + for _, oRaw := range optsRaw { + oMap, ok := oRaw.(map[string]any) + if !ok { + continue + } + q.Options = append(q.Options, core.UserQuestionOption{ + Label: strVal(oMap, "label"), + Description: strVal(oMap, "description"), + }) + } + } + if q.Question != "" { + questions = append(questions, q) + } + } + return questions +} + +func strVal(m map[string]any, key string) string { + v, _ := m[key].(string) + return v +} + +func boolVal(m map[string]any, key string) bool { + v, _ := m[key].(bool) + return v +} + +// encodeClaudeProjectKey converts an absolute path to Claude Code's project key format. +// Claude Code encodes paths by: +// 1. Replacing path separators (/ or \) with "-" +// 2. Replacing colons (:) with "-" (Windows drive letters) +// 3. Replacing underscores (_) with "-" +// 4. Replacing spaces and tildes (~) with "-" (common in macOS iCloud paths like +// "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/...") +// 5. Replacing all non-ASCII characters with "-" +func encodeClaudeProjectKey(absPath string) string { + // First, normalize to forward slashes for consistent processing + normalized := strings.ReplaceAll(absPath, "\\", "/") + + // Build the encoded key character by character + var result strings.Builder + for _, r := range normalized { + if r == '/' || r == ':' || r == '_' || r == ' ' || r == '~' { + result.WriteRune('-') + } else if r < 128 { // ASCII range (0-127) + result.WriteRune(r) + } else { + // Non-ASCII characters become hyphens + result.WriteRune('-') + } + } + return result.String() +} + +// findProjectDir locates the Claude Code session directory for a given work dir. +// Claude Code stores sessions at ~/.claude/projects/{projectKey}/ where projectKey +// is derived from the absolute path. On Windows, the key format may vary (colon +// handling, slash direction), so we try multiple key candidates and fall back to +// scanning the projects directory. +func findProjectDir(homeDir, absWorkDir string) string { + projectsBase := filepath.Join(homeDir, ".claude", "projects") + + // Build candidate keys: different ways Claude Code might encode the path. + // Primary encoding: Claude Code's actual algorithm (non-ASCII → "-") + candidates := []string{ + encodeClaudeProjectKey(absWorkDir), + // Legacy candidates for backward compatibility + strings.ReplaceAll(absWorkDir, string(filepath.Separator), "-"), + strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace(absWorkDir), + strings.NewReplacer("/", "-", "\\", "-", ":", "-", "_", "-").Replace(absWorkDir), + } + // Also try with forward slashes (config might use forward slashes on Windows) + fwd := strings.ReplaceAll(absWorkDir, "\\", "/") + candidates = append(candidates, strings.ReplaceAll(fwd, "/", "-")) + + for _, key := range candidates { + dir := filepath.Join(projectsBase, key) + if _, err := os.Stat(dir); err == nil { + return dir + } + } + + // Fallback: scan the projects directory and find a match by + // comparing the encoded path (handles variations in encoding). + entries, err := os.ReadDir(projectsBase) + if err != nil { + return "" + } + + // Use the primary encoding for comparison + encodedWorkDir := encodeClaudeProjectKey(absWorkDir) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + // Direct match with encoded key + if entry.Name() == encodedWorkDir { + return filepath.Join(projectsBase, entry.Name()) + } + // Case-insensitive match for Windows compatibility + if strings.EqualFold(entry.Name(), encodedWorkDir) { + return filepath.Join(projectsBase, entry.Name()) + } + } + + return "" +} diff --git a/agent/claudecode/claudecode_model_test.go b/agent/claudecode/claudecode_model_test.go new file mode 100644 index 0000000..28d0ea4 --- /dev/null +++ b/agent/claudecode/claudecode_model_test.go @@ -0,0 +1,57 @@ +package claudecode + +import ( + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestConfiguredModels_BoundaryConditions(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "first"}}}, + {Models: []core.ModelOption{{Name: "second"}}}, + }, + } + + tests := []struct { + name string + activeIdx int + wantNil bool + wantName string + }{ + {name: "negative index", activeIdx: -1, wantNil: true}, + {name: "out of range", activeIdx: 2, wantNil: true}, + {name: "valid index", activeIdx: 1, wantName: "second"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a.activeIdx = tt.activeIdx + got := a.configuredModels() + if tt.wantNil { + if got != nil { + t.Fatalf("configuredModels() = %v, want nil", got) + } + return + } + if len(got) != 1 || got[0].Name != tt.wantName { + t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName) + } + }) + } +} + +func TestGetModel_PrefersActiveProviderModel(t *testing.T) { + a := &Agent{ + model: "sonnet", + providers: []core.ProviderConfig{ + {Name: "anthropic", Model: "opus"}, + }, + activeIdx: 0, + } + + if got := a.GetModel(); got != "opus" { + t.Fatalf("GetModel() = %q, want opus", got) + } +} diff --git a/agent/claudecode/claudecode_test.go b/agent/claudecode/claudecode_test.go new file mode 100644 index 0000000..0389b48 --- /dev/null +++ b/agent/claudecode/claudecode_test.go @@ -0,0 +1,752 @@ +package claudecode + +import ( + "os" + "path/filepath" + "reflect" + "runtime" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestNew_ParsesRunAsUserAndRunAsEnv(t *testing.T) { + opts := map[string]any{ + "work_dir": "/tmp/claudecode-test", + "run_as_user": "partseeker-coder", + "run_as_env": []any{"PGSSLROOTCERT", "PGSSLMODE"}, + } + a, err := New(opts) + if err != nil { + t.Fatalf("New returned error: %v", err) + } + ag, ok := a.(*Agent) + if !ok { + t.Fatalf("agent is not *Agent: %T", a) + } + if ag.spawnOpts.RunAsUser != "partseeker-coder" { + t.Errorf("spawnOpts.RunAsUser = %q, want %q", ag.spawnOpts.RunAsUser, "partseeker-coder") + } + if got := ag.spawnOpts.EnvAllowlist; len(got) != 2 || got[0] != "PGSSLROOTCERT" || got[1] != "PGSSLMODE" { + t.Errorf("spawnOpts.EnvAllowlist = %v, want [PGSSLROOTCERT PGSSLMODE]", got) + } +} + +func TestNew_RunAsUserSkipsClaudeLookPath(t *testing.T) { + // With run_as_user set, the supervisor's PATH lookup for "claude" is + // skipped because the target user's PATH is what matters. Verify that + // New() doesn't fail even when claude isn't on this test process's PATH. + opts := map[string]any{ + "work_dir": "/tmp/claudecode-test", + "run_as_user": "target-that-definitely-exists", + } + // Note: this test relies on New() NOT calling exec.LookPath("claude") + // when run_as_user is set. If claude IS on PATH in the test env, + // either branch of the code returns success and the test still passes. + if _, err := New(opts); err != nil { + // The only other reason New() could fail for these opts is the + // LookPath check — fail loudly if that's what happened. + t.Errorf("New with run_as_user returned error (LookPath not skipped?): %v", err) + } + _ = core.AgentSystemPrompt // keep the core import used +} + +func TestParseUserQuestions_ValidInput(t *testing.T) { + input := map[string]any{ + "questions": []any{ + map[string]any{ + "question": "Which database?", + "header": "Setup", + "multiSelect": false, + "options": []any{ + map[string]any{"label": "PostgreSQL", "description": "Production"}, + map[string]any{"label": "SQLite", "description": "Dev"}, + }, + }, + }, + } + qs := parseUserQuestions(input) + if len(qs) != 1 { + t.Fatalf("expected 1 question, got %d", len(qs)) + } + q := qs[0] + if q.Question != "Which database?" { + t.Errorf("question = %q", q.Question) + } + if q.Header != "Setup" { + t.Errorf("header = %q", q.Header) + } + if q.MultiSelect { + t.Error("expected multiSelect=false") + } + if len(q.Options) != 2 { + t.Fatalf("expected 2 options, got %d", len(q.Options)) + } + if q.Options[0].Label != "PostgreSQL" { + t.Errorf("option[0].label = %q", q.Options[0].Label) + } + if q.Options[1].Description != "Dev" { + t.Errorf("option[1].description = %q", q.Options[1].Description) + } +} + +func TestParseUserQuestions_EmptyInput(t *testing.T) { + qs := parseUserQuestions(map[string]any{}) + if len(qs) != 0 { + t.Errorf("expected 0 questions, got %d", len(qs)) + } +} + +func TestParseUserQuestions_NoQuestionText(t *testing.T) { + input := map[string]any{ + "questions": []any{ + map[string]any{"header": "Setup"}, + }, + } + qs := parseUserQuestions(input) + if len(qs) != 0 { + t.Errorf("expected 0 questions (no question text), got %d", len(qs)) + } +} + +func TestParseUserQuestions_MultiSelect(t *testing.T) { + input := map[string]any{ + "questions": []any{ + map[string]any{ + "question": "Select features", + "multiSelect": true, + "options": []any{ + map[string]any{"label": "Auth"}, + map[string]any{"label": "Logging"}, + }, + }, + }, + } + qs := parseUserQuestions(input) + if len(qs) != 1 { + t.Fatalf("expected 1 question, got %d", len(qs)) + } + if !qs[0].MultiSelect { + t.Error("expected multiSelect=true") + } +} + +func TestNormalizePermissionMode(t *testing.T) { + tests := []struct { + input string + want string + }{ + // dontAsk aliases + {"dontAsk", "dontAsk"}, + {"dontask", "dontAsk"}, + {"dont-ask", "dontAsk"}, + {"dont_ask", "dontAsk"}, + // auto + {"auto", "auto"}, + // bypassPermissions aliases + {"bypassPermissions", "bypassPermissions"}, + {"yolo", "bypassPermissions"}, + // acceptEdits aliases + {"acceptEdits", "acceptEdits"}, + {"edit", "acceptEdits"}, + // plan + {"plan", "plan"}, + // default fallback + {"", "default"}, + {"unknown", "default"}, + } + for _, tt := range tests { + got := normalizePermissionMode(tt.input) + if got != tt.want { + t.Errorf("normalizePermissionMode(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestClaudeSessionSetLiveMode(t *testing.T) { + cs := &claudeSession{} + cs.setPermissionMode("default") + if cs.autoApprove.Load() || cs.acceptEditsOnly.Load() || cs.dontAsk.Load() { + t.Fatal("expected default mode flags to be off") + } + + if !cs.SetLiveMode("acceptEdits") { + t.Fatal("SetLiveMode(acceptEdits) = false, want true") + } + if !cs.acceptEditsOnly.Load() || cs.autoApprove.Load() || cs.dontAsk.Load() { + t.Fatal("acceptEdits flags not set correctly") + } + + if cs.SetLiveMode("auto") { + t.Fatal("SetLiveMode(auto) = true, want false") + } + + cs.SetLiveMode("dontAsk") + if !cs.dontAsk.Load() || cs.autoApprove.Load() || cs.acceptEditsOnly.Load() { + t.Fatal("dontAsk flags not set correctly") + } + + cs.SetLiveMode("bypassPermissions") + if !cs.autoApprove.Load() || cs.acceptEditsOnly.Load() || cs.dontAsk.Load() { + t.Fatal("bypassPermissions alias flags not set correctly") + } +} + +func TestClaudeSessionSetLiveMode_AutoSessionRequiresRestart(t *testing.T) { + cs := &claudeSession{} + cs.setPermissionMode("auto") + if cs.SetLiveMode("default") { + t.Fatal("SetLiveMode(default) from auto session = true, want false") + } +} + +func TestAgent_PermissionModes(t *testing.T) { + a := &Agent{} + modes := a.PermissionModes() + if len(modes) == 0 { + t.Fatal("PermissionModes() returned no modes") + } + + foundAuto := false + foundBypass := false + for _, mode := range modes { + if mode.Key == "auto" { + foundAuto = true + } + if mode.Key == "bypassPermissions" { + foundBypass = true + } + } + if !foundAuto { + t.Fatal("PermissionModes() missing auto mode") + } + if !foundBypass { + t.Fatal("PermissionModes() missing bypassPermissions mode") + } +} + +func TestIsClaudeEditTool(t *testing.T) { + for _, tool := range []string{"Edit", "Write", "NotebookEdit", "MultiEdit"} { + if !isClaudeEditTool(tool) { + t.Fatalf("isClaudeEditTool(%q) = false, want true", tool) + } + } + if isClaudeEditTool("Bash") { + t.Fatal("isClaudeEditTool(Bash) = true, want false") + } +} + +func TestSummarizeInput_AskUserQuestion(t *testing.T) { + input := map[string]any{ + "questions": []any{ + map[string]any{ + "question": "Which framework?", + "options": []any{ + map[string]any{"label": "React"}, + map[string]any{"label": "Vue"}, + }, + }, + }, + } + result := summarizeInput("AskUserQuestion", input) + if result == "" { + t.Error("expected non-empty summary for AskUserQuestion") + } +} + +func TestAgent_Name(t *testing.T) { + a := &Agent{} + if got := a.Name(); got != "claudecode" { + t.Errorf("Name() = %q, want %q", got, "claudecode") + } +} + +func TestAgent_CLIBinaryName(t *testing.T) { + a := &Agent{cliBin: "claude"} + if got := a.CLIBinaryName(); got != "claude" { + t.Errorf("CLIBinaryName() = %q, want %q", got, "claude") + } + + a2 := &Agent{cliBin: "my-cli"} + if got := a2.CLIBinaryName(); got != "my-cli" { + t.Errorf("CLIBinaryName() = %q, want %q", got, "my-cli") + } +} + +func TestAgent_CLIDisplayName(t *testing.T) { + a := &Agent{} + if got := a.CLIDisplayName(); got != "Claude" { + t.Errorf("CLIDisplayName() = %q, want %q", got, "Claude") + } +} + +func TestAgent_SetWorkDir(t *testing.T) { + a := &Agent{} + a.SetWorkDir("/tmp/test") + if got := a.GetWorkDir(); got != "/tmp/test" { + t.Errorf("GetWorkDir() = %q, want %q", got, "/tmp/test") + } +} + +func TestAgent_SetModel(t *testing.T) { + a := &Agent{} + a.SetModel("claude-sonnet-4-20250514") + if got := a.GetModel(); got != "claude-sonnet-4-20250514" { + t.Errorf("GetModel() = %q, want %q", got, "claude-sonnet-4-20250514") + } +} + +func TestAgent_SetSessionEnv(t *testing.T) { + a := &Agent{} + a.SetSessionEnv([]string{"KEY=value"}) + if len(a.sessionEnv) != 1 || a.sessionEnv[0] != "KEY=value" { + t.Errorf("sessionEnv = %v, want [KEY=value]", a.sessionEnv) + } +} + +func TestAgent_SetPlatformPrompt(t *testing.T) { + a := &Agent{} + a.SetPlatformPrompt("You are a helpful assistant on Feishu.") + if a.platformPrompt != "You are a helpful assistant on Feishu." { + t.Errorf("platformPrompt = %q, want %q", a.platformPrompt, "You are a helpful assistant on Feishu.") + } +} + +func TestAgent_SetMode(t *testing.T) { + a := &Agent{} + + a.SetMode("auto") + if got := a.GetMode(); got != "auto" { + t.Fatalf("GetMode() after SetMode(auto) = %q, want auto", got) + } + + a.SetMode("yolo") + if got := a.GetMode(); got != "bypassPermissions" { + t.Fatalf("GetMode() after SetMode(yolo) = %q, want bypassPermissions", got) + } +} + +func TestStripXMLTags(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"content", "content"}, + {"no tags", "no tags"}, + {"helloworld", "helloworld"}, + {"text", "text"}, + {"", ""}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := stripXMLTags(tt.input) + if got != tt.expected { + t.Errorf("stripXMLTags(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +// verify Agent implements core.Agent +var _ core.Agent = (*Agent)(nil) + +func TestEncodeClaudeProjectKey(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple ASCII path", + input: "/Users/username/Documents/project", + expected: "-Users-username-Documents-project", + }, + { + name: "path with Chinese characters", + input: "/Users/username/Documents/项目文件夹", + expected: "-Users-username-Documents------", // 6 hyphens: 1 for "/" + 5 for Chinese chars + }, + { + name: "path with Japanese characters", + input: "/Users/username/Documents/プロジェクト", + expected: "-Users-username-Documents-------", // 6 hyphens: 1 for "/" + 5 for Japanese chars + }, + { + name: "path with emoji", + input: "/Users/username/Documents/🎉project", + expected: "-Users-username-Documents--project", // 2 hyphens: 1 for "/" + 1 for emoji + }, + { + name: "Windows path with colon", + input: "C:\\Users\\username\\Documents", + expected: "C--Users-username-Documents", + }, + { + name: "path with underscore", + input: "/Users/username/my_project", + expected: "-Users-username-my-project", + }, + { + name: "path with spaces", + input: "/Users/username/Mobile Documents/my project", + expected: "-Users-username-Mobile-Documents-my-project", + }, + { + name: "path with tildes", + input: "/Users/username/com~apple~CloudDocs/project", + expected: "-Users-username-com-apple-CloudDocs-project", + }, + { + name: "iCloud path with spaces and tildes", + input: "/Users/username/Library/Mobile Documents/com~apple~CloudDocs/my project", + expected: "-Users-username-Library-Mobile-Documents-com-apple-CloudDocs-my-project", + }, + { + name: "mixed ASCII and non-ASCII", + input: "/Users/username/中文folder/english文件夹", + expected: "-Users-username---folder-english---", // "/中文" = 3 hyphens, "/文件夹" = 4 hyphens + }, + { + name: "empty path", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := encodeClaudeProjectKey(tt.input) + if got != tt.expected { + t.Errorf("encodeClaudeProjectKey(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestFindProjectDir_NonASCIIPath(t *testing.T) { + // This test verifies that findProjectDir can handle non-ASCII paths + // by creating a mock projects directory structure + homeDir := t.TempDir() + projectsBase := filepath.Join(homeDir, ".claude", "projects") + + // Test case: Chinese characters in path + chineseWorkDir := "/Users/test/Documents/项目文件夹" + expectedKey := encodeClaudeProjectKey(chineseWorkDir) + + // Create the mock project directory + mockProjectDir := filepath.Join(projectsBase, expectedKey) + if err := os.MkdirAll(mockProjectDir, 0755); err != nil { + t.Fatalf("failed to create mock project dir: %v", err) + } + + // Verify findProjectDir finds the directory + found := findProjectDir(homeDir, chineseWorkDir) + if found != mockProjectDir { + t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, chineseWorkDir, found, mockProjectDir) + } +} + +func TestFindProjectDir_ASCIIPath(t *testing.T) { + // Verify ASCII paths still work correctly + homeDir := t.TempDir() + projectsBase := filepath.Join(homeDir, ".claude", "projects") + + asciiWorkDir := "/Users/test/Documents/project" + expectedKey := encodeClaudeProjectKey(asciiWorkDir) + + mockProjectDir := filepath.Join(projectsBase, expectedKey) + if err := os.MkdirAll(mockProjectDir, 0755); err != nil { + t.Fatalf("failed to create mock project dir: %v", err) + } + + found := findProjectDir(homeDir, asciiWorkDir) + if found != mockProjectDir { + t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, asciiWorkDir, found, mockProjectDir) + } +} + +func TestFindProjectDir_NotFound(t *testing.T) { + homeDir := t.TempDir() + // Don't create any project directories + + workDir := "/Users/test/Documents/nonexistent" + found := findProjectDir(homeDir, workDir) + if found != "" { + t.Errorf("findProjectDir for nonexistent project = %q, want empty string", found) + } +} + +func TestFindProjectDir_ICloudPath(t *testing.T) { + // Regression for issue #500: paths containing spaces and "~" (common in macOS + // iCloud Drive paths like "/Users/x/Library/Mobile Documents/com~apple~CloudDocs/...") + // must match the on-disk project key that Claude Code CLI generates, which + // collapses both spaces and "~" to "-". + homeDir := t.TempDir() + projectsBase := filepath.Join(homeDir, ".claude", "projects") + + iCloudWorkDir := "/Users/test/Library/Mobile Documents/com~apple~CloudDocs/my project" + // The on-disk key Claude Code CLI actually writes (spaces and "~" → "-"). + expectedKey := "-Users-test-Library-Mobile-Documents-com-apple-CloudDocs-my-project" + + mockProjectDir := filepath.Join(projectsBase, expectedKey) + if err := os.MkdirAll(mockProjectDir, 0755); err != nil { + t.Fatalf("failed to create mock project dir: %v", err) + } + + found := findProjectDir(homeDir, iCloudWorkDir) + if found != mockProjectDir { + t.Errorf("findProjectDir(%q, %q) = %q, want %q", homeDir, iCloudWorkDir, found, mockProjectDir) + } +} + +func TestSnapshotCLIPath(t *testing.T) { + cases := []struct { + name string + cliBin string + extraArgs []string + want string + }{ + {"default-claude-skipped", "claude", nil, ""}, + {"empty-binary-skipped", "", nil, ""}, + {"custom-binary-only", "/usr/local/bin/claude", nil, "/usr/local/bin/claude"}, + {"wrapper-with-args", "my-cli", []string{"code", "-t", "foo"}, "my-cli code -t foo"}, + {"claude-with-add-dir", "claude", []string{"--add-dir", "/parent"}, "claude --add-dir /parent"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := snapshotCLIPath(tc.cliBin, tc.extraArgs) + if got != tc.want { + t.Errorf("snapshotCLIPath(%q, %v) = %q, want %q", tc.cliBin, tc.extraArgs, got, tc.want) + } + }) + } +} + +func TestWorkspaceAgentOptions_FullSnapshot(t *testing.T) { + // Construct an Agent directly so we don't depend on `claude` being on + // PATH. WorkspaceAgentOptions only reads fields that the production + // New() also writes; this just verifies the snapshot shape. + a := &Agent{ + cliBin: "my-cli", + cliExtraArgs: []string{"--add-dir", "/parent"}, + cliArgsFlag: "-a", + model: "claude-opus-4-7", + reasoningEffort: "high", + mode: "acceptEdits", + allowedTools: []string{"Edit", "Read"}, + disallowedTools: []string{"Bash"}, + maxContextTokens: 200000, + routerURL: "http://127.0.0.1:3456", + routerAPIKey: "secret", + } + got := a.WorkspaceAgentOptions() + + want := map[string]any{ + "mode": "acceptEdits", + "cli_path": "my-cli --add-dir /parent", + "cli_args_flag": "-a", + "model": "claude-opus-4-7", + "reasoning_effort": "high", + "allowed_tools": []any{"Edit", "Read"}, + "disallowed_tools": []any{"Bash"}, + "max_context_tokens": 200000, + "router_url": "http://127.0.0.1:3456", + "router_api_key": "secret", + } + if len(got) != len(want) { + t.Errorf("snapshot len = %d, want %d (got=%v)", len(got), len(want), got) + } + for k, wv := range want { + gv, ok := got[k] + if !ok { + t.Errorf("snapshot missing key %q", k) + continue + } + if !reflect.DeepEqual(gv, wv) { + t.Errorf("snapshot[%q] = %v (%T), want %v (%T)", k, gv, gv, wv, wv) + } + } +} + +func TestWorkspaceAgentOptions_OmitsZeroValues(t *testing.T) { + // Default agent (only mode is always emitted, plus default cliBin + // "claude" should be skipped by snapshotCLIPath). + a := &Agent{cliBin: "claude", mode: "default"} + got := a.WorkspaceAgentOptions() + + if len(got) != 1 { + t.Errorf("snapshot len = %d, want 1 (got=%v)", len(got), got) + } + if got["mode"] != "default" { + t.Errorf("snapshot[mode] = %v, want %q", got["mode"], "default") + } + for _, k := range []string{ + "cli_path", "cli_args_flag", "model", "reasoning_effort", + "allowed_tools", "disallowed_tools", "max_context_tokens", + "router_url", "router_api_key", + } { + if _, ok := got[k]; ok { + t.Errorf("snapshot unexpectedly includes %q = %v", k, got[k]) + } + } +} + +func TestWorkspaceAgentOptions_RoundTripsThroughNew(t *testing.T) { + // End-to-end: snapshot → New() should reproduce every field. Use + // run_as_user to skip the supervisor-side LookPath check, since the + // fake "my-cli" binary doesn't exist on the test host's PATH. + // + // run_as_user only short-circuits LookPath on platforms where + // SpawnOptions.IsolationMode() can be true — i.e. Unix. On Windows + // it always returns false (see core/runas_windows.go), so the fake + // CLI would fail LookPath and New() would error out before the + // round-trip assertions run. + if runtime.GOOS == "windows" { + t.Skip("run_as_user-based LookPath bypass is Unix-only") + } + parent := &Agent{ + cliBin: "my-cli", + cliExtraArgs: []string{"code", "--add-dir", "/parent"}, + cliArgsFlag: "-a", + model: "claude-opus-4-7", + reasoningEffort: "high", + mode: "acceptEdits", + allowedTools: []string{"Edit", "Read"}, + disallowedTools: []string{"Bash"}, + maxContextTokens: 200000, + routerURL: "http://127.0.0.1:3456", + routerAPIKey: "secret", + } + opts := parent.WorkspaceAgentOptions() + opts["work_dir"] = "/tmp/claudecode-test" + opts["run_as_user"] = "skip-lookpath" + + a, err := New(opts) + if err != nil { + t.Fatalf("New(snapshot) returned error: %v", err) + } + child := a.(*Agent) + + if child.cliBin != "my-cli" { + t.Errorf("cliBin = %q, want %q", child.cliBin, "my-cli") + } + if !reflect.DeepEqual(child.cliExtraArgs, []string{"code", "--add-dir", "/parent"}) { + t.Errorf("cliExtraArgs = %v, want [code --add-dir /parent]", child.cliExtraArgs) + } + if child.cliArgsFlag != "-a" { + t.Errorf("cliArgsFlag = %q, want -a", child.cliArgsFlag) + } + if child.model != "claude-opus-4-7" { + t.Errorf("model = %q, want claude-opus-4-7", child.model) + } + if child.reasoningEffort != "high" { + t.Errorf("reasoningEffort = %q, want high", child.reasoningEffort) + } + if child.mode != "acceptEdits" { + t.Errorf("mode = %q, want acceptEdits", child.mode) + } + if !reflect.DeepEqual(child.allowedTools, []string{"Edit", "Read"}) { + t.Errorf("allowedTools = %v, want [Edit Read]", child.allowedTools) + } + if !reflect.DeepEqual(child.disallowedTools, []string{"Bash"}) { + t.Errorf("disallowedTools = %v, want [Bash]", child.disallowedTools) + } + if child.maxContextTokens != 200000 { + t.Errorf("maxContextTokens = %d, want 200000", child.maxContextTokens) + } + if child.routerURL != "http://127.0.0.1:3456" { + t.Errorf("routerURL = %q, want http://127.0.0.1:3456", child.routerURL) + } + if child.routerAPIKey != "secret" { + t.Errorf("routerAPIKey = %q, want secret", child.routerAPIKey) + } +} + +func TestScanSessionMeta_ArrayContent(t *testing.T) { + // Regression test for: scanSessionMeta skips entries where content is a JSON array + // (e.g., assistant messages with thinking blocks, or user messages with tool results). + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.jsonl") + + lines := []string{ + `{"type": "queue-operation", "operation": "start"}`, + `{"type": "user", "message": {"content": "Hello world"}}`, + `{"type": "assistant", "message": {"content": [{"type": "thinking", "text": ""}, {"type": "text", "text": "Hi there"}]}}`, + `{"type": "user", "message": {"content": [{"tool_use_id": "call_abc", "type": "tool_result", "content": "result data"}]}}`, + `{"type": "assistant", "message": {"content": "Plain text reply"}}`, + `{"type": "last-prompt", "lastPrompt": "test"}`, + } + + data := "" + for _, line := range lines { + data += line + "\n" + } + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write test jsonl: %v", err) + } + + summary, count := scanSessionMeta(path) + + // Expected: 2 user + 2 assistant = 4 messages + if count != 4 { + t.Errorf("scanSessionMeta count = %d, want 4 (2 user + 2 assistant, array content should not be skipped)", count) + } + + // Summary should come from the last user message with string content (line 2) + if summary != "Hello world" { + t.Errorf("scanSessionMeta summary = %q, want %q", summary, "Hello world") + } +} + +func TestScanSessionMeta_AllArrayContent(t *testing.T) { + // When all user messages have array content, summary should remain empty + // and count should still be correct. + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "test.jsonl") + + lines := []string{ + `{"type": "user", "message": {"content": [{"type": "tool_result", "content": "data"}]}}`, + `{"type": "assistant", "message": {"content": [{"type": "text", "text": "reply"}]}}`, + } + + data := "" + for _, line := range lines { + data += line + "\n" + } + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write test jsonl: %v", err) + } + + summary, count := scanSessionMeta(path) + + if count != 2 { + t.Errorf("scanSessionMeta count = %d, want 2", count) + } + if summary != "" { + t.Errorf("scanSessionMeta summary = %q, want empty string when no string user content exists", summary) + } +} + +func TestExtractStringContent(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {"string", `"hello"`, "hello"}, + {"empty", `""`, ""}, + {"array", `[{"type": "text"}]`, ""}, + {"object", `{"key": "val"}`, ""}, + {"null", `null`, ""}, + {"number", `42`, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractStringContent([]byte(tt.raw)) + if got != tt.want { + t.Errorf("extractStringContent(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +} diff --git a/agent/claudecode/claudecode_workdir_race_test.go b/agent/claudecode/claudecode_workdir_race_test.go new file mode 100644 index 0000000..dfd20fb --- /dev/null +++ b/agent/claudecode/claudecode_workdir_race_test.go @@ -0,0 +1,53 @@ +package claudecode + +import ( + "path/filepath" + "sync" + "testing" +) + +// TestClaudecodeAgent_WorkDirRaceFreeReaders pins the bug where +// ListSessions, DeleteSession, GetSessionHistory, CommandDirs, +// SkillDirs, and ProjectMemoryFile read a.workDir without holding +// a.mu, while SetWorkDir writes a.workDir under the lock. Run with +// -race to detect the data race; with the production fix the +// detector stays quiet. +// +// claudecode is the primary agent and exposes the most readers of +// the workDir field across the project, so the cumulative impact of +// this race is the largest of any agent. +func TestClaudecodeAgent_WorkDirRaceFreeReaders(t *testing.T) { + dir := t.TempDir() + a := &Agent{workDir: dir} + + var wg sync.WaitGroup + for i := 0; i < 30; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if i%2 == 0 { + a.SetWorkDir(filepath.Join(dir, "a")) + } else { + a.SetWorkDir(filepath.Join(dir, "b")) + } + }(i) + } + for i := 0; i < 30; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = a.CommandDirs() + }() + wg.Add(1) + go func() { + defer wg.Done() + _ = a.SkillDirs() + }() + wg.Add(1) + go func() { + defer wg.Done() + _ = a.ProjectMemoryFile() + }() + } + wg.Wait() +} diff --git a/agent/claudecode/proc_unix.go b/agent/claudecode/proc_unix.go new file mode 100644 index 0000000..c9c338e --- /dev/null +++ b/agent/claudecode/proc_unix.go @@ -0,0 +1,48 @@ +//go:build unix + +package claudecode + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +// prepareCmdForKill puts the spawned child into its own process group so that +// the entire descendant tree can be terminated with a single signal aimed at +// the negative PID. Without this, cc-connect can only signal the direct +// child (e.g. the `claude` CLI), leaving any grandchildren (MCP server +// processes such as the Telegram bridge) as orphans that may spin at 100% +// CPU when their parent disappears. +// +// Mirrors the pattern used by agent/codex/proc_unix.go. +func prepareCmdForKill(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +// signalProcessGroup sends sig to the entire process group rooted at cmd. +// Returns nil if the group is already gone. +func signalProcessGroup(cmd *exec.Cmd, sig syscall.Signal) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, sig); err != nil && + !errors.Is(err, os.ErrProcessDone) && + !errors.Is(err, syscall.ESRCH) { + return err + } + return nil +} + +// forceKillCmd SIGKILLs the entire process group rooted at cmd. Use this +// as the last-resort escalation when graceful shutdown has timed out. +func forceKillCmd(cmd *exec.Cmd) error { + return signalProcessGroup(cmd, syscall.SIGKILL) +} diff --git a/agent/claudecode/proc_unix_test.go b/agent/claudecode/proc_unix_test.go new file mode 100644 index 0000000..ae865a7 --- /dev/null +++ b/agent/claudecode/proc_unix_test.go @@ -0,0 +1,113 @@ +//go:build unix + +package claudecode + +import ( + "os/exec" + "syscall" + "testing" + "time" +) + +func TestPrepareCmdForKill_SetsSetpgid(t *testing.T) { + cmd := exec.Command("/bin/true") + prepareCmdForKill(cmd) + if cmd.SysProcAttr == nil { + t.Fatal("SysProcAttr is nil after prepareCmdForKill") + } + if !cmd.SysProcAttr.Setpgid { + t.Fatal("Setpgid not set after prepareCmdForKill") + } +} + +func TestPrepareCmdForKill_PreservesExistingSysProcAttr(t *testing.T) { + cmd := exec.Command("/bin/true") + cmd.SysProcAttr = &syscall.SysProcAttr{Foreground: false} + prepareCmdForKill(cmd) + if !cmd.SysProcAttr.Setpgid { + t.Fatal("Setpgid not set when SysProcAttr was pre-populated") + } +} + +func TestPrepareCmdForKill_NilCmd(t *testing.T) { + // Must not panic on a nil *exec.Cmd. + prepareCmdForKill(nil) +} + +func TestForceKillCmd_NoProcess(t *testing.T) { + cmd := exec.Command("/bin/true") + // cmd has not been Start()ed, so cmd.Process is nil. + if err := forceKillCmd(cmd); err != nil { + t.Errorf("expected no error on un-started cmd, got %v", err) + } +} + +func TestForceKillCmd_NilCmd(t *testing.T) { + if err := forceKillCmd(nil); err != nil { + t.Errorf("expected no error on nil cmd, got %v", err) + } +} + +// TestForceKillCmd_KillsGrandchild is the regression test for the original +// bug: spawning a shell that backgrounds a long-running grandchild, then +// proving that forceKillCmd reaps the grandchild along with the direct +// child via process-group kill. Without prepareCmdForKill setting up the +// process group, the grandchild would survive and spin. +func TestForceKillCmd_KillsGrandchild(t *testing.T) { + // /bin/sh -c 'sleep 60 & echo $! ; wait' + // The grandchild PID is printed on stdout so we can verify it is reaped. + cmd := exec.Command("/bin/sh", "-c", "sleep 60 & echo $! ; wait") + prepareCmdForKill(cmd) + + stdout, err := cmd.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + + // Read the grandchild PID. + buf := make([]byte, 32) + deadline := time.Now().Add(2 * time.Second) + var grandchildPidStr string + for time.Now().Before(deadline) { + n, _ := stdout.Read(buf) + if n > 0 { + grandchildPidStr = string(buf[:n]) + break + } + time.Sleep(10 * time.Millisecond) + } + if grandchildPidStr == "" { + _ = forceKillCmd(cmd) + _ = cmd.Wait() + t.Fatal("did not receive grandchild PID") + } + + if err := forceKillCmd(cmd); err != nil { + t.Fatalf("forceKillCmd: %v", err) + } + _ = cmd.Wait() + + // Verify the grandchild is gone by checking that signaling it with 0 + // (no-op, just checks existence) returns ESRCH within a short window. + // We can't easily parse the PID without strconv import bloat in tests, + // so we rely on `pgrep` semantics: re-kill the group should be a no-op. + if err := forceKillCmd(cmd); err != nil { + t.Errorf("second forceKillCmd should be no-op, got %v", err) + } +} + +func TestSignalProcessGroup_NoProcess(t *testing.T) { + cmd := exec.Command("/bin/true") + if err := signalProcessGroup(cmd, syscall.SIGTERM); err != nil { + t.Errorf("expected no error on un-started cmd, got %v", err) + } +} + +func TestSignalProcessGroup_NilCmd(t *testing.T) { + if err := signalProcessGroup(nil, syscall.SIGTERM); err != nil { + t.Errorf("expected no error on nil cmd, got %v", err) + } +} diff --git a/agent/claudecode/proc_windows.go b/agent/claudecode/proc_windows.go new file mode 100644 index 0000000..8f0d6e5 --- /dev/null +++ b/agent/claudecode/proc_windows.go @@ -0,0 +1,85 @@ +//go:build windows + +package claudecode + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +// prepareCmdForKill puts the spawned child into a new process group on +// Windows so that taskkill /T can later terminate the entire descendant +// tree. Without this, cc-connect can only signal the direct child, +// leaving grandchildren (such as MCP server bridges) as orphans. +// +// Mirrors the pattern used by agent/codex/proc_windows.go. +func prepareCmdForKill(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP +} + +// signalProcessGroup is a graceful best-effort equivalent of forceKillCmd +// on Windows: taskkill without /F asks the target to close cleanly. Falls +// back to cmd.Process.Signal if taskkill is unavailable. +func signalProcessGroup(cmd *exec.Cmd, sig syscall.Signal) error { + if cmd == nil || cmd.Process == nil { + return nil + } + killCmd := exec.Command("taskkill", "/T", "/PID", strconv.Itoa(cmd.Process.Pid)) + output, err := killCmd.CombinedOutput() + if err == nil { + return nil + } + if isTaskkillNotRunning(output) { + return nil + } + if killErr := cmd.Process.Signal(sig); killErr == nil || errors.Is(killErr, os.ErrProcessDone) { + return nil + } + return fmt.Errorf("taskkill failed: %w: %s", err, processKillOutput(output)) +} + +// forceKillCmd taskkill /T /F's the entire descendant tree rooted at cmd. +func forceKillCmd(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + killCmd := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)) + output, err := killCmd.CombinedOutput() + if err == nil { + return nil + } + if isTaskkillNotRunning(output) { + return nil + } + if killErr := cmd.Process.Kill(); killErr == nil || errors.Is(killErr, os.ErrProcessDone) { + return nil + } else { + return fmt.Errorf("taskkill failed: %w: %s; process kill fallback failed: %w", err, processKillOutput(output), killErr) + } +} + +func isTaskkillNotRunning(output []byte) bool { + lower := bytes.ToLower(output) + return bytes.Contains(lower, []byte("there is no running instance")) || + bytes.Contains(lower, []byte("not found")) +} + +func processKillOutput(output []byte) string { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return "(empty output)" + } + return trimmed +} diff --git a/agent/claudecode/project_env_test.go b/agent/claudecode/project_env_test.go new file mode 100644 index 0000000..1409cc0 --- /dev/null +++ b/agent/claudecode/project_env_test.go @@ -0,0 +1,135 @@ +package claudecode + +import ( + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestNew_ParsesProjectEnvFromOpts(t *testing.T) { + opts := map[string]any{ + "work_dir": "/tmp/test", + "run_as_user": "skip-lookpath", + "env": map[string]string{ + "ANTHROPIC_BASE_URL": "https://api.kimi.com/coding", + "ANTHROPIC_AUTH_TOKEN": "sk-kimi-test", + "ANTHROPIC_MODEL": "K2.6", + "ANTHROPIC_REASONING_MODEL": "K2.6", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "K2.6", + }, + } + + a, err := New(opts) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + agent := a.(*Agent) + agent.mu.Lock() + defer agent.mu.Unlock() + + if len(agent.configEnv) != 5 { + t.Fatalf("expected 5 env vars, got %d: %v", len(agent.configEnv), agent.configEnv) + } + + envMap := envSliceToMap(agent.configEnv) + if got := envMap["ANTHROPIC_BASE_URL"]; got != "https://api.kimi.com/coding" { + t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", got, "https://api.kimi.com/coding") + } + if got := envMap["ANTHROPIC_AUTH_TOKEN"]; got != "sk-kimi-test" { + t.Errorf("ANTHROPIC_AUTH_TOKEN = %q, want %q", got, "sk-kimi-test") + } + if got := envMap["ANTHROPIC_MODEL"]; got != "K2.6" { + t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, "K2.6") + } +} + +func TestNew_ParsesProjectEnvFromMapStringAny(t *testing.T) { + opts := map[string]any{ + "work_dir": "/tmp/test", + "run_as_user": "test-user", + "env": map[string]any{ + "ANTHROPIC_BASE_URL": "https://api.mimo.com/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-mimo-test", + "ANTHROPIC_MODEL": "mimo-large", + }, + } + + a, err := New(opts) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + agent := a.(*Agent) + agent.mu.Lock() + defer agent.mu.Unlock() + + envMap := envSliceToMap(agent.configEnv) + if got := envMap["ANTHROPIC_BASE_URL"]; got != "https://api.mimo.com/v1" { + t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", got, "https://api.mimo.com/v1") + } + if got := envMap["ANTHROPIC_MODEL"]; got != "mimo-large" { + t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, "mimo-large") + } +} + +func TestNew_NoEnvOpts(t *testing.T) { + opts := map[string]any{ + "work_dir": "/tmp/test", + "run_as_user": "test-user", + } + + a, err := New(opts) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + agent := a.(*Agent) + agent.mu.Lock() + defer agent.mu.Unlock() + + if len(agent.configEnv) != 0 { + t.Fatalf("expected 0 env vars when no env in opts, got %d: %v", len(agent.configEnv), agent.configEnv) + } +} + +func TestNew_ProjectEnvOverridesProviderEnv(t *testing.T) { + opts := map[string]any{ + "work_dir": "/tmp/test", + "run_as_user": "test-user", + "env": map[string]string{ + "ANTHROPIC_BASE_URL": "https://api.deepseek.com/v1", + "ANTHROPIC_AUTH_TOKEN": "sk-deepseek-test", + "ANTHROPIC_MODEL": "deepseek-chat", + }, + } + + a, err := New(opts) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + agent := a.(*Agent) + // Set providers to simulate a provider being configured + agent.providers = []core.ProviderConfig{ + { + Name: "deepseek", + BaseURL: "https://api.deepseek.com/v1", + APIKey: "sk-deepseek-test", + Model: "deepseek-chat", + }, + } + agent.activeIdx = 0 + + // runtimeEnvLocked merges configEnv + providerEnv + sessionEnv + // configEnv (from opts["env"]) should be present + env := agent.runtimeEnvLocked() + envMap := envSliceToMap(env) + + if got := envMap["ANTHROPIC_BASE_URL"]; got != "https://api.deepseek.com/v1" { + t.Errorf("ANTHROPIC_BASE_URL = %q, want %q", got, "https://api.deepseek.com/v1") + } + if got := envMap["ANTHROPIC_MODEL"]; got != "deepseek-chat" { + t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, "deepseek-chat") + } +} diff --git a/agent/claudecode/provider_env_test.go b/agent/claudecode/provider_env_test.go new file mode 100644 index 0000000..09c44b5 --- /dev/null +++ b/agent/claudecode/provider_env_test.go @@ -0,0 +1,338 @@ +package claudecode + +import ( + "strings" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestAgentUsageProbeEnv_AddsHostManagedFlagForCustomProvider(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "custom", + BaseURL: "https://example.com/v1", + APIKey: "secret", + }, + }, + activeIdx: 0, + } + + env := envSliceToMap(a.usageProbeEnv()) + + if got := env["ANTHROPIC_BASE_URL"]; got != "https://example.com/v1" { + t.Fatalf("ANTHROPIC_BASE_URL = %q, want custom base URL", got) + } + if got := env["ANTHROPIC_AUTH_TOKEN"]; got != "secret" { + t.Fatalf("ANTHROPIC_AUTH_TOKEN = %q, want injected bearer token", got) + } + if got := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; got != "1" { + t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = %q, want 1", got) + } +} + +func TestAgentUsageProbeEnv_DoesNotAddHostManagedFlagForModelOnlyProvider(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "model-only", + Model: "claude-sonnet-4", + }, + }, + activeIdx: 0, + } + + env := envSliceToMap(a.usageProbeEnv()) + if _, ok := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; ok { + t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST unexpectedly set: %v", env) + } +} + +func TestAgentUsageProbeEnv_AddsHostManagedFlagForProviderEnvRoutingOverrides(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "bedrock", + Env: map[string]string{ + "CLAUDE_CODE_USE_BEDROCK": "1", + }, + }, + }, + activeIdx: 0, + } + + env := envSliceToMap(a.usageProbeEnv()) + if got := env["CLAUDE_CODE_USE_BEDROCK"]; got != "1" { + t.Fatalf("CLAUDE_CODE_USE_BEDROCK = %q, want 1", got) + } + if got := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; got != "1" { + t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = %q, want 1", got) + } +} + +func TestAgentUsageProbeEnv_AddsHostManagedFlagForSessionEnvRoutingOverrides(t *testing.T) { + a := &Agent{ + sessionEnv: []string{ + "ANTHROPIC_BASE_URL=https://session.example/v1", + }, + } + + env := envSliceToMap(a.usageProbeEnv()) + if got := env["ANTHROPIC_BASE_URL"]; got != "https://session.example/v1" { + t.Fatalf("ANTHROPIC_BASE_URL = %q, want session override", got) + } + if got := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; got != "1" { + t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = %q, want 1", got) + } +} + +func TestAgentUsageProbeEnv_AddsHostManagedFlagForRouterOverrides(t *testing.T) { + a := &Agent{ + routerURL: "http://127.0.0.1:3456", + routerAPIKey: "router-secret", + } + + env := envSliceToMap(a.usageProbeEnv()) + if got := env["ANTHROPIC_BASE_URL"]; got != "http://127.0.0.1:3456" { + t.Fatalf("ANTHROPIC_BASE_URL = %q, want router URL", got) + } + if got := env["ANTHROPIC_API_KEY"]; got != "router-secret" { + t.Fatalf("ANTHROPIC_API_KEY = %q, want router API key", got) + } + if got := env["CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST"]; got != "1" { + t.Fatalf("CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = %q, want 1", got) + } +} + +func TestProviderEnv_SetsAnthropicModel(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "provider-a", + BaseURL: "https://a.example.com/v1", + APIKey: "key-a", + Model: "model-a", + }, + { + Name: "provider-b", + BaseURL: "https://b.example.com/v1", + APIKey: "key-b", + Model: "model-b", + }, + }, + activeIdx: 0, + } + + env := envSliceToMap(a.providerEnvLocked()) + if got := env["ANTHROPIC_MODEL"]; got != "model-a" { + t.Fatalf("ANTHROPIC_MODEL = %q, want %q", got, "model-a") + } + if got := env["ANTHROPIC_BASE_URL"]; got != "https://a.example.com/v1" { + t.Fatalf("ANTHROPIC_BASE_URL = %q, want provider-a URL", got) + } + + a.SetActiveProvider("provider-b") + env = envSliceToMap(a.providerEnvLocked()) + if got := env["ANTHROPIC_MODEL"]; got != "model-b" { + t.Fatalf("after switch: ANTHROPIC_MODEL = %q, want %q", got, "model-b") + } + if got := env["ANTHROPIC_BASE_URL"]; got != "https://b.example.com/v1" { + t.Fatalf("after switch: ANTHROPIC_BASE_URL = %q, want provider-b URL", got) + } +} + +func TestProviderEnv_NoModelWhenEmpty(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "no-model", + BaseURL: "https://example.com/v1", + APIKey: "key", + }, + }, + activeIdx: 0, + } + env := envSliceToMap(a.providerEnvLocked()) + if _, ok := env["ANTHROPIC_MODEL"]; ok { + t.Fatalf("ANTHROPIC_MODEL should not be set when provider has no model") + } +} + +func TestProviderEnv_ClearReturnsNil(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Name: "p", BaseURL: "https://x.com", APIKey: "k", Model: "m"}, + }, + activeIdx: 0, + } + a.SetActiveProvider("") + env := a.providerEnvLocked() + if env != nil { + t.Fatalf("expected nil env after clearing provider, got %v", env) + } +} + +func TestStartSession_UsesActiveProviderModel(t *testing.T) { + a := &Agent{ + model: "default-model", + providers: []core.ProviderConfig{ + {Name: "p1", Model: "provider-model-1"}, + {Name: "p2", Model: "provider-model-2"}, + }, + activeIdx: 0, + } + + a.mu.Lock() + activeIdx := a.activeIdx + model := a.model + if activeIdx >= 0 && activeIdx < len(a.providers) { + if m := a.providers[activeIdx].Model; m != "" { + model = m + } + } + a.mu.Unlock() + + if model != "provider-model-1" { + t.Fatalf("model = %q, want %q", model, "provider-model-1") + } + + a.SetActiveProvider("p2") + a.mu.Lock() + activeIdx = a.activeIdx + model = a.model + if activeIdx >= 0 && activeIdx < len(a.providers) { + if m := a.providers[activeIdx].Model; m != "" { + model = m + } + } + a.mu.Unlock() + + if model != "provider-model-2" { + t.Fatalf("after switch: model = %q, want %q", model, "provider-model-2") + } +} + +func envSliceToMap(env []string) map[string]string { + out := make(map[string]string, len(env)) + for _, entry := range env { + key, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + out[key] = value + } + return out +} + +func TestProviderEnv_BedrockThinkingRewrite(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "bedrock", + Env: map[string]string{ + "CLAUDE_CODE_USE_BEDROCK": "1", + "AWS_PROFILE": "bedrock", + }, + Thinking: "disabled", + }, + }, + activeIdx: 0, + } + + env := envSliceToMap(a.providerEnvLocked()) + + // Should set ANTHROPIC_BEDROCK_BASE_URL to local proxy URL. + baseURL := env["ANTHROPIC_BEDROCK_BASE_URL"] + if baseURL == "" { + t.Fatalf("ANTHROPIC_BEDROCK_BASE_URL should be set for Bedrock with thinking rewrite") + } + if !strings.HasPrefix(baseURL, "http://127.0.0.1:") { + t.Fatalf("ANTHROPIC_BEDROCK_BASE_URL = %q, want local proxy URL", baseURL) + } + + // Should preserve Bedrock env vars. + if got := env["CLAUDE_CODE_USE_BEDROCK"]; got != "1" { + t.Fatalf("CLAUDE_CODE_USE_BEDROCK = %q, want 1", got) + } + + // Should set NO_PROXY for local proxy. + if got := env["NO_PROXY"]; got != "127.0.0.1" { + t.Fatalf("NO_PROXY = %q, want 127.0.0.1", got) + } +} + +func TestProviderEnv_VertexThinkingRewrite(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "vertex", + Env: map[string]string{ + "CLAUDE_CODE_USE_VERTEX": "1", + "CLOUD_ML_REGION": "us-east1", + }, + Thinking: "disabled", + }, + }, + activeIdx: 0, + } + + env := envSliceToMap(a.providerEnvLocked()) + + // Should set ANTHROPIC_VERTEX_BASE_URL to local proxy URL. + baseURL := env["ANTHROPIC_VERTEX_BASE_URL"] + if baseURL == "" { + t.Fatalf("ANTHROPIC_VERTEX_BASE_URL should be set for Vertex with thinking rewrite") + } + if !strings.HasPrefix(baseURL, "http://127.0.0.1:") { + t.Fatalf("ANTHROPIC_VERTEX_BASE_URL = %q, want local proxy URL", baseURL) + } +} + +func TestProviderEnv_BedrockNoThinking(t *testing.T) { + // Without thinking override, Bedrock provider should not use proxy. + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "bedrock", + Env: map[string]string{ + "CLAUDE_CODE_USE_BEDROCK": "1", + }, + }, + }, + activeIdx: 0, + } + + env := envSliceToMap(a.providerEnvLocked()) + + // Should NOT set ANTHROPIC_BEDROCK_BASE_URL when thinking is not set. + if _, ok := env["ANTHROPIC_BEDROCK_BASE_URL"]; ok { + t.Fatalf("ANTHROPIC_BEDROCK_BASE_URL should not be set without thinking override") + } + + // Should preserve Bedrock env var. + if got := env["CLAUDE_CODE_USE_BEDROCK"]; got != "1" { + t.Fatalf("CLAUDE_CODE_USE_BEDROCK = %q, want 1", got) + } +} + +func TestDetectEnvOnlyProviderType(t *testing.T) { + tests := []struct { + env map[string]string + expected string + }{ + {map[string]string{"CLAUDE_CODE_USE_BEDROCK": "1"}, "bedrock"}, + {map[string]string{"CLAUDE_CODE_USE_VERTEX": "1"}, "vertex"}, + {map[string]string{"CLAUDE_CODE_USE_FOUNDRY": "1"}, "foundry"}, + {map[string]string{"CLAUDE_CODE_USE_BEDROCK": "0"}, ""}, + {map[string]string{"OTHER_VAR": "1"}, ""}, + {nil, ""}, + } + + for _, tt := range tests { + got := detectEnvOnlyProviderType(tt.env) + if got != tt.expected { + t.Errorf("detectEnvOnlyProviderType(%v) = %q, want %q", tt.env, got, tt.expected) + } + } +} diff --git a/agent/claudecode/provider_integration_test.go b/agent/claudecode/provider_integration_test.go new file mode 100644 index 0000000..7b2b2eb --- /dev/null +++ b/agent/claudecode/provider_integration_test.go @@ -0,0 +1,288 @@ +package claudecode + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/config" + "github.com/chenhg5/cc-connect/core" +) + +// These integration tests use real provider credentials from ~/.cc-connect/config.toml. +// They verify that provider switching correctly sets up env vars and that agent +// sessions can be started with the right configuration. +// +// Run with: CC_RUN_PROVIDER_INTEGRATION=1 go test ./agent/claudecode -run TestIntegration -v +// Skip explicitly with: CC_SKIP_INTEGRATION=1 + +func skipIfNoConfig(t *testing.T) *config.Config { + t.Helper() + if os.Getenv("CC_SKIP_INTEGRATION") == "1" { + t.Skip("CC_SKIP_INTEGRATION=1") + } + if os.Getenv("CC_RUN_PROVIDER_INTEGRATION") != "1" { + t.Skip("set CC_RUN_PROVIDER_INTEGRATION=1 to run provider integration tests") + } + cfgPath := os.ExpandEnv("$HOME/.cc-connect/config.toml") + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + t.Skipf("config not found at %s", cfgPath) + } + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + cfg.ResolveProviderRefs() + return cfg +} + +func configToCoreProv(p config.ProviderConfig) core.ProviderConfig { + cp := core.ProviderConfig{ + Name: p.Name, + APIKey: p.APIKey, + BaseURL: p.BaseURL, + Model: p.Model, + Env: p.Env, + } + if p.Thinking != "" { + cp.Thinking = p.Thinking + } + for _, m := range p.Models { + cp.Models = append(cp.Models, core.ModelOption{Name: m.Model}) + } + return cp +} + +func findProjectProviders(cfg *config.Config, agentType string) (projName string, providers []core.ProviderConfig, workDir string) { + for i := range cfg.Projects { + proj := &cfg.Projects[i] + if proj.Agent.Type != agentType || len(proj.Agent.Providers) == 0 { + continue + } + for _, p := range proj.Agent.Providers { + providers = append(providers, configToCoreProv(p)) + } + projName = proj.Name + if wd, ok := proj.Agent.Options["work_dir"].(string); ok { + workDir = wd + } + return + } + return +} + +func TestIntegration_ProviderSwitch_EnvVars(t *testing.T) { + cfg := skipIfNoConfig(t) + + name, providers, _ := findProjectProviders(cfg, "claudecode") + if name == "" { + t.Skip("no claudecode project with providers found") + } + if len(providers) < 2 { + t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers)) + } + + a := &Agent{ + providers: providers, + activeIdx: -1, + } + + p0, p1 := providers[0], providers[1] + t.Logf("provider[0]: name=%s model=%s base_url=%s", p0.Name, p0.Model, p0.BaseURL) + t.Logf("provider[1]: name=%s model=%s base_url=%s", p1.Name, p1.Model, p1.BaseURL) + + a.SetActiveProvider(p0.Name) + env0 := envSliceToMap(a.providerEnvLocked()) + + if p0.BaseURL != "" { + if got := env0["ANTHROPIC_BASE_URL"]; got != p0.BaseURL { + t.Errorf("provider[0] ANTHROPIC_BASE_URL = %q, want %q", got, p0.BaseURL) + } + } + if p0.Model != "" { + if got := env0["ANTHROPIC_MODEL"]; got != p0.Model { + t.Errorf("provider[0] ANTHROPIC_MODEL = %q, want %q", got, p0.Model) + } + } + + a.SetActiveProvider(p1.Name) + env1 := envSliceToMap(a.providerEnvLocked()) + + if p1.BaseURL != "" { + if got := env1["ANTHROPIC_BASE_URL"]; got != p1.BaseURL { + t.Errorf("provider[1] ANTHROPIC_BASE_URL = %q, want %q", got, p1.BaseURL) + } + } + if p1.Model != "" { + if got := env1["ANTHROPIC_MODEL"]; got != p1.Model { + t.Errorf("provider[1] ANTHROPIC_MODEL = %q, want %q", got, p1.Model) + } + } + + if p0.BaseURL != p1.BaseURL { + if env0["ANTHROPIC_BASE_URL"] == env1["ANTHROPIC_BASE_URL"] { + t.Error("providers have different base_urls but env didn't change after switch") + } + } + if p0.Model != p1.Model && p0.Model != "" && p1.Model != "" { + if env0["ANTHROPIC_MODEL"] == env1["ANTHROPIC_MODEL"] { + t.Error("providers have different models but ANTHROPIC_MODEL didn't change after switch") + } + } +} + +func TestIntegration_ProviderSwitch_SessionStartModel(t *testing.T) { + cfg := skipIfNoConfig(t) + + name, providers, workDir := findProjectProviders(cfg, "claudecode") + if name == "" { + t.Skip("no claudecode project with providers found") + } + if len(providers) < 2 { + t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers)) + } + if workDir == "" { + workDir = "/tmp" + } + + cliBin, err := exec.LookPath("claude") + if err != nil { + t.Skipf("claude CLI not found: %v", err) + } + + for _, prov := range providers[:2] { + t.Run(prov.Name, func(t *testing.T) { + a := &Agent{ + model: "default-model", + providers: providers, + activeIdx: -1, + workDir: workDir, + cliBin: cliBin, + } + a.SetActiveProvider(prov.Name) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sess, err := a.StartSession(ctx, "") + if err != nil { + t.Fatalf("StartSession failed: %v", err) + } + defer sess.Close() + + cs := sess.(*claudeSession) + envMap := envSliceToMap(cs.cmd.Env) + + if prov.Model != "" { + found := false + for _, arg := range cs.cmd.Args { + if arg == prov.Model { + found = true + break + } + } + if !found { + t.Errorf("--model %q not found in args: %v", prov.Model, cs.cmd.Args) + } + } + + if prov.BaseURL != "" { + gotURL := envMap["ANTHROPIC_BASE_URL"] + if gotURL == "" { + t.Error("ANTHROPIC_BASE_URL not set in session env") + } else if !strings.Contains(gotURL, "127.0.0.1") && gotURL != prov.BaseURL { + t.Errorf("ANTHROPIC_BASE_URL = %q, want %q (or proxy URL)", gotURL, prov.BaseURL) + } + } + + if prov.Model != "" { + if got := envMap["ANTHROPIC_MODEL"]; got != prov.Model { + t.Errorf("ANTHROPIC_MODEL = %q, want %q", got, prov.Model) + } + } + + t.Logf("session started OK: pid=%d, model=%s, env_model=%s, env_base_url=%s", + cs.cmd.Process.Pid, + prov.Model, + envMap["ANTHROPIC_MODEL"], + envMap["ANTHROPIC_BASE_URL"]) + }) + } +} + +func TestIntegration_CodexProvider_EnvVars(t *testing.T) { + cfg := skipIfNoConfig(t) + + name, providers, _ := findProjectProviders(cfg, "codex") + if name == "" { + t.Skip("no codex project with providers found") + } + + for _, prov := range providers { + t.Run(prov.Name, func(t *testing.T) { + t.Logf("codex provider: name=%s base_url=%s model=%s", prov.Name, prov.BaseURL, prov.Model) + if prov.BaseURL == "" { + t.Error("codex provider should have a base_url") + } + if prov.Model == "" { + t.Error("codex provider should have a model") + } + }) + } +} + +func TestIntegration_AgentTypeChange_FiltersProviders(t *testing.T) { + cfg := skipIfNoConfig(t) + + if len(cfg.Providers) == 0 { + t.Skip("no global providers in config") + } + + globalByName := make(map[string]config.ProviderConfig, len(cfg.Providers)) + for _, p := range cfg.Providers { + globalByName[p.Name] = p + } + + for i := range cfg.Projects { + proj := &cfg.Projects[i] + if proj.Agent.Type != "claudecode" || len(proj.Agent.ProviderRefs) == 0 { + continue + } + + var compatible, incompatible []string + for _, ref := range proj.Agent.ProviderRefs { + gp, ok := globalByName[ref] + if !ok { + continue + } + if len(gp.AgentTypes) > 0 { + hasCodex := false + for _, at := range gp.AgentTypes { + if at == "codex" { + hasCodex = true + } + } + if hasCodex { + compatible = append(compatible, ref) + } else { + incompatible = append(incompatible, ref) + } + } else { + compatible = append(compatible, ref) + } + } + + if len(incompatible) == 0 { + continue + } + + t.Logf("project %q: switching claudecode→codex: keep=%v remove=%v", + proj.Name, compatible, incompatible) + t.Logf("provider filtering validated: %d provider(s) would be removed on agent type change", len(incompatible)) + return + } + t.Skip("no project with incompatible providers found for agent type change test") +} diff --git a/agent/claudecode/session.go b/agent/claudecode/session.go new file mode 100644 index 0000000..f5aec8b --- /dev/null +++ b/agent/claudecode/session.go @@ -0,0 +1,796 @@ +package claudecode + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// claudeSession manages a long-running Claude Code process using +// --input-format stream-json and --permission-prompt-tool stdio. +// +// In "auto" mode, permission requests are auto-approved internally +// (avoiding --dangerously-skip-permissions which fails under root). +type claudeSession struct { + cmd *exec.Cmd + stdin io.WriteCloser + stdinMu sync.Mutex + events chan core.Event + sessionID atomic.Value // stores string + permissionMode atomic.Value // stores string + autoApprove atomic.Bool + acceptEditsOnly atomic.Bool + dontAsk atomic.Bool + workDir string + ctx context.Context + cancel context.CancelFunc + done chan struct{} + alive atomic.Bool + + // gracefulStopTimeout is how long Close() waits for a clean exit + // (stdin close → Stop hooks → process exit) before escalating to + // SIGTERM and then SIGKILL. Default: 120s to match claude-mem's + // Stop hook timeout. The wait ends as soon as the process exits, + // so typical shutdowns take seconds, not the full timeout. + gracefulStopTimeout time.Duration +} + +func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs []string, cliArgsFlag string, model, effort, sessionID, mode, systemPrompt string, allowedTools, disallowedTools []string, extraEnv []string, platformPrompt string, disableVerbose bool, spawnOpts core.SpawnOptions, maxContextTokens int) (*claudeSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + // innerArgs are Claude Code CLI flags — when a wrapper is used with + // cliArgsFlag these get bundled into a single passthrough string. + // outerArgs are flags the wrapper itself understands (e.g. --model). + innerArgs := []string{ + "--output-format", "stream-json", + "--input-format", "stream-json", + "--permission-prompt-tool", "stdio", + "--replay-user-messages", + } + if !disableVerbose { + innerArgs = append(innerArgs, "--verbose") + } + + if mode != "" && mode != "default" { + innerArgs = append(innerArgs, "--permission-mode", mode) + } + switch sessionID { + case "", core.ContinueSession: + // Truly fresh session — no resume, no continue. + default: + // Resuming a known session ID — this is cc-connect's own session + // from a previous connection, safe to resume directly. + innerArgs = append(innerArgs, "--resume", sessionID) + } + if len(allowedTools) > 0 { + innerArgs = append(innerArgs, "--allowedTools", strings.Join(allowedTools, ",")) + } + if len(disallowedTools) > 0 { + innerArgs = append(innerArgs, "--disallowedTools", strings.Join(disallowedTools, ",")) + } + + // Handle custom system prompt + if systemPrompt != "" { + innerArgs = append(innerArgs, "--system-prompt", systemPrompt) + } + + // Always append cc-connect system prompt for functionality awareness + if sysPrompt := core.AgentSystemPrompt(); sysPrompt != "" { + if platformPrompt != "" { + sysPrompt += "\n## Formatting\n" + platformPrompt + "\n" + } + innerArgs = append(innerArgs, "--append-system-prompt", sysPrompt) + } + + if effort != "" { + innerArgs = append(innerArgs, "--effort", effort) + } + if maxContextTokens > 0 { + innerArgs = append(innerArgs, "--max-context-tokens", strconv.Itoa(maxContextTokens)) + } + + // outerArgs are understood by both the wrapper and Claude CLI directly. + var outerArgs []string + if model != "" { + outerArgs = append(outerArgs, "--model", model) + } + + slog.Debug("claudeSession: starting", "innerArgs", core.RedactArgs(innerArgs), "outerArgs", core.RedactArgs(outerArgs), "dir", workDir, "mode", mode, "run_as_user", spawnOpts.RunAsUser) + + // Per-spawn defense in depth: if run_as_user is set, re-run the cheap + // preflight (sudo still works + target still can't escalate) right + // before we build the command. This catches sudoers being edited + // between startup preflight and now. + if spawnOpts.IsolationMode() { + verifyCtx, verifyCancel := context.WithTimeout(sessionCtx, 10*time.Second) + err := core.VerifyRunAsUserCheap(verifyCtx, core.ExecSudoRunner{}, spawnOpts.RunAsUser) + verifyCancel() + if err != nil { + cancel() + return nil, fmt.Errorf("claudeSession: run_as_user spawn refused: %w", err) + } + } + + // Build final argument list. + // When cliArgsFlag is set (e.g. "-a"), inner args are bundled into a + // single passthrough string via that flag, while outer args (--model etc.) + // are appended directly so the wrapper can also interpret them. + // Args containing spaces/newlines are quoted so the wrapper's command-line + // parser (e.g. splitCommandLine) keeps them as single tokens. + // Result: my-cli code -t foo -a "--verbose --append-system-prompt 'long text'" --model x + var allArgs []string + if cliArgsFlag != "" { + allArgs = append(allArgs, cliExtraArgs...) + allArgs = append(allArgs, cliArgsFlag, shellJoinArgs(innerArgs)) + allArgs = append(allArgs, outerArgs...) + } else { + allArgs = append(allArgs, cliExtraArgs...) + allArgs = append(allArgs, innerArgs...) + allArgs = append(allArgs, outerArgs...) + } + cmd := core.BuildSpawnCommand(sessionCtx, spawnOpts, cliBin, allArgs...) + cmd.Dir = workDir + // Put the child into its own process group so Close() can terminate the + // entire descendant tree (claude CLI → MCP server bridges → ...) with a + // single signal. Without this, killing only the direct child can leave + // MCP grandchildren (e.g. the Telegram bridge bun process) spinning at + // 100% CPU after their parent's stdio pipe closes. + prepareCmdForKill(cmd) + // Filter out CLAUDECODE env var to prevent "nested session" detection, + // since cc-connect is a bridge, not a nested Claude Code session. + env := filterEnv(os.Environ(), "CLAUDECODE") + if len(extraEnv) > 0 { + env = core.MergeEnv(env, extraEnv) + } + // When run_as_user is set, strip the supervisor's environment down to + // the allowlist before passing it to sudo. sudo --preserve-env also + // enforces this, but filtering here makes the cc-connect spawn argv + // the single source of truth. + env = core.FilterEnvForSpawn(env, spawnOpts) + cmd.Env = env + + var providerEnvSnapshot []string + for _, e := range env { + for _, prefix := range []string{"ANTHROPIC_", "CLAUDE_", "AWS_", "NO_PROXY", "DISABLE_"} { + if strings.HasPrefix(e, prefix) { + providerEnvSnapshot = append(providerEnvSnapshot, e) + break + } + } + } + slog.Debug("claudeSession: spawn details", + "bin", cliBin, + "allArgs", core.RedactArgs(allArgs), + "model", model, + "providerEnv", core.RedactEnv(providerEnvSnapshot)) + + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("claudeSession: stdin pipe: %w", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("claudeSession: stdout pipe: %w", err) + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("claudeSession: start: %w", err) + } + + cs := &claudeSession{ + cmd: cmd, + stdin: stdin, + events: make(chan core.Event, 64), + workDir: workDir, + ctx: sessionCtx, + cancel: cancel, + done: make(chan struct{}), + gracefulStopTimeout: 120 * time.Second, + } + cs.setPermissionMode(mode) + cs.sessionID.Store(sessionID) + cs.alive.Store(true) + + go cs.readLoop(stdout, &stderrBuf) + + return cs, nil +} + +func (cs *claudeSession) readLoop(stdout io.ReadCloser, stderrBuf *bytes.Buffer) { + waitErrCh, waitDone := cs.startReadLoopWait(stdout) + defer cs.finishReadLoop(waitErrCh, stderrBuf) + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + for scanner.Scan() { + cs.handleReadLoopLine(scanner.Text()) + } + + cs.handleReadLoopScanErr(scanner.Err(), waitDone) +} + +func (cs *claudeSession) startReadLoopWait(stdout io.ReadCloser) (<-chan error, <-chan struct{}) { + waitErrCh := make(chan error, 1) + waitDone := make(chan struct{}) + + go func() { + waitErrCh <- cs.cmd.Wait() + close(waitDone) + }() + + go func() { + select { + case <-cs.ctx.Done(): + _ = stdout.Close() + return + case <-waitDone: + } + + // Grace period: give scanner a brief window to drain any data the + // agent wrote to the pipe buffer before exiting. If scanner finishes + // on its own (pipe fully closed, no descendants holding it), + // cs.done fires first and we skip the force-close entirely + select { + case <-cs.done: + return + case <-time.After(50 * time.Millisecond): + } + _ = stdout.Close() + }() + + return waitErrCh, waitDone +} + +func (cs *claudeSession) finishReadLoop(waitErrCh <-chan error, stderrBuf *bytes.Buffer) { + err := <-waitErrCh + + cs.alive.Store(false) + if err != nil { + stderrMsg := "" + if stderrBuf != nil { + stderrMsg = strings.TrimSpace(stderrBuf.String()) + } + if stderrMsg != "" { + slog.Error("claudeSession: process failed", "error", err, "stderr", stderrMsg) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + // INVARIANT: readLoop must close cs.events and cs.done exactly once + // on every termination path. Callers (engine event loop) rely on + // these closures to observe session end. + } + } + } + close(cs.events) + close(cs.done) +} + +func (cs *claudeSession) handleReadLoopScanErr(err error, waitDone <-chan struct{}) { + if err == nil { + return + } + + select { + case <-cs.ctx.Done(): + return + case <-waitDone: + return + default: + } + + slog.Error("claudeSession: scanner error", "error", err) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } +} + +func (cs *claudeSession) handleReadLoopLine(line string) { + if line == "" { + return + } + + var raw map[string]any + if err := json.Unmarshal([]byte(line), &raw); err != nil { + slog.Debug("claudeSession: non-JSON line", "line", line) + return + } + + eventType, _ := raw["type"].(string) + slog.Debug("claudeSession: event", "type", eventType) + + switch eventType { + case "system": + cs.handleSystem(raw) + case "assistant": + cs.handleAssistant(raw) + case "user": + cs.handleUser(raw) + case "result": + cs.handleResult(raw) + case "control_request": + cs.handleControlRequest(raw) + case "control_cancel_request": + requestID, _ := raw["request_id"].(string) + slog.Debug("claudeSession: permission cancelled", "request_id", requestID) + } +} + +func (cs *claudeSession) handleSystem(raw map[string]any) { + if sid, ok := raw["session_id"].(string); ok && sid != "" { + cs.sessionID.Store(sid) + evt := core.Event{Type: core.EventText, SessionID: sid} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } +} + +func (cs *claudeSession) handleAssistant(raw map[string]any) { + msg, ok := raw["message"].(map[string]any) + if !ok { + return + } + contentArr, ok := msg["content"].([]any) + if !ok { + return + } + for _, contentItem := range contentArr { + item, ok := contentItem.(map[string]any) + if !ok { + continue + } + contentType, _ := item["type"].(string) + switch contentType { + case "tool_use": + toolName, _ := item["name"].(string) + if toolName == "AskUserQuestion" { + continue + } + inputSummary := summarizeInput(toolName, item["input"]) + evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: inputSummary} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + case "thinking": + if thinking, ok := item["thinking"].(string); ok && thinking != "" { + evt := core.Event{Type: core.EventThinking, Content: thinking} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + case "text": + if text, ok := item["text"].(string); ok && text != "" { + evt := core.Event{Type: core.EventText, Content: text} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + } + } +} + +func (cs *claudeSession) handleUser(raw map[string]any) { + msg, ok := raw["message"].(map[string]any) + if !ok { + return + } + contentArr, ok := msg["content"].([]any) + if !ok { + return + } + for _, contentItem := range contentArr { + item, ok := contentItem.(map[string]any) + if !ok { + continue + } + contentType, _ := item["type"].(string) + if contentType == "tool_result" { + isError, _ := item["is_error"].(bool) + if isError { + result, _ := item["content"].(string) + slog.Debug("claudeSession: tool error", "content", result) + } + } + } +} + +func (cs *claudeSession) handleResult(raw map[string]any) { + var content string + if result, ok := raw["result"].(string); ok { + content = result + } + if sid, ok := raw["session_id"].(string); ok && sid != "" { + cs.sessionID.Store(sid) + } + + var inputTokens, outputTokens int + if usage, ok := raw["usage"].(map[string]any); ok { + if v, ok := usage["input_tokens"].(float64); ok { + inputTokens = int(v) + } + if v, ok := usage["output_tokens"].(float64); ok { + outputTokens = int(v) + } + } + + evt := core.Event{ + Type: core.EventResult, + Content: content, + SessionID: cs.CurrentSessionID(), + Done: true, + InputTokens: inputTokens, + OutputTokens: outputTokens, + } + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } +} + +func (cs *claudeSession) handleControlRequest(raw map[string]any) { + requestID, _ := raw["request_id"].(string) + request, _ := raw["request"].(map[string]any) + if request == nil { + return + } + subtype, _ := request["subtype"].(string) + if subtype != "can_use_tool" { + slog.Debug("claudeSession: unknown control request subtype", "subtype", subtype) + return + } + + toolName, _ := request["tool_name"].(string) + input, _ := request["input"].(map[string]any) + + if cs.autoApprove.Load() { + slog.Debug("claudeSession: auto-approving", "request_id", requestID, "tool", toolName) + _ = cs.RespondPermission(requestID, core.PermissionResult{ + Behavior: "allow", + UpdatedInput: input, + }) + return + } + if cs.dontAsk.Load() { + slog.Debug("claudeSession: auto-denying", "request_id", requestID, "tool", toolName) + _ = cs.RespondPermission(requestID, core.PermissionResult{ + Behavior: "deny", + Message: "Permission mode is set to dontAsk.", + }) + return + } + if cs.acceptEditsOnly.Load() && isClaudeEditTool(toolName) { + slog.Debug("claudeSession: auto-approving edit tool", "request_id", requestID, "tool", toolName) + _ = cs.RespondPermission(requestID, core.PermissionResult{ + Behavior: "allow", + UpdatedInput: input, + }) + return + } + + slog.Info("claudeSession: permission request", "request_id", requestID, "tool", toolName) + evt := core.Event{ + Type: core.EventPermissionRequest, + RequestID: requestID, + ToolName: toolName, + ToolInput: summarizeInput(toolName, input), + ToolInputRaw: input, + } + + if toolName == "AskUserQuestion" { + evt.Questions = parseUserQuestions(input) + } + + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } +} + +// Send writes a user message (with optional images and files) to the Claude process stdin. +// Images are sent as base64 in the multimodal content array. +// Files are saved to local temp files and referenced in the text prompt +// so Claude Code can read them with its built-in tools. +func (cs *claudeSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if !cs.alive.Load() { + return fmt.Errorf("session process is not running") + } + + if len(images) == 0 && len(files) == 0 { + return cs.writeJSON(map[string]any{ + "type": "user", + "message": map[string]any{"role": "user", "content": prompt}, + }) + } + + attachDir := filepath.Join(cs.workDir, ".cc-connect", "attachments") + if err := os.MkdirAll(attachDir, 0o755); err != nil { + slog.Warn("claudeSession: mkdir attachments failed", "error", err, "path", attachDir) + } + + var parts []map[string]any + var savedPaths []string + + // Save and encode images + for i, img := range images { + ext := extFromMime(img.MimeType) + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(attachDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o644); err != nil { + slog.Error("claudeSession: save image failed", "error", err) + continue + } + savedPaths = append(savedPaths, fpath) + slog.Debug("claudeSession: image saved", "path", fpath, "size", len(img.Data)) + + mimeType := img.MimeType + if mimeType == "" { + mimeType = "image/png" + } + parts = append(parts, map[string]any{ + "type": "image", + "source": map[string]any{ + "type": "base64", + "media_type": mimeType, + "data": base64.StdEncoding.EncodeToString(img.Data), + }, + }) + } + + // Save files to disk so Claude Code can read them + filePaths := core.SaveFilesToDisk(cs.workDir, files) + + // Build text part: user prompt + file path references + textPart := prompt + if textPart == "" && len(filePaths) > 0 { + textPart = "Please analyze the attached file(s)." + } else if textPart == "" { + textPart = "Please analyze the attached image(s)." + } + if len(savedPaths) > 0 { + textPart += "\n\n(Images also saved locally: " + strings.Join(savedPaths, ", ") + ")" + } + if len(filePaths) > 0 { + textPart += "\n\n(Files saved locally, please read them: " + strings.Join(filePaths, ", ") + ")" + } + parts = append(parts, map[string]any{"type": "text", "text": textPart}) + + return cs.writeJSON(map[string]any{ + "type": "user", + "message": map[string]any{"role": "user", "content": parts}, + }) +} + +func extFromMime(mime string) string { + switch mime { + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + default: + return ".png" + } +} + +// RespondPermission writes a control_response to the Claude process stdin. +func (cs *claudeSession) RespondPermission(requestID string, result core.PermissionResult) error { + if !cs.alive.Load() { + return fmt.Errorf("session process is not running") + } + + var permResponse map[string]any + if result.Behavior == "allow" { + updatedInput := result.UpdatedInput + if updatedInput == nil { + updatedInput = make(map[string]any) + } + permResponse = map[string]any{ + "behavior": "allow", + "updatedInput": updatedInput, + } + } else { + msg := result.Message + if msg == "" { + msg = "The user denied this tool use. Stop and wait for the user's instructions." + } + permResponse = map[string]any{ + "behavior": "deny", + "message": msg, + } + } + + controlResponse := map[string]any{ + "type": "control_response", + "response": map[string]any{ + "subtype": "success", + "request_id": requestID, + "response": permResponse, + }, + } + + slog.Debug("claudeSession: permission response", "request_id", requestID, "behavior", result.Behavior) + return cs.writeJSON(controlResponse) +} + +func (cs *claudeSession) writeJSON(v any) error { + cs.stdinMu.Lock() + defer cs.stdinMu.Unlock() + + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + if _, err := cs.stdin.Write(append(data, '\n')); err != nil { + return fmt.Errorf("write stdin: %w", err) + } + return nil +} + +func isClaudeEditTool(toolName string) bool { + switch toolName { + case "Edit", "Write", "NotebookEdit", "MultiEdit": + return true + default: + return false + } +} + +func (cs *claudeSession) setPermissionMode(mode string) { + cs.permissionMode.Store(mode) + cs.autoApprove.Store(mode == "bypassPermissions") + cs.acceptEditsOnly.Store(mode == "acceptEdits") + cs.dontAsk.Store(mode == "dontAsk") +} + +func (cs *claudeSession) SetLiveMode(mode string) bool { + current, _ := cs.permissionMode.Load().(string) + if mode == "auto" || mode == "plan" || current == "auto" || current == "plan" { + return false + } + cs.setPermissionMode(mode) + return true +} + +func (cs *claudeSession) Events() <-chan core.Event { + return cs.events +} + +func (cs *claudeSession) CurrentSessionID() string { + v, _ := cs.sessionID.Load().(string) + return v +} + +func (cs *claudeSession) Alive() bool { + return cs.alive.Load() +} + +func (cs *claudeSession) Close() error { + // Phase 1: Close stdin to signal EOF. Claude Code exits cleanly on + // stdin close, running Stop hooks (e.g. claude-mem session summary). + cs.stdinMu.Lock() + if err := cs.stdin.Close(); err != nil { + slog.Warn("claudeSession: close stdin", "error", err) + } + cs.stdinMu.Unlock() + + graceful := cs.gracefulStopTimeout + if graceful <= 0 { + graceful = 8 * time.Second // legacy fallback + } + + select { + case <-cs.done: + slog.Info("claudeSession: exited cleanly after stdin close") + return nil + case <-time.After(graceful): + slog.Warn("claudeSession: graceful stop timed out, sending SIGTERM", + "timeout", graceful) + } + + // Phase 2: SIGTERM the whole process group — gives the process and its + // descendants (e.g. MCP server bridges) a second chance to run cleanup + // handlers that respond to signals but not stdin EOF. + if err := signalProcessGroup(cs.cmd, syscall.SIGTERM); err != nil { + slog.Warn("claudeSession: signal SIGTERM", "error", err) + } + + select { + case <-cs.done: + slog.Info("claudeSession: exited after SIGTERM") + return nil + case <-time.After(5 * time.Second): + slog.Warn("claudeSession: SIGTERM timed out, sending SIGKILL") + } + + // Phase 3: SIGKILL the whole process group — last resort. Using a + // group-wide kill ensures grandchildren (Claude Code's MCP servers + // such as the Telegram bridge) are reaped along with the direct child; + // otherwise they can survive as orphans and spin at 100% CPU. + cs.cancel() + if err := forceKillCmd(cs.cmd); err != nil { + slog.Warn("claudeSession: force kill", "error", err) + } + <-cs.done + return nil +} + +// shellJoinArgs joins args into a single string, quoting any arg that +// contains whitespace so that a shell-style splitter (like my_cli's +// splitCommandLine) preserves each arg as one token. +// +// Uses single quotes because some splitters (e.g. my_cli) don't support +// backslash escapes inside double quotes. For values containing single +// quotes, we close the single-quoted segment, add an escaped single +// quote, and reopen: 'it'\”s' → it's +func shellJoinArgs(args []string) string { + var b strings.Builder + for i, a := range args { + if i > 0 { + b.WriteByte(' ') + } + if !strings.ContainsAny(a, " \t\n\r'\"\\") { + b.WriteString(a) + continue + } + b.WriteByte('\'') + for _, c := range a { + if c == '\'' { + b.WriteString("'\\''") + } else { + b.WriteRune(c) + } + } + b.WriteByte('\'') + } + return b.String() +} + +// filterEnv returns a copy of env with entries matching the given key removed. +func filterEnv(env []string, key string) []string { + prefix := key + "=" + out := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, prefix) { + out = append(out, e) + } + } + return out +} diff --git a/agent/claudecode/session_test.go b/agent/claudecode/session_test.go new file mode 100644 index 0000000..53f029a --- /dev/null +++ b/agent/claudecode/session_test.go @@ -0,0 +1,285 @@ +package claudecode + +import ( + "bytes" + "context" + "io" + "os" + "os/exec" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func TestHandleResultParsesUsage(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cs := &claudeSession{ + events: make(chan core.Event, 8), + ctx: ctx, + } + cs.sessionID.Store("test-session") + cs.alive.Store(true) + + raw := map[string]any{ + "type": "result", + "result": "done", + "session_id": "test-session", + "usage": map[string]any{ + "input_tokens": float64(150000), + "output_tokens": float64(2000), + }, + } + + cs.handleResult(raw) + + evt := <-cs.events + if evt.InputTokens != 150000 { + t.Errorf("InputTokens = %d, want 150000", evt.InputTokens) + } + if evt.OutputTokens != 2000 { + t.Errorf("OutputTokens = %d, want 2000", evt.OutputTokens) + } +} + +func TestHandleResultNoUsage(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cs := &claudeSession{ + events: make(chan core.Event, 8), + ctx: ctx, + } + cs.sessionID.Store("test-session") + cs.alive.Store(true) + + raw := map[string]any{ + "type": "result", + "result": "done", + } + + cs.handleResult(raw) + + evt := <-cs.events + if evt.InputTokens != 0 { + t.Errorf("InputTokens = %d, want 0", evt.InputTokens) + } + if evt.OutputTokens != 0 { + t.Errorf("OutputTokens = %d, want 0", evt.OutputTokens) + } +} + +func TestReadLoop_ChildHoldsStdoutPipe(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + pr, pw := io.Pipe() + t.Cleanup(func() { + _ = pw.Close() + }) + + writeDone := make(chan error, 1) + go func() { + _, err := io.WriteString(pw, `{"type":"system","session_id":"test-pipe"}`+"\n") + writeDone <- err + }() + + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^$") + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + cs := &claudeSession{ + cmd: cmd, + events: make(chan core.Event, 64), + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + } + cs.alive.Store(true) + go cs.readLoop(pr, &stderrBuf) + + timeout := time.After(5 * time.Second) + gotEvent := false + for { + select { + case err := <-writeDone: + if err != nil { + t.Fatal(err) + } + writeDone = nil + case evt, ok := <-cs.events: + if !ok { + if !gotEvent { + t.Fatal("events closed but system event lost") + } + return + } + if evt.SessionID == "test-pipe" { + gotEvent = true + } + case <-timeout: + t.Fatal("HANG: events not closed within 5s - readLoop stuck in scanner.Scan()") + } + } +} + +func TestReadLoop_CtxCancelClosesChannels(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + pr, pw := io.Pipe() + t.Cleanup(func() { + _ = pw.Close() + }) + + // "err-then-sleep" emits stderr before sleeping so that ctx cancel + // produces a non-empty stderrBuf in readLoop's defer — exercising the + // `case <-cs.ctx.Done()` select branch in finishReadLoop. + cmd := helperCommand(ctx, "err-then-sleep") + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + cs := &claudeSession{ + cmd: cmd, + events: make(chan core.Event, 64), + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + } + cs.alive.Store(true) + go cs.readLoop(pr, &stderrBuf) + + time.Sleep(200 * time.Millisecond) + cancel() + + timeout := time.After(5 * time.Second) + for { + select { + case _, ok := <-cs.events: + if !ok { + goto closed + } + case <-timeout: + t.Fatal("HANG: events not closed within 5s after ctx cancel") + } + } +closed: + select { + case <-cs.done: + case <-timeout: + t.Fatal("HANG: done not closed within 5s after ctx cancel") + } +} + +func TestClaudeSessionClose_IdempotentNoPanic(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cmd := helperCommand(ctx, "stdin-eof-exit") + stdin, err := cmd.StdinPipe() + if err != nil { + t.Fatal(err) + } + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + + done := make(chan struct{}) + go func() { + _ = cmd.Wait() + close(done) + }() + + cs := &claudeSession{ + cmd: cmd, + stdin: stdin, + ctx: ctx, + cancel: cancel, + done: done, + gracefulStopTimeout: 200 * time.Millisecond, + } + cs.alive.Store(true) + + defer func() { + if r := recover(); r != nil { + t.Errorf("Close panicked: %v", r) + } + }() + + if err := cs.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := cs.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } +} + +func TestShellJoinArgs(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {"empty", nil, ""}, + {"single_plain", []string{"--verbose"}, "--verbose"}, + {"multiple_plain", []string{"--verbose", "--model", "opus"}, "--verbose --model opus"}, + {"arg_with_space", []string{"--prompt", "hello world"}, "--prompt 'hello world'"}, + {"arg_with_tab", []string{"a\tb"}, "'a\tb'"}, + {"arg_with_newline", []string{"line1\nline2"}, "'line1\nline2'"}, + {"arg_with_single_quote", []string{"it's"}, "'it'\\''s'"}, + {"arg_with_double_quote", []string{`say "hi"`}, `'say "hi"'`}, + {"arg_with_backslash", []string{`path\to`}, `'path\to'`}, + {"mixed", []string{"--flag", "has space", "plain", "it's here"}, "--flag 'has space' plain 'it'\\''s here'"}, + {"empty_string_arg", []string{""}, ""}, + {"long_prompt", []string{"--append-system-prompt", "You are a helpful assistant.\nBe concise."}, "--append-system-prompt 'You are a helpful assistant.\nBe concise.'"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shellJoinArgs(tt.args) + if got != tt.want { + t.Errorf("shellJoinArgs(%v)\n got = %q\n want = %q", tt.args, got, tt.want) + } + }) + } +} + +func helperCommand(ctx context.Context, mode string) *exec.Cmd { + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestHelperProcess", "--", mode) + cmd.Env = append(os.Environ(), "GO_WANT_HELPER_PROCESS=1") + return cmd +} + +// TestHelperProcess lets this test binary act as a tiny external command for +// cases that need a process with controlled lifetime semantics. +func TestHelperProcess(t *testing.T) { + if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" { + return + } + + mode := os.Args[len(os.Args)-1] + switch mode { + case "sleep": + time.Sleep(30 * time.Second) + os.Exit(0) + case "err-then-sleep": + _, _ = os.Stderr.WriteString("helper: starting up\n") + time.Sleep(30 * time.Second) + os.Exit(0) + case "stdin-eof-exit": + _, _ = io.Copy(io.Discard, os.Stdin) + os.Exit(0) + default: + os.Exit(2) + } +} diff --git a/agent/claudecode/skilldirs_test.go b/agent/claudecode/skilldirs_test.go new file mode 100644 index 0000000..5c61271 --- /dev/null +++ b/agent/claudecode/skilldirs_test.go @@ -0,0 +1,67 @@ +package claudecode + +import ( + "os" + "path/filepath" + "testing" +) + +func TestSkillDirs_UsesClaudeConfigDirAndProjectParents(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + configHome := filepath.Join(tmp, "profile-home") + repo := filepath.Join(tmp, "repo") + workDir := filepath.Join(repo, "nested", "pkg") + + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", configHome) + + for _, dir := range []string{ + filepath.Join(repo, "nested", "pkg"), + filepath.Join(repo, "nested"), + repo, + configHome, + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: fake\n"), 0o644); err != nil { + t.Fatalf("write .git: %v", err) + } + + a := &Agent{workDir: workDir} + got := a.SkillDirs() + want := []string{ + filepath.Join(workDir, ".claude", "skills"), + filepath.Join(repo, "nested", ".claude", "skills"), + filepath.Join(repo, ".claude", "skills"), + filepath.Join(configHome, "skills"), + } + if len(got) != len(want) { + t.Fatalf("len(SkillDirs()) = %d, want %d\n got=%v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("SkillDirs()[%d] = %q, want %q\nfull=%v", i, got[i], want[i], got) + } + } +} + +func TestSkillDirs_FallsBackToHomeClaudeDir(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + workDir := filepath.Join(tmp, "workspace") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatalf("mkdir workdir: %v", err) + } + t.Setenv("HOME", home) + t.Setenv("CLAUDE_CONFIG_DIR", "") + + a := &Agent{workDir: workDir} + got := a.SkillDirs() + wantLast := filepath.Join(home, ".claude", "skills") + if got[len(got)-1] != wantLast { + t.Fatalf("last SkillDirs() = %q, want %q\nfull=%v", got[len(got)-1], wantLast, got) + } +} diff --git a/agent/codex/appserver_session.go b/agent/codex/appserver_session.go new file mode 100644 index 0000000..bad0c89 --- /dev/null +++ b/agent/codex/appserver_session.go @@ -0,0 +1,1443 @@ +package codex + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +type rpcResponseEnvelope struct { + ID any `json:"id"` + Result json.RawMessage `json:"result"` + Error *rpcError `json:"error"` +} + +type rpcNotificationEnvelope struct { + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type initResponse struct { + ProtocolVersion string `json:"protocolVersion"` +} + +type threadStartResponse struct { + Cwd string `json:"cwd"` + Model string `json:"model"` + ReasoningEffort *string `json:"reasoningEffort"` + Thread struct { + ID string `json:"id"` + } `json:"thread"` +} + +type threadResumeResponse struct { + Cwd string `json:"cwd"` + Model string `json:"model"` + ReasoningEffort *string `json:"reasoningEffort"` + Thread struct { + ID string `json:"id"` + } `json:"thread"` +} + +type turnStartResponse struct { + Turn struct { + ID string `json:"id"` + } `json:"turn"` +} + +type turnNotification struct { + ThreadID string `json:"threadId"` + Turn struct { + ID string `json:"id"` + Status string `json:"status"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } `json:"turn"` +} + +type itemNotification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + Item map[string]any `json:"item"` +} + +type errorNotification struct { + Message string `json:"message"` +} + +type appServerRateLimitsResponse struct { + RateLimits appServerRateLimitSnapshot `json:"rateLimits"` + RateLimitsByLimitID map[string]appServerRateLimitSnapshot `json:"rateLimitsByLimitId"` +} + +type appServerRateLimitSnapshot struct { + LimitID string `json:"limitId"` + LimitName string `json:"limitName"` + PlanType string `json:"planType"` + Primary *appServerRateLimitWindow `json:"primary"` + Secondary *appServerRateLimitWindow `json:"secondary"` + Credits *appServerCreditsSnapshot `json:"credits"` +} + +type appServerRateLimitWindow struct { + UsedPercent int `json:"usedPercent"` + WindowDurationMins int `json:"windowDurationMins"` + ResetsAt int64 `json:"resetsAt"` +} + +type appServerCreditsSnapshot struct { + Balance *string `json:"balance"` + HasCredits bool `json:"hasCredits"` + Unlimited bool `json:"unlimited"` +} + +type appServerSession struct { + url string + workDir string + model string + effort string + mode string + baseURL string + modelProvider string + extraEnv []string + codexHome string + + events chan core.Event + + ctx context.Context + cancel context.CancelFunc + + cmd *exec.Cmd + stdin io.WriteCloser + procMu sync.Mutex + writeMu sync.Mutex + + nextID atomic.Int64 + + pendingMu sync.Mutex + pending map[int64]chan rpcResponseEnvelope + + approvalsMu sync.Mutex + pendingApprovals map[string]chan core.PermissionResult + + threadID atomic.Value + alive atomic.Bool + + closeOnce sync.Once + wg sync.WaitGroup + + stateMu sync.Mutex + pendingMsgs []string + currentTurn string + + runtimeMu sync.RWMutex + usage *core.UsageReport + context *core.ContextUsage +} + +const ( + appServerRequestTimeout = 120 * time.Second + appServerUsageRefreshTimeout = 1500 * time.Millisecond +) + +func newAppServerSession(ctx context.Context, url, workDir, model, effort, mode, resumeID, baseURL, modelProvider string, extraEnv []string, codexHome string) (*appServerSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + s := &appServerSession{ + url: url, + workDir: workDir, + model: model, + effort: effort, + mode: mode, + baseURL: baseURL, + modelProvider: modelProvider, + extraEnv: append([]string(nil), extraEnv...), + codexHome: strings.TrimSpace(codexHome), + events: make(chan core.Event, 128), + ctx: sessionCtx, + cancel: cancel, + pending: make(map[int64]chan rpcResponseEnvelope), + pendingApprovals: make(map[string]chan core.PermissionResult), + } + s.alive.Store(true) + + if err := s.connect(); err != nil { + cancel() + return nil, err + } + + if err := s.initialize(); err != nil { + _ = s.Close() + return nil, err + } + + if err := s.ensureThread(resumeID); err != nil { + _ = s.Close() + return nil, err + } + if err := s.refreshUsage(context.Background()); err != nil { + slog.Debug("codex app-server: initial rate limit fetch failed", "error", err) + } + + return s, nil +} + +func (s *appServerSession) connect() error { + args := []string{"app-server"} + if strings.TrimSpace(s.url) != "" { + args = append(args, "--listen", strings.TrimSpace(s.url)) + } + if model := strings.TrimSpace(s.model); model != "" { + args = append(args, "-c", fmt.Sprintf("model=%q", model)) + } + if effort := strings.TrimSpace(s.effort); effort != "" { + args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", effort)) + } + if provider := strings.TrimSpace(s.modelProvider); provider != "" { + args = append(args, "-c", fmt.Sprintf("model_provider=%q", provider)) + } + if baseURL := strings.TrimSpace(s.baseURL); baseURL != "" { + args = append(args, "-c", fmt.Sprintf("openai_base_url=%q", baseURL)) + } + cmd := exec.CommandContext(s.ctx, "codex", args...) + cmd.Dir = s.workDir + env := append([]string(nil), s.extraEnv...) + if s.codexHome != "" { + env = append(env, "CODEX_HOME="+s.codexHome) + } + if len(env) > 0 { + cmd.Env = core.MergeEnv(os.Environ(), env) + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return fmt.Errorf("codex app-server stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("codex app-server stdout pipe: %w", err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("codex app-server stderr pipe: %w", err) + } + if err := cmd.Start(); err != nil { + return fmt.Errorf("codex app-server start: %w", err) + } + + s.procMu.Lock() + s.cmd = cmd + s.stdin = stdin + s.procMu.Unlock() + + slog.Info("codex app-server session started", "transport", "stdio", "pid", cmd.Process.Pid, "work_dir", s.workDir) + + s.wg.Add(3) + go s.readLoop(stdout) + go s.stderrLoop(stderr) + go s.waitLoop() + return nil +} + +func (s *appServerSession) initialize() error { + params := map[string]any{ + "clientInfo": map[string]any{ + "name": "cc-connect-codex-agent", + "title": "CC Connect Codex Agent", + "version": "0.1.0", + }, + "capabilities": map[string]any{ + "experimentalApi": true, + "optOutNotificationMethods": []string{ + "command/exec/outputDelta", + "item/agentMessage/delta", + "item/plan/delta", + "item/fileChange/outputDelta", + "item/reasoning/summaryTextDelta", + "item/reasoning/textDelta", + }, + }, + } + + var resp initResponse + if err := s.request("initialize", params, &resp); err != nil { + return fmt.Errorf("codex app-server initialize: %w", err) + } + if err := s.notify("initialized", nil); err != nil { + return fmt.Errorf("codex app-server initialized notify: %w", err) + } + return nil +} + +func (s *appServerSession) ensureThread(resumeID string) error { + if resumeID != "" && resumeID != core.ContinueSession { + params := s.threadRequestParams() + params["threadId"] = resumeID + params["persistExtendedHistory"] = true + + var resp threadResumeResponse + if err := s.request("thread/resume", params, &resp); err != nil { + return err + } + if resp.Thread.ID == "" { + return fmt.Errorf("codex app-server resume returned empty thread id") + } + s.applyThreadRuntimeState(resp.Cwd, resp.Model, resp.ReasoningEffort) + s.threadID.Store(resp.Thread.ID) + slog.Info("codex app-server thread resumed", "thread_id", resp.Thread.ID) + return nil + } + + var resp threadStartResponse + if err := s.request("thread/start", s.threadRequestParams(), &resp); err != nil { + return err + } + if resp.Thread.ID == "" { + return fmt.Errorf("codex app-server start returned empty thread id") + } + s.applyThreadRuntimeState(resp.Cwd, resp.Model, resp.ReasoningEffort) + s.threadID.Store(resp.Thread.ID) + slog.Info("codex app-server thread started", "thread_id", resp.Thread.ID) + return nil +} + +func (s *appServerSession) threadRequestParams() map[string]any { + params := map[string]any{ + "experimentalRawEvents": false, + "persistExtendedHistory": false, + } + if model := s.GetModel(); model != "" { + params["model"] = model + } + if approval, sandbox := appServerModeSettings(s.mode); approval != "" { + params["approvalPolicy"] = approval + if sandbox != "" { + params["sandbox"] = sandbox + } + } + return params +} + +func appServerModeSettings(mode string) (approval string, sandbox string) { + switch normalizeMode(mode) { + case "auto-edit", "full-auto": + return "never", "workspace-write" + case "yolo": + return "never", "danger-full-access" + default: + return "on-request", "read-only" + } +} + +func (s *appServerSession) applyThreadRuntimeState(workDir, model string, effort *string) { + s.runtimeMu.Lock() + defer s.runtimeMu.Unlock() + if dir := strings.TrimSpace(workDir); dir != "" { + s.workDir = dir + } + if m := strings.TrimSpace(model); m != "" { + s.model = m + } + s.effort = normalizeRuntimeReasoningEffort(stringValue(effort)) +} + +func (s *appServerSession) refreshUsage(ctx context.Context) error { + timeout := appServerUsageRefreshTimeout + if ctx != nil { + if deadline, ok := ctx.Deadline(); ok { + if until := time.Until(deadline); until > 0 && until < timeout { + timeout = until + } + } + if err := ctx.Err(); err != nil { + return err + } + } + if timeout <= 0 { + return context.DeadlineExceeded + } + + var resp appServerRateLimitsResponse + if err := s.requestWithTimeout("account/rateLimits/read", map[string]any{}, &resp, timeout); err != nil { + return err + } + s.storeUsage(mapAppServerRateLimits(resp)) + return nil +} + +func (s *appServerSession) cachedUsage() *core.UsageReport { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return cloneUsageReport(s.usage) +} + +func (s *appServerSession) cachedContextUsage() *core.ContextUsage { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return cloneContextUsage(s.context) +} + +func (s *appServerSession) storeUsage(report *core.UsageReport) { + s.runtimeMu.Lock() + defer s.runtimeMu.Unlock() + s.usage = cloneUsageReport(report) +} + +func (s *appServerSession) storeContextUsage(usage *core.ContextUsage) { + s.runtimeMu.Lock() + defer s.runtimeMu.Unlock() + s.context = cloneContextUsage(usage) +} + +func (s *appServerSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if !s.alive.Load() { + return fmt.Errorf("session is closed") + } + + if len(files) > 0 { + filePaths := core.SaveFilesToDisk(s.workDir, files) + prompt = core.AppendFileRefs(prompt, filePaths) + } + + prompt, imagePaths, err := s.stageImages(prompt, images) + if err != nil { + return err + } + + threadID := s.CurrentSessionID() + if threadID == "" { + return fmt.Errorf("codex app-server thread id is empty") + } + + input := make([]map[string]any, 0, 1+len(imagePaths)) + input = append(input, map[string]any{ + "type": "text", + "text": prompt, + "text_elements": []any{}, + }) + for _, path := range imagePaths { + input = append(input, map[string]any{ + "type": "localImage", + "path": path, + }) + } + + params := map[string]any{ + "threadId": threadID, + "input": input, + } + if model := s.GetModel(); model != "" { + params["model"] = model + } + if effort := s.GetReasoningEffort(); effort != "" { + params["effort"] = effort + } + if approval, _ := appServerModeSettings(s.mode); approval != "" { + params["approvalPolicy"] = approval + } + + var resp turnStartResponse + if err := s.request("turn/start", params, &resp); err != nil { + return fmt.Errorf("codex app-server turn/start: %w", err) + } + if resp.Turn.ID == "" { + return fmt.Errorf("codex app-server turn/start returned empty turn id") + } + + s.stateMu.Lock() + s.currentTurn = resp.Turn.ID + s.pendingMsgs = s.pendingMsgs[:0] + s.stateMu.Unlock() + + return nil +} + +func (s *appServerSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) { + if len(images) == 0 { + return prompt, nil, nil + } + + imgDir := filepath.Join(s.workDir, ".cc-connect", "images") + if err := os.MkdirAll(imgDir, 0o755); err != nil { + return "", nil, fmt.Errorf("codex app-server: create image dir: %w", err) + } + + imagePaths := make([]string, 0, len(images)) + for i, img := range images { + ext := codexImageExt(img.MimeType) + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(imgDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o644); err != nil { + return "", nil, fmt.Errorf("codex app-server: save image: %w", err) + } + imagePaths = append(imagePaths, fpath) + } + + if strings.TrimSpace(prompt) == "" { + prompt = "Please analyze the attached image(s)." + } + + return prompt, imagePaths, nil +} + +func (s *appServerSession) RespondPermission(requestID string, result core.PermissionResult) error { + s.approvalsMu.Lock() + ch := s.pendingApprovals[requestID] + s.approvalsMu.Unlock() + if ch == nil { + return fmt.Errorf("codex app-server: no pending approval for request %s", requestID) + } + select { + case ch <- result: + default: + } + return nil +} + +func (s *appServerSession) handleServerRequest(probe map[string]json.RawMessage) { + rawID := probe["id"] + var method string + if err := json.Unmarshal(probe["method"], &method); err != nil { + return + } + params := probe["params"] + + switch method { + case "item/commandExecution/requestApproval", "item/fileChange/requestApproval": + s.handleApprovalRequest(rawID, method, params) + case "item/permissions/requestApproval": + s.handlePermissionsApproval(rawID, params) + case "item/tool/call": + s.handleDynamicToolCall(rawID, params) + default: + _ = s.writeJSON(map[string]any{ + "jsonrpc": "2.0", "id": rawID, + "error": map[string]any{"code": -32601, "message": "method not found"}, + }) + } +} + +func (s *appServerSession) handleApprovalRequest(rawID json.RawMessage, method string, paramsRaw json.RawMessage) { + requestID := string(rawID) + var params map[string]any + if err := json.Unmarshal(paramsRaw, ¶ms); err != nil { + return + } + + toolName, toolInput := method, appServerJSON(params) + switch method { + case "item/commandExecution/requestApproval": + toolName = "Bash" + if cmd, _ := params["command"].(string); cmd != "" { + toolInput = cmd + if cwd, _ := params["cwd"].(string); cwd != "" { + toolInput += "\n(in " + cwd + ")" + } + } + case "item/fileChange/requestApproval": + toolName = "Patch" + if reason, _ := params["reason"].(string); reason != "" { + toolInput = reason + } + } + + ch := make(chan core.PermissionResult, 1) + s.approvalsMu.Lock() + s.pendingApprovals[requestID] = ch + s.approvalsMu.Unlock() + + s.flushPendingAsThinking() + s.emit(core.Event{ + Type: core.EventPermissionRequest, + RequestID: requestID, + ToolName: toolName, + ToolInput: toolInput, + ToolInputRaw: params, + }) + + go func() { + timer := time.NewTimer(5 * time.Minute) + defer timer.Stop() + var result core.PermissionResult + select { + case result = <-ch: + case <-s.ctx.Done(): + result = core.PermissionResult{Behavior: "deny"} + case <-timer.C: + result = core.PermissionResult{Behavior: "deny"} + } + s.approvalsMu.Lock() + delete(s.pendingApprovals, requestID) + s.approvalsMu.Unlock() + + decision := "decline" + if strings.EqualFold(result.Behavior, "allow") { + decision = "accept" + } + _ = s.writeJSON(map[string]any{ + "jsonrpc": "2.0", "id": rawID, + "result": map[string]any{"decision": decision}, + }) + }() +} + +func (s *appServerSession) handlePermissionsApproval(rawID json.RawMessage, paramsRaw json.RawMessage) { + requestID := string(rawID) + var params map[string]any + if err := json.Unmarshal(paramsRaw, ¶ms); err != nil { + return + } + + ch := make(chan core.PermissionResult, 1) + s.approvalsMu.Lock() + s.pendingApprovals[requestID] = ch + s.approvalsMu.Unlock() + + s.flushPendingAsThinking() + s.emit(core.Event{ + Type: core.EventPermissionRequest, + RequestID: requestID, + ToolName: "Permissions", + ToolInput: appServerJSON(params), + ToolInputRaw: params, + }) + + go func() { + timer := time.NewTimer(5 * time.Minute) + defer timer.Stop() + var result core.PermissionResult + select { + case result = <-ch: + case <-s.ctx.Done(): + result = core.PermissionResult{Behavior: "deny"} + case <-timer.C: + result = core.PermissionResult{Behavior: "deny"} + } + s.approvalsMu.Lock() + delete(s.pendingApprovals, requestID) + s.approvalsMu.Unlock() + + if strings.EqualFold(result.Behavior, "allow") { + perms := params["permissions"] + if perms == nil { + perms = map[string]any{} + } + _ = s.writeJSON(map[string]any{ + "jsonrpc": "2.0", "id": rawID, + "result": map[string]any{"permissions": perms, "scope": "turn"}, + }) + } else { + _ = s.writeJSON(map[string]any{ + "jsonrpc": "2.0", "id": rawID, + "result": map[string]any{"permissions": map[string]any{}}, + }) + } + }() +} + +func (s *appServerSession) handleDynamicToolCall(rawID json.RawMessage, paramsRaw json.RawMessage) { + _ = s.writeJSON(map[string]any{ + "jsonrpc": "2.0", "id": rawID, + "result": map[string]any{ + "success": false, + "contentItems": []map[string]any{{"type": "inputText", "text": "tool not available on this client"}}, + }, + }) +} + +func (s *appServerSession) rejectPendingApprovals(err error) { + s.approvalsMu.Lock() + defer s.approvalsMu.Unlock() + for id, ch := range s.pendingApprovals { + delete(s.pendingApprovals, id) + select { + case ch <- core.PermissionResult{Behavior: "deny"}: + default: + } + } +} + +func (s *appServerSession) Events() <-chan core.Event { + return s.events +} + +func (s *appServerSession) CurrentSessionID() string { + v, _ := s.threadID.Load().(string) + return v +} + +func (s *appServerSession) GetWorkDir() string { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return s.workDir +} + +func (s *appServerSession) GetModel() string { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return strings.TrimSpace(s.model) +} + +func (s *appServerSession) GetReasoningEffort() string { + s.runtimeMu.RLock() + defer s.runtimeMu.RUnlock() + return strings.TrimSpace(s.effort) +} + +func (s *appServerSession) GetUsage(ctx context.Context) (*core.UsageReport, error) { + if err := s.refreshUsage(ctx); err != nil { + if cached := s.cachedUsage(); cached != nil { + return cached, nil + } + return nil, err + } + if cached := s.cachedUsage(); cached != nil { + return cached, nil + } + return nil, fmt.Errorf("codex app-server usage unavailable") +} + +func (s *appServerSession) GetContextUsage() *core.ContextUsage { + return s.cachedContextUsage() +} + +func (s *appServerSession) Alive() bool { + return s.alive.Load() +} + +func (s *appServerSession) Close() error { + s.alive.Store(false) + s.cancel() + + s.procMu.Lock() + if s.stdin != nil { + _ = s.stdin.Close() + s.stdin = nil + } + if s.cmd != nil && s.cmd.Process != nil { + _ = s.cmd.Process.Kill() + } + s.procMu.Unlock() + + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + + select { + case <-done: + case <-time.After(2 * time.Second): + } + + s.closeOnce.Do(func() { + close(s.events) + }) + return nil +} + +func (s *appServerSession) readLoop(r io.Reader) { + defer s.wg.Done() + scanner := bufio.NewScanner(r) + scanBuf := make([]byte, 0, 64*1024) + const maxLineSize = 10 * 1024 * 1024 // 10MB + scanner.Buffer(scanBuf, maxLineSize) + + for scanner.Scan() { + select { + case <-s.ctx.Done(): + return + default: + } + + data := scanner.Bytes() + + var probe map[string]json.RawMessage + if err := json.Unmarshal(data, &probe); err != nil { + slog.Debug("codex app-server: invalid JSON", "error", err) + continue + } + + _, hasID := probe["id"] + _, hasMethod := probe["method"] + + switch { + case hasID && !hasMethod: + // Response to one of our requests. + var resp rpcResponseEnvelope + if err := json.Unmarshal(data, &resp); err != nil { + slog.Debug("codex app-server: bad response envelope", "error", err) + continue + } + s.handleResponse(resp) + + case hasID && hasMethod: + // Server-initiated request that requires a response (e.g. approval). + s.handleServerRequest(probe) + + default: + // Notification (no id). + var notif rpcNotificationEnvelope + if err := json.Unmarshal(data, ¬if); err != nil { + slog.Debug("codex app-server: bad notification envelope", "error", err) + continue + } + s.handleNotification(notif.Method, notif.Params) + } + } + + err := scanner.Err() + if err != nil { + if s.ctx.Err() == nil && !errors.Is(err, io.EOF) { + slog.Warn("codex app-server read failed", "error", err) + if errors.Is(err, bufio.ErrTooLong) { + s.emitError(fmt.Errorf("codex app-server line exceeds max size (%d bytes): %w", maxLineSize, err)) + } else { + s.emitError(fmt.Errorf("codex app-server connection closed: %w", err)) + } + } + s.alive.Store(false) + s.rejectPending(err) + s.rejectPendingApprovals(err) + return + } + + s.alive.Store(false) + s.rejectPending(io.EOF) + s.rejectPendingApprovals(io.EOF) +} + +func (s *appServerSession) stderrLoop(r io.Reader) { + defer s.wg.Done() + scanner := bufio.NewScanner(r) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + slog.Debug("codex app-server stderr", "line", line) + } + if err := scanner.Err(); err != nil && s.ctx.Err() == nil { + slog.Debug("codex app-server stderr read failed", "error", err) + } +} + +func (s *appServerSession) waitLoop() { + defer s.wg.Done() + + s.procMu.Lock() + cmd := s.cmd + s.procMu.Unlock() + if cmd == nil { + return + } + + err := cmd.Wait() + if s.ctx.Err() == nil && err != nil { + slog.Warn("codex app-server exited unexpectedly", "error", err) + s.emitError(fmt.Errorf("codex app-server exited: %w", err)) + } + s.alive.Store(false) + if err == nil { + err = io.EOF + } + s.rejectPending(err) +} + +func (s *appServerSession) handleResponse(resp rpcResponseEnvelope) { + id, ok := rpcIDToInt64(resp.ID) + if !ok { + return + } + + s.pendingMu.Lock() + ch := s.pending[id] + delete(s.pending, id) + s.pendingMu.Unlock() + + if ch == nil { + return + } + + select { + case ch <- resp: + default: + } +} + +func (s *appServerSession) handleNotification(method string, paramsRaw json.RawMessage) { + switch method { + case "turn/started": + var notif turnNotification + if err := json.Unmarshal(paramsRaw, ¬if); err == nil { + s.stateMu.Lock() + s.currentTurn = notif.Turn.ID + s.pendingMsgs = s.pendingMsgs[:0] + s.stateMu.Unlock() + s.storeContextUsage(nil) + } + + case "item/started": + var notif itemNotification + if err := json.Unmarshal(paramsRaw, ¬if); err == nil { + s.handleItemStarted(notif.Item) + } + + case "item/completed": + var notif itemNotification + if err := json.Unmarshal(paramsRaw, ¬if); err == nil { + s.handleItemCompleted(notif.Item) + } + + case "turn/completed": + var notif turnNotification + if err := json.Unmarshal(paramsRaw, ¬if); err == nil { + s.completeTurn() + } + + case "thread/status/changed": + var notif struct { + ThreadID string `json:"threadId"` + Status struct { + Type string `json:"type"` + } `json:"status"` + } + if err := json.Unmarshal(paramsRaw, ¬if); err == nil && notif.Status.Type == "idle" { + // In codex 0.125+, thread going idle signals turn completion. + s.completeTurn() + } + + case "account/rateLimits/updated": + var notif appServerRateLimitsResponse + if err := json.Unmarshal(paramsRaw, ¬if); err == nil { + s.storeUsage(mapAppServerRateLimits(notif)) + } + + case "thread/tokenUsage/updated": + var notif appServerThreadTokenUsageNotification + if err := json.Unmarshal(paramsRaw, ¬if); err == nil { + s.storeContextUsage(mapAppServerTokenUsage(notif)) + } + + case "error": + var notif errorNotification + if err := json.Unmarshal(paramsRaw, ¬if); err == nil && strings.TrimSpace(notif.Message) != "" { + s.emitError(fmt.Errorf("%s", notif.Message)) + } + } +} + +func (s *appServerSession) handleItemStarted(item map[string]any) { + itemType, _ := item["type"].(string) + if itemType == "" { + return + } + + switch itemType { + case "agentMessage", "reasoning", "userMessage", "plan", "hookPrompt", "contextCompaction": + return + } + + s.flushPendingAsThinking() + + switch itemType { + case "commandExecution": + command, _ := item["command"].(string) + s.emit(core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: command}) + + case "mcpToolCall": + server, _ := item["server"].(string) + tool, _ := item["tool"].(string) + name := strings.Trim(strings.Join([]string{server, tool}, ":"), ":") + s.emit(core.Event{Type: core.EventToolUse, ToolName: "MCP", ToolInput: name + "\n" + appServerJSON(item["arguments"])}) + + case "webSearch": + query, _ := item["query"].(string) + s.emit(core.Event{Type: core.EventToolUse, ToolName: "WebSearch", ToolInput: query}) + + case "dynamicToolCall": + tool, _ := item["tool"].(string) + s.emit(core.Event{Type: core.EventToolUse, ToolName: tool, ToolInput: appServerJSON(item["arguments"])}) + + case "fileChange": + s.emit(core.Event{Type: core.EventToolUse, ToolName: "Patch", ToolInput: appServerJSON(item["changes"])}) + } +} + +func (s *appServerSession) handleItemCompleted(item map[string]any) { + itemType, _ := item["type"].(string) + if itemType == "" { + return + } + + switch itemType { + case "reasoning": + text := appServerReasoningText(item) + if text != "" { + s.emit(core.Event{Type: core.EventThinking, Content: text}) + } + + case "agentMessage": + text, _ := item["text"].(string) + if strings.TrimSpace(text) != "" { + s.stateMu.Lock() + s.pendingMsgs = append(s.pendingMsgs, text) + s.stateMu.Unlock() + } + + case "commandExecution": + command, _ := item["command"].(string) + status, _ := item["status"].(string) + output, _ := item["aggregatedOutput"].(string) + exitCode, hasExitCode := toInt(item["exitCode"]) + var exitCodePtr *int + if hasExitCode { + exitCodePtr = &exitCode + } + success := appServerToolSuccess(status, exitCodePtr) + s.emit(core.Event{ + Type: core.EventToolResult, + ToolName: "Bash", + ToolInput: command, + ToolResult: truncate(strings.TrimSpace(output), 500), + ToolStatus: strings.TrimSpace(status), + ToolExitCode: exitCodePtr, + ToolSuccess: &success, + }) + + case "mcpToolCall": + tool, _ := item["tool"].(string) + status, _ := item["status"].(string) + result := appServerJSON(item["result"]) + if errText := appServerJSON(item["error"]); strings.TrimSpace(errText) != "" && result == "" { + result = errText + } + success := appServerToolSuccess(status, nil) + s.emit(core.Event{ + Type: core.EventToolResult, + ToolName: tool, + ToolResult: truncate(strings.TrimSpace(result), 500), + ToolStatus: strings.TrimSpace(status), + ToolSuccess: &success, + }) + + case "webSearch": + query, _ := item["query"].(string) + s.emit(core.Event{ + Type: core.EventToolResult, + ToolName: "WebSearch", + ToolResult: truncate(strings.TrimSpace(query), 500), + }) + + case "dynamicToolCall": + tool, _ := item["tool"].(string) + status, _ := item["status"].(string) + result := appServerDynamicToolText(item["contentItems"]) + success := appServerToolSuccess(status, nil) + s.emit(core.Event{ + Type: core.EventToolResult, + ToolName: tool, + ToolResult: truncate(strings.TrimSpace(result), 500), + ToolStatus: strings.TrimSpace(status), + ToolSuccess: &success, + }) + } +} + +func appServerReasoningText(item map[string]any) string { + var parts []string + if summary, ok := item["summary"].([]any); ok { + for _, entry := range summary { + if text, ok := entry.(string); ok && strings.TrimSpace(text) != "" { + parts = append(parts, text) + } + } + } + if len(parts) == 0 { + if content, ok := item["content"].([]any); ok { + for _, entry := range content { + if text, ok := entry.(string); ok && strings.TrimSpace(text) != "" { + parts = append(parts, text) + } + } + } + } + return strings.Join(parts, "\n") +} + +func appServerDynamicToolText(raw any) string { + items, ok := raw.([]any) + if !ok { + return appServerJSON(raw) + } + var parts []string + for _, entry := range items { + m, ok := entry.(map[string]any) + if !ok { + continue + } + if text, _ := m["text"].(string); strings.TrimSpace(text) != "" { + parts = append(parts, text) + } + } + if len(parts) == 0 { + return appServerJSON(raw) + } + return strings.Join(parts, "\n") +} + +func appServerToolSuccess(status string, exitCode *int) bool { + s := strings.ToLower(strings.TrimSpace(status)) + if exitCode != nil { + return *exitCode == 0 + } + return s == "completed" || s == "success" || s == "succeeded" || s == "ok" +} + +func mapAppServerRateLimits(payload appServerRateLimitsResponse) *core.UsageReport { + report := &core.UsageReport{Provider: "codex"} + + var snapshots []appServerRateLimitSnapshot + if len(payload.RateLimitsByLimitID) > 0 { + keys := make([]string, 0, len(payload.RateLimitsByLimitID)) + for key := range payload.RateLimitsByLimitID { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + snapshots = append(snapshots, payload.RateLimitsByLimitID[key]) + } + } else if payload.RateLimits.LimitID != "" || payload.RateLimits.Primary != nil || payload.RateLimits.Secondary != nil || payload.RateLimits.Credits != nil { + snapshots = append(snapshots, payload.RateLimits) + } + + for _, snapshot := range snapshots { + if report.Plan == "" && strings.TrimSpace(snapshot.PlanType) != "" { + report.Plan = strings.TrimSpace(snapshot.PlanType) + } + if report.Credits == nil && snapshot.Credits != nil { + report.Credits = &core.UsageCredits{ + HasCredits: snapshot.Credits.HasCredits, + Unlimited: snapshot.Credits.Unlimited, + } + if snapshot.Credits.Balance != nil { + report.Credits.Balance = strings.TrimSpace(*snapshot.Credits.Balance) + } + } + + windows := appServerUsageWindows(snapshot) + if len(windows) == 0 { + continue + } + limitReached := false + for _, window := range windows { + if window.UsedPercent >= 100 { + limitReached = true + break + } + } + + report.Buckets = append(report.Buckets, core.UsageBucket{ + Name: appServerBucketName(snapshot), + Allowed: !limitReached, + LimitReached: limitReached, + Windows: windows, + }) + } + + return report +} + +func appServerBucketName(snapshot appServerRateLimitSnapshot) string { + if name := strings.TrimSpace(snapshot.LimitName); name != "" { + return name + } + if id := strings.TrimSpace(snapshot.LimitID); id != "" { + return id + } + return "Rate limit" +} + +func appServerUsageWindows(snapshot appServerRateLimitSnapshot) []core.UsageWindow { + var windows []core.UsageWindow + if snapshot.Primary != nil { + windows = append(windows, appServerUsageWindow("Primary", snapshot.Primary)) + } + if snapshot.Secondary != nil { + windows = append(windows, appServerUsageWindow("Secondary", snapshot.Secondary)) + } + return windows +} + +func appServerUsageWindow(name string, window *appServerRateLimitWindow) core.UsageWindow { + resetAfter := 0 + if window != nil && window.ResetsAt > 0 { + resetAfter = int(time.Until(time.Unix(window.ResetsAt, 0)).Seconds()) + if resetAfter < 0 { + resetAfter = 0 + } + } + return core.UsageWindow{ + Name: name, + UsedPercent: window.UsedPercent, + WindowSeconds: window.WindowDurationMins * 60, + ResetAfterSeconds: resetAfter, + ResetAtUnix: window.ResetsAt, + } +} + +func cloneUsageReport(report *core.UsageReport) *core.UsageReport { + if report == nil { + return nil + } + cloned := *report + if len(report.Buckets) > 0 { + cloned.Buckets = make([]core.UsageBucket, len(report.Buckets)) + for i, bucket := range report.Buckets { + cloned.Buckets[i] = bucket + if len(bucket.Windows) > 0 { + cloned.Buckets[i].Windows = append([]core.UsageWindow(nil), bucket.Windows...) + } + } + } + if report.Credits != nil { + credits := *report.Credits + cloned.Credits = &credits + } + return &cloned +} + +func normalizeRuntimeReasoningEffort(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return "" + case "med": + return "medium" + case "x-high", "very-high": + return "xhigh" + default: + return strings.ToLower(strings.TrimSpace(raw)) + } +} + +func stringValue(v *string) string { + if v == nil { + return "" + } + return strings.TrimSpace(*v) +} + +func appServerJSON(v any) string { + if v == nil { + return "" + } + b, err := json.Marshal(v) + if err != nil { + return "" + } + s := strings.TrimSpace(string(b)) + if s == "null" || s == "{}" || s == "[]" || s == `""` { + return "" + } + return s +} + +func toInt(v any) (int, bool) { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case int64: + return int(n), true + case json.Number: + i, err := n.Int64() + if err == nil { + return int(i), true + } + } + return 0, false +} + +func rpcIDToInt64(v any) (int64, bool) { + switch id := v.(type) { + case float64: + return int64(id), true + case int64: + return id, true + case int: + return int64(id), true + case json.Number: + i, err := id.Int64() + return i, err == nil + } + return 0, false +} + +func (s *appServerSession) completeTurn() { + s.stateMu.Lock() + if s.currentTurn == "" { + s.stateMu.Unlock() + return + } + s.currentTurn = "" + s.stateMu.Unlock() + s.flushPendingAsText() + s.emit(core.Event{Type: core.EventResult, SessionID: s.CurrentSessionID(), Done: true}) +} + +func (s *appServerSession) flushPendingAsThinking() { + s.stateMu.Lock() + msgs := append([]string(nil), s.pendingMsgs...) + s.pendingMsgs = s.pendingMsgs[:0] + s.stateMu.Unlock() + + for _, text := range msgs { + if strings.TrimSpace(text) != "" { + s.emit(core.Event{Type: core.EventThinking, Content: text}) + } + } +} + +func (s *appServerSession) flushPendingAsText() { + s.stateMu.Lock() + msgs := append([]string(nil), s.pendingMsgs...) + s.pendingMsgs = s.pendingMsgs[:0] + s.stateMu.Unlock() + + for _, text := range msgs { + if strings.TrimSpace(text) != "" { + s.emit(core.Event{Type: core.EventText, Content: text}) + } + } +} + +func (s *appServerSession) emit(event core.Event) { + select { + case s.events <- event: + default: + slog.Warn("codex appserver: event channel full, dropping event", "type", event.Type) + } +} + +func (s *appServerSession) emitError(err error) { + if err == nil { + return + } + s.emit(core.Event{Type: core.EventError, Error: err}) +} + +func (s *appServerSession) rejectPending(err error) { + s.pendingMu.Lock() + defer s.pendingMu.Unlock() + for id, ch := range s.pending { + delete(s.pending, id) + select { + case ch <- rpcResponseEnvelope{ID: id, Error: &rpcError{Message: err.Error()}}: + default: + } + } +} + +func (s *appServerSession) request(method string, params any, out any) error { + return s.requestWithTimeout(method, params, out, appServerRequestTimeout) +} + +func (s *appServerSession) requestWithTimeout(method string, params any, out any, timeout time.Duration) error { + id := s.nextID.Add(1) + ch := make(chan rpcResponseEnvelope, 1) + + s.pendingMu.Lock() + if s.pending == nil { + s.pending = make(map[int64]chan rpcResponseEnvelope) + } + s.pending[id] = ch + s.pendingMu.Unlock() + + payload := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + + if err := s.writeJSON(payload); err != nil { + s.pendingMu.Lock() + delete(s.pending, id) + s.pendingMu.Unlock() + return err + } + + select { + case resp := <-ch: + if resp.Error != nil { + return fmt.Errorf("%s", strings.TrimSpace(resp.Error.Message)) + } + if out != nil { + if err := json.Unmarshal(resp.Result, out); err != nil { + return fmt.Errorf("decode %s response: %w", method, err) + } + } + return nil + case <-s.ctx.Done(): + return s.ctx.Err() + case <-time.After(timeout): + s.pendingMu.Lock() + delete(s.pending, id) + s.pendingMu.Unlock() + return fmt.Errorf("%s timed out", method) + } +} + +func (s *appServerSession) notify(method string, params any) error { + payload := map[string]any{ + "jsonrpc": "2.0", + "method": method, + } + if params != nil { + payload["params"] = params + } + return s.writeJSON(payload) +} + +func (s *appServerSession) writeJSON(v any) error { + b, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("codex app-server encode: %w", err) + } + + s.procMu.Lock() + stdin := s.stdin + s.procMu.Unlock() + if stdin == nil { + return fmt.Errorf("codex app-server connection is closed") + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + if _, err := stdin.Write(append(b, '\n')); err != nil { + return fmt.Errorf("codex app-server write: %w", err) + } + return nil +} diff --git a/agent/codex/appserver_session_test.go b/agent/codex/appserver_session_test.go new file mode 100644 index 0000000..f865ed9 --- /dev/null +++ b/agent/codex/appserver_session_test.go @@ -0,0 +1,171 @@ +package codex + +import ( + "context" + "encoding/json" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestAppServerSession_ApplyThreadRuntimeState(t *testing.T) { + s := &appServerSession{} + effort := "xhigh" + + s.applyThreadRuntimeState("/tmp/project", "gpt-5.4", &effort) + + if got := s.GetWorkDir(); got != "/tmp/project" { + t.Fatalf("GetWorkDir() = %q, want /tmp/project", got) + } + if got := s.GetModel(); got != "gpt-5.4" { + t.Fatalf("GetModel() = %q, want gpt-5.4", got) + } + if got := s.GetReasoningEffort(); got != "xhigh" { + t.Fatalf("GetReasoningEffort() = %q, want xhigh", got) + } +} + +func TestAppServerSession_HandleRateLimitsUpdatedCachesUsage(t *testing.T) { + s := &appServerSession{} + raw, err := json.Marshal(appServerRateLimitsResponse{ + RateLimits: appServerRateLimitSnapshot{ + LimitID: "codex", + PlanType: "pro", + Primary: &appServerRateLimitWindow{UsedPercent: 25, WindowDurationMins: 15, ResetsAt: 1730947200}, + Secondary: &appServerRateLimitWindow{UsedPercent: 42, WindowDurationMins: 60, ResetsAt: 1730950800}, + Credits: &appServerCreditsSnapshot{HasCredits: true, Unlimited: false}, + }, + }) + if err != nil { + t.Fatalf("marshal notification: %v", err) + } + + s.handleNotification("account/rateLimits/updated", raw) + + report, err := s.GetUsage(context.Background()) + if err != nil { + t.Fatalf("GetUsage() returned error: %v", err) + } + if report.Provider != "codex" { + t.Fatalf("provider = %q, want codex", report.Provider) + } + if report.Plan != "pro" { + t.Fatalf("plan = %q, want pro", report.Plan) + } + if len(report.Buckets) != 1 { + t.Fatalf("buckets = %d, want 1", len(report.Buckets)) + } + if got := report.Buckets[0].Name; got != "codex" { + t.Fatalf("bucket name = %q, want codex", got) + } + if got := report.Buckets[0].Windows[0].WindowSeconds; got != 15*60 { + t.Fatalf("primary window seconds = %d, want %d", got, 15*60) + } + if got := report.Buckets[0].Windows[1].UsedPercent; got != 42 { + t.Fatalf("secondary used percent = %d, want 42", got) + } + if report.Credits == nil || !report.Credits.HasCredits { + t.Fatalf("credits = %#v, want has credits", report.Credits) + } +} + +func TestAppServerSession_HandleThreadTokenUsageUpdatedCachesContextUsage(t *testing.T) { + s := &appServerSession{} + raw, err := json.Marshal(appServerThreadTokenUsageNotification{ + ThreadID: "thread-1", + TurnID: "turn-1", + TokenUsage: struct { + Total codexTokenUsage `json:"total"` + Last codexTokenUsage `json:"last"` + ModelContextWindow int `json:"modelContextWindow"` + }{ + Total: codexTokenUsage{ + TotalTokens: 52011395, + InputTokens: 51847383, + CachedInputTokens: 48187904, + OutputTokens: 164012, + ReasoningOutputTokens: 78910, + }, + Last: codexTokenUsage{ + TotalTokens: 41061, + InputTokens: 40849, + CachedInputTokens: 36864, + OutputTokens: 212, + ReasoningOutputTokens: 32, + }, + ModelContextWindow: 258400, + }, + }) + if err != nil { + t.Fatalf("marshal notification: %v", err) + } + + s.handleNotification("thread/tokenUsage/updated", raw) + + usage := s.GetContextUsage() + if usage == nil { + t.Fatal("GetContextUsage() = nil, want cached context usage") + } + if usage.UsedTokens != 41061 { + t.Fatalf("used tokens = %d, want 41061", usage.UsedTokens) + } + if usage.BaselineTokens != codexContextBaselineTokens { + t.Fatalf("baseline tokens = %d, want %d", usage.BaselineTokens, codexContextBaselineTokens) + } + if usage.TotalTokens != 41061 { + t.Fatalf("total tokens = %d, want 41061", usage.TotalTokens) + } + if usage.ContextWindow != 258400 { + t.Fatalf("context window = %d, want 258400", usage.ContextWindow) + } + if usage.CachedInputTokens != 36864 { + t.Fatalf("cached input tokens = %d, want 36864", usage.CachedInputTokens) + } + if usage.InputTokens != 40849 { + t.Fatalf("input tokens = %d, want 40849", usage.InputTokens) + } +} + +func TestMapAppServerRateLimits_PrefersMultiBucketView(t *testing.T) { + report := mapAppServerRateLimits(appServerRateLimitsResponse{ + RateLimits: appServerRateLimitSnapshot{ + LimitID: "legacy", + PlanType: "team", + Primary: &appServerRateLimitWindow{UsedPercent: 99, WindowDurationMins: 15}, + }, + RateLimitsByLimitID: map[string]appServerRateLimitSnapshot{ + "codex": { + LimitID: "codex", + LimitName: "Codex", + PlanType: "team", + Primary: &appServerRateLimitWindow{UsedPercent: 10, WindowDurationMins: 15}, + }, + "codex_other": { + LimitID: "codex_other", + PlanType: "team", + Primary: &appServerRateLimitWindow{UsedPercent: 20, WindowDurationMins: 60}, + }, + }, + }) + + if report.Plan != "team" { + t.Fatalf("plan = %q, want team", report.Plan) + } + if len(report.Buckets) != 2 { + t.Fatalf("buckets = %d, want 2", len(report.Buckets)) + } + if report.Buckets[0].Name != "Codex" { + t.Fatalf("first bucket = %q, want Codex", report.Buckets[0].Name) + } + if report.Buckets[1].Name != "codex_other" { + t.Fatalf("second bucket = %q, want codex_other", report.Buckets[1].Name) + } +} + +var _ interface { + GetUsage(context.Context) (*core.UsageReport, error) +} = (*appServerSession)(nil) + +var _ interface { + GetContextUsage() *core.ContextUsage +} = (*appServerSession)(nil) diff --git a/agent/codex/codex.go b/agent/codex/codex.go new file mode 100644 index 0000000..3e53c14 --- /dev/null +++ b/agent/codex/codex.go @@ -0,0 +1,684 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("codex", New) +} + +// Agent drives OpenAI Codex CLI using `codex exec --json`. +// +// Modes (maps to codex exec flags): +// - "suggest": default, no special flags (safe commands only) +// - "auto-edit": --full-auto (sandbox-protected auto execution) +// - "full-auto": --full-auto (sandbox-protected auto execution) +// - "yolo": --dangerously-bypass-approvals-and-sandbox +type Agent struct { + workDir string + model string + reasoningEffort string + mode string // "suggest" | "auto-edit" | "full-auto" | "yolo" + backend string // "exec" | "app_server" + appServerURL string + codexHome string + cliBin string // CLI binary name, default "codex" + cliExtraArgs []string // extra args parsed from cli_path after the binary + providers []core.ProviderConfig + activeIdx int // -1 = no provider set + configEnv []string // env vars from [projects.agent.options.env] — persists across SetSessionEnv calls + sessionEnv []string + mu sync.RWMutex +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + reasoningEffort, _ := opts["reasoning_effort"].(string) + mode, _ := opts["mode"].(string) + backend, _ := opts["backend"].(string) + appServerURL, _ := opts["app_server_url"].(string) + codexHome, _ := opts["codex_home"].(string) + mode = normalizeMode(mode) + backend = normalizeBackend(backend) + appServerURL = normalizeAppServerURL(appServerURL) + + // cli_path allows overriding the binary, e.g. "omx" or "omx --flag val" + cliBin := "codex" + var cliExtraArgs []string + if cliPath, _ := opts["cli_path"].(string); strings.TrimSpace(cliPath) != "" { + parts := strings.Fields(cliPath) + cliBin = parts[0] + if len(parts) > 1 { + cliExtraArgs = parts[1:] + } + } + + if _, err := exec.LookPath(cliBin); err != nil { + return nil, fmt.Errorf("codex: %q CLI not found in PATH, install with: npm install -g @openai/codex", cliBin) + } + + // Parse project-level env from opts["env"] (set via [projects.agent.options.env] in config.toml). + // Stored separately from runtime sessionEnv so SetSessionEnv calls cannot overwrite it. + // MergeEnv semantics ensure these override any same-named keys inherited from os.Environ() + // when the codex subprocess is spawned (e.g. user-scoped HTTPS_PROXY leaking into the agent). + var configEnv []string + if envMap, ok := opts["env"].(map[string]string); ok { + for k, v := range envMap { + configEnv = append(configEnv, k+"="+v) + } + } else if envMap, ok := opts["env"].(map[string]any); ok { + for k, v := range envMap { + if s, ok := v.(string); ok { + configEnv = append(configEnv, k+"="+s) + } + } + } + + return &Agent{ + workDir: workDir, + model: model, + reasoningEffort: normalizeReasoningEffort(reasoningEffort), + mode: mode, + backend: backend, + appServerURL: appServerURL, + codexHome: strings.TrimSpace(codexHome), + cliBin: cliBin, + cliExtraArgs: cliExtraArgs, + configEnv: configEnv, + activeIdx: -1, + }, nil +} + +func normalizeBackend(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "app-server", "app_server", "appserver", "ws": + return "app_server" + default: + return "exec" + } +} + +func normalizeAppServerURL(raw string) string { + url := strings.TrimSpace(raw) + if url == "" { + return "ws://127.0.0.1:3845" + } + if strings.EqualFold(url, "stdio") { + return "stdio://" + } + return url +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "auto-edit", "autoedit", "auto_edit", "edit": + return "auto-edit" + case "full-auto", "fullauto", "full_auto", "auto": + return "full-auto" + case "yolo", "bypass", "dangerously-bypass": + return "yolo" + default: + return "suggest" + } +} + +func normalizeReasoningEffort(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return "" + case "low": + return "low" + case "medium", "med": + return "medium" + case "high": + return "high" + case "xhigh", "x-high", "very-high": + return "xhigh" + default: + return "" + } +} + +func (a *Agent) Name() string { return "codex" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("codex: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("codex: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) SetReasoningEffort(effort string) { + a.mu.Lock() + defer a.mu.Unlock() + a.reasoningEffort = normalizeReasoningEffort(effort) + slog.Info("codex: reasoning effort changed", "reasoning_effort", a.reasoningEffort) +} + +func (a *Agent) GetReasoningEffort() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.reasoningEffort +} + +func (a *Agent) AvailableReasoningEfforts() []string { + return []string{"low", "medium", "high", "xhigh"} +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { + if models := a.configuredModels(); len(models) > 0 { + return models + } + if models := a.fetchModelsFromAPI(ctx); len(models) > 0 { + return models + } + if models := readCodexCachedModels(); len(models) > 0 { + return models + } + return []core.ModelOption{ + {Name: "o4-mini", Desc: "O4 Mini (fast reasoning)"}, + {Name: "o3", Desc: "O3 (most capable reasoning)"}, + {Name: "gpt-4.1", Desc: "GPT-4.1 (balanced)"}, + {Name: "gpt-4.1-mini", Desc: "GPT-4.1 Mini (fast)"}, + {Name: "gpt-4.1-nano", Desc: "GPT-4.1 Nano (fastest)"}, + {Name: "codex-mini-latest", Desc: "Codex Mini (code-optimized)"}, + } +} + +var openaiChatModels = map[string]bool{ + "o4-mini": true, "o3": true, "o3-mini": true, "o1": true, "o1-mini": true, + "gpt-4.1": true, "gpt-4.1-mini": true, "gpt-4.1-nano": true, + "gpt-4o": true, "gpt-4o-mini": true, + "codex-mini-latest": true, +} + +func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption { + a.mu.Lock() + apiKey := "" + baseURL := "" + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + apiKey = a.providers[a.activeIdx].APIKey + baseURL = a.providers[a.activeIdx].BaseURL + } + a.mu.Unlock() + + if apiKey == "" { + apiKey = os.Getenv("OPENAI_API_KEY") + } + if apiKey == "" { + return nil + } + if baseURL == "" { + baseURL = os.Getenv("OPENAI_BASE_URL") + } + if baseURL == "" { + baseURL = "https://api.openai.com" + } + baseURL = strings.TrimRight(baseURL, "/") + + req, err := http.NewRequestWithContext(ctx, "GET", baseURL+"/v1/models", nil) + if err != nil { + return nil + } + req.Header.Set("Authorization", "Bearer "+apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Debug("codex: failed to fetch models", "error", err) + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil + } + + var result struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil + } + + var models []core.ModelOption + for _, m := range result.Data { + if openaiChatModels[m.ID] { + models = append(models, core.ModelOption{Name: m.ID}) + } + } + sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name }) + return models +} + +func readCodexCachedModels() []core.ModelOption { + codexHome := os.Getenv("CODEX_HOME") + if codexHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return nil + } + codexHome = filepath.Join(home, ".codex") + } + path := filepath.Join(codexHome, "models_cache.json") + b, err := os.ReadFile(path) + if err != nil { + return nil + } + var payload struct { + Models []struct { + Slug string `json:"slug"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + SupportedInAPI bool `json:"supported_in_api"` + } `json:"models"` + } + if err := json.Unmarshal(b, &payload); err != nil { + return nil + } + + var models []core.ModelOption + seen := make(map[string]struct{}, len(payload.Models)) + for _, m := range payload.Models { + name := strings.TrimSpace(m.Slug) + if name == "" { + name = strings.TrimSpace(m.DisplayName) + } + if name == "" { + continue + } + if m.Visibility != "" && m.Visibility != "list" { + continue + } + if !m.SupportedInAPI { + continue + } + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + models = append(models, core.ModelOption{ + Name: name, + Desc: strings.TrimSpace(m.Description), + }) + } + return models +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + mode := a.mode + model := a.model + reasoningEffort := a.reasoningEffort + backend := a.backend + appServerURL := a.appServerURL + codexHome := a.codexHome + cliBin := a.cliBin + cliExtraArgs := a.cliExtraArgs + workDir := a.workDir + // Order matters for MergeEnv override semantics (later wins): + // 1. configEnv — static env from [projects.agent.options.env] + // 2. providerEnv — per-provider keys (OPENAI_API_KEY etc.) + // 3. sessionEnv — runtime overrides from /env or admin actions + extraEnv := append([]string(nil), a.configEnv...) + extraEnv = append(extraEnv, a.providerEnvLocked()...) + extraEnv = append(extraEnv, a.sessionEnv...) + var baseURL string + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + if m := a.providers[a.activeIdx].Model; m != "" { + model = m + } + baseURL = a.providers[a.activeIdx].BaseURL + } + provName, provAPIKey, provWireAPI, provHeaders := a.activeProviderCodexConfig() + a.mu.Unlock() + + if provName != "" { + if err := ensureCodexProviderConfig(codexHome, provName, baseURL, provWireAPI, provHeaders); err != nil { + slog.Warn("codex: failed to write provider config", "provider", provName, "error", err) + } + if err := ensureCodexAuth(codexHome, provAPIKey); err != nil { + slog.Warn("codex: failed to write auth.json", "provider", provName, "error", err) + } + } + + if backend == "app_server" { + return newAppServerSession(ctx, appServerURL, workDir, model, reasoningEffort, mode, sessionID, baseURL, provName, extraEnv, codexHome) + } + if codexHome != "" { + extraEnv = append(extraEnv, "CODEX_HOME="+codexHome) + } + + return newCodexSession(ctx, cliBin, cliExtraArgs, workDir, model, reasoningEffort, mode, sessionID, baseURL, extraEnv, provName) +} + +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + a.mu.RLock() + codexHome := a.codexHome + workDir := a.workDir + a.mu.RUnlock() + return listCodexSessions(workDir, codexHome) +} + +func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) { + a.mu.RLock() + codexHome := a.codexHome + a.mu.RUnlock() + return getSessionHistory(sessionID, codexHome, limit) +} + +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + a.mu.RLock() + codexHome := a.codexHome + a.mu.RUnlock() + path := findSessionFile(sessionID, codexHome) + if path == "" { + return fmt.Errorf("session file not found: %s", sessionID) + } + return os.Remove(path) +} + +func (a *Agent) Stop() error { return nil } + +// SetMode changes the approval mode for future sessions. +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("codex: approval mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +func (a *Agent) WorkspaceAgentOptions() map[string]any { + a.mu.RLock() + defer a.mu.RUnlock() + + opts := map[string]any{ + "mode": a.mode, + "backend": a.backend, + } + if a.model != "" { + opts["model"] = a.model + } + if a.reasoningEffort != "" { + opts["reasoning_effort"] = a.reasoningEffort + } + if a.appServerURL != "" { + opts["app_server_url"] = a.appServerURL + } + if a.codexHome != "" { + opts["codex_home"] = a.codexHome + } + return opts +} + +// ── SkillProvider implementation ────────────────────────────── + +func (a *Agent) SkillDirs() []string { + a.mu.RLock() + workDir := a.workDir + codexHome := a.codexHome + a.mu.RUnlock() + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + return codexSkillDirs(absDir, codexHome) +} + +// ── ContextCompressor implementation ────────────────────────── + +// CompressCommand returns "" because Codex native slash commands (/compact, /clear) +// are not reliably executed in exec/resume mode — they may be treated as plain text. +// See: https://github.com/chenhg5/cc-connect/issues/378 +func (a *Agent) CompressCommand() string { return "" } + +func codexSkillDirs(workDir, explicitCodexHome string) []string { + homeDir, _ := os.UserHomeDir() + codexHome := strings.TrimSpace(explicitCodexHome) + if codexHome == "" { + codexHome = strings.TrimSpace(os.Getenv("CODEX_HOME")) + } + if codexHome == "" && homeDir != "" { + codexHome = filepath.Join(homeDir, ".codex") + } + + projectDirs := walkUpCodexProjectSkillDirs(workDir, homeDir) + userDirs := make([]string, 0, 2) + if codexHome != "" { + userDirs = append(userDirs, filepath.Join(codexHome, "skills")) + } + if homeDir != "" { + userDirs = append(userDirs, filepath.Join(homeDir, ".agents", "skills")) + } + return uniqueCodexSkillDirs(append(projectDirs, userDirs...)) +} + +func walkUpCodexProjectSkillDirs(workDir, homeDir string) []string { + current := filepath.Clean(workDir) + homeDir = filepath.Clean(homeDir) + stopAt := findCodexProjectRoot(current) + + var dirs []string + for { + if homeDir != "" && sameCodexPath(current, homeDir) { + break + } + dirs = append(dirs, + filepath.Join(current, ".agents", "skills"), + filepath.Join(current, ".codex", "skills"), + ) + if stopAt != "" && sameCodexPath(current, stopAt) { + break + } + parent := filepath.Dir(current) + if parent == current { + break + } + current = parent + } + return uniqueCodexSkillDirs(dirs) +} + +func findCodexProjectRoot(start string) string { + current := filepath.Clean(start) + for { + for _, marker := range []string{".git", ".jj"} { + if _, err := os.Stat(filepath.Join(current, marker)); err == nil { + return current + } + } + parent := filepath.Dir(current) + if parent == current { + return "" + } + current = parent + } +} + +func sameCodexPath(a, b string) bool { + if a == "" || b == "" { + return false + } + return filepath.Clean(a) == filepath.Clean(b) +} + +func uniqueCodexSkillDirs(paths []string) []string { + seen := make(map[string]struct{}, len(paths)) + out := make([]string, 0, len(paths)) + for _, path := range paths { + if path == "" { + continue + } + clean := filepath.Clean(path) + if _, ok := seen[clean]; ok { + continue + } + seen[clean] = struct{}{} + out = append(out, clean) + } + return out +} + +// ── MemoryFileProvider implementation ───────────────────────── + +func (a *Agent) ProjectMemoryFile() string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + return filepath.Join(absDir, "AGENTS.md") +} + +func (a *Agent) GlobalMemoryFile() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + codexHome := os.Getenv("CODEX_HOME") + if codexHome == "" { + codexHome = filepath.Join(homeDir, ".codex") + } + return filepath.Join(codexHome, "AGENTS.md") +} + +// ── ProviderSwitcher implementation ────────────────────────── + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.activeIdx = -1 + slog.Info("codex: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("codex: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + var env []string + if p.APIKey != "" { + env = append(env, "OPENAI_API_KEY="+p.APIKey) + } + if p.BaseURL != "" { + env = append(env, "OPENAI_BASE_URL="+p.BaseURL) + } + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +// activeProviderCodexConfig returns Codex-specific config for the active provider. +// Returns non-empty name when the provider has codex config (wire_api, headers) +// OR when it has a BaseURL (third-party provider needing auth.json). +func (a *Agent) activeProviderCodexConfig() (name string, apiKey string, wireAPI string, headers map[string]string) { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return + } + p := a.providers[a.activeIdx] + hasCodexConfig := p.CodexWireAPI != "" || len(p.CodexHTTPHeaders) > 0 + isThirdParty := p.BaseURL != "" && p.APIKey != "" + if !hasCodexConfig && !isThirdParty { + return + } + return p.Name, p.APIKey, p.CodexWireAPI, p.CodexHTTPHeaders +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "suggest", Name: "Suggest", NameZh: "建议", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"}, + {Key: "auto-edit", Name: "Auto Edit", NameZh: "自动编辑", Desc: "Auto-approve file edits, ask for shell commands", DescZh: "自动允许文件编辑,Shell 命令需确认"}, + {Key: "full-auto", Name: "Full Auto", NameZh: "全自动", Desc: "Auto-approve with workspace sandbox", DescZh: "自动通过(工作区沙箱)"}, + {Key: "yolo", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Bypass all approvals and sandbox", DescZh: "跳过所有审批和沙箱"}, + } +} diff --git a/agent/codex/codex_cache_test.go b/agent/codex/codex_cache_test.go new file mode 100644 index 0000000..2710722 --- /dev/null +++ b/agent/codex/codex_cache_test.go @@ -0,0 +1,42 @@ +package codex + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestAvailableModels_FallbackToModelsCache(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "forbidden", http.StatusForbidden) + })) + defer srv.Close() + + tmp := t.TempDir() + cache := `{ + "models": [ + {"slug":"gpt-5.4","description":"Latest frontier agentic coding model.","visibility":"list","supported_in_api":true}, + {"slug":"gpt-5.3-codex","description":"Great for coding","visibility":"list","supported_in_api":true}, + {"slug":"hidden-internal","visibility":"hidden","supported_in_api":true}, + {"slug":"tool-only","visibility":"list","supported_in_api":false} + ] +}` + if err := os.WriteFile(tmp+"/models_cache.json", []byte(cache), 0o600); err != nil { + t.Fatalf("write models_cache.json: %v", err) + } + + t.Setenv("CODEX_HOME", tmp) + t.Setenv("OPENAI_API_KEY", "test-key") + t.Setenv("OPENAI_BASE_URL", srv.URL) + + a := &Agent{activeIdx: -1} + models := a.AvailableModels(context.Background()) + if len(models) != 2 { + t.Fatalf("models length = %d, want 2, models=%v", len(models), models) + } + if models[0].Name != "gpt-5.4" || models[1].Name != "gpt-5.3-codex" { + t.Fatalf("models = %v, want [gpt-5.4 gpt-5.3-codex]", models) + } +} diff --git a/agent/codex/codex_model_test.go b/agent/codex/codex_model_test.go new file mode 100644 index 0000000..1f7d01a --- /dev/null +++ b/agent/codex/codex_model_test.go @@ -0,0 +1,83 @@ +package codex + +import ( + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestConfiguredModels_BoundaryConditions(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "first"}}}, + {Models: []core.ModelOption{{Name: "second"}}}, + }, + } + + tests := []struct { + name string + activeIdx int + wantNil bool + wantName string + }{ + {name: "negative index", activeIdx: -1, wantNil: true}, + {name: "out of range", activeIdx: 2, wantNil: true}, + {name: "valid index", activeIdx: 1, wantName: "second"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a.activeIdx = tt.activeIdx + got := a.configuredModels() + if tt.wantNil { + if got != nil { + t.Fatalf("configuredModels() = %v, want nil", got) + } + return + } + if len(got) != 1 || got[0].Name != tt.wantName { + t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName) + } + }) + } +} + +func TestGetModel_PrefersActiveProviderModel(t *testing.T) { + a := &Agent{ + model: "gpt-4.1-mini", + providers: []core.ProviderConfig{ + {Name: "openai", Model: "gpt-5.4"}, + }, + activeIdx: 0, + } + + if got := a.GetModel(); got != "gpt-5.4" { + t.Fatalf("GetModel() = %q, want gpt-5.4", got) + } +} + +func TestNormalizeAppServerURL_StdIOIsExplicit(t *testing.T) { + for _, raw := range []string{"stdio", " stdio "} { + if got := normalizeAppServerURL(raw); got != "stdio://" { + t.Fatalf("normalizeAppServerURL(%q) = %q, want stdio://", raw, got) + } + } +} + +func TestNormalizeAppServerURL_EmptyKeepsWebSocketDefault(t *testing.T) { + if got := normalizeAppServerURL(""); got != "ws://127.0.0.1:3845" { + t.Fatalf("normalizeAppServerURL(empty) = %q, want ws://127.0.0.1:3845", got) + } +} + +func TestWorkspaceAgentOptions_PreservesStdIOAppServerURL(t *testing.T) { + a := &Agent{ + backend: "app_server", + appServerURL: normalizeAppServerURL("stdio"), + } + + opts := a.WorkspaceAgentOptions() + if got := opts["app_server_url"]; got != "stdio://" { + t.Fatalf("WorkspaceAgentOptions()[app_server_url] = %#v, want stdio://", got) + } +} diff --git a/agent/codex/context_usage.go b/agent/codex/context_usage.go new file mode 100644 index 0000000..397871f --- /dev/null +++ b/agent/codex/context_usage.go @@ -0,0 +1,291 @@ +package codex + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/chenhg5/cc-connect/core" +) + +const codexRolloutTailBytes int64 = 1 << 20 +const codexContextBaselineTokens = 12000 + +type codexTokenUsage struct { + TotalTokens int `json:"totalTokens"` + InputTokens int `json:"inputTokens"` + CachedInputTokens int `json:"cachedInputTokens"` + OutputTokens int `json:"outputTokens"` + ReasoningOutputTokens int `json:"reasoningOutputTokens"` +} + +type codexSnakeTokenUsage struct { + TotalTokens int `json:"total_tokens"` + InputTokens int `json:"input_tokens"` + CachedInputTokens int `json:"cached_input_tokens"` + OutputTokens int `json:"output_tokens"` + ReasoningOutputTokens int `json:"reasoning_output_tokens"` +} + +type appServerThreadTokenUsageNotification struct { + ThreadID string `json:"threadId"` + TurnID string `json:"turnId"` + TokenUsage struct { + Total codexTokenUsage `json:"total"` + Last codexTokenUsage `json:"last"` + ModelContextWindow int `json:"modelContextWindow"` + } `json:"tokenUsage"` +} + +func mapAppServerTokenUsage(notif appServerThreadTokenUsageNotification) *core.ContextUsage { + return contextUsageFromCamel(notif.TokenUsage.Last, notif.TokenUsage.ModelContextWindow) +} + +func contextUsageFromCamel(usage codexTokenUsage, contextWindow int) *core.ContextUsage { + return contextUsageFromParts( + currentContextTokens(usage.TotalTokens, usage.InputTokens, usage.OutputTokens), + usage.TotalTokens, + usage.InputTokens, + usage.CachedInputTokens, + usage.OutputTokens, + usage.ReasoningOutputTokens, + contextWindow, + ) +} + +func contextUsageFromSnake(usage codexSnakeTokenUsage, contextWindow int) *core.ContextUsage { + return contextUsageFromParts( + currentContextTokens(usage.TotalTokens, usage.InputTokens, usage.OutputTokens), + usage.TotalTokens, + usage.InputTokens, + usage.CachedInputTokens, + usage.OutputTokens, + usage.ReasoningOutputTokens, + contextWindow, + ) +} + +func currentContextTokens(totalTokens, inputTokens, outputTokens int) int { + if totalTokens > 0 { + return totalTokens + } + if inputTokens > 0 || outputTokens > 0 { + return inputTokens + outputTokens + } + return 0 +} + +func contextUsageFromParts(usedTokens, totalTokens, inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens, contextWindow int) *core.ContextUsage { + if totalTokens <= 0 && inputTokens <= 0 && outputTokens <= 0 { + return nil + } + if contextWindow <= 0 { + return nil + } + return &core.ContextUsage{ + UsedTokens: usedTokens, + BaselineTokens: codexContextBaselineTokens, + TotalTokens: totalTokens, + InputTokens: inputTokens, + CachedInputTokens: cachedInputTokens, + OutputTokens: outputTokens, + ReasoningOutputTokens: reasoningOutputTokens, + ContextWindow: contextWindow, + } +} + +func cloneContextUsage(usage *core.ContextUsage) *core.ContextUsage { + if usage == nil { + return nil + } + cloned := *usage + return &cloned +} + +func loadContextUsageFromRollout(extraEnv []string, sessionID, cachedPath string) (*core.ContextUsage, string, error) { + path := strings.TrimSpace(cachedPath) + if path != "" { + usage, err := readContextUsageFromRollout(path) + if err == nil && usage != nil { + return usage, path, nil + } + } + + codexHome, err := resolveCodexHome(extraEnv) + if err != nil { + return nil, "", err + } + path = findSessionFileInCodexHome(codexHome, sessionID) + if path == "" { + return nil, "", fmt.Errorf("session file not found for %s", sessionID) + } + usage, err := readContextUsageFromRollout(path) + if err != nil { + return nil, path, err + } + if usage == nil { + return nil, path, fmt.Errorf("context usage not found in rollout") + } + return usage, path, nil +} + +func resolveCodexHome(extraEnv []string) (string, error) { + if value := getenvFromList(extraEnv, "CODEX_HOME"); value != "" { + return strings.TrimSpace(value), nil + } + if value := strings.TrimSpace(os.Getenv("CODEX_HOME")); value != "" { + return value, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(homeDir, ".codex"), nil +} + +func getenvFromList(env []string, key string) string { + prefix := key + "=" + for i := len(env) - 1; i >= 0; i-- { + entry := env[i] + if strings.HasPrefix(entry, prefix) { + return strings.TrimSpace(strings.TrimPrefix(entry, prefix)) + } + } + return "" +} + +func findSessionFileInCodexHome(codexHome, sessionID string) string { + if strings.TrimSpace(codexHome) == "" || strings.TrimSpace(sessionID) == "" { + return "" + } + + pattern := filepath.Join(codexHome, "sessions", "*", "*", "*", "rollout-*"+sessionID+".jsonl") + if matches, _ := filepath.Glob(pattern); len(matches) > 0 { + sort.Strings(matches) + return matches[len(matches)-1] + } + + sessionsDir := filepath.Join(codexHome, "sessions") + var found string + _ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() || found != "" { + return nil + } + if strings.Contains(filepath.Base(path), sessionID) { + found = path + } + return nil + }) + return found +} + +func readContextUsageFromRollout(path string) (*core.ContextUsage, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + if usage, err := readContextUsageFromRolloutTail(f); err != nil { + return nil, err + } else if usage != nil { + return usage, nil + } + + if _, err := f.Seek(0, io.SeekStart); err != nil { + return nil, err + } + return scanContextUsageFromRollout(f) +} + +func readContextUsageFromRolloutTail(f *os.File) (*core.ContextUsage, error) { + info, err := f.Stat() + if err != nil { + return nil, err + } + if info.Size() <= 0 { + return nil, nil + } + + start := int64(0) + if info.Size() > codexRolloutTailBytes { + start = info.Size() - codexRolloutTailBytes + } + buf := make([]byte, int(info.Size()-start)) + n, err := f.ReadAt(buf, start) + if err != nil && err != io.EOF { + return nil, err + } + buf = buf[:n] + if start > 0 { + if idx := bytes.IndexByte(buf, '\n'); idx >= 0 { + buf = buf[idx+1:] + } + } + return parseContextUsageFromRolloutBytes(buf), nil +} + +func parseContextUsageFromRolloutBytes(data []byte) *core.ContextUsage { + lines := bytes.Split(data, []byte{'\n'}) + for i := len(lines) - 1; i >= 0; i-- { + line := bytes.TrimSpace(lines[i]) + if len(line) == 0 { + continue + } + if usage := parseContextUsageFromRolloutLine(line); usage != nil { + return usage + } + } + return nil +} + +func scanContextUsageFromRollout(r io.Reader) (*core.ContextUsage, error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 64*1024), 2*1024*1024) + + var last *core.ContextUsage + for scanner.Scan() { + if usage := parseContextUsageFromRolloutLine(scanner.Bytes()); usage != nil { + last = usage + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return last, nil +} + +func parseContextUsageFromRolloutLine(line []byte) *core.ContextUsage { + var entry struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal(line, &entry); err != nil { + return nil + } + if entry.Type != "event_msg" { + return nil + } + + var payload struct { + Type string `json:"type"` + Info *struct { + TotalTokenUsage codexSnakeTokenUsage `json:"total_token_usage"` + LastTokenUsage codexSnakeTokenUsage `json:"last_token_usage"` + ModelContextWindow int `json:"model_context_window"` + } `json:"info"` + } + if err := json.Unmarshal(entry.Payload, &payload); err != nil { + return nil + } + if payload.Type != "token_count" || payload.Info == nil { + return nil + } + return contextUsageFromSnake(payload.Info.LastTokenUsage, payload.Info.ModelContextWindow) +} diff --git a/agent/codex/integration_test.go b/agent/codex/integration_test.go new file mode 100644 index 0000000..ae57713 --- /dev/null +++ b/agent/codex/integration_test.go @@ -0,0 +1,109 @@ +package codex + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestIntegration_CodexProviderFlow verifies the full provider config flow: +// 1. ensureCodexProviderConfig writes correct config.toml +// 2. ensureCodexAuth writes correct auth.json +// 3. Codex CLI can authenticate and respond using the written config +// +// Requires: SHENGSUANYUN_API_KEY env var and `codex` CLI in PATH. +// Skip with: go test ./agent/codex/ -run TestIntegration -v +func TestIntegration_CodexProviderFlow(t *testing.T) { + apiKey := os.Getenv("SHENGSUANYUN_API_KEY") + if apiKey == "" { + t.Skip("SHENGSUANYUN_API_KEY not set, skipping integration test") + } + if _, err := exec.LookPath("codex"); err != nil { + t.Skip("codex CLI not in PATH, skipping integration test") + } + + home := filepath.Join(t.TempDir(), ".codex") + + // Step 1: write config.toml via our function + err := ensureCodexProviderConfig(home, "shengsuanyun-codex", + "https://router.shengsuanyun.com/api/v1", "responses", nil) + if err != nil { + t.Fatalf("ensureCodexProviderConfig: %v", err) + } + + // Verify config.toml content + cfgData, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatalf("read config.toml: %v", err) + } + cfgContent := string(cfgData) + t.Logf("Generated config.toml:\n%s", cfgContent) + + for _, want := range []string{ + `[model_providers.shengsuanyun-codex]`, + `base_url = "https://router.shengsuanyun.com/api/v1"`, + `wire_api = "responses"`, + `env_key = "OPENAI_API_KEY"`, + } { + if !strings.Contains(cfgContent, want) { + t.Errorf("config.toml missing: %s", want) + } + } + + // Step 2: write auth.json via our function + err = ensureCodexAuth(home, apiKey) + if err != nil { + t.Fatalf("ensureCodexAuth: %v", err) + } + + // Verify auth.json content + authData, err := os.ReadFile(filepath.Join(home, "auth.json")) + if err != nil { + t.Fatalf("read auth.json: %v", err) + } + var authMap map[string]any + if err := json.Unmarshal(authData, &authMap); err != nil { + t.Fatalf("parse auth.json: %v", err) + } + if authMap["auth_mode"] != "apikey" { + t.Errorf("auth_mode = %v, want apikey", authMap["auth_mode"]) + } + if authMap["OPENAI_API_KEY"] != apiKey { + t.Errorf("OPENAI_API_KEY not set correctly in auth.json") + } + + // Step 3: run codex exec with the generated config + workDir := filepath.Join(t.TempDir(), "repo") + os.MkdirAll(workDir, 0o755) + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = workDir + if err := gitInit.Run(); err != nil { + t.Fatalf("git init: %v", err) + } + + cmd := exec.Command("codex", "exec", + "--model", "openai/gpt-5.3-codex", + "-c", `model_provider="shengsuanyun-codex"`, + "-c", `openai_base_url="https://router.shengsuanyun.com/api/v1"`, + "--full-auto", + ) + cmd.Dir = workDir + cmd.Env = append(os.Environ(), + "CODEX_HOME="+home, + "OPENAI_API_KEY="+apiKey, + ) + cmd.Stdin = strings.NewReader("reply with exactly 'integration-ok' and nothing else") + + output, err := cmd.CombinedOutput() + t.Logf("Codex output:\n%s", string(output)) + + if err != nil { + t.Fatalf("codex exec failed: %v\noutput: %s", err, output) + } + if !strings.Contains(string(output), "integration-ok") { + t.Errorf("expected 'integration-ok' in output, got:\n%s", output) + } +} diff --git a/agent/codex/list.go b/agent/codex/list.go new file mode 100644 index 0000000..7c00cd3 --- /dev/null +++ b/agent/codex/list.go @@ -0,0 +1,321 @@ +package codex + +import ( + "bufio" + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// resolveCodexHomeDir returns the effective CODEX_HOME directory. +// Priority: explicit config value > CODEX_HOME env > ~/.codex +func resolveCodexHomeDir(explicit string) string { + if h := strings.TrimSpace(explicit); h != "" { + return h + } + if h := os.Getenv("CODEX_HOME"); h != "" { + return h + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".codex") +} + +// listCodexSessions scans the codex sessions directory for JSONL transcript +// files whose cwd matches workDir. +func listCodexSessions(workDir, codexHome string) ([]core.AgentSessionInfo, error) { + absWorkDir, err := filepath.Abs(workDir) + if err != nil { + absWorkDir = workDir + } + + sessionsDir := filepath.Join(resolveCodexHomeDir(codexHome), "sessions") + + var files []string + _ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if strings.HasSuffix(path, ".jsonl") { + files = append(files, path) + } + return nil + }) + + if len(files) == 0 { + return nil, nil + } + + var sessions []core.AgentSessionInfo + for _, f := range files { + info := parseCodexSessionFile(f, absWorkDir) + if info != nil { + patchSessionSource(info.ID, codexHome) + sessions = append(sessions, *info) + } + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + + return sessions, nil +} + +// parseCodexSessionFile reads a Codex JSONL transcript. +// Returns nil if the session's cwd doesn't match filterCwd. +func parseCodexSessionFile(path, filterCwd string) *core.AgentSessionInfo { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return nil + } + + var sessionID string + var sessionCwd string + var summary string + var msgCount int + userMsgSeen := 0 + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var entry struct { + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + if err := json.Unmarshal([]byte(line), &entry); err != nil { + continue + } + + switch entry.Type { + case "session_meta": + var meta struct { + ID string `json:"id"` + Cwd string `json:"cwd"` + } + if json.Unmarshal(entry.Payload, &meta) == nil { + sessionID = meta.ID + sessionCwd = meta.Cwd + } + + case "response_item": + var item struct { + Role string `json:"role"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(entry.Payload, &item) == nil { + if item.Role == "user" { + userMsgSeen++ + msgCount++ + // The actual user prompt is the last user response_item + // (earlier ones are system/AGENTS.md instructions). + // Pick the last content block that looks like a real prompt. + for _, c := range item.Content { + if c.Type == "input_text" && c.Text != "" && isUserPrompt(c.Text) { + summary = c.Text + } + } + } else if item.Role == "assistant" { + msgCount++ + } + } + } + } + + // Filter by cwd + if filterCwd != "" && sessionCwd != "" && sessionCwd != filterCwd { + return nil + } + + if sessionID == "" { + return nil + } + + if len([]rune(summary)) > 60 { + summary = string([]rune(summary)[:60]) + "..." + } + + return &core.AgentSessionInfo{ + ID: sessionID, + Summary: summary, + MessageCount: msgCount, + ModifiedAt: stat.ModTime(), + } +} + +// findSessionFile locates the JSONL transcript for a given session ID. +func findSessionFile(sessionID, codexHome string) string { + sessionsDir := filepath.Join(resolveCodexHomeDir(codexHome), "sessions") + + var found string + _ = filepath.Walk(sessionsDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || found != "" { + return nil + } + if strings.Contains(filepath.Base(path), sessionID) { + found = path + } + return nil + }) + return found +} + +// getSessionHistory reads the JSONL transcript and returns user/assistant messages. +func getSessionHistory(sessionID, codexHome string, limit int) ([]core.HistoryEntry, error) { + path := findSessionFile(sessionID, codexHome) + if path == "" { + return nil, fmt.Errorf("session file not found for %s", sessionID) + } + + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var entries []core.HistoryEntry + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var raw struct { + Timestamp string `json:"timestamp"` + Type string `json:"type"` + Payload json.RawMessage `json:"payload"` + } + if json.Unmarshal([]byte(line), &raw) != nil { + continue + } + if raw.Type != "response_item" { + continue + } + + var item struct { + Role string `json:"role"` + Type string `json:"type"` + Text string `json:"text"` + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + } + if json.Unmarshal(raw.Payload, &item) != nil { + continue + } + + ts, _ := time.Parse(time.RFC3339Nano, raw.Timestamp) + + switch { + case item.Role == "user" && len(item.Content) > 0: + for _, c := range item.Content { + if c.Type == "input_text" && c.Text != "" && isUserPrompt(c.Text) { + entries = append(entries, core.HistoryEntry{ + Role: "user", Content: c.Text, Timestamp: ts, + }) + } + } + case item.Role == "assistant" && len(item.Content) > 0: + for _, c := range item.Content { + if c.Type == "output_text" && c.Text != "" { + entries = append(entries, core.HistoryEntry{ + Role: "assistant", Content: c.Text, Timestamp: ts, + }) + } + } + case item.Type == "reasoning" && item.Text != "": + // skip reasoning items + } + } + + if limit > 0 && len(entries) > limit { + entries = entries[len(entries)-limit:] + } + return entries, nil +} + +// patchSessionSource rewrites the session_meta line in a Codex JSONL transcript +// so that source="cli" and originator="codex_cli_rs", making the session visible +// in the interactive `codex` terminal. +func patchSessionSource(sessionID, codexHome string) { + path := findSessionFile(sessionID, codexHome) + if path == "" { + return + } + + data, err := os.ReadFile(path) + if err != nil { + return + } + + idx := bytes.IndexByte(data, '\n') + if idx < 0 { + return + } + firstLine := data[:idx] + + // Only patch if it's actually an exec-sourced session + if !bytes.Contains(firstLine, []byte(`"source":"exec"`)) { + return + } + + patched := bytes.Replace(firstLine, []byte(`"source":"exec"`), []byte(`"source":"cli"`), 1) + patched = bytes.Replace(patched, []byte(`"originator":"codex_exec"`), []byte(`"originator":"codex_cli_rs"`), 1) + + if bytes.Equal(patched, firstLine) { + return + } + + out := make([]byte, 0, len(patched)+len(data)-idx) + out = append(out, patched...) + out = append(out, data[idx:]...) + + _ = os.WriteFile(path, out, 0o644) +} + +// isUserPrompt returns true if the text looks like an actual user prompt +// rather than system context (AGENTS.md, environment_context, permissions, etc.) +func isUserPrompt(text string) bool { + t := strings.TrimSpace(text) + if t == "" { + return false + } + // Skip XML-style system context + if strings.HasPrefix(t, "<") { + return false + } + // Skip AGENTS.md instructions injected by Codex + if strings.HasPrefix(t, "# AGENTS.md") || strings.HasPrefix(t, "#AGENTS.md") { + return false + } + return true +} diff --git a/agent/codex/patch_test.go b/agent/codex/patch_test.go new file mode 100644 index 0000000..9ea8db5 --- /dev/null +++ b/agent/codex/patch_test.go @@ -0,0 +1,75 @@ +package codex + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPatchSessionSource(t *testing.T) { + tmpDir := t.TempDir() + + sessionID := "test-session-abc123" + sessionsDir := filepath.Join(tmpDir, ".codex", "sessions") + if err := os.MkdirAll(sessionsDir, 0o755); err != nil { + t.Fatal(err) + } + + fname := filepath.Join(sessionsDir, "rollout-"+sessionID+".jsonl") + line1 := `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","source":"exec","originator":"codex_exec","cwd":"/tmp"}}` + line2 := `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"role":"user"}}` + content := line1 + "\n" + line2 + "\n" + + if err := os.WriteFile(fname, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + codexHome := filepath.Join(tmpDir, ".codex") + + patchSessionSource(sessionID, codexHome) + + data, err := os.ReadFile(fname) + if err != nil { + t.Fatal(err) + } + + lines := strings.SplitN(string(data), "\n", 2) + + if !strings.Contains(lines[0], `"source":"cli"`) { + t.Errorf("expected source:cli, got first line: %s", lines[0]) + } + if !strings.Contains(lines[0], `"originator":"codex_cli_rs"`) { + t.Errorf("expected originator:codex_cli_rs, got first line: %s", lines[0]) + } + if strings.Contains(lines[0], `"source":"exec"`) { + t.Error("source:exec was not replaced") + } + + // Second line should be untouched + if !strings.HasPrefix(lines[1], `{"timestamp":"2026-01-01T00:00:01Z"`) { + t.Errorf("second line was corrupted: %s", lines[1]) + } +} + +func TestPatchSessionSource_Idempotent(t *testing.T) { + tmpDir := t.TempDir() + sessionID := "test-idempotent-xyz" + sessionsDir := filepath.Join(tmpDir, ".codex", "sessions") + os.MkdirAll(sessionsDir, 0o755) + + fname := filepath.Join(sessionsDir, "rollout-"+sessionID+".jsonl") + line1 := `{"type":"session_meta","payload":{"id":"` + sessionID + `","source":"cli","originator":"codex_cli_rs"}}` + if err := os.WriteFile(fname, []byte(line1+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + codexHome := filepath.Join(tmpDir, ".codex") + + patchSessionSource(sessionID, codexHome) + + data, _ := os.ReadFile(fname) + if string(data) != line1+"\n" { + t.Errorf("file was modified when it shouldn't have been") + } +} diff --git a/agent/codex/proc_unix.go b/agent/codex/proc_unix.go new file mode 100644 index 0000000..6b1b74c --- /dev/null +++ b/agent/codex/proc_unix.go @@ -0,0 +1,30 @@ +//go:build unix + +package codex + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func prepareCmdForKill(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +func forceKillCmd(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, os.ErrProcessDone) && !errors.Is(err, syscall.ESRCH) { + return err + } + return nil +} diff --git a/agent/codex/proc_windows.go b/agent/codex/proc_windows.go new file mode 100644 index 0000000..d63ab5d --- /dev/null +++ b/agent/codex/proc_windows.go @@ -0,0 +1,54 @@ +//go:build windows + +package codex + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +func prepareCmdForKill(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP +} + +func forceKillCmd(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + killCmd := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)) + output, err := killCmd.CombinedOutput() + if err == nil { + return nil + } + if bytes.Contains(bytes.ToLower(output), []byte("there is no running instance")) { + return nil + } + if bytes.Contains(bytes.ToLower(output), []byte("not found")) { + return nil + } + if killErr := cmd.Process.Kill(); killErr == nil || errors.Is(killErr, os.ErrProcessDone) { + return nil + } else { + return fmt.Errorf("taskkill failed: %w: %s; process kill fallback failed: %w", err, processKillOutput(output), killErr) + } +} + +func processKillOutput(output []byte) string { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return "(empty output)" + } + return trimmed +} diff --git a/agent/codex/project_env_test.go b/agent/codex/project_env_test.go new file mode 100644 index 0000000..711a950 --- /dev/null +++ b/agent/codex/project_env_test.go @@ -0,0 +1,98 @@ +package codex + +import ( + "testing" +) + +// TestNew_ParsesProjectEnvFromOpts verifies that env vars declared under +// [projects.agent.options.env] in config.toml are loaded into the agent's +// configEnv field. Without this, user-scoped env (e.g. HTTPS_PROXY in the +// shell that launched cc-connect) silently overrides the values intended +// for the codex subprocess. +// +// Regression for: codex agent ignoring opts["env"] in factory. +func TestNew_ParsesProjectEnvFromOpts(t *testing.T) { + // Use "go" as cliBin to satisfy exec.LookPath without requiring codex + // to be installed on the test runner. + opts := map[string]any{ + "work_dir": t.TempDir(), + "cli_path": "go", + "env": map[string]string{ + "HTTPS_PROXY": "http://127.0.0.1:10808", + "HTTP_PROXY": "http://127.0.0.1:10808", + "ALL_PROXY": "http://127.0.0.1:10808", + }, + } + + a, err := New(opts) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + agent := a.(*Agent) + agent.mu.RLock() + got := envSliceToMap(agent.configEnv) + agent.mu.RUnlock() + + if len(got) != 3 { + t.Fatalf("expected 3 env vars, got %d: %v", len(got), agent.configEnv) + } + if v := got["HTTPS_PROXY"]; v != "http://127.0.0.1:10808" { + t.Errorf("HTTPS_PROXY = %q, want http://127.0.0.1:10808", v) + } + if v := got["ALL_PROXY"]; v != "http://127.0.0.1:10808" { + t.Errorf("ALL_PROXY = %q, want http://127.0.0.1:10808", v) + } +} + +// TestNew_ParsesProjectEnvFromMapStringAny covers the TOML decoder path +// where the env table arrives as map[string]any rather than map[string]string. +func TestNew_ParsesProjectEnvFromMapStringAny(t *testing.T) { + opts := map[string]any{ + "work_dir": t.TempDir(), + "cli_path": "go", + "env": map[string]any{ + "OPENAI_BASE_URL": "https://api.example.com/v1", + "CUSTOM_FLAG": "yes", + }, + } + + a, err := New(opts) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + agent := a.(*Agent) + agent.mu.RLock() + got := envSliceToMap(agent.configEnv) + agent.mu.RUnlock() + + if v := got["OPENAI_BASE_URL"]; v != "https://api.example.com/v1" { + t.Errorf("OPENAI_BASE_URL = %q", v) + } + if v := got["CUSTOM_FLAG"]; v != "yes" { + t.Errorf("CUSTOM_FLAG = %q", v) + } +} + +// TestNew_NoEnvOpts ensures the absence of an env block produces an empty +// configEnv slice (no panics, no surprise inheritance). +func TestNew_NoEnvOpts(t *testing.T) { + opts := map[string]any{ + "work_dir": t.TempDir(), + "cli_path": "go", + } + + a, err := New(opts) + if err != nil { + t.Fatalf("New() error: %v", err) + } + + agent := a.(*Agent) + agent.mu.RLock() + defer agent.mu.RUnlock() + + if len(agent.configEnv) != 0 { + t.Fatalf("expected 0 env vars, got %d: %v", len(agent.configEnv), agent.configEnv) + } +} diff --git a/agent/codex/provider_config.go b/agent/codex/provider_config.go new file mode 100644 index 0000000..521b4a3 --- /dev/null +++ b/agent/codex/provider_config.go @@ -0,0 +1,136 @@ +package codex + +import ( + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" +) + +// ensureCodexProviderConfig writes or updates a [model_providers.] section +// in $CODEX_HOME/config.toml so that Codex CLI can use the provider's wire_api +// and http_headers settings. +func ensureCodexProviderConfig(codexHome, name, baseURL, wireAPI string, headers map[string]string) error { + if name == "" { + return nil + } + home, err := resolveCodexHomeForConfig(codexHome) + if err != nil { + return fmt.Errorf("codex: resolve codex home: %w", err) + } + if err := os.MkdirAll(home, 0o755); err != nil { + return fmt.Errorf("codex: mkdir codex home: %w", err) + } + + cfgPath := filepath.Join(home, "config.toml") + raw, _ := os.ReadFile(cfgPath) + content := string(raw) + + section := buildProviderSection(name, baseURL, wireAPI, headers) + updated := upsertProviderSection(content, name, section) + + if err := os.WriteFile(cfgPath, []byte(updated), 0o644); err != nil { + return fmt.Errorf("codex: write config.toml: %w", err) + } + slog.Debug("codex: wrote provider config", "provider", name, "path", cfgPath) + return nil +} + +// ensureCodexAuth writes $CODEX_HOME/auth.json with the provider's API key, +// matching cc-switch's approach: {"OPENAI_API_KEY": "...", "auth_mode": "api_key"}. +// This is the standard way to authenticate Codex CLI with third-party providers. +func ensureCodexAuth(codexHome, apiKey string) error { + if apiKey == "" { + return nil + } + home, err := resolveCodexHomeForConfig(codexHome) + if err != nil { + return fmt.Errorf("codex: resolve codex home: %w", err) + } + if err := os.MkdirAll(home, 0o755); err != nil { + return fmt.Errorf("codex: mkdir codex home: %w", err) + } + + authPath := filepath.Join(home, "auth.json") + payload := map[string]any{ + "OPENAI_API_KEY": apiKey, + "auth_mode": "apikey", + } + data, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return fmt.Errorf("codex: marshal auth.json: %w", err) + } + if err := os.WriteFile(authPath, append(data, '\n'), 0o600); err != nil { + return fmt.Errorf("codex: write auth.json: %w", err) + } + slog.Debug("codex: wrote auth.json", "path", authPath) + return nil +} + +func resolveCodexHomeForConfig(explicit string) (string, error) { + if h := strings.TrimSpace(explicit); h != "" { + return h, nil + } + if h := strings.TrimSpace(os.Getenv("CODEX_HOME")); h != "" { + return h, nil + } + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".codex"), nil +} + +func buildProviderSection(name, baseURL, wireAPI string, headers map[string]string) string { + var sb strings.Builder + fmt.Fprintf(&sb, "[model_providers.%s]\n", name) + fmt.Fprintf(&sb, "name = %q\n", name) + if baseURL != "" { + fmt.Fprintf(&sb, "base_url = %q\n", baseURL) + } + fmt.Fprintf(&sb, "env_key = %q\n", "OPENAI_API_KEY") + if wireAPI != "" { + fmt.Fprintf(&sb, "wire_api = %q\n", wireAPI) + } + if len(headers) > 0 { + fmt.Fprintf(&sb, "\n[model_providers.%s.http_headers]\n", name) + for k, v := range headers { + fmt.Fprintf(&sb, "%q = %q\n", k, v) + } + } + return sb.String() +} + +// upsertProviderSection replaces an existing [model_providers.] section +// or appends a new one at the end of the config content. +func upsertProviderSection(content, name, newSection string) string { + sectionHeader := fmt.Sprintf("[model_providers.%s]", name) + subSectionPrefix := fmt.Sprintf("[model_providers.%s.", name) + + if !strings.Contains(content, sectionHeader) { + trimmed := strings.TrimRight(content, "\n\t ") + if trimmed == "" { + return newSection + } + return trimmed + "\n\n" + newSection + } + + idx := strings.Index(content, sectionHeader) + + after := content[idx+len(sectionHeader):] + end := len(content) + lines := strings.Split(after, "\n") + offset := idx + len(sectionHeader) + for _, line := range lines { + offset += len(line) + 1 + trimmed := strings.TrimSpace(line) + if len(trimmed) > 0 && trimmed[0] == '[' && !strings.HasPrefix(trimmed, subSectionPrefix) && trimmed != sectionHeader { + end = offset - len(line) - 1 + break + } + } + + return strings.TrimRight(content[:idx], "\n") + "\n\n" + newSection + "\n" + content[end:] +} diff --git a/agent/codex/provider_config_test.go b/agent/codex/provider_config_test.go new file mode 100644 index 0000000..f1561c9 --- /dev/null +++ b/agent/codex/provider_config_test.go @@ -0,0 +1,215 @@ +package codex + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureCodexProviderConfig_CreatesNewFile(t *testing.T) { + home := filepath.Join(t.TempDir(), ".codex") + + err := ensureCodexProviderConfig(home, "shengsuanyun", + "https://router.shengsuanyun.com/api/v1", "responses", + map[string]string{"HTTP-Referer": "https://openai.com/zh-Hans-CN/codex/", "X-Title": "CodeX"}) + if err != nil { + t.Fatalf("ensureCodexProviderConfig: %v", err) + } + + data, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatalf("read config.toml: %v", err) + } + content := string(data) + + for _, want := range []string{ + `[model_providers.shengsuanyun]`, + `env_key = "OPENAI_API_KEY"`, + `wire_api = "responses"`, + `base_url = "https://router.shengsuanyun.com/api/v1"`, + `[model_providers.shengsuanyun.http_headers]`, + `"HTTP-Referer" = "https://openai.com/zh-Hans-CN/codex/"`, + `"X-Title" = "CodeX"`, + } { + if !strings.Contains(content, want) { + t.Errorf("config.toml missing %q\ngot:\n%s", want, content) + } + } +} + +func TestEnsureCodexProviderConfig_UpdatesExistingSection(t *testing.T) { + home := filepath.Join(t.TempDir(), ".codex") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatal(err) + } + + initial := `model = "gpt-5.4" + +[model_providers.shengsuanyun] +name = "shengsuanyun" +env_key = "OLD_KEY" +wire_api = "chat" + +[some_other_section] +key = "value" +` + if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + err := ensureCodexProviderConfig(home, "shengsuanyun", + "https://router.shengsuanyun.com/api/v1", "responses", nil) + if err != nil { + t.Fatalf("ensureCodexProviderConfig: %v", err) + } + + data, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatal(err) + } + content := string(data) + + if !strings.Contains(content, `env_key = "OPENAI_API_KEY"`) { + t.Errorf("updated config missing new env_key\ngot:\n%s", content) + } + if strings.Contains(content, `env_key = "OLD_KEY"`) { + t.Errorf("updated config still has old env_key\ngot:\n%s", content) + } + if !strings.Contains(content, `wire_api = "responses"`) { + t.Errorf("updated config missing new wire_api\ngot:\n%s", content) + } + if !strings.Contains(content, `[some_other_section]`) { + t.Errorf("updated config lost other section\ngot:\n%s", content) + } + if !strings.Contains(content, `model = "gpt-5.4"`) { + t.Errorf("updated config lost top-level key\ngot:\n%s", content) + } +} + +func TestEnsureCodexProviderConfig_DefaultEnvKey(t *testing.T) { + home := filepath.Join(t.TempDir(), ".codex") + + err := ensureCodexProviderConfig(home, "dmxapi", "https://www.dmxapi.cn/v1", "responses", nil) + if err != nil { + t.Fatalf("ensureCodexProviderConfig: %v", err) + } + + data, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatal(err) + } + content := string(data) + + if !strings.Contains(content, `env_key = "OPENAI_API_KEY"`) { + t.Errorf("config should contain default env_key OPENAI_API_KEY\ngot:\n%s", content) + } + if strings.Contains(content, "requires_openai_auth") { + t.Errorf("config should NOT contain requires_openai_auth\ngot:\n%s", content) + } +} + +func TestEnsureCodexProviderConfig_PreservesOtherProviders(t *testing.T) { + home := filepath.Join(t.TempDir(), ".codex") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatal(err) + } + + initial := `[model_providers.other] +name = "other" +env_key = "OTHER_KEY" + +[model_providers.other.http_headers] +"X-Custom" = "val" +` + if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(initial), 0o644); err != nil { + t.Fatal(err) + } + + err := ensureCodexProviderConfig(home, "shengsuanyun", "", "responses", nil) + if err != nil { + t.Fatalf("ensureCodexProviderConfig: %v", err) + } + + data, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatal(err) + } + content := string(data) + + if !strings.Contains(content, `[model_providers.other]`) { + t.Errorf("lost other provider section\ngot:\n%s", content) + } + if !strings.Contains(content, `[model_providers.shengsuanyun]`) { + t.Errorf("new provider not added\ngot:\n%s", content) + } +} + +func TestEnsureCodexProviderConfig_SkipsWhenEmpty(t *testing.T) { + err := ensureCodexProviderConfig("", "", "", "", nil) + if err != nil { + t.Fatalf("unexpected error for empty name: %v", err) + } +} + +func TestEnsureCodexAuth_WritesAuthJSON(t *testing.T) { + home := filepath.Join(t.TempDir(), ".codex") + + err := ensureCodexAuth(home, "sk-test-key-123") + if err != nil { + t.Fatalf("ensureCodexAuth: %v", err) + } + + data, err := os.ReadFile(filepath.Join(home, "auth.json")) + if err != nil { + t.Fatalf("read auth.json: %v", err) + } + content := string(data) + + for _, want := range []string{ + `"OPENAI_API_KEY": "sk-test-key-123"`, + `"auth_mode": "apikey"`, + } { + if !strings.Contains(content, want) { + t.Errorf("auth.json missing %q\ngot:\n%s", want, content) + } + } +} + +func TestEnsureCodexAuth_SkipsEmptyKey(t *testing.T) { + err := ensureCodexAuth(t.TempDir(), "") + if err != nil { + t.Fatalf("unexpected error for empty key: %v", err) + } +} + +func TestEnsureCodexAuth_OverwritesExisting(t *testing.T) { + home := filepath.Join(t.TempDir(), ".codex") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(home, "auth.json"), []byte(`{"auth_mode":"chatgpt"}`), 0o644); err != nil { + t.Fatal(err) + } + + err := ensureCodexAuth(home, "new-api-key") + if err != nil { + t.Fatalf("ensureCodexAuth: %v", err) + } + + data, err := os.ReadFile(filepath.Join(home, "auth.json")) + if err != nil { + t.Fatal(err) + } + content := string(data) + + if strings.Contains(content, "chatgpt") { + t.Errorf("auth.json still has old auth_mode\ngot:\n%s", content) + } + if !strings.Contains(content, `"auth_mode": "apikey"`) { + t.Errorf("auth.json missing apikey mode\ngot:\n%s", content) + } + if !strings.Contains(content, `"OPENAI_API_KEY": "new-api-key"`) { + t.Errorf("auth.json missing new key\ngot:\n%s", content) + } +} diff --git a/agent/codex/provider_switch_test.go b/agent/codex/provider_switch_test.go new file mode 100644 index 0000000..73f50f8 --- /dev/null +++ b/agent/codex/provider_switch_test.go @@ -0,0 +1,358 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/config" + "github.com/chenhg5/cc-connect/core" +) + +func skipIfNoConfig(t *testing.T) *config.Config { + t.Helper() + if os.Getenv("CC_SKIP_INTEGRATION") == "1" { + t.Skip("CC_SKIP_INTEGRATION=1") + } + if os.Getenv("CC_RUN_PROVIDER_INTEGRATION") != "1" { + t.Skip("set CC_RUN_PROVIDER_INTEGRATION=1 to run provider integration tests") + } + cfgPath := os.ExpandEnv("$HOME/.cc-connect/config.toml") + if _, err := os.Stat(cfgPath); os.IsNotExist(err) { + t.Skipf("config not found at %s", cfgPath) + } + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + cfg.ResolveProviderRefs() + return cfg +} + +func configToCoreProv(p config.ProviderConfig) core.ProviderConfig { + cp := core.ProviderConfig{ + Name: p.Name, + APIKey: p.APIKey, + BaseURL: p.BaseURL, + Model: p.Model, + Env: p.Env, + } + if p.Codex != nil { + cp.CodexWireAPI = p.Codex.WireAPI + cp.CodexHTTPHeaders = p.Codex.HTTPHeaders + } + for _, m := range p.Models { + cp.Models = append(cp.Models, core.ModelOption{Name: m.Model}) + } + return cp +} + +func envSliceToMap(env []string) map[string]string { + out := make(map[string]string, len(env)) + for _, entry := range env { + key, value, ok := strings.Cut(entry, "=") + if !ok { + continue + } + out[key] = value + } + return out +} + +func findCodexProject(cfg *config.Config) (name string, providers []core.ProviderConfig, workDir, codexHome string) { + for i := range cfg.Projects { + proj := &cfg.Projects[i] + if proj.Agent.Type != "codex" || len(proj.Agent.Providers) == 0 { + continue + } + for _, p := range proj.Agent.Providers { + providers = append(providers, configToCoreProv(p)) + } + name = proj.Name + if wd, ok := proj.Agent.Options["work_dir"].(string); ok { + workDir = wd + } + if ch, ok := proj.Agent.Options["codex_home"].(string); ok { + codexHome = ch + } + return + } + return +} + +func TestIntegration_Codex_ProviderSwitch_EnvVars(t *testing.T) { + cfg := skipIfNoConfig(t) + + name, providers, _, _ := findCodexProject(cfg) + if name == "" { + t.Skip("no codex project with providers found") + } + if len(providers) < 2 { + t.Skipf("project %q has only %d provider(s), need at least 2", name, len(providers)) + } + + a := &Agent{ + providers: providers, + activeIdx: -1, + } + + p0, p1 := providers[0], providers[1] + t.Logf("provider[0]: name=%s model=%s base_url=%s", p0.Name, p0.Model, p0.BaseURL) + t.Logf("provider[1]: name=%s model=%s base_url=%s", p1.Name, p1.Model, p1.BaseURL) + + a.SetActiveProvider(p0.Name) + a.mu.RLock() + env0 := envSliceToMap(a.providerEnvLocked()) + a.mu.RUnlock() + + if p0.BaseURL != "" { + if got := env0["OPENAI_BASE_URL"]; got != p0.BaseURL { + t.Errorf("provider[0] OPENAI_BASE_URL = %q, want %q", got, p0.BaseURL) + } + } + if p0.APIKey != "" { + if got := env0["OPENAI_API_KEY"]; got == "" { + t.Error("provider[0] OPENAI_API_KEY not set") + } + } + + a.SetActiveProvider(p1.Name) + a.mu.RLock() + env1 := envSliceToMap(a.providerEnvLocked()) + a.mu.RUnlock() + + if p1.BaseURL != "" { + if got := env1["OPENAI_BASE_URL"]; got != p1.BaseURL { + t.Errorf("provider[1] OPENAI_BASE_URL = %q, want %q", got, p1.BaseURL) + } + } + if p1.APIKey != "" { + if got := env1["OPENAI_API_KEY"]; got == "" { + t.Error("provider[1] OPENAI_API_KEY not set") + } + } + + if p0.BaseURL != p1.BaseURL { + if env0["OPENAI_BASE_URL"] == env1["OPENAI_BASE_URL"] { + t.Error("providers have different base_urls but OPENAI_BASE_URL didn't change after switch") + } + } +} + +func TestIntegration_Codex_ProviderSwitch_SessionArgs(t *testing.T) { + cfg := skipIfNoConfig(t) + + name, providers, workDir, codexHome := findCodexProject(cfg) + if name == "" { + t.Skip("no codex project with providers found") + } + if workDir == "" { + workDir = t.TempDir() + } + + for _, prov := range providers { + t.Run(prov.Name, func(t *testing.T) { + a := &Agent{ + model: "default-model", + providers: providers, + activeIdx: -1, + workDir: workDir, + codexHome: codexHome, + mode: "suggest", + backend: "exec", + } + a.SetActiveProvider(prov.Name) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sess, err := a.StartSession(ctx, "") + if err != nil { + t.Fatalf("StartSession failed: %v", err) + } + defer sess.Close() + + cs := sess.(*codexSession) + + if prov.Model != "" && cs.model != prov.Model { + t.Errorf("session model = %q, want %q", cs.model, prov.Model) + } + if prov.BaseURL != "" && cs.baseURL != prov.BaseURL { + t.Errorf("session baseURL = %q, want %q", cs.baseURL, prov.BaseURL) + } + + envMap := envSliceToMap(cs.extraEnv) + if prov.APIKey != "" { + if got := envMap["OPENAI_API_KEY"]; got == "" { + t.Error("OPENAI_API_KEY not set in session extraEnv") + } + } + if prov.BaseURL != "" { + if got := envMap["OPENAI_BASE_URL"]; got != prov.BaseURL { + t.Errorf("OPENAI_BASE_URL = %q, want %q", got, prov.BaseURL) + } + } + + t.Logf("session OK: model=%s baseURL=%s modelProvider=%s", + cs.model, cs.baseURL, cs.modelProvider) + }) + } +} + +func TestIntegration_Codex_ProviderConfig_WrittenCorrectly(t *testing.T) { + cfg := skipIfNoConfig(t) + + name, providers, _, _ := findCodexProject(cfg) + if name == "" { + t.Skip("no codex project with providers found") + } + + for _, prov := range providers { + t.Run(prov.Name, func(t *testing.T) { + if prov.BaseURL == "" || prov.APIKey == "" { + t.Skip("provider has no base_url or api_key") + } + + home := filepath.Join(t.TempDir(), ".codex") + + err := ensureCodexProviderConfig(home, prov.Name, prov.BaseURL, prov.CodexWireAPI, prov.CodexHTTPHeaders) + if err != nil { + t.Fatalf("ensureCodexProviderConfig failed: %v", err) + } + + cfgData, err := os.ReadFile(filepath.Join(home, "config.toml")) + if err != nil { + t.Fatalf("read config.toml: %v", err) + } + cfgContent := string(cfgData) + + if !strings.Contains(cfgContent, `[model_providers.`+prov.Name+`]`) { + t.Errorf("config.toml missing provider section for %s", prov.Name) + } + if !strings.Contains(cfgContent, prov.BaseURL) { + t.Errorf("config.toml missing base_url %s", prov.BaseURL) + } + + err = ensureCodexAuth(home, prov.APIKey) + if err != nil { + t.Fatalf("ensureCodexAuth failed: %v", err) + } + + authData, err := os.ReadFile(filepath.Join(home, "auth.json")) + if err != nil { + t.Fatalf("read auth.json: %v", err) + } + var authMap map[string]any + if err := json.Unmarshal(authData, &authMap); err != nil { + t.Fatalf("parse auth.json: %v", err) + } + if authMap["OPENAI_API_KEY"] != prov.APIKey { + t.Error("OPENAI_API_KEY not written correctly to auth.json") + } + + t.Logf("provider config written OK: name=%s wireAPI=%s", prov.Name, prov.CodexWireAPI) + }) + } +} + +func TestIntegration_Codex_ProviderSwitch_SendMessage(t *testing.T) { + cfg := skipIfNoConfig(t) + if _, err := exec.LookPath("codex"); err != nil { + t.Skip("codex CLI not in PATH") + } + + name, providers, workDir, codexHome := findCodexProject(cfg) + if name == "" { + t.Skip("no codex project with providers found") + } + if codexHome == "" { + home, _ := os.UserHomeDir() + codexHome = filepath.Join(home, ".codex") + } + + if workDir == "" { + workDir = t.TempDir() + gitInit := exec.Command("git", "init", "-q") + gitInit.Dir = workDir + if err := gitInit.Run(); err != nil { + t.Fatalf("git init: %v", err) + } + } + + for _, prov := range providers { + t.Run(prov.Name, func(t *testing.T) { + if prov.APIKey == "" || prov.BaseURL == "" { + t.Skip("provider missing api_key or base_url") + } + + a := &Agent{ + model: "default-model", + providers: providers, + activeIdx: -1, + workDir: workDir, + codexHome: codexHome, + mode: "full-auto", + backend: "exec", + } + a.SetActiveProvider(prov.Name) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + sess, err := a.StartSession(ctx, "") + if err != nil { + t.Fatalf("StartSession failed: %v", err) + } + defer sess.Close() + + err = sess.Send("reply with exactly 'codex-provider-ok' and nothing else", nil, nil) + if err != nil { + t.Fatalf("Send failed: %v", err) + } + + timer := time.NewTimer(25 * time.Second) + defer timer.Stop() + + var allText strings.Builder + for { + select { + case ev, ok := <-sess.Events(): + if !ok { + if allText.Len() > 0 && strings.Contains(allText.String(), "codex-provider-ok") { + t.Logf("provider %s: send+receive OK (from text events)", prov.Name) + return + } + t.Fatalf("event channel closed, collected text: %s", allText.String()) + return + } + t.Logf("event: type=%s content_len=%d", ev.Type, len(ev.Content)) + if ev.Type == core.EventText || ev.Type == core.EventResult { + allText.WriteString(ev.Content) + } + if ev.Type == core.EventResult { + if strings.Contains(allText.String(), "codex-provider-ok") { + t.Logf("provider %s: send+receive OK", prov.Name) + } else { + t.Errorf("expected 'codex-provider-ok' in output, got: %s", allText.String()) + } + return + } + if ev.Type == core.EventError { + t.Fatalf("agent error: %s", ev.Content) + } + case <-timer.C: + if strings.Contains(allText.String(), "codex-provider-ok") { + t.Logf("provider %s: send+receive OK (timeout but got result)", prov.Name) + return + } + t.Fatalf("timeout waiting for codex response, collected: %s", allText.String()) + } + } + }) + } +} diff --git a/agent/codex/session.go b/agent/codex/session.go new file mode 100644 index 0000000..d8b3139 --- /dev/null +++ b/agent/codex/session.go @@ -0,0 +1,945 @@ +package codex + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +// codexSession manages a multi-turn Codex conversation. +// First Send() uses `codex exec`, subsequent ones use `codex exec resume `. +type codexSession struct { + workDir string + model string + effort string + mode string + baseURL string // provider base URL; passed as -c openai_base_url= + modelProvider string // Codex model_provider name; passed as -c model_provider= + cliBin string // CLI binary, default "codex" + cliExtraArgs []string // extra args from cli_path, prepended before exec args + extraEnv []string + events chan core.Event + threadID atomic.Value // stores string — Codex thread_id + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + closeOnce sync.Once + cmdMu sync.Mutex + cmds map[*exec.Cmd]struct{} + + pendingMsgs []string // buffered agent_message texts awaiting classification + + runtimeCfgMu sync.Mutex + runtimeCfgModel string + runtimeCfgEffort string + runtimeCfgFetched time.Time + runtimeCfgFetchErr error + + contextMu sync.RWMutex + contextUsage *core.ContextUsage + sessionFile string +} + +var codexSessionCloseTimeout = 8 * time.Second +var codexSessionForceKillWait = 2 * time.Second +var codexRuntimeConfigCacheTTL = 5 * time.Second +var codexRuntimeConfigTimeout = 1500 * time.Millisecond +var codexContextUsageRetryDelay = 50 * time.Millisecond +var codexContextUsageRetryCount = 4 + +func newCodexSession(ctx context.Context, cliBin string, cliExtraArgs []string, workDir, model, effort, mode, resumeID, baseURL string, extraEnv []string, modelProvider string) (*codexSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + cs := &codexSession{ + workDir: workDir, + model: model, + effort: effort, + mode: mode, + baseURL: baseURL, + modelProvider: modelProvider, + cliBin: cliBin, + cliExtraArgs: cliExtraArgs, + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: sessionCtx, + cancel: cancel, + cmds: make(map[*exec.Cmd]struct{}), + } + cs.alive.Store(true) + + if resumeID != "" && resumeID != core.ContinueSession { + cs.threadID.Store(resumeID) + } + + return cs, nil +} + +// Send launches a codex subprocess. +// If a threadID exists (from a prior turn or resume), uses `codex exec resume `. +// Otherwise uses `codex exec ` to start a new conversation. +func (cs *codexSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if len(files) > 0 { + filePaths := core.SaveFilesToDisk(cs.workDir, files) + prompt = core.AppendFileRefs(prompt, filePaths) + } + if !cs.alive.Load() { + return fmt.Errorf("session is closed") + } + + prompt, imagePaths, err := cs.stageImages(prompt, images) + if err != nil { + return err + } + + isResume := cs.CurrentSessionID() != "" + args := cs.buildExecArgs(prompt, imagePaths) + if len(cs.cliExtraArgs) > 0 { + args = append(append([]string{}, cs.cliExtraArgs...), args...) + } + + bin := cs.cliBin + if bin == "" { + bin = "codex" + } + + slog.Debug("codexSession: launching", "resume", isResume, "args", core.RedactArgs(args)) + + cmd := exec.CommandContext(cs.ctx, bin, args...) + cmd.Dir = cs.workDir + prepareCmdForKill(cmd) + if len(cs.extraEnv) > 0 { + cmd.Env = core.MergeEnv(os.Environ(), cs.extraEnv) + } + cmd.Stdin = strings.NewReader(prompt) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("codexSession: stdout pipe: %w", err) + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + return fmt.Errorf("codexSession: start: %w", err) + } + cs.addCmd(cmd) + + cs.wg.Add(1) + go cs.readLoop(cmd, stdout, &stderrBuf) + + return nil +} + +func (cs *codexSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) { + if len(images) == 0 { + return prompt, nil, nil + } + + imgDir := filepath.Join(cs.workDir, ".cc-connect", "images") + if err := os.MkdirAll(imgDir, 0o755); err != nil { + return "", nil, fmt.Errorf("codexSession: create image dir: %w", err) + } + + imagePaths := make([]string, 0, len(images)) + for i, img := range images { + ext := codexImageExt(img.MimeType) + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(imgDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o644); err != nil { + return "", nil, fmt.Errorf("codexSession: save image: %w", err) + } + imagePaths = append(imagePaths, fpath) + } + + if strings.TrimSpace(prompt) == "" { + prompt = "Please analyze the attached image(s)." + } + + return prompt, imagePaths, nil +} + +func (cs *codexSession) buildExecArgs(prompt string, imagePaths []string) []string { + tid := cs.CurrentSessionID() + isResume := tid != "" + + var args []string + if isResume { + // For resume: codex exec resume ... [--image ...] --json --cd + // The codex CLI requires --json after the thread_id positional argument. + args = []string{"exec", "resume", "--skip-git-repo-check"} + } else { + args = []string{"exec", "--skip-git-repo-check"} + } + + switch cs.mode { + case "auto-edit", "full-auto": + args = append(args, "--full-auto") + case "yolo": + args = append(args, "--dangerously-bypass-approvals-and-sandbox") + } + + if cs.model != "" { + args = append(args, "--model", cs.model) + } + if cs.modelProvider != "" { + args = append(args, "-c", fmt.Sprintf("model_provider=%q", cs.modelProvider)) + } + if cs.baseURL != "" { + args = append(args, "-c", fmt.Sprintf("openai_base_url=%q", cs.baseURL)) + } + if cs.effort != "" { + args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", cs.effort)) + } + + if isResume { + args = append(args, tid) + for _, imagePath := range imagePaths { + args = append(args, "--image", imagePath) + } + // codex exec resume does not support --cd; cmd.Dir handles cwd instead. + // Use stdin ("-") so multiline prompts are preserved reliably on Windows. + args = append(args, "--json", "-") + } else { + for _, imagePath := range imagePaths { + args = append(args, "--image", imagePath) + } + args = append(args, "--json", "--cd", cs.workDir, "-") + } + return args +} + +func codexImageExt(mime string) string { + switch mime { + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + default: + return ".png" + } +} + +func (cs *codexSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) { + defer cs.wg.Done() + defer func() { + defer cs.removeCmd(cmd) + if err := cmd.Wait(); err != nil { + stderrMsg := strings.TrimSpace(stderrBuf.String()) + if stderrMsg != "" { + slog.Error("codexSession: process failed", "error", err, "stderr", stderrMsg) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + } + if tid := cs.CurrentSessionID(); tid != "" { + patchSessionSource(tid, getenvFromList(cs.extraEnv, "CODEX_HOME")) + } + }() + + if err := readJSONLines(stdout, func(line []byte) error { + lineText := string(line) + if lineText == "" { + return nil + } + + slog.Debug("codexSession: raw", "line", truncate(lineText, 500)) + + var raw map[string]any + if err := json.Unmarshal(line, &raw); err != nil { + slog.Debug("codexSession: non-JSON line", "line", lineText) + return nil + } + + cs.handleEvent(raw) + return nil + }); err != nil { + slog.Error("codexSession: read stdout error", "error", err) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } +} + +func readJSONLines(r io.Reader, handle func([]byte) error) error { + reader := bufio.NewReader(r) + + for { + line, err := reader.ReadBytes('\n') + if errors.Is(err, io.EOF) && len(line) == 0 { + return nil + } + if err != nil && !errors.Is(err, io.EOF) { + return err + } + + line = bytes.TrimRight(line, "\r\n") + if len(line) > 0 { + if err := handle(line); err != nil { + return err + } + } + + if errors.Is(err, io.EOF) { + return nil + } + } +} + +func (cs *codexSession) handleEvent(raw map[string]any) { + eventType, _ := raw["type"].(string) + + switch eventType { + case "thread.started": + if tid, ok := raw["thread_id"].(string); ok { + cs.threadID.Store(tid) + cs.contextMu.Lock() + cs.sessionFile = "" + cs.contextUsage = nil + cs.contextMu.Unlock() + slog.Debug("codexSession: thread started", "thread_id", tid) + } + + case "turn.started": + cs.pendingMsgs = cs.pendingMsgs[:0] + cs.contextMu.Lock() + cs.contextUsage = nil + cs.contextMu.Unlock() + slog.Debug("codexSession: turn started") + + case "item.started": + cs.handleItemStarted(raw) + + case "item.completed": + cs.handleItemCompleted(raw) + + case "turn.completed": + cs.refreshContextUsageFromRollout() + cs.flushPendingAsText() + evt := core.Event{Type: core.EventResult, SessionID: cs.CurrentSessionID(), Done: true} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + + case "turn.failed": + errMsg := "" + if errObj, ok := raw["error"].(map[string]any); ok { + errMsg, _ = errObj["message"].(string) + } + if errMsg == "" { + errMsg = "turn failed (no details)" + } + slog.Warn("codexSession: turn failed", "error", errMsg) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", errMsg)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + + case "error": + msg, _ := raw["message"].(string) + if strings.Contains(msg, "Reconnecting") || strings.Contains(msg, "Falling back") { + slog.Debug("codexSession: transient error", "message", msg) + } else { + slog.Warn("codexSession: error event", "message", msg) + } + + default: + slog.Debug("codexSession: unhandled event type", "type", eventType) + } +} + +// flushPendingAsThinking emits all buffered agent_messages as EventThinking. +func (cs *codexSession) flushPendingAsThinking() { + if cs.ctx.Err() != nil { + return + } + for _, text := range cs.pendingMsgs { + if cs.ctx.Err() != nil { + return + } + evt := core.Event{Type: core.EventThinking, Content: text} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + cs.pendingMsgs = cs.pendingMsgs[:0] +} + +// flushPendingAsText emits all buffered agent_messages as EventText (final response). +func (cs *codexSession) flushPendingAsText() { + if cs.ctx.Err() != nil { + return + } + for _, text := range cs.pendingMsgs { + if cs.ctx.Err() != nil { + return + } + evt := core.Event{Type: core.EventText, Content: text} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + cs.pendingMsgs = cs.pendingMsgs[:0] +} + +var codexToolNames = map[string]string{ + "web_search": "WebSearch", + "file_search": "FileSearch", + "code_interpreter": "CodeInterpreter", + "computer_use": "ComputerUse", + "mcp_tool": "MCP", +} + +func (cs *codexSession) handleItemStarted(raw map[string]any) { + item, ok := raw["item"].(map[string]any) + if !ok { + slog.Debug("codexSession: item.started missing item field") + return + } + itemType, _ := item["type"].(string) + slog.Debug("codexSession: item.started", "item_type", itemType) + + if itemType == "agent_message" || itemType == "message" || itemType == "reasoning" { + return + } + + // Any non-message item is a tool use; flush pending messages as thinking first. + cs.flushPendingAsThinking() + + switch itemType { + case "command_execution": + command, _ := item["command"].(string) + evt := core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: command} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + case "function_call": + name, _ := item["name"].(string) + args, _ := item["arguments"].(string) + evt := core.Event{Type: core.EventToolUse, ToolName: name, ToolInput: args} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + // Other tool types (web_search etc.) have empty fields at start; + // their EventToolUse is emitted from handleItemCompleted instead. +} + +func (cs *codexSession) handleItemCompleted(raw map[string]any) { + item, ok := raw["item"].(map[string]any) + if !ok { + slog.Debug("codexSession: item.completed missing item field") + return + } + itemType, _ := item["type"].(string) + slog.Debug("codexSession: item.completed", "item_type", itemType) + + switch itemType { + case "reasoning": + text := extractItemText(item, "summary", "summary_text") + if text != "" { + evt := core.Event{Type: core.EventThinking, Content: text} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + + case "agent_message", "message": + text := extractItemText(item, "content", "output_text") + if text != "" { + cs.pendingMsgs = append(cs.pendingMsgs, text) + } + + case "command_execution": + command, _ := item["command"].(string) + status, _ := item["status"].(string) + output, _ := item["aggregated_output"].(string) + exitCode, _ := item["exit_code"].(float64) + code := int(exitCode) + success := codexToolSuccess(status, &code) + + slog.Debug("codexSession: command completed", + "command", truncate(command, 100), + "status", status, + "exit_code", code, + "output_len", len(output), + ) + evt := core.Event{ + Type: core.EventToolResult, + ToolName: "Bash", + ToolResult: truncate(strings.TrimSpace(output), 500), + ToolStatus: strings.TrimSpace(status), + ToolExitCode: &code, + ToolSuccess: &success, + } + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + + case "function_call": + name, _ := item["name"].(string) + status, _ := item["status"].(string) + output, _ := item["output"].(string) + success := codexToolSuccess(status, nil) + slog.Debug("codexSession: function_call completed", + "name", name, "status", status, "output_len", len(output), + ) + evt := core.Event{ + Type: core.EventToolResult, + ToolName: name, + ToolResult: truncate(strings.TrimSpace(output), 500), + ToolStatus: strings.TrimSpace(status), + ToolSuccess: &success, + } + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + + case "function_call_output": + slog.Debug("codexSession: function_call_output") + + case "error": + msg, _ := item["message"].(string) + if msg != "" && !strings.Contains(msg, "Falling back") { + slog.Warn("codexSession: item error", "message", msg) + } + + default: + if toolName, known := codexToolNames[itemType]; known { + input := codexExtractToolInput(item) + evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } else { + slog.Debug("codexSession: unhandled item type", "item_type", itemType) + } + } +} + +// codexExtractToolInput extracts a human-readable input from a Codex tool item. +// For web_search, it reads action.queries[] or falls back to the top-level query. +func codexExtractToolInput(item map[string]any) string { + if action, ok := item["action"].(map[string]any); ok { + if queries, ok := action["queries"].([]any); ok && len(queries) > 0 { + var parts []string + for _, q := range queries { + if s, ok := q.(string); ok && s != "" { + parts = append(parts, s) + } + } + if len(parts) > 0 { + return strings.Join(parts, "\n") + } + } + if q, _ := action["query"].(string); q != "" { + return q + } + } + if q, _ := item["query"].(string); q != "" { + return q + } + if n, _ := item["name"].(string); n != "" { + return n + } + return "" +} + +func codexToolSuccess(status string, exitCode *int) bool { + s := strings.ToLower(strings.TrimSpace(status)) + if exitCode != nil { + return *exitCode == 0 + } + return s == "completed" || s == "success" || s == "succeeded" || s == "ok" +} + +func loadCodexRuntimeConfig(ctx context.Context, workDir string, extraEnv []string) (string, string, error) { + cmd := exec.CommandContext(ctx, "codex", "app-server") + cmd.Dir = workDir + prepareCmdForKill(cmd) + if len(extraEnv) > 0 { + cmd.Env = core.MergeEnv(os.Environ(), extraEnv) + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return "", "", fmt.Errorf("runtime config stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", "", fmt.Errorf("runtime config stdout pipe: %w", err) + } + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Start(); err != nil { + return "", "", fmt.Errorf("runtime config start app-server: %w", err) + } + defer func() { + _ = stdin.Close() + if cmd.Process != nil { + _ = cmd.Process.Kill() + } + _ = cmd.Wait() + }() + + reader := bufio.NewReader(stdout) + nextID := int64(1) + + if err := rpcRequestOverIO(stdin, reader, nextID, "initialize", map[string]any{ + "clientInfo": map[string]any{ + "name": "cc-connect-codex-runtime-config", + "title": "CC Connect Codex Runtime Config", + "version": "0.1.0", + }, + }, nil); err != nil { + return "", "", err + } + nextID++ + + if err := rpcNotifyOverIO(stdin, "initialized", map[string]any{}); err != nil { + return "", "", err + } + + var resp struct { + Config struct { + Model string `json:"model"` + ModelReasoningEffort *string `json:"model_reasoning_effort"` + } `json:"config"` + } + if err := rpcRequestOverIO(stdin, reader, nextID, "config/read", map[string]any{ + "includeLayers": false, + }, &resp); err != nil { + return "", "", err + } + + return strings.TrimSpace(resp.Config.Model), normalizeRuntimeReasoningEffort(stringValue(resp.Config.ModelReasoningEffort)), nil +} + +func rpcRequestOverIO(stdin io.Writer, reader *bufio.Reader, id int64, method string, params any, out any) error { + payload := map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + } + if err := writeRPCMessage(stdin, payload); err != nil { + return err + } + + for { + line, err := reader.ReadBytes('\n') + if err != nil { + return fmt.Errorf("%s read response: %w", method, err) + } + + var probe map[string]json.RawMessage + if err := json.Unmarshal(bytes.TrimSpace(line), &probe); err != nil { + continue + } + if _, ok := probe["id"]; !ok { + continue + } + + var resp rpcResponseEnvelope + if err := json.Unmarshal(bytes.TrimSpace(line), &resp); err != nil { + continue + } + respID, ok := rpcIDToInt64(resp.ID) + if !ok || respID != id { + continue + } + if resp.Error != nil { + return fmt.Errorf("%s: %s", method, strings.TrimSpace(resp.Error.Message)) + } + if out != nil { + if err := json.Unmarshal(resp.Result, out); err != nil { + return fmt.Errorf("%s decode response: %w", method, err) + } + } + return nil + } +} + +func rpcNotifyOverIO(stdin io.Writer, method string, params any) error { + payload := map[string]any{ + "jsonrpc": "2.0", + "method": method, + "params": params, + } + return writeRPCMessage(stdin, payload) +} + +func writeRPCMessage(w io.Writer, payload any) error { + b, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("encode rpc message: %w", err) + } + if _, err := w.Write(append(b, '\n')); err != nil { + return fmt.Errorf("write rpc message: %w", err) + } + return nil +} + +// RespondPermission is a no-op for Codex — permissions are handled via CLI flags. +func (cs *codexSession) RespondPermission(_ string, _ core.PermissionResult) error { + return nil +} + +func (cs *codexSession) Events() <-chan core.Event { + return cs.events +} + +func (cs *codexSession) CurrentSessionID() string { + v, _ := cs.threadID.Load().(string) + return v +} + +func (cs *codexSession) GetWorkDir() string { + return cs.workDir +} + +func (cs *codexSession) GetModel() string { + if model := strings.TrimSpace(cs.model); model != "" { + return model + } + model, _ := cs.runtimeConfig() + return model +} + +func (cs *codexSession) GetReasoningEffort() string { + if effort := strings.TrimSpace(cs.effort); effort != "" { + return effort + } + _, effort := cs.runtimeConfig() + return effort +} + +func (cs *codexSession) Alive() bool { + return cs.alive.Load() +} + +func (cs *codexSession) GetContextUsage() *core.ContextUsage { + cs.contextMu.RLock() + defer cs.contextMu.RUnlock() + return cloneContextUsage(cs.contextUsage) +} + +func (cs *codexSession) runtimeConfig() (string, string) { + cs.runtimeCfgMu.Lock() + defer cs.runtimeCfgMu.Unlock() + + if !cs.runtimeCfgFetched.IsZero() && time.Since(cs.runtimeCfgFetched) < codexRuntimeConfigCacheTTL { + return cs.runtimeCfgModel, cs.runtimeCfgEffort + } + + ctx, cancel := context.WithTimeout(cs.ctx, codexRuntimeConfigTimeout) + defer cancel() + + model, effort, err := loadCodexRuntimeConfig(ctx, cs.workDir, cs.extraEnv) + if err == nil { + cs.runtimeCfgModel = model + cs.runtimeCfgEffort = effort + cs.runtimeCfgFetchErr = nil + cs.runtimeCfgFetched = time.Now() + return model, effort + } + + cs.runtimeCfgFetchErr = err + if !cs.runtimeCfgFetched.IsZero() { + return cs.runtimeCfgModel, cs.runtimeCfgEffort + } + return "", "" +} + +func (cs *codexSession) refreshContextUsageFromRollout() { + sessionID := strings.TrimSpace(cs.CurrentSessionID()) + if sessionID == "" { + return + } + + for attempt := 0; attempt < codexContextUsageRetryCount; attempt++ { + cs.contextMu.RLock() + cachedPath := cs.sessionFile + cs.contextMu.RUnlock() + + usage, path, err := loadContextUsageFromRollout(cs.extraEnv, sessionID, cachedPath) + if err == nil && usage != nil { + cs.contextMu.Lock() + cs.sessionFile = path + cs.contextUsage = cloneContextUsage(usage) + cs.contextMu.Unlock() + return + } + + if attempt == codexContextUsageRetryCount-1 { + if err != nil { + slog.Debug("codexSession: context usage unavailable", "thread_id", sessionID, "error", err) + } + return + } + + select { + case <-time.After(codexContextUsageRetryDelay): + case <-cs.ctx.Done(): + return + } + } +} + +func (cs *codexSession) Close() error { + cs.alive.Store(false) + cs.cancel() + done := make(chan struct{}) + go func() { + cs.wg.Wait() + close(done) + }() + select { + case <-done: + // readLoop has exited; safe to close the events channel. + cs.closeOnce.Do(func() { + close(cs.events) + }) + return nil + case <-time.After(codexSessionCloseTimeout): + cmds := cs.activeCmds() + slog.Warn("codexSession: graceful close timed out, killing active process groups", + "wait", codexSessionCloseTimeout, + "count", len(cmds)) + if err := forceKillAllCmds(cmds); err != nil { + slog.Debug("codexSession: force kill failed", "error", err) + } + select { + case <-done: + cs.closeOnce.Do(func() { + close(cs.events) + }) + return nil + case <-time.After(codexSessionForceKillWait): + // Do not close(cs.events) here: readLoop may still be in handleEvent + // (e.g. turn.completed -> flushPendingAsText) and would panic on send. + slog.Warn("codexSession: force kill wait timed out, deferring events channel close until readLoop exits", + "wait", codexSessionForceKillWait) + go func() { + <-done + cs.closeOnce.Do(func() { + close(cs.events) + }) + }() + return nil + } + } +} + +func (cs *codexSession) addCmd(cmd *exec.Cmd) { + cs.cmdMu.Lock() + defer cs.cmdMu.Unlock() + cs.cmds[cmd] = struct{}{} +} + +func (cs *codexSession) removeCmd(cmd *exec.Cmd) { + cs.cmdMu.Lock() + defer cs.cmdMu.Unlock() + delete(cs.cmds, cmd) +} + +func (cs *codexSession) activeCmds() []*exec.Cmd { + cs.cmdMu.Lock() + defer cs.cmdMu.Unlock() + cmds := make([]*exec.Cmd, 0, len(cs.cmds)) + for cmd := range cs.cmds { + cmds = append(cmds, cmd) + } + return cmds +} + +func forceKillAllCmds(cmds []*exec.Cmd) error { + var errs []error + for _, cmd := range cmds { + if err := forceKillCmd(cmd); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// extractItemText extracts text from an item's array field (e.g. "summary" or "content"). +// It looks for elements matching the given elementType and concatenates their "text" fields. +// Falls back to the item's top-level "text" field if the array is missing or empty. +func extractItemText(item map[string]any, arrayField, elementType string) string { + if arr, ok := item[arrayField].([]any); ok { + var parts []string + for _, elem := range arr { + m, ok := elem.(map[string]any) + if !ok { + continue + } + if elementType != "" { + if t, _ := m["type"].(string); t != elementType { + continue + } + } + if t, _ := m["text"].(string); t != "" { + parts = append(parts, t) + } + } + if len(parts) > 0 { + return strings.Join(parts, "\n") + } + } + text, _ := item["text"].(string) + return text +} + +func truncate(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + return string([]rune(s)[:maxRunes]) + "..." +} diff --git a/agent/codex/session_test.go b/agent/codex/session_test.go new file mode 100644 index 0000000..812679b --- /dev/null +++ b/agent/codex/session_test.go @@ -0,0 +1,811 @@ +package codex + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func TestNormalizeReasoningEffort_RejectsMinimal(t *testing.T) { + if got := normalizeReasoningEffort("minimal"); got != "" { + t.Fatalf("normalizeReasoningEffort(minimal) = %q, want empty", got) + } + if got := normalizeReasoningEffort("min"); got != "" { + t.Fatalf("normalizeReasoningEffort(min) = %q, want empty", got) + } +} + +func TestAvailableReasoningEfforts_ExcludesMinimal(t *testing.T) { + agent := &Agent{} + got := agent.AvailableReasoningEfforts() + want := []string{"low", "medium", "high", "xhigh"} + if len(got) != len(want) { + t.Fatalf("AvailableReasoningEfforts len = %d, want %d, got=%v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("AvailableReasoningEfforts[%d] = %q, want %q, got=%v", i, got[i], want[i], got) + } + } +} + +func TestBuildExecArgs_IncludesReasoningEffort(t *testing.T) { + cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "o3", "high", "full-auto", "", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + + args := cs.buildExecArgs("hello", nil) + + want := []string{ + "exec", + "--skip-git-repo-check", + "--full-auto", + "--model", + "o3", + "-c", + `model_reasoning_effort="high"`, + "--json", + "--cd", + "/tmp/project", + "-", + } + if len(args) != len(want) { + t.Fatalf("args len = %d, want %d, args=%v", len(args), len(want), args) + } + for i := range want { + if args[i] != want[i] { + t.Fatalf("args[%d] = %q, want %q, args=%v", i, args[i], want[i], args) + } + } +} + +func TestBuildExecArgs_IncludesBaseURL(t *testing.T) { + cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "o3", "high", "full-auto", "", "https://custom.api.example.com", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + + args := cs.buildExecArgs("hello", nil) + + if !containsSequence(args, []string{"-c", `openai_base_url="https://custom.api.example.com"`}) { + t.Fatalf("args missing openai_base_url config flag: %v", args) + } +} + +func TestBuildExecArgs_IncludesModelProvider(t *testing.T) { + cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "openai/gpt-5.3-codex", "", "full-auto", "", "https://router.example.com/api/v1", nil, "shengsuanyun") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + + args := cs.buildExecArgs("hello", nil) + + if !containsSequence(args, []string{"-c", `model_provider="shengsuanyun"`}) { + t.Fatalf("args missing model_provider config flag: %v", args) + } + if !containsSequence(args, []string{"-c", `openai_base_url="https://router.example.com/api/v1"`}) { + t.Fatalf("args missing openai_base_url config flag: %v", args) + } +} + +func TestBuildExecArgs_ResumeOmitsCdFlag(t *testing.T) { + cs, err := newCodexSession(context.Background(), "codex", nil, "/tmp/project", "", "", "full-auto", "thread-abc", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + + args := cs.buildExecArgs("hello", nil) + + // codex exec resume does not support --cd; verify it's absent. + for i, arg := range args { + if arg == "--cd" { + t.Fatalf("resume args should not contain --cd, but found at index %d: %v", i, args) + } + } + + // --json and stdin marker must still be present. + if !containsSequence(args, []string{"--json", "-"}) { + t.Fatalf("resume args missing --json + stdin marker: %v", args) + } +} + +func TestGetModelAndReasoningEffort_FromRuntimeConfigWhenUnset(t *testing.T) { + workDir := t.TempDir() + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + + script := `#!/bin/sh +while IFS= read -r line; do + id=$(printf '%s' "$line" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p') + case "$line" in + *'"method":"initialize"'*) + printf '{"id":%s,"result":{"protocolVersion":"2"}}\n' "$id" + ;; + *'"method":"config/read"'*) + printf '{"id":%s,"result":{"config":{"model":"gpt-5.4","model_reasoning_effort":"xhigh"},"origins":{}}}\n' "$id" + ;; + esac +done +` + powershellScript := ` +while (($line = [Console]::In.ReadLine()) -ne $null) { + if ($line -like '*"method":"initialize"*') { + [Console]::Out.WriteLine('{"id":1,"result":{"protocolVersion":"2"}}') + } elseif ($line -like '*"method":"config/read"*') { + [Console]::Out.WriteLine('{"id":2,"result":{"config":{"model":"gpt-5.4","model_reasoning_effort":"xhigh"},"origins":{}}}') + } +} +` + writeFakeCodexScript(t, binDir, script, powershellScript) + + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + defer cs.Close() + + if got := cs.GetModel(); got != "gpt-5.4" { + t.Fatalf("GetModel() = %q, want gpt-5.4", got) + } + if got := cs.GetReasoningEffort(); got != "xhigh" { + t.Fatalf("GetReasoningEffort() = %q, want xhigh", got) + } +} + +func TestRefreshContextUsageFromRollout_UsesLastTokenCount(t *testing.T) { + workDir := t.TempDir() + codexHome := filepath.Join(workDir, ".codex") + rolloutDir := filepath.Join(codexHome, "sessions", "2026", "04", "12") + if err := os.MkdirAll(rolloutDir, 0o755); err != nil { + t.Fatalf("mkdir rollout dir: %v", err) + } + + sessionID := "019d8019-d05a-7612-ace2-db549494c0f9" + rolloutPath := filepath.Join(rolloutDir, "rollout-2026-04-12T05-11-08-"+sessionID+".jsonl") + rollout := strings.Join([]string{ + `{"type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"/tmp/project"}}`, + `{"type":"event_msg","payload":{"type":"token_count","info":null,"rate_limits":{"limit_id":"codex"}}}`, + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":50665316,"cached_input_tokens":46971872,"output_tokens":156453,"reasoning_output_tokens":75023,"total_tokens":50821769},"last_token_usage":{"input_tokens":180805,"cached_input_tokens":139776,"output_tokens":619,"reasoning_output_tokens":32,"total_tokens":181424},"model_context_window":258400},"rate_limits":{"limit_id":"codex"}}}`, + "", + }, "\n") + if err := os.WriteFile(rolloutPath, []byte(rollout), 0o644); err != nil { + t.Fatalf("write rollout: %v", err) + } + + cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", sessionID, "", []string{"CODEX_HOME=" + codexHome}, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + defer cs.Close() + + cs.refreshContextUsageFromRollout() + + usage := cs.GetContextUsage() + if usage == nil { + t.Fatal("GetContextUsage() = nil, want rollout token count") + } + if usage.UsedTokens != 181424 { + t.Fatalf("used tokens = %d, want 181424", usage.UsedTokens) + } + if usage.BaselineTokens != codexContextBaselineTokens { + t.Fatalf("baseline tokens = %d, want %d", usage.BaselineTokens, codexContextBaselineTokens) + } + if usage.TotalTokens != 181424 { + t.Fatalf("total tokens = %d, want 181424", usage.TotalTokens) + } + if usage.InputTokens != 180805 { + t.Fatalf("input tokens = %d, want 180805", usage.InputTokens) + } + if usage.ContextWindow != 258400 { + t.Fatalf("context window = %d, want 258400", usage.ContextWindow) + } +} + +func TestSend_WithImages_PassesImageArgsAndDefaultPrompt(t *testing.T) { + workDir := t.TempDir() + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + + argsFile := filepath.Join(workDir, "args.txt") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > \"$CODEX_ARGS_FILE\"\n" + + "printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-1\"}'\n" + + "printf '%s\\n' '{\"type\":\"turn.completed\"}'\n" + powershellScript := ` +[IO.File]::WriteAllLines($env:CODEX_ARGS_FILE, (fakeCodexArgs)) +[Console]::Out.WriteLine('{"type":"thread.started","thread_id":"thread-1"}') +[Console]::Out.WriteLine('{"type":"turn.completed"}') +` + writeFakeCodexScript(t, binDir, script, powershellScript) + + t.Setenv("CODEX_ARGS_FILE", argsFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + defer cs.Close() + + img := core.ImageAttachment{ + MimeType: "image/png", + Data: []byte("png-bytes"), + FileName: "sample.png", + } + if err := cs.Send("", []core.ImageAttachment{img}, nil); err != nil { + t.Fatalf("Send: %v", err) + } + + args := waitForArgsFile(t, argsFile) + if !containsSequence(args, []string{"exec", "--skip-git-repo-check"}) { + t.Fatalf("args missing exec prelude: %v", args) + } + if !containsSequence(args, []string{"--json", "--cd"}) { + t.Fatalf("args missing --json --cd sequence: %v", args) + } + imagePath := valueAfter(args, "--image") + if imagePath == "" { + t.Fatalf("args missing --image: %v", args) + } + if !strings.HasPrefix(imagePath, filepath.Join(workDir, ".cc-connect", "images")+string(filepath.Separator)) { + t.Fatalf("image path = %q, want under work dir image cache", imagePath) + } + data, err := os.ReadFile(imagePath) + if err != nil { + t.Fatalf("read staged image: %v", err) + } + if string(data) != string(img.Data) { + t.Fatalf("staged image content = %q, want %q", string(data), string(img.Data)) + } + if got := args[len(args)-1]; got != "-" { + t.Fatalf("last arg = %q, want stdin marker '-'; args=%v", got, args) + } +} + +func TestSend_ResumeWithImages_PlacesSessionBeforeImageFlags(t *testing.T) { + workDir := t.TempDir() + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + + argsFile := filepath.Join(workDir, "args.txt") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > \"$CODEX_ARGS_FILE\"\n" + + "printf '%s\\n' '{\"type\":\"turn.completed\"}'\n" + powershellScript := ` +[IO.File]::WriteAllLines($env:CODEX_ARGS_FILE, (fakeCodexArgs)) +[Console]::Out.WriteLine('{"type":"turn.completed"}') +` + writeFakeCodexScript(t, binDir, script, powershellScript) + + t.Setenv("CODEX_ARGS_FILE", argsFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "thread-123", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + defer cs.Close() + + if err := cs.Send("describe this", []core.ImageAttachment{{MimeType: "image/jpeg", Data: []byte("jpg")}}, nil); err != nil { + t.Fatalf("Send: %v", err) + } + + args := waitForArgsFile(t, argsFile) + if !containsSequence(args, []string{"exec", "resume", "--skip-git-repo-check"}) { + t.Fatalf("args missing resume prelude: %v", args) + } + tidIndex := indexOf(args, "thread-123") + imageIndex := indexOf(args, "--image") + jsonIndex := indexOf(args, "--json") + promptIndex := indexOf(args, "-") + if tidIndex == -1 || imageIndex == -1 || jsonIndex == -1 || promptIndex == -1 { + t.Fatalf("missing resume/image/json/stdin args: %v", args) + } + // Verify order: thread-id -> --image -> --json -> --cd -> prompt + if !(tidIndex < imageIndex && imageIndex < jsonIndex && jsonIndex < promptIndex) { + t.Fatalf("unexpected arg order: %v", args) + } +} + +func TestSend_UsesStdinForMultilinePrompt(t *testing.T) { + workDir := t.TempDir() + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + + argsFile := filepath.Join(workDir, "args.txt") + stdinFile := filepath.Join(workDir, "stdin.txt") + script := "#!/bin/sh\n" + + "printf '%s\\n' \"$@\" > \"$CODEX_ARGS_FILE\"\n" + + "cat > \"$CODEX_STDIN_FILE\"\n" + + "printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-stdin\"}'\n" + + "printf '%s\\n' '{\"type\":\"turn.completed\"}'\n" + powershellScript := ` +[IO.File]::WriteAllLines($env:CODEX_ARGS_FILE, (fakeCodexArgs)) +[IO.File]::WriteAllText($env:CODEX_STDIN_FILE, [Console]::In.ReadToEnd()) +[Console]::Out.WriteLine('{"type":"thread.started","thread_id":"thread-stdin"}') +[Console]::Out.WriteLine('{"type":"turn.completed"}') +` + writeFakeCodexScript(t, binDir, script, powershellScript) + + t.Setenv("CODEX_ARGS_FILE", argsFile) + t.Setenv("CODEX_STDIN_FILE", stdinFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "thread-stdin", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + defer cs.Close() + + prompt := "line1\nline2" + if err := cs.Send(prompt, nil, nil); err != nil { + t.Fatalf("Send: %v", err) + } + + args := waitForArgsFile(t, argsFile) + if !containsSequence(args, []string{"--json", "-"}) { + t.Fatalf("args missing stdin marker: %v", args) + } + + // cat > file creates the path before stdin is fully read; polling until + // content matches avoids racing an empty read (flaky under -cover / CI). + waitForFileEquals(t, stdinFile, prompt) +} + +func TestSend_HandlesLargeJSONLines(t *testing.T) { + workDir := t.TempDir() + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + + largeText := strings.Repeat("x", 11*1024*1024) + encodedText, err := json.Marshal(largeText) + if err != nil { + t.Fatalf("marshal large text: %v", err) + } + + payload := strings.Join([]string{ + `{"type":"thread.started","thread_id":"thread-large"}`, + `{"type":"item.completed","item":{"type":"agent_message","content":[{"type":"output_text","text":` + string(encodedText) + `}]}}`, + `{"type":"turn.completed"}`, + }, "\n") + "\n" + + payloadFile := filepath.Join(workDir, "payload.jsonl") + if err := os.WriteFile(payloadFile, []byte(payload), 0o644); err != nil { + t.Fatalf("write payload: %v", err) + } + + script := "#!/bin/sh\ncat \"$CODEX_PAYLOAD_FILE\"\n" + powershellScript := `[Console]::Out.Write([IO.File]::ReadAllText($env:CODEX_PAYLOAD_FILE)) +` + writeFakeCodexScript(t, binDir, script, powershellScript) + + t.Setenv("CODEX_PAYLOAD_FILE", payloadFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + defer cs.Close() + + if err := cs.Send("hello", nil, nil); err != nil { + t.Fatalf("Send: %v", err) + } + + var gotTextLen int + var gotResult bool + timeout := time.After(5 * time.Second) + + for !gotResult { + select { + case evt := <-cs.Events(): + if evt.Type == core.EventError { + t.Fatalf("unexpected error event: %v", evt.Error) + } + if evt.Type == core.EventText { + gotTextLen = len(evt.Content) + } + if evt.Type == core.EventResult && evt.Done { + gotResult = true + } + case <-timeout: + t.Fatal("timed out waiting for large JSON line events") + } + } + + if gotTextLen != len(largeText) { + t.Fatalf("text len = %d, want %d", gotTextLen, len(largeText)) + } + if got := cs.CurrentSessionID(); got != "thread-large" { + t.Fatalf("CurrentSessionID() = %q, want thread-large", got) + } +} + +func TestWaitForArgsFile_WaitsForNonEmptyContent(t *testing.T) { + workDir := t.TempDir() + argsFile := filepath.Join(workDir, "args.txt") + + if err := os.WriteFile(argsFile, []byte(""), 0o644); err != nil { + t.Fatalf("write empty args file: %v", err) + } + + go func() { + time.Sleep(100 * time.Millisecond) + _ = os.WriteFile(argsFile, []byte("exec\n--json\n"), 0o644) + }() + + args := waitForArgsFile(t, argsFile) + if !containsSequence(args, []string{"exec", "--json"}) { + t.Fatalf("expected non-empty args sequence, got: %v", args) + } +} + +func TestWriteFakeCodexScript_PreservesArgsWithSpaces(t *testing.T) { + workDir := t.TempDir() + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + + argsFile := filepath.Join(workDir, "args.txt") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$CODEX_ARGS_FILE\"\n" + powershellScript := `[IO.File]::WriteAllLines($env:CODEX_ARGS_FILE, (fakeCodexArgs)) +` + writeFakeCodexScript(t, binDir, script, powershellScript) + t.Setenv("CODEX_ARGS_FILE", argsFile) + + cmd := exec.Command(filepath.Join(binDir, "codex"), "exec", "--cd", filepath.Join(workDir, "dir with spaces"), "-") + if err := cmd.Run(); err != nil { + t.Fatalf("fake codex run: %v", err) + } + + args := waitForArgsFile(t, argsFile) + wantPath := filepath.Join(workDir, "dir with spaces") + if !containsSequence(args, []string{"exec", "--cd", wantPath, "-"}) { + t.Fatalf("args = %v, want path with spaces preserved as %q", args, wantPath) + } +} + +const fakeCodexPowerShellPrelude = ` +function fakeCodexArgs { + if ([string]::IsNullOrWhiteSpace($env:CODEX_FAKE_ARGS_FILE) -or -not (Test-Path -LiteralPath $env:CODEX_FAKE_ARGS_FILE)) { + return @() + } + return @(Get-Content -LiteralPath $env:CODEX_FAKE_ARGS_FILE) +} +` + +func writeFakeCodexScript(t *testing.T, dir, shellScript, powershellScript string) { + t.Helper() + if runtime.GOOS == "windows" { + psPath := filepath.Join(dir, "codex.ps1") + if err := os.WriteFile(psPath, []byte(fakeCodexPowerShellPrelude+powershellScript), 0o644); err != nil { + t.Fatalf("write fake codex powershell script: %v", err) + } + cmdPath := filepath.Join(dir, "codex.cmd") + cmdScript := "@echo off\r\n" + + "setlocal\r\n" + + "set \"CODEX_FAKE_SCRIPT=%~dp0codex.ps1\"\r\n" + + "set \"CODEX_FAKE_ARGS_FILE=%TEMP%\\codex-fake-args-%RANDOM%-%RANDOM%.txt\"\r\n" + + "type nul > \"%CODEX_FAKE_ARGS_FILE%\"\r\n" + + ":args\r\n" + + "if \"%~1\"==\"\" goto run\r\n" + + ">> \"%CODEX_FAKE_ARGS_FILE%\" echo(%~1\r\n" + + "shift\r\n" + + "goto args\r\n" + + ":run\r\n" + + "powershell -NoProfile -ExecutionPolicy Bypass -File \"%CODEX_FAKE_SCRIPT%\"\r\n" + + "set \"CODEX_FAKE_EXIT=%ERRORLEVEL%\"\r\n" + + "del \"%CODEX_FAKE_ARGS_FILE%\" >nul 2>nul\r\n" + + "exit /b %CODEX_FAKE_EXIT%\r\n" + if err := os.WriteFile(cmdPath, []byte(cmdScript), 0o755); err != nil { + t.Fatalf("write fake codex cmd shim: %v", err) + } + return + } + scriptPath := filepath.Join(dir, "codex") + if err := os.WriteFile(scriptPath, []byte(shellScript), 0o755); err != nil { + t.Fatalf("write fake codex: %v", err) + } +} + +func waitForArgsFile(t *testing.T, path string) []string { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil { + text := strings.TrimSpace(string(data)) + if text != "" { + lines := strings.Split(text, "\n") + args := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + args = append(args, line) + } + } + if len(args) > 0 { + return args + } + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("timed out waiting for non-empty args file: %s", path) + return nil +} + +func waitForFileEquals(t *testing.T, path, want string) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil && string(data) == want { + return + } + time.Sleep(20 * time.Millisecond) + } + data, _ := os.ReadFile(path) + t.Fatalf("stdin file %s: got %q, want %q", path, string(data), want) +} + +func containsSequence(args, want []string) bool { + if len(want) == 0 { + return true + } + for i := 0; i+len(want) <= len(args); i++ { + match := true + for j := range want { + if args[i+j] != want[j] { + match = false + break + } + } + if match { + return true + } + } + return false +} + +func valueAfter(args []string, key string) string { + for i := 0; i+1 < len(args); i++ { + if args[i] == key { + return args[i+1] + } + } + return "" +} + +func indexOf(args []string, target string) int { + for i, arg := range args { + if arg == target { + return i + } + } + return -1 +} + +func TestCodexSession_ContinueSessionTreatedAsFresh(t *testing.T) { + s, err := newCodexSession(context.Background(), "codex", nil, "/tmp", "", "", "full-auto", core.ContinueSession, "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + defer s.Close() + + if got := s.CurrentSessionID(); got != "" { + t.Errorf("ContinueSession should be treated as fresh: threadID = %q, want empty", got) + } +} + +func TestClose_ForceKillsProcessGroupAfterGracefulTimeout(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("process-group semantics differ on windows") + } + + workDir := t.TempDir() + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + + script := "#!/bin/sh\n" + + "printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-close\"}'\n" + + "(sleep 0.12; printf '%s\\n' '{\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"late child output\"}}'; sleep 30) &\n" + + "wait\n" + scriptPath := filepath.Join(binDir, "codex") + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake codex: %v", err) + } + + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + oldCloseTimeout := codexSessionCloseTimeout + oldForceKillWait := codexSessionForceKillWait + codexSessionCloseTimeout = 50 * time.Millisecond + codexSessionForceKillWait = 500 * time.Millisecond + t.Cleanup(func() { + codexSessionCloseTimeout = oldCloseTimeout + codexSessionForceKillWait = oldForceKillWait + }) + + cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + + if err := cs.Send("hello", nil, nil); err != nil { + t.Fatalf("Send: %v", err) + } + + waitForThreadID(t, cs, "thread-close") + + closeStarted := time.Now() + if err := cs.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if elapsed := time.Since(closeStarted); elapsed > time.Second { + t.Fatalf("Close took too long after force kill: %v", elapsed) + } + + select { + case evt, ok := <-cs.Events(): + if ok { + t.Fatalf("unexpected event after Close: %#v", evt) + } + case <-time.After(700 * time.Millisecond): + t.Fatal("timed out waiting for events channel to close") + } +} + +func TestClose_ForceKillsAllTrackedProcessesAfterCmdOverwrite(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("process-group semantics differ on windows") + } + + workDir := t.TempDir() + binDir := filepath.Join(workDir, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin: %v", err) + } + + startsFile := filepath.Join(workDir, "starts.txt") + // Prompt is passed on stdin (--json -), not as a trailing argv argument. + script := "#!/bin/sh\n" + + "prompt=$(cat)\n" + + "printf '%s\\n' \"$prompt\" >> \"$CODEX_STARTS_FILE\"\n" + + "if [ \"$prompt\" = \"first\" ]; then\n" + + " printf '%s\\n' '{\"type\":\"thread.started\",\"thread_id\":\"thread-overlap\"}'\n" + + " printf '%s\\n' '{\"type\":\"turn.completed\"}'\n" + + "fi\n" + + "sleep 30\n" + scriptPath := filepath.Join(binDir, "codex") + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + t.Fatalf("write fake codex: %v", err) + } + + t.Setenv("CODEX_STARTS_FILE", startsFile) + t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + oldCloseTimeout := codexSessionCloseTimeout + oldForceKillWait := codexSessionForceKillWait + codexSessionCloseTimeout = 50 * time.Millisecond + codexSessionForceKillWait = 500 * time.Millisecond + t.Cleanup(func() { + codexSessionCloseTimeout = oldCloseTimeout + codexSessionForceKillWait = oldForceKillWait + }) + + cs, err := newCodexSession(context.Background(), "codex", nil, workDir, "", "", "", "", "", nil, "") + if err != nil { + t.Fatalf("newCodexSession: %v", err) + } + + if err := cs.Send("first", nil, nil); err != nil { + t.Fatalf("Send(first): %v", err) + } + waitForThreadID(t, cs, "thread-overlap") + waitForDoneResult(t, cs.Events()) + + if err := cs.Send("second", nil, nil); err != nil { + t.Fatalf("Send(second): %v", err) + } + waitForFileLines(t, startsFile, 2) + + closeStarted := time.Now() + if err := cs.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if elapsed := time.Since(closeStarted); elapsed > time.Second { + t.Fatalf("Close took too long after force killing tracked processes: %v", elapsed) + } + + select { + case evt, ok := <-cs.Events(): + if ok { + t.Fatalf("unexpected event after Close: %#v", evt) + } + case <-time.After(700 * time.Millisecond): + t.Fatal("timed out waiting for events channel to close") + } +} + +func waitForThreadID(t *testing.T, cs *codexSession, want string) { + t.Helper() + timeout := time.After(5 * time.Second) + for { + select { + case <-time.After(10 * time.Millisecond): + if cs.CurrentSessionID() == want { + return + } + case <-timeout: + t.Fatalf("timed out waiting for thread id %q", want) + } + } +} + +func waitForDoneResult(t *testing.T, events <-chan core.Event) { + t.Helper() + timeout := time.After(5 * time.Second) + for { + select { + case evt, ok := <-events: + if !ok { + t.Fatal("events channel closed before done result") + } + if evt.Type == core.EventError { + t.Fatalf("unexpected error event: %v", evt.Error) + } + if evt.Type == core.EventResult && evt.Done { + return + } + case <-timeout: + t.Fatal("timed out waiting for done result") + } + } +} + +func waitForFileLines(t *testing.T, path string, want int) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil { + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + count := 0 + for _, line := range lines { + if strings.TrimSpace(line) != "" { + count++ + } + } + if count >= want { + return + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("timed out waiting for %d lines in %s", want, path) +} diff --git a/agent/codex/skilldirs_test.go b/agent/codex/skilldirs_test.go new file mode 100644 index 0000000..418adf6 --- /dev/null +++ b/agent/codex/skilldirs_test.go @@ -0,0 +1,121 @@ +package codex + +import ( + "os" + "path/filepath" + "runtime" + "sync" + "testing" +) + +func TestSkillDirs_UsesProjectAgentAndCodexHomes(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + codexHome := filepath.Join(tmp, "codex-home") + repo := filepath.Join(tmp, "repo") + workDir := filepath.Join(repo, "nested", "pkg") + + setTestHome(t, home) + t.Setenv("CODEX_HOME", "") + + for _, dir := range []string{ + filepath.Join(repo, "nested", "pkg"), + filepath.Join(repo, "nested"), + repo, + codexHome, + } { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("mkdir %s: %v", dir, err) + } + } + if err := os.WriteFile(filepath.Join(repo, ".git"), []byte("gitdir: fake\n"), 0o644); err != nil { + t.Fatalf("write .git: %v", err) + } + + a := &Agent{workDir: workDir, codexHome: codexHome} + got := a.SkillDirs() + want := []string{ + filepath.Join(workDir, ".agents", "skills"), + filepath.Join(workDir, ".codex", "skills"), + filepath.Join(repo, "nested", ".agents", "skills"), + filepath.Join(repo, "nested", ".codex", "skills"), + filepath.Join(repo, ".agents", "skills"), + filepath.Join(repo, ".codex", "skills"), + filepath.Join(codexHome, "skills"), + filepath.Join(home, ".agents", "skills"), + } + if len(got) != len(want) { + t.Fatalf("len(SkillDirs()) = %d, want %d\n got=%v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("SkillDirs()[%d] = %q, want %q\nfull=%v", i, got[i], want[i], got) + } + } +} + +func TestSkillDirs_FallsBackToEnvCodexHome(t *testing.T) { + tmp := t.TempDir() + home := filepath.Join(tmp, "home") + codexHome := filepath.Join(tmp, "profile-home") + workDir := filepath.Join(tmp, "workspace") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatalf("mkdir workdir: %v", err) + } + + setTestHome(t, home) + t.Setenv("CODEX_HOME", codexHome) + + a := &Agent{workDir: workDir} + got := a.SkillDirs() + found := false + for _, dir := range got { + if dir == filepath.Join(codexHome, "skills") { + found = true + break + } + } + if !found { + t.Fatalf("SkillDirs() missing CODEX_HOME skills dir: %v", got) + } +} + +func setTestHome(t *testing.T, home string) { + t.Helper() + t.Setenv("HOME", home) + if runtime.GOOS == "windows" { + t.Setenv("USERPROFILE", home) + t.Setenv("HOMEDRIVE", "") + t.Setenv("HOMEPATH", "") + } +} + +// TestSkillDirs_RaceFreeAgainstSetWorkDir pins the bug where SkillDirs +// read a.workDir and a.codexHome without holding a.mu, while +// SetWorkDir writes a.workDir under the lock. Run with -race to detect +// the data race; with the production fix the test stays clean. +func TestSkillDirs_RaceFreeAgainstSetWorkDir(t *testing.T) { + tmp := t.TempDir() + a := &Agent{workDir: tmp, codexHome: filepath.Join(tmp, "codex")} + + var wg sync.WaitGroup + for i := 0; i < 30; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if i%2 == 0 { + a.SetWorkDir(filepath.Join(tmp, "a")) + } else { + a.SetWorkDir(filepath.Join(tmp, "b")) + } + }(i) + } + for i := 0; i < 30; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = a.SkillDirs() + }() + } + wg.Wait() +} diff --git a/agent/codex/usage.go b/agent/codex/usage.go new file mode 100644 index 0000000..4509d37 --- /dev/null +++ b/agent/codex/usage.go @@ -0,0 +1,195 @@ +package codex + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + "github.com/chenhg5/cc-connect/core" +) + +const codexUsageURL = "https://chatgpt.com/backend-api/wham/usage" + +type codexOAuthTokens struct { + AccessToken string + AccountID string +} + +type codexUsageResponse struct { + UserID string `json:"user_id"` + AccountID string `json:"account_id"` + Email string `json:"email"` + PlanType string `json:"plan_type"` + RateLimit *codexUsageBucket `json:"rate_limit"` + CodeReviewRateLimit *codexUsageBucket `json:"code_review_rate_limit"` + Credits *codexUsageCredits `json:"credits"` +} + +type codexUsageBucket struct { + Allowed bool `json:"allowed"` + LimitReached bool `json:"limit_reached"` + PrimaryWindow *codexUsageWindow `json:"primary_window"` + SecondaryWindow *codexUsageWindow `json:"secondary_window"` +} + +type codexUsageWindow struct { + UsedPercent int `json:"used_percent"` + LimitWindowSeconds int `json:"limit_window_seconds"` + ResetAfterSeconds int `json:"reset_after_seconds"` + ResetAt int64 `json:"reset_at"` +} + +type codexUsageCredits struct { + HasCredits bool `json:"has_credits"` + Unlimited bool `json:"unlimited"` + Balance any `json:"balance"` +} + +func (a *Agent) GetUsage(ctx context.Context) (*core.UsageReport, error) { + tokens, err := a.readOAuthTokens(os.ReadFile) + if err != nil { + return nil, err + } + return a.fetchUsage(ctx, http.DefaultClient, tokens) +} + +func (a *Agent) readOAuthTokens(readFile func(string) ([]byte, error)) (codexOAuthTokens, error) { + path, err := codexAuthPath() + if err != nil { + return codexOAuthTokens{}, err + } + data, err := readFile(path) + if err != nil { + return codexOAuthTokens{}, fmt.Errorf("read %s: %w", path, err) + } + + var payload struct { + Tokens struct { + AccessToken string `json:"access_token"` + AccountID string `json:"account_id"` + } `json:"tokens"` + } + if err := json.Unmarshal(data, &payload); err != nil { + return codexOAuthTokens{}, fmt.Errorf("parse auth.json: %w", err) + } + if strings.TrimSpace(payload.Tokens.AccessToken) == "" { + return codexOAuthTokens{}, fmt.Errorf("auth.json missing tokens.access_token") + } + if strings.TrimSpace(payload.Tokens.AccountID) == "" { + return codexOAuthTokens{}, fmt.Errorf("auth.json missing tokens.account_id") + } + + return codexOAuthTokens{ + AccessToken: payload.Tokens.AccessToken, + AccountID: payload.Tokens.AccountID, + }, nil +} + +func (a *Agent) fetchUsage(ctx context.Context, client *http.Client, tokens codexOAuthTokens) (*core.UsageReport, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, codexUsageURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tokens.AccessToken) + req.Header.Set("ChatGPT-Account-Id", tokens.AccountID) + req.Header.Set("User-Agent", "codex-cli") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request usage endpoint: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, fmt.Errorf("usage endpoint returned status %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + + var payload codexUsageResponse + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return nil, fmt.Errorf("decode usage response: %w", err) + } + + return mapCodexUsage(payload), nil +} + +func mapCodexUsage(payload codexUsageResponse) *core.UsageReport { + report := &core.UsageReport{ + Provider: "codex", + AccountID: payload.AccountID, + UserID: payload.UserID, + Email: payload.Email, + Plan: payload.PlanType, + } + + if payload.RateLimit != nil { + report.Buckets = append(report.Buckets, core.UsageBucket{ + Name: "Rate limit", + Allowed: payload.RateLimit.Allowed, + LimitReached: payload.RateLimit.LimitReached, + Windows: mapCodexUsageWindows(payload.RateLimit), + }) + } + if payload.CodeReviewRateLimit != nil { + report.Buckets = append(report.Buckets, core.UsageBucket{ + Name: "Code review", + Allowed: payload.CodeReviewRateLimit.Allowed, + LimitReached: payload.CodeReviewRateLimit.LimitReached, + Windows: mapCodexUsageWindows(payload.CodeReviewRateLimit), + }) + } + if payload.Credits != nil { + report.Credits = &core.UsageCredits{ + HasCredits: payload.Credits.HasCredits, + Unlimited: payload.Credits.Unlimited, + } + if payload.Credits.Balance != nil { + report.Credits.Balance = fmt.Sprintf("%v", payload.Credits.Balance) + } + } + + return report +} + +func mapCodexUsageWindows(bucket *codexUsageBucket) []core.UsageWindow { + if bucket == nil { + return nil + } + var windows []core.UsageWindow + if bucket.PrimaryWindow != nil { + windows = append(windows, core.UsageWindow{ + Name: "Primary", + UsedPercent: bucket.PrimaryWindow.UsedPercent, + WindowSeconds: bucket.PrimaryWindow.LimitWindowSeconds, + ResetAfterSeconds: bucket.PrimaryWindow.ResetAfterSeconds, + ResetAtUnix: bucket.PrimaryWindow.ResetAt, + }) + } + if bucket.SecondaryWindow != nil { + windows = append(windows, core.UsageWindow{ + Name: "Secondary", + UsedPercent: bucket.SecondaryWindow.UsedPercent, + WindowSeconds: bucket.SecondaryWindow.LimitWindowSeconds, + ResetAfterSeconds: bucket.SecondaryWindow.ResetAfterSeconds, + ResetAtUnix: bucket.SecondaryWindow.ResetAt, + }) + } + return windows +} + +func codexAuthPath() (string, error) { + codexHome := os.Getenv("CODEX_HOME") + if codexHome == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + codexHome = filepath.Join(home, ".codex") + } + return filepath.Join(codexHome, "auth.json"), nil +} diff --git a/agent/codex/usage_test.go b/agent/codex/usage_test.go new file mode 100644 index 0000000..675edb0 --- /dev/null +++ b/agent/codex/usage_test.go @@ -0,0 +1,139 @@ +package codex + +import ( + "context" + "io" + "net/http" + "strings" + "testing" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestFetchUsage_Success(t *testing.T) { + agent := &Agent{} + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got := req.URL.String(); got != "https://chatgpt.com/backend-api/wham/usage" { + t.Fatalf("request URL = %q", got) + } + if got := req.Header.Get("Authorization"); got != "Bearer token-123" { + t.Fatalf("authorization = %q", got) + } + if got := req.Header.Get("ChatGPT-Account-Id"); got != "acct-123" { + t.Fatalf("account id header = %q", got) + } + body := `{ + "user_id": "user-1", + "account_id": "acct-123", + "email": "dev@example.com", + "plan_type": "team", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 23, + "limit_window_seconds": 18000, + "reset_after_seconds": 6665, + "reset_at": 1773292109 + }, + "secondary_window": { + "used_percent": 42, + "limit_window_seconds": 604800, + "reset_after_seconds": 512698, + "reset_at": 1773798142 + } + }, + "code_review_rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 0, + "limit_window_seconds": 604800, + "reset_after_seconds": 604800, + "reset_at": 1773890244 + }, + "secondary_window": null + }, + "credits": { + "has_credits": false, + "unlimited": false, + "balance": null + } + }` + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }), + } + + report, err := agent.fetchUsage(context.Background(), client, codexOAuthTokens{ + AccessToken: "token-123", + AccountID: "acct-123", + }) + if err != nil { + t.Fatalf("fetchUsage returned error: %v", err) + } + if report.Provider != "codex" { + t.Fatalf("provider = %q, want codex", report.Provider) + } + if report.Plan != "team" { + t.Fatalf("plan = %q, want team", report.Plan) + } + if len(report.Buckets) != 2 { + t.Fatalf("buckets = %d, want 2", len(report.Buckets)) + } + if got := report.Buckets[0].Windows[0].UsedPercent; got != 23 { + t.Fatalf("primary used percent = %d, want 23", got) + } + if got := report.Buckets[0].Windows[1].UsedPercent; got != 42 { + t.Fatalf("secondary used percent = %d, want 42", got) + } +} + +func TestFetchUsage_HTTPError(t *testing.T) { + agent := &Agent{} + client := &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)), + }, nil + }), + } + + _, err := agent.fetchUsage(context.Background(), client, codexOAuthTokens{ + AccessToken: "token-123", + AccountID: "acct-123", + }) + if err == nil || !strings.Contains(err.Error(), "status 401") { + t.Fatalf("err = %v, want status 401", err) + } +} + +func TestReadOAuthTokens_MissingFields(t *testing.T) { + agent := &Agent{} + _, err := agent.readOAuthTokens(func(string) ([]byte, error) { + return []byte(`{"tokens":{"access_token":"token-only"}}`), nil + }) + if err == nil || !strings.Contains(err.Error(), "account_id") { + t.Fatalf("err = %v, want missing account_id", err) + } +} + +func TestReadOAuthTokens_InvalidJSON(t *testing.T) { + agent := &Agent{} + _, err := agent.readOAuthTokens(func(string) ([]byte, error) { + return []byte(`{`), nil + }) + if err == nil || !strings.Contains(err.Error(), "parse") { + t.Fatalf("err = %v, want parse error", err) + } +} diff --git a/agent/copilot/copilot.go b/agent/copilot/copilot.go new file mode 100644 index 0000000..85a6066 --- /dev/null +++ b/agent/copilot/copilot.go @@ -0,0 +1,540 @@ +package copilot + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("copilot", New) +} + +// Agent drives GitHub Copilot CLI using --headless --stdio --no-auto-update +// for persistent JSON-RPC 2.0 communication over Content-Length framed stdio. +// +// Permission modes: +// - "default": every tool call requires user approval +// - "bypassPermissions": auto-approve everything (alias: yolo) +type Agent struct { + workDir string + cliBin string // CLI binary name or path (default: "copilot") + model string + mode string // "default" | "bypassPermissions" + providers []core.ProviderConfig + activeIdx int // -1 = no provider set + sessionEnv []string + + mu sync.RWMutex +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + cliBin := "copilot" + if cliPath, _ := opts["cli_path"].(string); strings.TrimSpace(cliPath) != "" { + cliBin = strings.TrimSpace(cliPath) + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + + if _, err := exec.LookPath(cliBin); err != nil { + return nil, fmt.Errorf("copilot: %q CLI not found in PATH, please install it first", cliBin) + } + + return &Agent{ + workDir: workDir, + cliBin: cliBin, + model: model, + mode: mode, + activeIdx: -1, + }, nil +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "bypasspermissions", "bypass-permissions", "bypass_permissions", "yolo": + return "bypassPermissions" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "copilot" } +func (a *Agent) CLIBinaryName() string { return a.cliBin } +func (a *Agent) CLIDisplayName() string { return "GitHub Copilot" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("copilot: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("copilot: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption { + if models := a.configuredModels(); len(models) > 0 { + return models + } + return []core.ModelOption{ + {Name: "gpt-4.1", Desc: "GPT-4.1"}, + {Name: "claude-sonnet-4.6", Desc: "Claude Sonnet 4.6"}, + {Name: "o3", Desc: "O3"}, + } +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("copilot: permission mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Ask permission for every tool call", DescZh: "每次工具调用都需确认"}, + {Key: "bypassPermissions", Name: "YOLO", NameZh: "YOLO 模式", Desc: "Auto-approve everything", DescZh: "全部自动通过"}, + } +} + +// StartSession creates a persistent interactive Copilot session. +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.RLock() + model := a.model + mode := a.mode + workDir := a.workDir + cliBin := a.cliBin + extraEnv := a.providerEnvLocked() + extraEnv = append(extraEnv, a.sessionEnv...) + provider := a.providerConfigLocked() + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + if m := a.providers[a.activeIdx].Model; m != "" { + model = m + } + } + a.mu.RUnlock() + + return newCopilotSession(ctx, workDir, cliBin, model, mode, sessionID, extraEnv, provider) +} + +// listSessionsProbeTimeout bounds how long we wait for a session.list probe. +var listSessionsProbeTimeout = 15 * time.Second + +// copilotSessionMetadata represents one session entry returned by session.list. +type copilotSessionMetadata struct { + SessionID string `json:"sessionId"` + StartTime string `json:"startTime"` + ModifiedTime string `json:"modifiedTime"` + Summary *string `json:"summary,omitempty"` +} + +// copilotListSessionsResponse is the session.list response payload. +type copilotListSessionsResponse struct { + Sessions []copilotSessionMetadata `json:"sessions"` +} + +// copilotDeleteSessionResponse is the session.delete response payload. +type copilotDeleteSessionResponse struct { + Success bool `json:"success"` + Error *string `json:"error,omitempty"` +} + +// probeSession is a short-lived copilot probe process with a managed read loop. +type probeSession struct { + rpc *rpcClient + cancel context.CancelFunc + done chan struct{} +} + +type probeSnapshot struct { + cliBin string + workDir string + env []string +} + +// newProbeSession spawns a copilot --headless --stdio probe process, starts a +// read loop, and returns a probeSession. Caller must call close() when done. +func newProbeSession(ctx context.Context, snapshot probeSnapshot) (*probeSession, error) { + probeCtx, cancel := context.WithCancel(ctx) + + cmd := exec.CommandContext(probeCtx, snapshot.cliBin, "--headless", "--stdio", "--no-auto-update") + cmd.Dir = snapshot.workDir + cmd.Env = snapshot.env + + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("copilot probe: stdin pipe: %w", err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("copilot probe: stdout pipe: %w", err) + } + cmd.Stderr = io.Discard + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("copilot probe: start: %w", err) + } + + rpc := newRPCClient(stdin) + reader := newLSPReader(stdout) + done := make(chan struct{}) + + go func() { + defer func() { + _ = stdin.Close() + _ = cmd.Wait() + close(done) + }() + for { + body, err := reader.readMessage() + if err != nil { + return + } + var resp jsonRPCResponse + if json.Unmarshal(body, &resp) == nil && + len(resp.ID) > 0 && string(resp.ID) != "null" { + rpc.dispatch(&resp) + } + } + }() + + return &probeSession{rpc: rpc, cancel: cancel, done: done}, nil +} + +// call sends a JSON-RPC request and blocks until a response or ctx expires. +func (ps *probeSession) call(ctx context.Context, method string, params any) (*jsonRPCResponse, error) { + _, ch := ps.rpc.call(method, params) + select { + case resp := <-ch: + return resp, nil + case <-ctx.Done(): + return nil, ctx.Err() + } +} + +// close cancels the probe context, terminating the process and read loop. +func (ps *probeSession) close() { + ps.cancel() + <-ps.done +} + +// ListSessions returns past sessions by spawning a short-lived copilot probe +// that performs ping + session.list, then exits. Returns nil gracefully if +// the binary is missing or the RPC fails. +func (a *Agent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) { + a.mu.RLock() + snapshot := a.probeSnapshotLocked() + a.mu.RUnlock() + + if _, err := exec.LookPath(snapshot.cliBin); err != nil { + return nil, nil + } + + probeCtx, cancel := context.WithTimeout(ctx, listSessionsProbeTimeout) + defer cancel() + + probe, err := newProbeSession(probeCtx, snapshot) + if err != nil { + slog.Debug("copilot: ListSessions probe spawn failed", "error", err) + return nil, nil + } + defer probe.close() + + // Ping + pingResp, err := probe.call(probeCtx, "ping", nil) + if err != nil || pingResp.Error != nil { + slog.Debug("copilot: ListSessions ping failed", "error", err) + return nil, nil + } + + // session.list + listResp, err := probe.call(probeCtx, "session.list", map[string]any{}) + if err != nil { + return nil, nil + } + if listResp.Error != nil { + slog.Debug("copilot: session.list RPC error", "code", listResp.Error.Code, "msg", listResp.Error.Message) + return nil, nil + } + + var result copilotListSessionsResponse + if err := json.Unmarshal(listResp.Result, &result); err != nil { + slog.Debug("copilot: session.list parse error", "error", err) + return nil, nil + } + + out := make([]core.AgentSessionInfo, 0, len(result.Sessions)) + for _, s := range result.Sessions { + info := core.AgentSessionInfo{ID: s.SessionID} + if s.Summary != nil { + info.Summary = *s.Summary + } + if t, err := time.Parse(time.RFC3339, s.ModifiedTime); err == nil { + info.ModifiedAt = t + } else if t, err := time.Parse(time.RFC3339Nano, s.ModifiedTime); err == nil { + info.ModifiedAt = t + } + out = append(out, info) + } + slog.Info("copilot: ListSessions", "count", len(out)) + return out, nil +} + +// WorkspaceAgentOptions implements core.WorkspaceSnapshotter. +// Returns the options needed to recreate an equivalent session for workspace reuse. +func (a *Agent) WorkspaceAgentOptions() map[string]any { + a.mu.RLock() + defer a.mu.RUnlock() + + opts := map[string]any{ + "mode": a.mode, + } + if a.model != "" { + opts["model"] = a.model + } + if a.cliBin != "copilot" && a.cliBin != "" { + opts["cli_path"] = a.cliBin + } + return opts +} + +func (a *Agent) Stop() error { return nil } + +// DeleteSession implements core.SessionDeleter by calling session.delete +// via a short-lived probe process. Returns nil gracefully if the binary is +// missing or the RPC is unsupported. +func (a *Agent) DeleteSession(ctx context.Context, sessionID string) error { + if sessionID == "" { + return nil + } + + a.mu.RLock() + snapshot := a.probeSnapshotLocked() + a.mu.RUnlock() + + if _, err := exec.LookPath(snapshot.cliBin); err != nil { + return nil + } + + probeCtx, cancel := context.WithTimeout(ctx, listSessionsProbeTimeout) + defer cancel() + + probe, err := newProbeSession(probeCtx, snapshot) + if err != nil { + slog.Debug("copilot: DeleteSession probe spawn failed", "error", err) + return nil + } + defer probe.close() + + pingResp, err := probe.call(probeCtx, "ping", nil) + if err != nil || pingResp.Error != nil { + slog.Debug("copilot: DeleteSession ping failed", "error", err) + return nil + } + + delResp, err := probe.call(probeCtx, "session.delete", map[string]any{"sessionId": sessionID}) + if err != nil { + return nil + } + + if delResp.Error != nil { + // method-not-found or invalid-request means unsupported + if delResp.Error.Code == -32601 || delResp.Error.Code == -32600 { + return nil + } + return fmt.Errorf("copilot: session.delete: %s", delResp.Error.Message) + } + + var result copilotDeleteSessionResponse + if err := json.Unmarshal(delResp.Result, &result); err != nil { + // Ignore parse errors - treat as success + return nil + } + if !result.Success { + if result.Error != nil { + return fmt.Errorf("copilot: session.delete failed: %s", *result.Error) + } + return fmt.Errorf("copilot: session.delete failed: unknown error") + } + slog.Info("copilot: session deleted", "sessionId", sessionID) + return nil +} + +// GetSessionHistory implements core.HistoryProvider. +// Copilot does not expose a history RPC; return empty gracefully. +func (a *Agent) GetSessionHistory(_ context.Context, _ string, _ int) ([]core.HistoryEntry, error) { + return nil, nil +} + +// CompressCommand implements core.ContextCompressor. +// Copilot has no built-in compact/compress command. +func (a *Agent) CompressCommand() string { return "" } + +// ── ProviderSwitcher implementation ────────────────────────── + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.activeIdx = -1 + slog.Info("copilot: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("copilot: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.RLock() + defer a.mu.RUnlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.RLock() + defer a.mu.RUnlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + var env []string + if p.BaseURL != "" { + env = append(env, "COPILOT_PROVIDER_BASE_URL="+p.BaseURL) + } + if p.APIKey != "" { + env = append(env, "COPILOT_PROVIDER_API_KEY="+p.APIKey) + } + if p.Model != "" { + env = append(env, "COPILOT_MODEL="+p.Model) + } + if p.CodexWireAPI != "" { + env = append(env, "COPILOT_PROVIDER_WIRE_API="+p.CodexWireAPI) + } + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +func (a *Agent) probeSnapshotLocked() probeSnapshot { + env := os.Environ() + if extraEnv := a.providerEnvLocked(); len(extraEnv) > 0 { + env = core.MergeEnv(env, extraEnv) + } + if len(a.sessionEnv) > 0 { + env = core.MergeEnv(env, a.sessionEnv) + } + return probeSnapshot{cliBin: a.cliBin, workDir: a.workDir, env: env} +} + +func (a *Agent) providerConfigLocked() *copilotWireProviderConfig { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + if p.BaseURL == "" { + return nil + } + provider := &copilotWireProviderConfig{ + Type: "openai", + BaseURL: p.BaseURL, + APIKey: p.APIKey, + ModelID: p.Model, + Headers: p.CodexHTTPHeaders, + WireAPI: p.CodexWireAPI, + } + if typ := strings.TrimSpace(p.Env["COPILOT_PROVIDER_TYPE"]); typ != "" { + provider.Type = typ + } + if wireModel := strings.TrimSpace(p.Env["COPILOT_PROVIDER_WIRE_MODEL"]); wireModel != "" { + provider.WireModel = wireModel + } + if bearer := strings.TrimSpace(p.Env["COPILOT_PROVIDER_BEARER_TOKEN"]); bearer != "" { + provider.BearerToken = bearer + } + return provider +} + +// Compile-time interface assertions. +var ( + _ core.Agent = (*Agent)(nil) + _ core.AgentDoctorInfo = (*Agent)(nil) + _ core.WorkspaceAgentOptionSnapshotter = (*Agent)(nil) + _ core.SessionDeleter = (*Agent)(nil) + _ core.HistoryProvider = (*Agent)(nil) + _ core.ContextCompressor = (*Agent)(nil) + _ core.ProviderSwitcher = (*Agent)(nil) +) diff --git a/agent/copilot/copilot_test.go b/agent/copilot/copilot_test.go new file mode 100644 index 0000000..40320a9 --- /dev/null +++ b/agent/copilot/copilot_test.go @@ -0,0 +1,369 @@ +package copilot + +import ( + "context" + "os" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func TestNew_MissingBinary(t *testing.T) { + _, err := New(map[string]any{ + "cli_path": "copilot-nonexistent-binary-xyz", + }) + if err == nil { + t.Fatal("expected error for missing binary") + } +} + +func TestAgent_Name(t *testing.T) { + a := &Agent{} + if a.Name() != "copilot" { + t.Fatalf("Name() = %q, want copilot", a.Name()) + } +} + +func TestNormalizeMode(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"default", "default"}, + {"bypassPermissions", "bypassPermissions"}, + {"bypass-permissions", "bypassPermissions"}, + {"bypass_permissions", "bypassPermissions"}, + {"yolo", "bypassPermissions"}, + {"YOLO", "bypassPermissions"}, + {"", "default"}, + {"unknown", "default"}, + } + for _, tt := range tests { + got := normalizeMode(tt.input) + if got != tt.want { + t.Errorf("normalizeMode(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestAgent_SetGetModel(t *testing.T) { + a := &Agent{} + a.SetModel("gpt-4o") + if got := a.GetModel(); got != "gpt-4o" { + t.Fatalf("GetModel() = %q, want gpt-4o", got) + } +} + +func TestAgent_SetGetMode(t *testing.T) { + a := &Agent{} + a.SetMode("yolo") + if got := a.GetMode(); got != "bypassPermissions" { + t.Fatalf("GetMode() = %q, want bypassPermissions", got) + } + a.SetMode("default") + if got := a.GetMode(); got != "default" { + t.Fatalf("GetMode() = %q, want default", got) + } +} + +func TestAgent_SetGetWorkDir(t *testing.T) { + a := &Agent{} + a.SetWorkDir("/tmp/test") + if got := a.GetWorkDir(); got != "/tmp/test" { + t.Fatalf("GetWorkDir() = %q, want /tmp/test", got) + } +} + +func TestAgent_PermissionModes(t *testing.T) { + a := &Agent{} + modes := a.PermissionModes() + if len(modes) != 2 { + t.Fatalf("PermissionModes() len = %d, want 2", len(modes)) + } + if modes[0].Key != "default" { + t.Errorf("modes[0].Key = %q, want default", modes[0].Key) + } + if modes[1].Key != "bypassPermissions" { + t.Errorf("modes[1].Key = %q, want bypassPermissions", modes[1].Key) + } +} + +func TestAgent_DeleteSession(t *testing.T) { + a := &Agent{} + err := a.DeleteSession(context.Background(), "some-session-id") + if err != nil { + t.Fatalf("DeleteSession() = %v, want nil", err) + } +} + +func TestAgent_GetSessionHistory(t *testing.T) { + a := &Agent{} + entries, err := a.GetSessionHistory(context.Background(), "some-session-id", 10) + if err != nil { + t.Fatalf("GetSessionHistory() error = %v", err) + } + if entries != nil { + t.Fatalf("GetSessionHistory() = %v, want nil", entries) + } +} + +func TestAgent_CompressCommand(t *testing.T) { + a := &Agent{} + if got := a.CompressCommand(); got != "" { + t.Fatalf("CompressCommand() = %q, want empty", got) + } +} + +func TestAgent_AvailableModels(t *testing.T) { + a := &Agent{} + models := a.AvailableModels(context.Background()) + if len(models) == 0 { + t.Fatal("AvailableModels() returned empty") + } +} + +func TestAgent_ProviderSwitcher(t *testing.T) { + a := &Agent{activeIdx: -1} + if p := a.GetActiveProvider(); p != nil { + t.Fatalf("GetActiveProvider() = %v, want nil", p) + } + if got := a.SetActiveProvider("nonexistent"); got { + t.Fatal("SetActiveProvider(nonexistent) = true, want false") + } +} + +func TestAgent_ProviderConfigLocked(t *testing.T) { + a := &Agent{activeIdx: -1} + a.SetProviders([]core.ProviderConfig{ + { + Name: "byok", + APIKey: "sk-test", + BaseURL: "https://provider.example/v1", + Model: "gpt-5.2", + CodexWireAPI: "responses", + CodexHTTPHeaders: map[string]string{"X-Provider": "test"}, + Env: map[string]string{ + "COPILOT_PROVIDER_TYPE": "anthropic", + "COPILOT_PROVIDER_WIRE_MODEL": "deployment-1", + "COPILOT_PROVIDER_BEARER_TOKEN": "bearer-token", + }, + }, + }) + if !a.SetActiveProvider("byok") { + t.Fatal("SetActiveProvider(byok) = false, want true") + } + + provider := a.providerConfigLocked() + if provider == nil { + t.Fatal("providerConfigLocked() = nil") + } + if provider.Type != "anthropic" { + t.Fatalf("Type = %q, want anthropic", provider.Type) + } + if provider.BaseURL != "https://provider.example/v1" { + t.Fatalf("BaseURL = %q", provider.BaseURL) + } + if provider.APIKey != "sk-test" { + t.Fatalf("APIKey = %q", provider.APIKey) + } + if provider.ModelID != "gpt-5.2" { + t.Fatalf("ModelID = %q", provider.ModelID) + } + if provider.WireAPI != "responses" { + t.Fatalf("WireAPI = %q", provider.WireAPI) + } + if provider.WireModel != "deployment-1" { + t.Fatalf("WireModel = %q", provider.WireModel) + } + if provider.BearerToken != "bearer-token" { + t.Fatalf("BearerToken = %q", provider.BearerToken) + } + if provider.Headers["X-Provider"] != "test" { + t.Fatalf("Headers = %v", provider.Headers) + } +} + +func TestAgent_ProviderEnvLocked(t *testing.T) { + a := &Agent{activeIdx: -1} + a.SetProviders([]core.ProviderConfig{ + { + Name: "byok", + APIKey: "sk-test", + BaseURL: "https://provider.example/v1", + Model: "gpt-5.2", + CodexWireAPI: "responses", + Env: map[string]string{"EXTRA": "1"}, + }, + }) + if !a.SetActiveProvider("byok") { + t.Fatal("SetActiveProvider(byok) = false, want true") + } + env := envMap(a.providerEnvLocked()) + if env["COPILOT_PROVIDER_BASE_URL"] != "https://provider.example/v1" { + t.Fatalf("COPILOT_PROVIDER_BASE_URL = %q", env["COPILOT_PROVIDER_BASE_URL"]) + } + if env["COPILOT_PROVIDER_API_KEY"] != "sk-test" { + t.Fatalf("COPILOT_PROVIDER_API_KEY = %q", env["COPILOT_PROVIDER_API_KEY"]) + } + if env["COPILOT_MODEL"] != "gpt-5.2" { + t.Fatalf("COPILOT_MODEL = %q", env["COPILOT_MODEL"]) + } + if env["COPILOT_PROVIDER_WIRE_API"] != "responses" { + t.Fatalf("COPILOT_PROVIDER_WIRE_API = %q", env["COPILOT_PROVIDER_WIRE_API"]) + } + if env["EXTRA"] != "1" { + t.Fatalf("EXTRA = %q", env["EXTRA"]) + } +} + +func TestAgent_ListSessions(t *testing.T) { + a := &Agent{} + sessions, err := a.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if sessions != nil { + t.Fatalf("ListSessions() = %v, want nil", sessions) + } +} + +func TestAgent_CLIBinaryName(t *testing.T) { + a := &Agent{cliBin: "copilot"} + if got := a.CLIBinaryName(); got != "copilot" { + t.Fatalf("CLIBinaryName() = %q, want copilot", got) + } +} + +func TestAgent_CLIDisplayName(t *testing.T) { + a := &Agent{} + if got := a.CLIDisplayName(); got != "GitHub Copilot" { + t.Fatalf("CLIDisplayName() = %q, want 'GitHub Copilot'", got) + } +} + +func TestAgent_WorkspaceAgentOptions(t *testing.T) { + a := &Agent{mode: "bypassPermissions", model: "gpt-4o", cliBin: "copilot"} + opts := a.WorkspaceAgentOptions() + if opts["mode"] != "bypassPermissions" { + t.Fatalf("mode = %v, want bypassPermissions", opts["mode"]) + } + if opts["model"] != "gpt-4o" { + t.Fatalf("model = %v, want gpt-4o", opts["model"]) + } + // cliBin is "copilot" (default) so cli_path should not be set + if _, ok := opts["cli_path"]; ok { + t.Fatal("cli_path should not be set for default binary name") + } + + // Custom CLI path should be included + a2 := &Agent{mode: "default", cliBin: "/custom/copilot"} + opts2 := a2.WorkspaceAgentOptions() + if opts2["cli_path"] != "/custom/copilot" { + t.Fatalf("cli_path = %v, want /custom/copilot", opts2["cli_path"]) + } +} + +// --------------------------------------------------------------------------- +// ListSessions / DeleteSession tests using a fake process helper +// --------------------------------------------------------------------------- + +// TestAgent_ListSessions_NoBinary verifies graceful nil return when binary is missing. +func TestAgent_ListSessions_NoBinary(t *testing.T) { + a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."} + sessions, err := a.ListSessions(context.Background()) + if err != nil { + t.Fatalf("ListSessions with missing binary returned error: %v", err) + } + if sessions != nil { + t.Fatalf("expected nil sessions, got %v", sessions) + } +} + +// TestAgent_DeleteSession_NoBinary verifies graceful nil return when binary is missing. +func TestAgent_DeleteSession_NoBinary(t *testing.T) { + a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."} + if err := a.DeleteSession(context.Background(), "some-session-id"); err != nil { + t.Fatalf("DeleteSession with missing binary returned error: %v", err) + } +} + +// TestAgent_DeleteSession_EmptyID verifies empty session ID returns nil immediately. +func TestAgent_DeleteSession_EmptyID(t *testing.T) { + a := &Agent{cliBin: "copilot-nonexistent-xyz", workDir: "."} + if err := a.DeleteSession(context.Background(), ""); err != nil { + t.Fatalf("DeleteSession with empty ID returned error: %v", err) + } +} + +// TestAgent_ListSessions_RPC tests ListSessions by using a real fake-process +// approach: we re-exec the test binary as a "mock copilot" that speaks the +// JSON-RPC protocol over stdio and responds to ping + session.list. +// +// The fake process is selected by the CC_MOCK_COPILOT_MODE env var. +func TestAgent_ListSessions_RPC(t *testing.T) { + bin, err := os.Executable() + if err != nil { + t.Skip("cannot get test executable path") + } + + // Point agent at the test binary itself acting as a mock copilot + a := &Agent{ + cliBin: bin, + workDir: ".", + } + + t.Setenv("CC_MOCK_COPILOT_MODE", "list_sessions") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + sessions, err := a.ListSessions(ctx) + if err != nil { + t.Fatalf("ListSessions returned error: %v", err) + } + if len(sessions) != 2 { + t.Fatalf("expected 2 sessions, got %d: %v", len(sessions), sessions) + } + if sessions[0].ID != "sess-001" { + t.Fatalf("sessions[0].ID = %q, want sess-001", sessions[0].ID) + } + if sessions[1].ID != "sess-002" { + t.Fatalf("sessions[1].ID = %q, want sess-002", sessions[1].ID) + } +} + +// TestAgent_DeleteSession_RPC tests DeleteSession using the same mock process approach. +func TestAgent_DeleteSession_RPC(t *testing.T) { + bin, err := os.Executable() + if err != nil { + t.Skip("cannot get test executable path") + } + + a := &Agent{ + cliBin: bin, + workDir: ".", + } + + t.Setenv("CC_MOCK_COPILOT_MODE", "delete_session") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := a.DeleteSession(ctx, "sess-001"); err != nil { + t.Fatalf("DeleteSession returned error: %v", err) + } +} + +func envMap(env []string) map[string]string { + out := make(map[string]string, len(env)) + for _, entry := range env { + for i := range entry { + if entry[i] == '=' { + out[entry[:i]] = entry[i+1:] + break + } + } + } + return out +} diff --git a/agent/copilot/integration_test.go b/agent/copilot/integration_test.go new file mode 100644 index 0000000..487ab03 --- /dev/null +++ b/agent/copilot/integration_test.go @@ -0,0 +1,69 @@ +package copilot + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func TestIntegration_CopilotFlow(t *testing.T) { + if os.Getenv("COPILOT_INTEGRATION") != "1" { + t.Skip("set COPILOT_INTEGRATION=1 to run") + } + if _, err := exec.LookPath("copilot"); err != nil { + t.Skip("copilot CLI not in PATH, skipping") + } + + agent, err := New(map[string]any{ + "work_dir": t.TempDir(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + session, err := agent.StartSession(ctx, "integration-test") + if err != nil { + t.Fatalf("StartSession: %v", err) + } + defer func() { _ = session.Close() }() + + // Send a deterministic prompt + err = session.Send("Reply with exactly 'integration-ok' and nothing else", nil, nil) + if err != nil { + t.Fatalf("Send: %v", err) + } + + // Collect events until done + var result string + timeout := time.After(25 * time.Second) + for { + select { + case ev, ok := <-session.Events(): + if !ok { + goto done + } + switch ev.Type { + case core.EventText: + result += ev.Content + case core.EventResult: + result += ev.Content + goto done + } + case <-timeout: + t.Fatal("timeout waiting for response") + } + } +done: + t.Logf("Response: %s", result) + if !strings.Contains(strings.ToLower(result), "integration-ok") { + t.Errorf("expected 'integration-ok' in response, got: %s", result) + } +} diff --git a/agent/copilot/jsonrpc.go b/agent/copilot/jsonrpc.go new file mode 100644 index 0000000..ef8eac8 --- /dev/null +++ b/agent/copilot/jsonrpc.go @@ -0,0 +1,233 @@ +package copilot + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "sync" + "sync/atomic" +) + +// jsonRPCRequest is a JSON-RPC 2.0 request. +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID int64 `json:"id"` + Method string `json:"method"` + Params any `json:"params,omitempty"` +} + +// jsonRPCNotification is a JSON-RPC 2.0 notification (server push, no id). +type jsonRPCNotification struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +// jsonRPCResponse is a JSON-RPC 2.0 response. +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +// jsonRPCError is the error object in a JSON-RPC 2.0 response. +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +func (e *jsonRPCError) Error() string { + return fmt.Sprintf("JSON-RPC error %d: %s", e.Code, e.Message) +} + +// lspWriter writes Content-Length framed JSON-RPC messages. +type lspWriter struct { + w io.Writer + mu sync.Mutex +} + +func newLSPWriter(w io.Writer) *lspWriter { + return &lspWriter{w: w} +} + +func (lw *lspWriter) writeMessage(v any) error { + data, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("marshal: %w", err) + } + + header := fmt.Sprintf("Content-Length: %d\r\n\r\n", len(data)) + + lw.mu.Lock() + defer lw.mu.Unlock() + + if _, err := io.WriteString(lw.w, header); err != nil { + return fmt.Errorf("write header: %w", err) + } + if _, err := lw.w.Write(data); err != nil { + return fmt.Errorf("write body: %w", err) + } + return nil +} + +// lspReader reads Content-Length framed JSON-RPC messages. +type lspReader struct { + reader *bufio.Reader +} + +func newLSPReader(r io.Reader) *lspReader { + return &lspReader{reader: bufio.NewReaderSize(r, 64*1024)} +} + +// readMessage reads one Content-Length framed message and returns the raw JSON body. +func (lr *lspReader) readMessage() ([]byte, error) { + contentLength := -1 + for { + line, err := lr.reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("read header: %w", err) + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + // End of headers + break + } + if strings.HasPrefix(line, "Content-Length: ") { + val := strings.TrimPrefix(line, "Content-Length: ") + n, err := strconv.Atoi(strings.TrimSpace(val)) + if err != nil { + return nil, fmt.Errorf("invalid Content-Length: %w", err) + } + contentLength = n + } + // Ignore other headers (Content-Type, etc.) + } + + if contentLength < 0 { + return nil, fmt.Errorf("missing Content-Length header") + } + if contentLength > 10*1024*1024 { + return nil, fmt.Errorf("Content-Length too large: %d", contentLength) + } + + body := make([]byte, contentLength) + if _, err := io.ReadFull(lr.reader, body); err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + return body, nil +} + +// rpcClient manages JSON-RPC request IDs and pending responses. +type rpcClient struct { + writer *lspWriter + nextID atomic.Int64 + + pendingMu sync.Mutex + pending map[int64]chan *jsonRPCResponse +} + +func newRPCClient(w io.Writer) *rpcClient { + c := &rpcClient{ + writer: newLSPWriter(w), + pending: make(map[int64]chan *jsonRPCResponse), + } + c.nextID.Store(1) + return c +} + +// call sends a JSON-RPC request and returns the response channel. +func (c *rpcClient) call(method string, params any) (int64, <-chan *jsonRPCResponse) { + id := c.nextID.Add(1) - 1 + ch := make(chan *jsonRPCResponse, 1) + + c.pendingMu.Lock() + c.pending[id] = ch + c.pendingMu.Unlock() + + req := jsonRPCRequest{ + JSONRPC: "2.0", + ID: id, + Method: method, + Params: params, + } + if err := c.writer.writeMessage(req); err != nil { + c.pendingMu.Lock() + delete(c.pending, id) + c.pendingMu.Unlock() + ch <- &jsonRPCResponse{Error: &jsonRPCError{Code: -1, Message: err.Error()}} + return id, ch + } + return id, ch +} + +// notify sends a JSON-RPC notification (no response expected). +func (c *rpcClient) notify(method string, params any) error { + // Notification: no ID field. We use a separate struct to omit it. + type notification struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params any `json:"params,omitempty"` + } + return c.writer.writeMessage(notification{ + JSONRPC: "2.0", + Method: method, + Params: params, + }) +} + +// dispatch routes a response to the pending caller. +func (c *rpcClient) dispatch(resp *jsonRPCResponse) bool { + var id int64 + if err := json.Unmarshal(resp.ID, &id); err != nil { + return false + } + + c.pendingMu.Lock() + ch, ok := c.pending[id] + if ok { + delete(c.pending, id) + } + c.pendingMu.Unlock() + + if ok { + ch <- resp + return true + } + return false +} + +// respond sends a JSON-RPC 2.0 response to a server-to-client request. +func (c *rpcClient) respond(id json.RawMessage, result any, rpcErr *jsonRPCError) error { + type response struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Result any `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` + } + return c.writer.writeMessage(response{ + JSONRPC: "2.0", + ID: id, + Result: result, + Error: rpcErr, + }) +} + +// cancelAll cancels all pending requests with the given error. +func (c *rpcClient) cancelAll(err error) { + c.pendingMu.Lock() + pending := c.pending + c.pending = make(map[int64]chan *jsonRPCResponse) + c.pendingMu.Unlock() + + resp := &jsonRPCResponse{ + Error: &jsonRPCError{Code: -1, Message: err.Error()}, + } + for _, ch := range pending { + ch <- resp + } +} diff --git a/agent/copilot/jsonrpc_test.go b/agent/copilot/jsonrpc_test.go new file mode 100644 index 0000000..90d1b4a --- /dev/null +++ b/agent/copilot/jsonrpc_test.go @@ -0,0 +1,176 @@ +package copilot + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestLSPWriter_WriteMessage(t *testing.T) { + var buf bytes.Buffer + w := newLSPWriter(&buf) + + msg := jsonRPCRequest{JSONRPC: "2.0", ID: 1, Method: "ping"} + if err := w.writeMessage(msg); err != nil { + t.Fatalf("writeMessage: %v", err) + } + + output := buf.String() + if !strings.HasPrefix(output, "Content-Length: ") { + t.Fatalf("output missing Content-Length header: %q", output) + } + if !strings.Contains(output, "\r\n\r\n") { + t.Fatalf("output missing header separator: %q", output) + } + // Verify JSON body + parts := strings.SplitN(output, "\r\n\r\n", 2) + var decoded jsonRPCRequest + if err := json.Unmarshal([]byte(parts[1]), &decoded); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if decoded.Method != "ping" { + t.Fatalf("method = %q, want ping", decoded.Method) + } +} + +func TestLSPReader_ReadMessage(t *testing.T) { + body := `{"jsonrpc":"2.0","method":"test"}` + frame := fmt.Sprintf("Content-Length: %d\r\n\r\n%s", len(body), body) + + r := newLSPReader(strings.NewReader(frame)) + got, err := r.readMessage() + if err != nil { + t.Fatalf("readMessage: %v", err) + } + if string(got) != body { + t.Fatalf("body = %q, want %q", got, body) + } +} + +func TestLSPReader_MultipleMessages(t *testing.T) { + body1 := `{"id":1}` + body2 := `{"id":2}` + frame := fmt.Sprintf("Content-Length: %d\r\n\r\n%sContent-Length: %d\r\n\r\n%s", + len(body1), body1, len(body2), body2) + + r := newLSPReader(strings.NewReader(frame)) + + got1, err := r.readMessage() + if err != nil { + t.Fatalf("readMessage 1: %v", err) + } + if string(got1) != body1 { + t.Fatalf("msg1 = %q, want %q", got1, body1) + } + + got2, err := r.readMessage() + if err != nil { + t.Fatalf("readMessage 2: %v", err) + } + if string(got2) != body2 { + t.Fatalf("msg2 = %q, want %q", got2, body2) + } +} + +func TestLSPReader_MissingContentLength(t *testing.T) { + frame := "Some-Header: value\r\n\r\n{}" + r := newLSPReader(strings.NewReader(frame)) + _, err := r.readMessage() + if err == nil { + t.Fatal("expected error for missing Content-Length") + } +} + +func TestLSPReader_TooLargeContentLength(t *testing.T) { + frame := "Content-Length: 99999999999\r\n\r\n" + r := newLSPReader(strings.NewReader(frame)) + _, err := r.readMessage() + if err == nil { + t.Fatal("expected error for oversized Content-Length") + } +} + +func TestRPCClient_CallAndDispatch(t *testing.T) { + var buf bytes.Buffer + c := newRPCClient(&buf) + + id, ch := c.call("test.method", map[string]string{"key": "val"}) + + // Simulate response + resp := &jsonRPCResponse{ + JSONRPC: "2.0", + Result: json.RawMessage(`{"ok":true}`), + } + idBytes, _ := json.Marshal(id) + resp.ID = idBytes + + if !c.dispatch(resp) { + t.Fatal("dispatch returned false") + } + + got := <-ch + if got.Error != nil { + t.Fatalf("unexpected error: %v", got.Error) + } + if string(got.Result) != `{"ok":true}` { + t.Fatalf("result = %s, want {\"ok\":true}", got.Result) + } +} + +func TestRPCClient_CancelAll(t *testing.T) { + var buf bytes.Buffer + c := newRPCClient(&buf) + + _, ch := c.call("foo", nil) + c.cancelAll(fmt.Errorf("test cancel")) + + got := <-ch + if got.Error == nil { + t.Fatal("expected error after cancelAll") + } +} + +func TestRPCClient_Notify(t *testing.T) { + var buf bytes.Buffer + c := newRPCClient(&buf) + + err := c.notify("permission.respond", map[string]string{"requestId": "123"}) + if err != nil { + t.Fatalf("notify: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "permission.respond") { + t.Fatalf("output missing method: %q", output) + } + // Notifications should NOT have an id field + if strings.Contains(output, `"id"`) { + t.Fatalf("notification should not have id field: %q", output) + } +} + +func TestSummarizeToolInput(t *testing.T) { + got := summarizeToolInput("shell", map[string]any{"command": "ls -la"}) + if !strings.Contains(got, "ls -la") { + t.Fatalf("summarizeToolInput = %q, want to contain 'ls -la'", got) + } + + // Nil input + if got := summarizeToolInput("test", nil); got != "" { + t.Fatalf("summarizeToolInput(nil) = %q, want empty", got) + } + + // Long input gets truncated + longInput := map[string]any{"data": strings.Repeat("x", 300)} + got = summarizeToolInput("test", longInput) + if len(got) > 210 { + t.Fatalf("summarizeToolInput length = %d, should be truncated", len(got)) + } +} + +// Verify interface compliance at compile time +var _ core.ContextUsageReporter = (*copilotSession)(nil) diff --git a/agent/copilot/mock_copilot_test.go b/agent/copilot/mock_copilot_test.go new file mode 100644 index 0000000..4133d40 --- /dev/null +++ b/agent/copilot/mock_copilot_test.go @@ -0,0 +1,118 @@ +package copilot + +import ( + "encoding/json" + "fmt" + "io" + "os" + "testing" + "time" +) + +// TestMain intercepts test binary re-executions used as a fake copilot process. +// When CC_MOCK_COPILOT_MODE is set, we run the mock server instead of tests. +func TestMain(m *testing.M) { + mode := os.Getenv("CC_MOCK_COPILOT_MODE") + switch mode { + case "list_sessions": + runMockCopilot(handleListSessions) + os.Exit(0) + case "delete_session": + runMockCopilot(handleDeleteSession) + os.Exit(0) + default: + os.Exit(m.Run()) + } +} + +// mockCopilotHandler is called for each incoming JSON-RPC request. +// It should write a framed JSON-RPC response to stdout. +type mockCopilotHandler func(method string, id json.RawMessage, params json.RawMessage, w *lspWriter) + +// runMockCopilot reads Content-Length framed JSON-RPC messages from stdin, +// dispatches them to handler, and exits on EOF. +func runMockCopilot(handler mockCopilotHandler) { + reader := newLSPReader(os.Stdin) + writer := newLSPWriter(os.Stdout) + + for { + body, err := reader.readMessage() + if err != nil { + if err == io.EOF { + return + } + return + } + + var probe struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if json.Unmarshal(body, &probe) != nil { + continue + } + // Only handle requests (have ID), skip notifications + if len(probe.ID) == 0 { + continue + } + handler(probe.Method, probe.ID, body, writer) + } +} + +func writeResponse(w *lspWriter, id json.RawMessage, result any) { + res, _ := json.Marshal(result) + _ = w.writeMessage(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "result": json.RawMessage(res), + }) +} + +func writeError(w *lspWriter, id json.RawMessage, code int, msg string) { + _ = w.writeMessage(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "error": map[string]any{"code": code, "message": msg}, + }) +} + +// handleListSessions is the mock that responds to ping + session.list. +func handleListSessions(method string, id json.RawMessage, _ json.RawMessage, w *lspWriter) { + switch method { + case "ping": + writeResponse(w, id, map[string]any{"pong": true}) + case "session.list": + summary1 := "First session" + summary2 := "Second session" + writeResponse(w, id, map[string]any{ + "sessions": []map[string]any{ + { + "sessionId": "sess-001", + "startTime": time.Now().Format(time.RFC3339), + "modifiedTime": time.Now().Format(time.RFC3339), + "summary": &summary1, + }, + { + "sessionId": "sess-002", + "startTime": time.Now().Format(time.RFC3339), + "modifiedTime": time.Now().Format(time.RFC3339), + "summary": &summary2, + }, + }, + }) + default: + writeError(w, id, -32601, fmt.Sprintf("method not found: %s", method)) + } +} + +// handleDeleteSession is the mock that responds to ping + session.delete. +func handleDeleteSession(method string, id json.RawMessage, _ json.RawMessage, w *lspWriter) { + switch method { + case "ping": + writeResponse(w, id, map[string]any{"pong": true}) + case "session.delete": + writeResponse(w, id, map[string]any{"success": true}) + default: + writeError(w, id, -32601, fmt.Sprintf("method not found: %s", method)) + } +} diff --git a/agent/copilot/proc_unix.go b/agent/copilot/proc_unix.go new file mode 100644 index 0000000..1243de7 --- /dev/null +++ b/agent/copilot/proc_unix.go @@ -0,0 +1,37 @@ +//go:build unix + +package copilot + +import ( + "errors" + "os" + "os/exec" + "syscall" +) + +func prepareCmdForKill(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true +} + +func terminateCmd(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) +} + +func forceKillCmd(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL); err != nil && !errors.Is(err, os.ErrProcessDone) && !errors.Is(err, syscall.ESRCH) { + return err + } + return nil +} diff --git a/agent/copilot/proc_windows.go b/agent/copilot/proc_windows.go new file mode 100644 index 0000000..3e7a660 --- /dev/null +++ b/agent/copilot/proc_windows.go @@ -0,0 +1,61 @@ +//go:build windows + +package copilot + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "syscall" +) + +func prepareCmdForKill(cmd *exec.Cmd) { + if cmd == nil { + return + } + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.CreationFlags |= syscall.CREATE_NEW_PROCESS_GROUP +} + +func terminateCmd(cmd *exec.Cmd) { + if cmd == nil || cmd.Process == nil { + return + } + _ = cmd.Process.Signal(os.Interrupt) +} + +func forceKillCmd(cmd *exec.Cmd) error { + if cmd == nil || cmd.Process == nil { + return nil + } + killCmd := exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(cmd.Process.Pid)) + output, err := killCmd.CombinedOutput() + if err == nil { + return nil + } + if bytes.Contains(bytes.ToLower(output), []byte("there is no running instance")) { + return nil + } + if bytes.Contains(bytes.ToLower(output), []byte("not found")) { + return nil + } + if killErr := cmd.Process.Kill(); killErr == nil || errors.Is(killErr, os.ErrProcessDone) { + return nil + } else { + return fmt.Errorf("taskkill failed: %w: %s; process kill fallback failed: %w", err, processKillOutput(output), killErr) + } +} + +func processKillOutput(output []byte) string { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return "(empty output)" + } + return trimmed +} diff --git a/agent/copilot/session.go b/agent/copilot/session.go new file mode 100644 index 0000000..f552437 --- /dev/null +++ b/agent/copilot/session.go @@ -0,0 +1,891 @@ +package copilot + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// copilotSession manages a long-running Copilot CLI process using +// JSON-RPC 2.0 over Content-Length framed stdio. +type copilotSession struct { + cmd *exec.Cmd + rpc *rpcClient + reader *lspReader + events chan core.Event + sessionID atomic.Value // stores string - Copilot session ID + mode string // permission mode + model string + provider *copilotWireProviderConfig + workDir string + ctx context.Context + cancel context.CancelFunc + done chan struct{} + alive atomic.Bool + + // autoApprove is set when mode == "bypassPermissions" + autoApprove atomic.Bool + + // pendingPermissions maps Copilot requestId to JSON-RPC request ids + // for permission.request server-to-client RPC calls. eventPermissions + // tracks permission.requested session events, which are answered via + // session.permissions.handlePendingPermissionRequest. + pendingPermMu sync.Mutex + pendingPermissions map[string]json.RawMessage + eventPermissions map[string]struct{} + + // context usage tracking + contextMu sync.RWMutex + contextUsage *core.ContextUsage +} + +type copilotWireProviderConfig struct { + Type string `json:"type,omitempty"` + WireAPI string `json:"wireApi,omitempty"` + BaseURL string `json:"baseUrl"` + APIKey string `json:"apiKey,omitempty"` + BearerToken string `json:"bearerToken,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + ModelID string `json:"modelId,omitempty"` + WireModel string `json:"wireModel,omitempty"` +} + +type copilotSessionConfig struct { + SessionID string `json:"sessionId,omitempty"` + ClientName string `json:"clientName,omitempty"` + Model string `json:"model,omitempty"` + Provider *copilotWireProviderConfig `json:"provider,omitempty"` + RequestPermission *bool `json:"requestPermission,omitempty"` + WorkingDirectory string `json:"workingDirectory,omitempty"` + Streaming *bool `json:"streaming,omitempty"` + IncludeSubAgentStreamingEvents *bool `json:"includeSubAgentStreamingEvents,omitempty"` + EnvValueMode string `json:"envValueMode,omitempty"` +} + +type copilotPermissionResult struct { + Kind string `json:"kind"` + Rules []any `json:"rules,omitempty"` +} + +func newCopilotSession(ctx context.Context, workDir, cliBin, model, mode, resumeSessionID string, extraEnv []string, provider *copilotWireProviderConfig) (*copilotSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + args := []string{"--headless", "--stdio", "--no-auto-update"} + + slog.Debug("copilotSession: starting", "bin", cliBin, "args", args, "dir", workDir) + + cmd := exec.CommandContext(sessionCtx, cliBin, args...) + cmd.Dir = workDir + prepareCmdForKill(cmd) + + env := os.Environ() + if len(extraEnv) > 0 { + env = core.MergeEnv(env, extraEnv) + } + cmd.Env = env + + stdin, err := cmd.StdinPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("copilotSession: stdin pipe: %w", err) + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + cancel() + return nil, fmt.Errorf("copilotSession: stdout pipe: %w", err) + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("copilotSession: start: %w", err) + } + + cs := &copilotSession{ + cmd: cmd, + rpc: newRPCClient(stdin), + reader: newLSPReader(stdout), + events: make(chan core.Event, 64), + mode: mode, + model: model, + provider: provider, + workDir: workDir, + ctx: sessionCtx, + cancel: cancel, + done: make(chan struct{}), + pendingPermissions: make(map[string]json.RawMessage), + eventPermissions: make(map[string]struct{}), + } + cs.alive.Store(true) + cs.autoApprove.Store(mode == "bypassPermissions") + if resumeSessionID != "" && resumeSessionID != core.ContinueSession { + cs.sessionID.Store(resumeSessionID) + } + + // Start reading loop + go cs.readLoop(&stderrBuf) + + // Perform handshake: ping then create/resume session + if err := cs.handshake(resumeSessionID); err != nil { + _ = cs.Close() + return nil, fmt.Errorf("copilotSession: handshake failed: %w", err) + } + + return cs, nil +} + +func (cs *copilotSession) handshake(resumeSessionID string) error { + // Step 1: Ping + _, pingCh := cs.rpc.call("ping", nil) + select { + case resp := <-pingCh: + if resp.Error != nil { + return fmt.Errorf("ping: %w", resp.Error) + } + slog.Debug("copilotSession: ping OK") + case <-time.After(10 * time.Second): + return fmt.Errorf("ping timeout") + case <-cs.ctx.Done(): + return cs.ctx.Err() + } + + // Step 2: Create or resume session + if resumeSessionID != "" && resumeSessionID != core.ContinueSession { + _, resumeCh := cs.rpc.call("session.resume", cs.sessionConfig(resumeSessionID)) + select { + case resp := <-resumeCh: + if resp.Error != nil { + slog.Warn("copilotSession: resume failed, creating new session", "error", resp.Error) + return cs.createSession() + } + cs.sessionID.Store(resumeSessionID) + slog.Info("copilotSession: session resumed", "sessionId", resumeSessionID) + case <-time.After(10 * time.Second): + return fmt.Errorf("session.resume timeout") + case <-cs.ctx.Done(): + return cs.ctx.Err() + } + } else { + return cs.createSession() + } + return nil +} + +func (cs *copilotSession) createSession() error { + _, createCh := cs.rpc.call("session.create", cs.sessionConfig(newCopilotSessionID())) + select { + case resp := <-createCh: + if resp.Error != nil { + return fmt.Errorf("session.create: %w", resp.Error) + } + var result struct { + SessionID string `json:"sessionId"` + } + if err := json.Unmarshal(resp.Result, &result); err != nil { + return fmt.Errorf("session.create decode: %w", err) + } + cs.sessionID.Store(result.SessionID) + slog.Info("copilotSession: session created", "sessionId", result.SessionID) + case <-time.After(10 * time.Second): + return fmt.Errorf("session.create timeout") + case <-cs.ctx.Done(): + return cs.ctx.Err() + } + return nil +} + +func (cs *copilotSession) sessionConfig(sessionID string) copilotSessionConfig { + requestPermission := true + streaming := true + includeSubAgentStreaming := true + return copilotSessionConfig{ + SessionID: sessionID, + ClientName: "cc-connect", + Model: strings.TrimSpace(cs.model), + Provider: cs.provider, + RequestPermission: &requestPermission, + WorkingDirectory: cs.workDir, + Streaming: &streaming, + IncludeSubAgentStreamingEvents: &includeSubAgentStreaming, + EnvValueMode: "direct", + } +} + +func newCopilotSessionID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("cc-connect-%d", time.Now().UnixNano()) + } + b[6] = (b[6] & 0x0f) | 0x40 + b[8] = (b[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} + +func (cs *copilotSession) readLoop(stderrBuf *bytes.Buffer) { + defer func() { + cs.alive.Store(false) + cs.rpc.cancelAll(fmt.Errorf("process exited")) + + // Wait for process exit + if err := cs.cmd.Wait(); err != nil { + stderrMsg := strings.TrimSpace(stderrBuf.String()) + if stderrMsg != "" { + slog.Error("copilotSession: process failed", "error", err, "stderr", stderrMsg) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + } + } + } + close(cs.events) + close(cs.done) + }() + + for { + body, err := cs.reader.readMessage() + if err != nil { + if cs.ctx.Err() != nil { + return + } + slog.Error("copilotSession: read error", "error", err) + return + } + + cs.handleMessage(body) + } +} + +func (cs *copilotSession) handleMessage(body []byte) { + var probe struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + if err := json.Unmarshal(body, &probe); err != nil { + slog.Debug("copilotSession: invalid JSON", "error", err) + return + } + + hasID := len(probe.ID) > 0 && string(probe.ID) != "null" + hasMethod := probe.Method != "" + + // server-to-client request: has both id AND method + if hasID && hasMethod { + cs.handleServerRequest(probe.ID, probe.Method, body) + return + } + + // Response: has id but no method + if hasID && !hasMethod { + var resp jsonRPCResponse + if err := json.Unmarshal(body, &resp); err == nil { + cs.rpc.dispatch(&resp) + } + return + } + + // Notification: no id + var notif jsonRPCNotification + if err := json.Unmarshal(body, ¬if); err != nil { + slog.Debug("copilotSession: failed to parse notification", "error", err) + return + } + cs.handleNotification(notif) +} + +// handleServerRequest handles server-to-client RPC requests (have both id and method). +func (cs *copilotSession) handleServerRequest(rpcID json.RawMessage, method string, body []byte) { + slog.Debug("copilotSession: server-to-client request", "method", method) + + switch method { + case "session.event": + var req struct { + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(body, &req); err != nil { + slog.Debug("copilotSession: failed to parse session.event request", "error", err) + _ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"}) + return + } + cs.handleSessionEvent(req.Params) + _ = cs.rpc.respond(rpcID, map[string]any{}, nil) + + case "permission.request": + var req struct { + Params json.RawMessage `json:"params"` + } + if err := json.Unmarshal(body, &req); err != nil { + slog.Debug("copilotSession: failed to parse permission.request", "error", err) + _ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"}) + return + } + permReq, err := parsePermissionRequest(req.Params) + if err != nil || permReq.RequestID == "" { + slog.Debug("copilotSession: failed to parse permission.request params", "error", err) + _ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32602, Message: "invalid params"}) + return + } + if permReq.Tool == "" { + permReq.Tool = permReq.ToolName + } + if permReq.Input == nil { + permReq.Input = permReq.Arguments + } + cs.pendingPermMu.Lock() + cs.pendingPermissions[permReq.RequestID] = rpcID + cs.pendingPermMu.Unlock() + cs.handlePermissionRequest(req.Params) + default: + // Send method-not-found response for unhandled server-to-client requests + _ = cs.rpc.respond(rpcID, nil, &jsonRPCError{Code: -32601, Message: "method not found"}) + } +} + +func (cs *copilotSession) handleNotification(notif jsonRPCNotification) { + slog.Debug("copilotSession: notification", "method", notif.Method) + + switch notif.Method { + case "session.event": + cs.handleSessionEvent(notif.Params) + case "permission.request": + // Copilot may send permission.request as a notification (no id). + cs.handlePermissionRequest(notif.Params) + default: + slog.Debug("copilotSession: unhandled notification", "method", notif.Method) + } +} + +// sessionEvent wraps Copilot's session.event notification params. +type sessionEvent struct { + SessionID string `json:"sessionId"` + Event sessionEventInner `json:"event"` +} + +type sessionEventInner struct { + Type string `json:"type"` + Kind string `json:"kind"` + Data json.RawMessage `json:"data"` +} + +func (cs *copilotSession) handleSessionEvent(params json.RawMessage) { + var evt sessionEvent + if err := json.Unmarshal(params, &evt); err != nil { + slog.Debug("copilotSession: failed to parse session.event", "error", err) + return + } + + eventType := evt.Event.Type + if eventType == "" { + eventType = evt.Event.Kind + } + eventType = normalizeCopilotEventType(eventType) + + slog.Debug("copilotSession: session event", "type", eventType, "sessionId", evt.SessionID) + + switch eventType { + case "assistant.message_delta": + content := copilotEventText(evt.Event.Data) + if content != "" { + e := core.Event{Type: core.EventText, Content: content} + select { + case cs.events <- e: + case <-cs.ctx.Done(): + } + } + + case "assistant.message": + usage := copilotEventUsage(evt.Event.Data) + if len(evt.Event.Data) > 0 { + cs.addContextUsage(usage.inputTokens, usage.outputTokens) + + e := core.Event{ + Type: core.EventResult, + SessionID: cs.CurrentSessionID(), + Done: true, + OutputTokens: usage.outputTokens, + } + select { + case cs.events <- e: + case <-cs.ctx.Done(): + } + } + + case "assistant.usage": + usage := copilotEventUsage(evt.Event.Data) + cs.addContextUsage(usage.inputTokens, usage.outputTokens) + + case "permission.requested": + cs.handlePermissionRequestedEvent(evt.Event.Data) + + case "assistant.turn_start": + slog.Debug("copilotSession: turn started") + + case "assistant.turn_end": + slog.Debug("copilotSession: turn ended") + + case "session.idle": + slog.Debug("copilotSession: session idle") + + case "session.skills_loaded", "session.mcp_servers_loaded", "session.tools_updated", "session.usage_info", "session.title_changed": + slog.Debug("copilotSession: capabilities updated", "type", eventType) + + case "system.message": + // System prompt notification, can be ignored + slog.Debug("copilotSession: system message received") + + case "pending_messages.modified", "custom_agents_updated", "session.start", "assistant.message_start", "assistant.streaming_delta", "response", "messages_snapshot": + // Known informational events; no action required + slog.Debug("copilotSession: informational event", "type", eventType) + + default: + slog.Debug("copilotSession: unhandled session event", "type", eventType) + } +} + +// permissionRequest represents Copilot's permission.request notification. +type permissionRequest struct { + SessionID string `json:"sessionId"` + RequestID string `json:"requestId"` + Tool string `json:"tool"` + ToolName string `json:"toolName"` + Kind string `json:"kind"` + Input map[string]any `json:"input"` + Arguments map[string]any `json:"arguments"` +} + +func (cs *copilotSession) handlePermissionRequest(params json.RawMessage) { + req, err := parsePermissionRequest(params) + if err != nil { + slog.Debug("copilotSession: failed to parse permission request", "error", err) + return + } + cs.emitPermissionRequest(req) +} + +func normalizeCopilotEventType(eventType string) string { + switch eventType { + case "assistant_message": + return "assistant.message" + case "assistant_usage": + return "assistant.usage" + case "assistant_turn_start": + return "assistant.turn_start" + case "assistant_turn_end": + return "assistant.turn_end" + case "assistant_message_start": + return "assistant.message_start" + case "assistant_message_delta": + return "assistant.message_delta" + case "assistant_streaming_delta": + return "assistant.streaming_delta" + } + return eventType +} + +func copilotEventText(raw json.RawMessage) string { + var data struct { + DeltaContent string `json:"deltaContent"` + Content string `json:"content"` + Text string `json:"text"` + Delta string `json:"delta"` + } + if err := json.Unmarshal(raw, &data); err != nil { + return "" + } + for _, s := range []string{data.DeltaContent, data.Content, data.Text, data.Delta} { + if s != "" { + return s + } + } + return "" +} + +type copilotUsage struct { + inputTokens int + outputTokens int +} + +func copilotEventUsage(raw json.RawMessage) copilotUsage { + var data struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + Usage struct { + InputTokens int `json:"inputTokens"` + OutputTokens int `json:"outputTokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(raw, &data); err != nil { + return copilotUsage{} + } + usage := copilotUsage{inputTokens: data.InputTokens, outputTokens: data.OutputTokens} + if usage.inputTokens == 0 { + usage.inputTokens = data.PromptTokens + } + if usage.outputTokens == 0 { + usage.outputTokens = data.CompletionTokens + } + if usage.inputTokens == 0 { + usage.inputTokens = data.Usage.InputTokens + } + if usage.inputTokens == 0 { + usage.inputTokens = data.Usage.PromptTokens + } + if usage.outputTokens == 0 { + usage.outputTokens = data.Usage.OutputTokens + } + if usage.outputTokens == 0 { + usage.outputTokens = data.Usage.CompletionTokens + } + return usage +} + +func (cs *copilotSession) addContextUsage(inputTokens, outputTokens int) { + if inputTokens == 0 && outputTokens == 0 { + return + } + cs.contextMu.Lock() + defer cs.contextMu.Unlock() + if cs.contextUsage == nil { + cs.contextUsage = &core.ContextUsage{} + } + cs.contextUsage.OutputTokens += outputTokens + cs.contextUsage.InputTokens += inputTokens + cs.contextUsage.TotalTokens = cs.contextUsage.InputTokens + cs.contextUsage.OutputTokens + cs.contextUsage.UsedTokens = cs.contextUsage.TotalTokens +} + +func (cs *copilotSession) handlePermissionRequestedEvent(data json.RawMessage) { + var evt struct { + RequestID string `json:"requestId"` + PermissionRequest json.RawMessage `json:"permissionRequest"` + } + if err := json.Unmarshal(data, &evt); err != nil || evt.RequestID == "" || len(evt.PermissionRequest) == 0 { + slog.Debug("copilotSession: failed to parse permission.requested event", "error", err) + return + } + + req, err := parsePermissionRequest(evt.PermissionRequest) + if err != nil { + slog.Debug("copilotSession: failed to parse permission.requested payload", "error", err) + return + } + req.RequestID = evt.RequestID + if req.Input == nil { + req.Input = rawJSONMap(evt.PermissionRequest) + } + + cs.pendingPermMu.Lock() + if cs.eventPermissions == nil { + cs.eventPermissions = make(map[string]struct{}) + } + cs.eventPermissions[req.RequestID] = struct{}{} + cs.pendingPermMu.Unlock() + + cs.emitPermissionRequest(req) +} + +func (cs *copilotSession) emitPermissionRequest(req permissionRequest) { + if req.Tool == "" { + req.Tool = req.ToolName + } + if req.Tool == "" { + req.Tool = strings.ReplaceAll(req.Kind, "_", ".") + } + if req.Input == nil { + req.Input = req.Arguments + } + + slog.Info("copilotSession: permission request", "requestId", req.RequestID, "tool", req.Tool) + + if cs.autoApprove.Load() { + slog.Debug("copilotSession: auto-approving", "requestId", req.RequestID, "tool", req.Tool) + _ = cs.RespondPermission(req.RequestID, core.PermissionResult{ + Behavior: "allow", + UpdatedInput: req.Input, + }) + return + } + + inputSummary := summarizeToolInput(req.Tool, req.Input) + evt := core.Event{ + Type: core.EventPermissionRequest, + RequestID: req.RequestID, + ToolName: req.Tool, + ToolInput: inputSummary, + ToolInputRaw: req.Input, + } + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + } +} + +func parsePermissionRequest(params json.RawMessage) (permissionRequest, error) { + var req permissionRequest + if err := json.Unmarshal(params, &req); err != nil { + return permissionRequest{}, err + } + if req.RequestID != "" || req.Tool != "" || req.ToolName != "" || req.Kind != "" { + return req, nil + } + var wrapped struct { + SessionID string `json:"sessionId"` + PermissionRequest permissionRequest `json:"permissionRequest"` + } + if err := json.Unmarshal(params, &wrapped); err != nil { + return permissionRequest{}, err + } + wrapped.PermissionRequest.SessionID = wrapped.SessionID + return wrapped.PermissionRequest, nil +} + +func rawJSONMap(raw json.RawMessage) map[string]any { + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil + } + return out +} + +func summarizeToolInput(tool string, input map[string]any) string { + if input == nil { + return "" + } + b, err := json.Marshal(input) + if err != nil { + return "" + } + s := string(b) + if len(s) > 200 { + s = s[:200] + "..." + } + return s +} + +// Send sends a user message to the running Copilot process. +func (cs *copilotSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if !cs.alive.Load() { + return fmt.Errorf("session process is not running") + } + + // Handle images: save to temp dir and append file references + if len(images) > 0 { + imgPaths, err := saveImagesToTempDir(cs.workDir, images) + if err != nil { + slog.Warn("copilotSession: failed to save images", "error", err) + } else { + prompt = core.AppendFileRefs(prompt, imgPaths) + } + } + + // Handle files + if len(files) > 0 { + filePaths := core.SaveFilesToDisk(cs.workDir, files) + prompt = core.AppendFileRefs(prompt, filePaths) + } + + sid := cs.CurrentSessionID() + if sid == "" { + return fmt.Errorf("no active session") + } + + params := map[string]any{ + "sessionId": sid, + "prompt": prompt, + } + + _, sendCh := cs.rpc.call("session.send", params) + + // Don't block - just validate the send was accepted + go func() { + select { + case resp := <-sendCh: + if resp.Error != nil { + slog.Error("copilotSession: send failed", "error", resp.Error) + if cs.alive.Load() { + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("send: %s", resp.Error.Message)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + } + } + } else { + slog.Debug("copilotSession: send accepted") + } + case <-cs.ctx.Done(): + } + }() + + return nil +} + +// RespondPermission sends a permission decision back to the Copilot process. +// If the permission request came as a server-to-client RPC request (has a JSON-RPC id), +// a proper JSON-RPC response is sent; otherwise a notification is used. +func (cs *copilotSession) RespondPermission(requestID string, result core.PermissionResult) error { + if !cs.alive.Load() { + return fmt.Errorf("session process is not running") + } + + decision := copilotPermissionResult{Kind: copilotPermissionKind(result)} + + // Check if this permission came as a server-to-client RPC request + cs.pendingPermMu.Lock() + rpcID, hasRPCID := cs.pendingPermissions[requestID] + if hasRPCID { + delete(cs.pendingPermissions, requestID) + } + _, hasEventPermission := cs.eventPermissions[requestID] + if hasEventPermission { + delete(cs.eventPermissions, requestID) + } + cs.pendingPermMu.Unlock() + + if hasRPCID { + return cs.rpc.respond(rpcID, decision, nil) + } + if hasEventPermission { + _, ch := cs.rpc.call("session.permissions.handlePendingPermissionRequest", map[string]any{ + "sessionId": cs.CurrentSessionID(), + "requestId": requestID, + "result": decision, + }) + go func() { + select { + case resp := <-ch: + if resp.Error != nil { + slog.Error("copilotSession: permission response failed", "error", resp.Error) + } + case <-cs.ctx.Done(): + } + }() + return nil + } + return cs.rpc.notify("permission.respond", map[string]any{ + "requestId": requestID, + "result": decision, + }) +} + +func copilotPermissionKind(result core.PermissionResult) string { + if result.Behavior == "allow" { + return "approve-once" + } + if strings.TrimSpace(result.Message) == "denied: no active user turn" { + return "user-not-available" + } + return "reject" +} + +func (cs *copilotSession) Events() <-chan core.Event { + return cs.events +} + +func (cs *copilotSession) CurrentSessionID() string { + v, _ := cs.sessionID.Load().(string) + return v +} + +func (cs *copilotSession) Alive() bool { + return cs.alive.Load() +} + +// GetContextUsage implements core.ContextUsageReporter. +func (cs *copilotSession) GetContextUsage() *core.ContextUsage { + cs.contextMu.RLock() + defer cs.contextMu.RUnlock() + if cs.contextUsage == nil { + return nil + } + cu := *cs.contextUsage + return &cu +} + +var ( + copilotGracefulTimeout = 3 * time.Second + copilotSigtermWait = 5 * time.Second +) + +func (cs *copilotSession) Close() error { + // Close stdin to signal EOF + if w, ok := cs.rpc.writer.w.(io.Closer); ok { + _ = w.Close() + } + + select { + case <-cs.done: + slog.Info("copilotSession: exited cleanly after stdin close") + return nil + case <-time.After(copilotGracefulTimeout): + slog.Warn("copilotSession: graceful stop timed out, sending SIGTERM") + } + + terminateCmd(cs.cmd) + + select { + case <-cs.done: + slog.Info("copilotSession: exited after SIGTERM") + return nil + case <-time.After(copilotSigtermWait): + slog.Warn("copilotSession: SIGTERM timed out, sending SIGKILL") + } + + cs.cancel() + _ = forceKillCmd(cs.cmd) + <-cs.done + return nil +} + +// saveImagesToTempDir saves image attachments to a temp directory under workDir +// and returns their file paths for inclusion in the prompt. +func saveImagesToTempDir(workDir string, images []core.ImageAttachment) ([]string, error) { + imgDir := filepath.Join(workDir, ".cc-connect", "images") + if err := os.MkdirAll(imgDir, 0o755); err != nil { + return nil, fmt.Errorf("saveImagesToTempDir: mkdir: %w", err) + } + + paths := make([]string, 0, len(images)) + for i, img := range images { + ext := imageExt(img.MimeType) + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(imgDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o644); err != nil { + return nil, fmt.Errorf("saveImagesToTempDir: write %s: %w", fname, err) + } + paths = append(paths, fpath) + } + return paths, nil +} + +func imageExt(mimeType string) string { + switch strings.ToLower(mimeType) { + case "image/png": + return ".png" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + default: + return ".jpg" + } +} diff --git a/agent/copilot/session_test.go b/agent/copilot/session_test.go new file mode 100644 index 0000000..87187ec --- /dev/null +++ b/agent/copilot/session_test.go @@ -0,0 +1,608 @@ +package copilot + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestHandleSessionEvent_AssistantMessage(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + } + cs.sessionID.Store("test-session") + cs.alive.Store(true) + + data, _ := json.Marshal(map[string]any{ + "content": "Hello, world!", + "outputTokens": 42, + "inputTokens": 100, + }) + cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{ + SessionID: "test-session", + Event: sessionEventInner{Type: "assistant.message", Data: data}, + }))) + + select { + case evt := <-cs.events: + if evt.Type != core.EventResult { + t.Fatalf("event type = %v, want EventResult", evt.Type) + } + if evt.Content != "" { + t.Fatalf("content = %q, want empty final content because text was streamed", evt.Content) + } + if evt.OutputTokens != 42 { + t.Fatalf("outputTokens = %d, want 42", evt.OutputTokens) + } + default: + t.Fatal("no event emitted") + } + + // Verify context usage was updated + usage := cs.GetContextUsage() + if usage == nil { + t.Fatal("context usage is nil") + } + if usage.OutputTokens != 42 { + t.Fatalf("usage.OutputTokens = %d, want 42", usage.OutputTokens) + } + if usage.InputTokens != 100 { + t.Fatalf("usage.InputTokens = %d, want 100", usage.InputTokens) + } + if usage.TotalTokens != 142 { + t.Fatalf("usage.TotalTokens = %d, want 142", usage.TotalTokens) + } +} + +func TestHandleSessionEvent_MessageDelta(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + } + cs.alive.Store(true) + + data, _ := json.Marshal(map[string]any{ + "deltaContent": "chunk", + }) + cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{ + Event: sessionEventInner{Type: "assistant.message_delta", Data: data}, + }))) + + select { + case evt := <-cs.events: + if evt.Type != core.EventText { + t.Fatalf("event type = %v, want EventText", evt.Type) + } + if evt.Content != "chunk" { + t.Fatalf("content = %q, want 'chunk'", evt.Content) + } + default: + t.Fatal("no event emitted") + } +} + +func TestHandleSessionEvent_KindAndCurrentCopilotEvents(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cs := &copilotSession{events: make(chan core.Event, 10), ctx: ctx, cancel: cancel} + cs.sessionID.Store("test-session") + + delta, _ := json.Marshal(map[string]any{"content": "integration-ok"}) + cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{ + SessionID: "test-session", + Event: sessionEventInner{Kind: "assistant.message_delta", Data: delta}, + }))) + select { + case evt := <-cs.events: + if evt.Type != core.EventText || evt.Content != "integration-ok" { + t.Fatalf("event = %+v, want integration text", evt) + } + default: + t.Fatal("no streaming event emitted") + } + + usage, _ := json.Marshal(map[string]any{"usage": map[string]any{"prompt_tokens": 12, "completion_tokens": 3}}) + cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{ + SessionID: "test-session", + Event: sessionEventInner{Kind: "assistant.usage", Data: usage}, + }))) + + final, _ := json.Marshal(map[string]any{"content": "integration-ok"}) + cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{ + SessionID: "test-session", + Event: sessionEventInner{Kind: "assistant.message", Data: final}, + }))) + select { + case evt := <-cs.events: + if evt.Type != core.EventResult || !evt.Done { + t.Fatalf("event = %+v, want done result", evt) + } + default: + t.Fatal("no final event emitted") + } + + got := cs.GetContextUsage() + if got == nil || got.InputTokens != 12 || got.OutputTokens != 3 { + t.Fatalf("usage = %+v, want 12 input and 3 output", got) + } +} + +func TestHandlePermissionRequest_AutoApprove(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + } + cs.alive.Store(true) + cs.autoApprove.Store(true) + // rpc needs to be set for RespondPermission to work - but in auto-approve + // mode we call notify which writes to rpc.writer. Use a no-op writer. + var nopBuf nopWriter + cs.rpc = newRPCClient(&nopBuf) + + params, _ := json.Marshal(permissionRequest{ + RequestID: "req-1", + Tool: "shell", + Input: map[string]any{"command": "ls"}, + }) + cs.handlePermissionRequest(params) + + // In auto-approve mode, no event should be forwarded to the engine + select { + case evt := <-cs.events: + t.Fatalf("unexpected event in auto-approve mode: %v", evt) + default: + // expected - no event + } +} + +func TestHandlePermissionRequest_AskUser(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + } + cs.alive.Store(true) + cs.autoApprove.Store(false) + + params, _ := json.Marshal(permissionRequest{ + RequestID: "req-2", + Tool: "file_write", + Input: map[string]any{"path": "/tmp/test.txt"}, + }) + cs.handlePermissionRequest(params) + + select { + case evt := <-cs.events: + if evt.Type != core.EventPermissionRequest { + t.Fatalf("event type = %v, want EventPermissionRequest", evt.Type) + } + if evt.RequestID != "req-2" { + t.Fatalf("requestID = %q, want req-2", evt.RequestID) + } + if evt.ToolName != "file_write" { + t.Fatalf("toolName = %q, want file_write", evt.ToolName) + } + default: + t.Fatal("no event emitted for permission request") + } +} + +func TestHandlePermissionRequest_WrappedCopilotShape(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + } + cs.alive.Store(true) + + params, _ := json.Marshal(map[string]any{ + "sessionId": "sess-1", + "permissionRequest": map[string]any{ + "requestId": "req-3", + "toolName": "write", + "arguments": map[string]any{"path": "README.md"}, + }, + }) + cs.handlePermissionRequest(params) + + select { + case evt := <-cs.events: + if evt.RequestID != "req-3" { + t.Fatalf("requestID = %q, want req-3", evt.RequestID) + } + if evt.ToolName != "write" { + t.Fatalf("toolName = %q, want write", evt.ToolName) + } + if evt.ToolInputRaw["path"] != "README.md" { + t.Fatalf("raw input = %v, want path README.md", evt.ToolInputRaw) + } + default: + t.Fatal("no event emitted for wrapped permission request") + } +} + +func TestHandleServerRequest_PermissionRequestWrappedCopilotShape(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var buf bytes.Buffer + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + rpc: newRPCClient(&buf), + pendingPermissions: make(map[string]json.RawMessage), + } + cs.alive.Store(true) + + body, _ := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": 11, + "method": "permission.request", + "params": map[string]any{ + "sessionId": "sess-1", + "permissionRequest": map[string]any{ + "requestId": "req-4", + "toolName": "shell", + "arguments": map[string]any{"command": "pwd"}, + }, + }, + }) + cs.handleServerRequest(json.RawMessage(`11`), "permission.request", body) + + select { + case evt := <-cs.events: + if evt.RequestID != "req-4" { + t.Fatalf("requestID = %q, want req-4", evt.RequestID) + } + if evt.ToolName != "shell" { + t.Fatalf("toolName = %q, want shell", evt.ToolName) + } + default: + t.Fatal("no event emitted for server permission request") + } + cs.pendingPermMu.Lock() + _, ok := cs.pendingPermissions["req-4"] + cs.pendingPermMu.Unlock() + if !ok { + t.Fatal("pending permission req-4 not recorded") + } +} + +func TestHandleSessionEvent_PermissionRequestedEvent(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + eventPermissions: make(map[string]struct{}), + } + cs.alive.Store(true) + + data, _ := json.Marshal(map[string]any{ + "requestId": "req-event-1", + "permissionRequest": map[string]any{ + "kind": "shell", + "command": "pwd", + }, + }) + cs.handleSessionEvent(json.RawMessage(mustMarshal(t, sessionEvent{ + SessionID: "sess-1", + Event: sessionEventInner{Type: "permission.requested", Data: data}, + }))) + + select { + case evt := <-cs.events: + if evt.Type != core.EventPermissionRequest { + t.Fatalf("event type = %v, want EventPermissionRequest", evt.Type) + } + if evt.RequestID != "req-event-1" { + t.Fatalf("requestID = %q, want req-event-1", evt.RequestID) + } + if evt.ToolName != "shell" { + t.Fatalf("toolName = %q, want shell", evt.ToolName) + } + if evt.ToolInputRaw["command"] != "pwd" { + t.Fatalf("raw input = %v, want command pwd", evt.ToolInputRaw) + } + default: + t.Fatal("no event emitted for permission.requested") + } + + cs.pendingPermMu.Lock() + _, ok := cs.eventPermissions["req-event-1"] + cs.pendingPermMu.Unlock() + if !ok { + t.Fatal("event permission req-event-1 not recorded") + } +} + +func TestRespondPermission_EventPermissionUsesSessionScopedRPC(t *testing.T) { + var buf bytes.Buffer + cs := &copilotSession{ + rpc: newRPCClient(&buf), + eventPermissions: map[string]struct{}{"req-event-2": {}}, + ctx: context.Background(), + } + cs.alive.Store(true) + cs.sessionID.Store("sess-2") + + if err := cs.RespondPermission("req-event-2", core.PermissionResult{Behavior: "allow"}); err != nil { + t.Fatalf("RespondPermission: %v", err) + } + + _, body := decodeFramedMessage(t, buf.String()) + var req struct { + Method string `json:"method"` + Params struct { + SessionID string `json:"sessionId"` + RequestID string `json:"requestId"` + Result struct { + Kind string `json:"kind"` + } `json:"result"` + } `json:"params"` + } + if err := json.Unmarshal([]byte(body), &req); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if req.Method != "session.permissions.handlePendingPermissionRequest" { + t.Fatalf("method = %q", req.Method) + } + if req.Params.SessionID != "sess-2" || req.Params.RequestID != "req-event-2" { + t.Fatalf("params = %+v", req.Params) + } + if req.Params.Result.Kind != "approve-once" { + t.Fatalf("kind = %q, want approve-once", req.Params.Result.Kind) + } +} + +func TestSessionConfig_MatchesCopilotCreateResumeShape(t *testing.T) { + cs := &copilotSession{ + model: "gpt-5.2", + workDir: "/work/project", + provider: &copilotWireProviderConfig{ + Type: "anthropic", + BaseURL: "https://api.anthropic.com", + APIKey: "sk-ant", + ModelID: "claude-sonnet-4.6", + WireAPI: "responses", + Headers: map[string]string{"X-Test": "1"}, + WireModel: "deployment-a", + }, + } + cfg := cs.sessionConfig("sess-1") + if cfg.SessionID != "sess-1" { + t.Fatalf("SessionID = %q, want sess-1", cfg.SessionID) + } + if cfg.ClientName != "cc-connect" { + t.Fatalf("ClientName = %q, want cc-connect", cfg.ClientName) + } + if cfg.Model != "gpt-5.2" { + t.Fatalf("Model = %q, want gpt-5.2", cfg.Model) + } + if cfg.WorkingDirectory != "/work/project" { + t.Fatalf("WorkingDirectory = %q, want /work/project", cfg.WorkingDirectory) + } + if cfg.RequestPermission == nil || !*cfg.RequestPermission { + t.Fatalf("RequestPermission = %v, want true", cfg.RequestPermission) + } + if cfg.Streaming == nil || !*cfg.Streaming { + t.Fatalf("Streaming = %v, want true", cfg.Streaming) + } + if cfg.IncludeSubAgentStreamingEvents == nil || !*cfg.IncludeSubAgentStreamingEvents { + t.Fatalf("IncludeSubAgentStreamingEvents = %v, want true", cfg.IncludeSubAgentStreamingEvents) + } + if cfg.EnvValueMode != "direct" { + t.Fatalf("EnvValueMode = %q, want direct", cfg.EnvValueMode) + } + if cfg.Provider == nil || cfg.Provider.Type != "anthropic" || cfg.Provider.WireModel != "deployment-a" { + t.Fatalf("Provider = %+v, want anthropic/deployment-a", cfg.Provider) + } +} + +func TestRespondPermission_RPCUsesCopilotResultShape(t *testing.T) { + var buf bytes.Buffer + cs := &copilotSession{ + rpc: newRPCClient(&buf), + pendingPermissions: map[string]json.RawMessage{"req-1": json.RawMessage(`7`)}, + } + cs.alive.Store(true) + + if err := cs.RespondPermission("req-1", core.PermissionResult{Behavior: "allow"}); err != nil { + t.Fatalf("RespondPermission: %v", err) + } + + _, body := decodeFramedMessage(t, buf.String()) + var resp struct { + ID int `json:"id"` + Result struct { + Kind string `json:"kind"` + } `json:"result"` + } + if err := json.Unmarshal([]byte(body), &resp); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if resp.ID != 7 { + t.Fatalf("id = %d, want 7", resp.ID) + } + if resp.Result.Kind != "approve-once" { + t.Fatalf("kind = %q, want approve-once", resp.Result.Kind) + } +} + +func TestRespondPermission_NotifyUsesCopilotResultShape(t *testing.T) { + var buf bytes.Buffer + cs := &copilotSession{rpc: newRPCClient(&buf)} + cs.alive.Store(true) + + if err := cs.RespondPermission("req-2", core.PermissionResult{Behavior: "deny", Message: "no"}); err != nil { + t.Fatalf("RespondPermission: %v", err) + } + + _, body := decodeFramedMessage(t, buf.String()) + var notif struct { + Method string `json:"method"` + Params struct { + RequestID string `json:"requestId"` + Result struct { + Kind string `json:"kind"` + } `json:"result"` + } `json:"params"` + } + if err := json.Unmarshal([]byte(body), ¬if); err != nil { + t.Fatalf("unmarshal notification: %v", err) + } + if notif.Method != "permission.respond" { + t.Fatalf("method = %q, want permission.respond", notif.Method) + } + if notif.Params.RequestID != "req-2" { + t.Fatalf("requestID = %q, want req-2", notif.Params.RequestID) + } + if notif.Params.Result.Kind != "reject" { + t.Fatalf("kind = %q, want reject", notif.Params.Result.Kind) + } +} + +func TestCopilotSession_CurrentSessionID(t *testing.T) { + cs := &copilotSession{} + if got := cs.CurrentSessionID(); got != "" { + t.Fatalf("CurrentSessionID() = %q, want empty", got) + } + cs.sessionID.Store("abc-123") + if got := cs.CurrentSessionID(); got != "abc-123" { + t.Fatalf("CurrentSessionID() = %q, want abc-123", got) + } +} + +func TestCopilotSession_GetContextUsage_Nil(t *testing.T) { + cs := &copilotSession{} + if got := cs.GetContextUsage(); got != nil { + t.Fatalf("GetContextUsage() = %v, want nil", got) + } +} + +// helpers + +func mustMarshal(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return b +} + +func decodeFramedMessage(t *testing.T, frame string) (string, string) { + t.Helper() + parts := bytes.SplitN([]byte(frame), []byte("\r\n\r\n"), 2) + if len(parts) != 2 { + t.Fatalf("frame missing header separator: %q", frame) + } + return string(parts[0]), string(parts[1]) +} + +type nopWriter struct{} + +func (nopWriter) Write(p []byte) (int, error) { return len(p), nil } + +func TestSession_SendWithFiles(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tmpDir := t.TempDir() + var nopBuf nopWriter + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + workDir: tmpDir, + rpc: newRPCClient(&nopBuf), + } + cs.alive.Store(true) + cs.sessionID.Store("test-session") + + files := []core.FileAttachment{ + {FileName: "test.txt", Data: []byte("hello file")}, + } + + // Send returns error because rpc.call tries to write to nopBuf then + // the goroutine gets a nil channel. Just check it doesn't panic. + // The file should still be saved before Send tries to contact the process. + _ = cs.Send("please review", nil, files) +} + +func TestSession_SendWithImages(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + tmpDir := t.TempDir() + var nopBuf nopWriter + cs := &copilotSession{ + events: make(chan core.Event, 10), + ctx: ctx, + cancel: cancel, + workDir: tmpDir, + rpc: newRPCClient(&nopBuf), + } + cs.alive.Store(true) + cs.sessionID.Store("test-session") + + images := []core.ImageAttachment{ + {MimeType: "image/png", Data: []byte{0x89, 0x50, 0x4e, 0x47}}, + } + + // Just ensure no panic and images dir gets created + _ = cs.Send("describe image", images, nil) + imgDir := tmpDir + "/.cc-connect/images" + entries, _ := os.ReadDir(imgDir) + if len(entries) != 1 { + t.Fatalf("expected 1 image file, got %d", len(entries)) + } +} + +func TestSaveImagesToTempDir(t *testing.T) { + tmpDir := t.TempDir() + images := []core.ImageAttachment{ + {MimeType: "image/jpeg", Data: []byte{0xff, 0xd8}}, + {MimeType: "image/png", Data: []byte{0x89, 0x50}}, + {MimeType: "image/webp", Data: []byte{0x52, 0x49}}, + {MimeType: "image/gif", Data: []byte{0x47, 0x49}}, + } + + paths, err := saveImagesToTempDir(tmpDir, images) + if err != nil { + t.Fatalf("saveImagesToTempDir() error = %v", err) + } + if len(paths) != 4 { + t.Fatalf("expected 4 paths, got %d", len(paths)) + } + + exts := []string{".jpg", ".png", ".webp", ".gif"} + for i, p := range paths { + if filepath.Ext(p) != exts[i] { + t.Errorf("path[%d] ext = %q, want %q", i, filepath.Ext(p), exts[i]) + } + if _, err := os.Stat(p); err != nil { + t.Errorf("file %s not found: %v", p, err) + } + } +} diff --git a/agent/cursor/cursor.go b/agent/cursor/cursor.go new file mode 100644 index 0000000..c824554 --- /dev/null +++ b/agent/cursor/cursor.go @@ -0,0 +1,568 @@ +package cursor + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("cursor", New) +} + +// Agent drives the Cursor Agent CLI (`agent`) using --print --output-format stream-json. +// +// Modes (maps to Cursor agent CLI flags): +// - "default": default permissions (ask permission for tools) +// - "force": --force (auto-approve tools unless explicitly denied) +// - "plan": --mode plan (read-only analysis) +// - "ask": --mode ask (Q&A style, read-only) +type Agent struct { + workDir string + model string + mode string + cmd string // CLI binary name, default "agent" + providers []core.ProviderConfig + activeIdx int + sessionEnv []string + mu sync.RWMutex +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + cmd, _ := opts["cmd"].(string) + if cmd == "" { + cmd = "agent" + } + if _, err := exec.LookPath(cmd); err != nil { + return nil, fmt.Errorf("cursor: %q CLI not found in PATH, install with: npm i -g @anthropic-ai/cursor-agent (or from Cursor IDE settings)", cmd) + } + + return &Agent{ + workDir: workDir, + model: model, + mode: mode, + cmd: cmd, + activeIdx: -1, + }, nil +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "force", "yolo", "auto": + return "force" + case "plan": + return "plan" + case "ask": + return "ask" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "cursor" } +func (a *Agent) CLIBinaryName() string { return "agent" } +func (a *Agent) CLIDisplayName() string { return "Cursor Agent" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("cursor: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("cursor: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { + if models := a.configuredModels(); len(models) > 0 { + return models + } + a.mu.RLock() + cmd := a.cmd + extraEnv := a.providerEnvLocked() + extraEnv = append(extraEnv, a.sessionEnv...) + a.mu.RUnlock() + + if models := fetchModelsFromAgentCLI(ctx, cmd, extraEnv); len(models) > 0 { + return models + } + return cursorFallbackModels() +} + +// fetchModelsFromAgentCLI runs `agent models` and parses the output. +// Output format: "model-id - Display Name (current)" or "model-id - Display Name" +func fetchModelsFromAgentCLI(ctx context.Context, cmd string, extraEnv []string) []core.ModelOption { + c := exec.CommandContext(ctx, cmd, "models") + c.Env = append(os.Environ(), extraEnv...) + out, err := c.Output() + if err != nil { + slog.Debug("cursor: agent models failed", "error", err) + return nil + } + + var models []core.ModelOption + seen := make(map[string]struct{}) + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" || line == "Available models" || strings.HasPrefix(line, "Tip:") { + continue + } + idx := strings.Index(line, " - ") + if idx < 0 { + continue + } + name := strings.TrimSpace(line[:idx]) + desc := strings.TrimSpace(line[idx+3:]) + if name == "" { + continue + } + // Remove trailing markers like "(current)", "(default)" + desc = strings.TrimSuffix(desc, " (current)") + desc = strings.TrimSuffix(desc, " (default)") + desc = strings.TrimSpace(desc) + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + models = append(models, core.ModelOption{Name: name, Desc: desc}) + } + sort.Slice(models, func(i, j int) bool { return models[i].Name < models[j].Name }) + return models +} + +func cursorFallbackModels() []core.ModelOption { + return []core.ModelOption{ + {Name: "claude-sonnet-4-20250514", Desc: "Claude Sonnet 4"}, + {Name: "claude-opus-4-20250514", Desc: "Claude Opus 4"}, + {Name: "gpt-4o", Desc: "GPT-4o"}, + {Name: "gemini-2.5-pro", Desc: "Gemini 2.5 Pro"}, + {Name: "cursor-small", Desc: "Cursor Small (fast)"}, + } +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.RLock() + model := a.model + mode := a.mode + cmd := a.cmd + workDir := a.workDir + extraEnv := a.providerEnvLocked() + extraEnv = append(extraEnv, a.sessionEnv...) + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + if m := a.providers[a.activeIdx].Model; m != "" { + model = m + } + } + a.mu.RUnlock() + + return newCursorSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv) +} + +// ListSessions reads sessions from ~/.cursor/chats//. +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + workDir := a.GetWorkDir() + return listCursorSessions(workDir) +} + +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cursor: cannot determine home dir: %w", err) + } + workDir := a.GetWorkDir() + hash := workspaceHash(workDir) + dir := filepath.Join(homeDir, ".cursor", "chats", hash, sessionID) + if _, err := os.Stat(dir); os.IsNotExist(err) { + return fmt.Errorf("session not found: %s", sessionID) + } + return os.RemoveAll(dir) +} + +func (a *Agent) Stop() error { return nil } + +// ── SkillProvider implementation ────────────────────────────── + +func (a *Agent) SkillDirs() []string { + workDir := a.GetWorkDir() + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + dirs := []string{filepath.Join(absDir, ".claude", "skills")} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".claude", "skills")) + } + return dirs +} + +// ── ContextCompressor implementation ────────────────────────── + +func (a *Agent) CompressCommand() string { return "" } + +// ── ModeSwitcher ──────────────────────────────────────────────── + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("cursor: mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Trust workspace, ask before tool use", DescZh: "信任工作区,工具调用前询问"}, + {Key: "force", Name: "Force (YOLO)", NameZh: "强制执行", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"}, + {Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only analysis, no edits", DescZh: "只读分析,不做修改"}, + {Key: "ask", Name: "Ask", NameZh: "问答模式", Desc: "Q&A style, read-only", DescZh: "问答风格,只读"}, + } +} + +// ── ProviderSwitcher ──────────────────────────────────────────── + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.activeIdx = -1 + slog.Info("cursor: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("cursor: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.RLock() + defer a.mu.RUnlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.RLock() + defer a.mu.RUnlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + var env []string + if p.APIKey != "" { + env = append(env, "CURSOR_API_KEY="+p.APIKey) + } + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +// ── Session listing ───────────────────────────────────────────── + +// workspaceHash returns the MD5 hash that Cursor uses to organize chats by workspace. +func workspaceHash(workDir string) string { + abs, err := filepath.Abs(workDir) + if err != nil { + abs = workDir + } + h := md5.Sum([]byte(abs)) + return hex.EncodeToString(h[:]) +} + +func listCursorSessions(workDir string) ([]core.AgentSessionInfo, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("cursor: cannot determine home dir: %w", err) + } + + hash := workspaceHash(workDir) + chatsDir := filepath.Join(homeDir, ".cursor", "chats", hash) + + entries, err := os.ReadDir(chatsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("cursor: read chats dir: %w", err) + } + + var sessions []core.AgentSessionInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + sessionID := entry.Name() + dbPath := filepath.Join(chatsDir, sessionID, "store.db") + if _, err := os.Stat(dbPath); err != nil { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + meta := readSessionMeta(dbPath) + msgCount, firstUserMsg := countSessionMessages(dbPath, meta.RootBlobID) + + summary := meta.Name + if summary == "" || summary == "New Agent" { + if firstUserMsg != "" { + summary = firstUserMsg + } else { + summary = sessionID[:12] + "..." + } + } + if utf8.RuneCountInString(summary) > 60 { + summary = string([]rune(summary)[:60]) + "..." + } + + sessions = append(sessions, core.AgentSessionInfo{ + ID: sessionID, + Summary: summary, + MessageCount: msgCount, + ModifiedAt: info.ModTime(), + }) + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + + return sessions, nil +} + +// sessionMeta holds metadata extracted from a Cursor chat store.db. +type sessionMeta struct { + AgentID string + Name string + Mode string + RootBlobID string +} + +// readSessionMeta reads the meta table from store.db without importing database/sql. +// The meta value at key "0" is already a hex-encoded JSON string in the TEXT column, +// so we read it directly (no extra hex() wrapping) and decode once. +func readSessionMeta(dbPath string) sessionMeta { + sqliteBin, err := exec.LookPath("sqlite3") + if err != nil { + return sessionMeta{} + } + + out, err := exec.Command(sqliteBin, dbPath, + "SELECT value FROM meta WHERE key='0' LIMIT 1;", + ).Output() + if err != nil { + return sessionMeta{} + } + + hexStr := strings.TrimSpace(string(out)) + if hexStr == "" { + return sessionMeta{} + } + + decoded, err := hex.DecodeString(hexStr) + if err != nil { + // Fallback: value might be raw JSON (not hex-encoded) in some versions + decoded = []byte(hexStr) + } + + var m struct { + AgentID string `json:"agentId"` + Name string `json:"name"` + Mode string `json:"mode"` + RootBlobID string `json:"latestRootBlobId"` + } + if json.Unmarshal(decoded, &m) != nil { + return sessionMeta{} + } + + return sessionMeta{AgentID: m.AgentID, Name: m.Name, Mode: m.Mode, RootBlobID: m.RootBlobID} +} + +// countSessionMessages reads the root blob from store.db and counts conversation +// messages. It also returns the first user message text as a summary fallback. +// The root blob uses a protobuf-like encoding where field 1 (tag 0x0a, length 0x20) +// entries are 32-byte SHA-256 references to child message blobs. +func countSessionMessages(dbPath, rootBlobID string) (int, string) { + if rootBlobID == "" { + return 0, "" + } + sqliteBin, err := exec.LookPath("sqlite3") + if err != nil { + return 0, "" + } + + // Read root blob header (first ~8KB is enough for counting refs) + out, err := exec.Command(sqliteBin, dbPath, + fmt.Sprintf("SELECT hex(substr(data,1,8192)) FROM blobs WHERE id='%s' LIMIT 1;", rootBlobID), + ).Output() + if err != nil { + return 0, "" + } + rootHex := strings.TrimSpace(string(out)) + rootBytes, err := hex.DecodeString(rootHex) + if err != nil || len(rootBytes) == 0 { + return 0, "" + } + + // Count field-1 entries (0x0a 0x20 + 32-byte hash) + var childIDs []string + i := 0 + for i+33 < len(rootBytes) && rootBytes[i] == 0x0a && rootBytes[i+1] == 0x20 { + childIDs = append(childIDs, hex.EncodeToString(rootBytes[i+2:i+34])) + i += 34 + } + if len(childIDs) == 0 { + return 0, "" + } + + // Read the first few children to find the first real user message for summary, + // and count roles to determine message count (excluding system). + msgCount := 0 + var firstUserMsg string + limit := len(childIDs) + if limit > 80 { + limit = 80 + } + + // Build a single query to read multiple children + var ids []string + for _, cid := range childIDs[:limit] { + ids = append(ids, "'"+cid+"'") + } + query := fmt.Sprintf( + "SELECT id, data FROM blobs WHERE id IN (%s);", + strings.Join(ids, ","), + ) + blobOut, err := exec.Command(sqliteBin, "-separator", "|", dbPath, query).Output() + if err != nil { + // Fallback: estimate from child count minus 1 (system message) + if len(childIDs) > 1 { + return len(childIDs) - 1, "" + } + return 0, "" + } + + roleCount := make(map[string]int) + blobMap := make(map[string][]byte) + for _, line := range strings.Split(string(blobOut), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + continue + } + blobMap[parts[0]] = []byte(parts[1]) + } + + for _, cid := range childIDs[:limit] { + raw, ok := blobMap[cid] + if !ok || len(raw) == 0 { + continue + } + var msg struct { + Role string `json:"role"` + Content any `json:"content"` + } + if json.Unmarshal(raw, &msg) != nil { + continue + } + roleCount[msg.Role]++ + if msg.Role == "user" && firstUserMsg == "" { + if s, ok := msg.Content.(string); ok { + s = strings.TrimSpace(s) + // Skip injected context (XML tags, conversation summaries, etc.) + if len(s) > 0 && !strings.HasPrefix(s, "<") && !strings.HasPrefix(s, "[") && !strings.HasPrefix(s, "{") { + if utf8.RuneCountInString(s) > 50 { + s = string([]rune(s)[:50]) + "..." + } + firstUserMsg = s + } + } + } + } + + msgCount = roleCount["user"] + roleCount["assistant"] + if limit < len(childIDs) { + // Extrapolate for remaining children + total := len(childIDs) + msgCount = msgCount * total / limit + } + + return msgCount, firstUserMsg +} diff --git a/agent/cursor/cursor_concurrent_test.go b/agent/cursor/cursor_concurrent_test.go new file mode 100644 index 0000000..906f3f8 --- /dev/null +++ b/agent/cursor/cursor_concurrent_test.go @@ -0,0 +1,40 @@ +package cursor + +import ( + "context" + "fmt" + "sync" + "testing" +) + +// TestAgent_StartSessionWorkDirRace exercises concurrent SetWorkDir + StartSession. +// Without the fix, StartSession reads a.workDir without holding a.mu while +// SetWorkDir writes it under the lock, which Go's -race detector flags as a +// data race. With the fix, the field is captured inside the existing critical +// section and no race is reported. +// +// newCursorSession only initialises the session struct; it does not spawn the +// Cursor agent CLI until Send() is called, so this test runs without requiring +// the binary on PATH. +func TestAgent_StartSessionWorkDirRace(t *testing.T) { + a := &Agent{cmd: "agent", workDir: "/initial"} + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(2) + go func(i int) { + defer wg.Done() + a.SetWorkDir(fmt.Sprintf("/path-%d", i)) + }(i) + go func() { + defer wg.Done() + sess, err := a.StartSession(context.Background(), "") + if err != nil { + t.Errorf("StartSession: %v", err) + return + } + _ = sess.Close() + }() + } + wg.Wait() +} diff --git a/agent/cursor/cursor_model_test.go b/agent/cursor/cursor_model_test.go new file mode 100644 index 0000000..d7f414e --- /dev/null +++ b/agent/cursor/cursor_model_test.go @@ -0,0 +1,123 @@ +package cursor + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func shortTestContext(t *testing.T) (context.Context, context.CancelFunc) { + t.Helper() + timeout := 30 * time.Second + if deadline, ok := t.Deadline(); ok { + remaining := time.Until(deadline) + if remaining <= 0 { + timeout = 100 * time.Millisecond + } else if remaining < timeout { + timeout = remaining + } + } + return context.WithTimeout(context.Background(), timeout) +} + +func requireWorkingAgentCLI(t *testing.T) { + t.Helper() + if os.Getenv("CI") != "" { + t.Skip("skipping agent CLI test in CI (no real cursor agent available)") + } + if os.Getenv("SKIP_REAL_AGENT_CLI") != "" { + t.Skip("skipping real agent CLI test (SKIP_REAL_AGENT_CLI is set)") + } + if _, err := exec.LookPath("agent"); err != nil { + t.Skip("agent CLI not in PATH") + } + ctx, cancel := shortTestContext(t) + defer cancel() + + out, err := exec.CommandContext(ctx, "agent", "models").CombinedOutput() + if err != nil { + t.Skipf("agent CLI is not runnable in this environment: %v (%s)", err, strings.TrimSpace(string(out))) + } +} + +func TestFetchModelsFromAgentCLI(t *testing.T) { + ctx, cancel := shortTestContext(t) + defer cancel() + requireWorkingAgentCLI(t) + + models := fetchModelsFromAgentCLI(ctx, "agent", nil) + if len(models) == 0 { + t.Fatal("expected models from agent models, got none") + } + + // Verify format: each model has non-empty Name + for i, m := range models { + if m.Name == "" { + t.Errorf("models[%d].Name is empty", i) + } + } + // 运行 go test -v 时可见 + t.Logf("fetched %d models:", len(models)) + for i, m := range models { + t.Logf(" %2d. %s - %s", i+1, m.Name, m.Desc) + } +} + +func TestFetchModelsFromAgentCLI_FailsGracefully(t *testing.T) { + ctx, cancel := shortTestContext(t) + defer cancel() + models := fetchModelsFromAgentCLI(ctx, "nonexistent-agent-xyz", nil) + if len(models) != 0 { + t.Errorf("expected empty when command fails, got %d models", len(models)) + } +} + +func TestAvailableModels_Fallback(t *testing.T) { + // When agent models fails, should fall back to hardcoded list + ctx, cancel := shortTestContext(t) + defer cancel() + a := &Agent{cmd: "nonexistent-cmd-that-will-fail"} + models := a.AvailableModels(ctx) + fallback := cursorFallbackModels() + if len(models) != len(fallback) { + t.Fatalf("fallback models length = %d, want %d", len(models), len(fallback)) + } + for i := range models { + if models[i].Name != fallback[i].Name { + t.Errorf("models[%d].Name = %q, want %q", i, models[i].Name, fallback[i].Name) + } + } +} + +func TestAvailableModels_FetchFromAgent(t *testing.T) { + requireWorkingAgentCLI(t) + ctx, cancel := shortTestContext(t) + defer cancel() + + a := &Agent{cmd: "agent"} + models := a.AvailableModels(ctx) + if len(models) == 0 { + t.Fatal("expected models from agent models, got none") + } + + t.Logf("AvailableModels returned %d models:", len(models)) + for i, m := range models { + t.Logf(" %2d. %s - %s", i+1, m.Name, m.Desc) + } + + // Should have real models like gpt-5.3-codex, opus-4.6-thinking, etc. + hasCodex := false + for _, m := range models { + if m.Name == "gpt-5.3-codex" || m.Name == "opus-4.6-thinking" || m.Name == "auto" { + hasCodex = true + break + } + } + if !hasCodex { + t.Logf("models: %v", models) + t.Log("agent models returned models but none of the expected ones (gpt-5.3-codex, opus-4.6-thinking, auto) - may be OK if CLI output format changed") + } +} diff --git a/agent/cursor/session.go b/agent/cursor/session.go new file mode 100644 index 0000000..04c8564 --- /dev/null +++ b/agent/cursor/session.go @@ -0,0 +1,478 @@ +package cursor + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +// cursorSession manages multi-turn conversations with the Cursor Agent CLI. +// Each Send() launches a new `agent --print` process with --resume for continuity. +type cursorSession struct { + cmd string // CLI binary name + workDir string + model string + mode string + extraEnv []string + events chan core.Event + chatID atomic.Value // stores string — Cursor chat/session ID + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + + thinkingBuf strings.Builder // accumulate thinking deltas +} + +func newCursorSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*cursorSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + cs := &cursorSession{ + cmd: cmd, + workDir: workDir, + model: model, + mode: mode, + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: sessionCtx, + cancel: cancel, + } + cs.alive.Store(true) + + if resumeID != "" && resumeID != core.ContinueSession { + cs.chatID.Store(resumeID) + } + + return cs, nil +} + +func (cs *cursorSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if len(images) > 0 { + slog.Warn("cursorSession: images not yet supported in CLI mode, ignoring") + } + if len(files) > 0 { + filePaths := core.SaveFilesToDisk(cs.workDir, files) + prompt = core.AppendFileRefs(prompt, filePaths) + } + if !cs.alive.Load() { + return fmt.Errorf("session is closed") + } + + chatID := cs.CurrentSessionID() + isResume := chatID != "" + + args := []string{ + "--print", + "--output-format", "stream-json", + } + + switch cs.mode { + case "force": + args = append(args, "--force") + case "plan": + args = append(args, "--mode", "plan") + case "ask": + args = append(args, "--mode", "ask") + } + + if isResume { + args = append(args, "--resume", chatID) + } + if cs.model != "" { + args = append(args, "--model", cs.model) + } + args = append(args, "--workspace", cs.workDir, "--", prompt) + + slog.Debug("cursorSession: launching", "resume", isResume, "args", core.RedactArgs(args)) + + cmd := exec.CommandContext(cs.ctx, cs.cmd, args...) + cmd.Dir = cs.workDir + env := os.Environ() + if len(cs.extraEnv) > 0 { + env = core.MergeEnv(env, cs.extraEnv) + } + cmd.Env = env + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("cursorSession: stdout pipe: %w", err) + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + return fmt.Errorf("cursorSession: start: %w", err) + } + + cs.wg.Add(1) + go cs.readLoop(cmd, stdout, &stderrBuf) + + return nil +} + +func (cs *cursorSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) { + defer cs.wg.Done() + defer func() { + if err := cmd.Wait(); err != nil { + stderrMsg := strings.TrimSpace(stderrBuf.String()) + if stderrMsg != "" { + slog.Error("cursorSession: process failed", "error", err, "stderr", stderrMsg) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + } + }() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + slog.Debug("cursorSession: raw", "line", truncateStr(line, 500)) + + var raw map[string]any + if err := json.Unmarshal([]byte(line), &raw); err != nil { + slog.Debug("cursorSession: non-JSON line", "line", line) + continue + } + + cs.handleEvent(raw) + } + + if err := scanner.Err(); err != nil { + slog.Error("cursorSession: scanner error", "error", err) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } +} + +func (cs *cursorSession) handleEvent(raw map[string]any) { + eventType, _ := raw["type"].(string) + + switch eventType { + case "system": + cs.handleSystem(raw) + + case "user": + // User echo — nothing to do + + case "thinking": + cs.handleThinking(raw) + + case "assistant": + cs.handleAssistant(raw) + + case "tool_call": + cs.handleToolCall(raw) + + case "interaction_query": + cs.handleInteractionQuery(raw) + + case "result": + cs.handleResult(raw) + + default: + slog.Debug("cursorSession: unhandled event", "type", eventType) + } +} + +func (cs *cursorSession) handleSystem(raw map[string]any) { + if sid, ok := raw["session_id"].(string); ok && sid != "" { + cs.chatID.Store(sid) + slog.Debug("cursorSession: session init", "session_id", sid) + + model, _ := raw["model"].(string) + evt := core.Event{Type: core.EventText, SessionID: sid, Content: "", ToolName: model} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } +} + +func (cs *cursorSession) handleThinking(raw map[string]any) { + subtype, _ := raw["subtype"].(string) + switch subtype { + case "delta": + if text, _ := raw["text"].(string); text != "" { + cs.thinkingBuf.WriteString(text) + } + default: + text := cs.thinkingBuf.String() + cs.thinkingBuf.Reset() + if text != "" { + evt := core.Event{Type: core.EventThinking, Content: text} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + } +} + +func (cs *cursorSession) handleAssistant(raw map[string]any) { + msg, ok := raw["message"].(map[string]any) + if !ok { + return + } + contentArr, ok := msg["content"].([]any) + if !ok { + return + } + for _, contentItem := range contentArr { + item, ok := contentItem.(map[string]any) + if !ok { + continue + } + contentType, _ := item["type"].(string) + if contentType == "text" { + if text, ok := item["text"].(string); ok && text != "" { + evt := core.Event{Type: core.EventText, Content: text} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + } + } +} + +func (cs *cursorSession) handleToolCall(raw map[string]any) { + subtype, _ := raw["subtype"].(string) + tc, _ := raw["tool_call"].(map[string]any) + if tc == nil { + return + } + + if subtype == "started" { + name, input := extractToolInfo(tc) + if name != "" { + evt := core.Event{Type: core.EventToolUse, ToolName: name, ToolInput: input} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } + } + } + // "completed" tool_call events contain results; we log but don't emit to chat + if subtype == "completed" { + name, _ := extractToolInfo(tc) + slog.Debug("cursorSession: tool completed", "tool", name) + } +} + +func (cs *cursorSession) handleInteractionQuery(raw map[string]any) { + subtype, _ := raw["subtype"].(string) + if subtype != "request" { + return + } + + queryType, _ := raw["query_type"].(string) + query, _ := raw["query"].(map[string]any) + if query == nil { + return + } + + toolName, input := extractInteractionQueryInfo(queryType, query) + if toolName == "" { + return + } + + evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } +} + +func extractInteractionQueryInfo(queryType string, query map[string]any) (string, string) { + switch queryType { + case "webFetchRequestQuery": + if inner, ok := query["webFetchRequestQuery"].(map[string]any); ok { + if args, ok := inner["args"].(map[string]any); ok { + url, _ := args["url"].(string) + return "WebFetch", url + } + } + case "shellRequestQuery": + if inner, ok := query["shellRequestQuery"].(map[string]any); ok { + if args, ok := inner["args"].(map[string]any); ok { + cmd, _ := args["command"].(string) + return "Bash", cmd + } + } + } + + name := strings.TrimSuffix(queryType, "RequestQuery") + name = strings.TrimSuffix(name, "Query") + if name == "" { + name = queryType + } + return name, "" +} + +// extractToolInfo parses the nested tool_call structure from Cursor's stream-json. +// Tool calls can be shellToolCall, readToolCall, editToolCall, etc. +func extractToolInfo(tc map[string]any) (name string, input string) { + toolTypes := []struct { + key string + toolName string + }{ + {"shellToolCall", "Bash"}, + {"readToolCall", "Read"}, + {"editToolCall", "Edit"}, + {"writeToolCall", "Write"}, + {"listToolCall", "List"}, + {"searchToolCall", "Search"}, + {"grepToolCall", "Grep"}, + {"globToolCall", "Glob"}, + {"webFetchToolCall", "WebFetch"}, + } + + for _, tt := range toolTypes { + if call, ok := tc[tt.key].(map[string]any); ok { + name = tt.toolName + input = extractToolInput(name, call) + return + } + } + + // Generic: try "description" field at top level + if desc, ok := tc["description"].(string); ok && desc != "" { + return "Tool", truncateStr(desc, 200) + } + + return "", "" +} + +func extractToolInput(toolName string, call map[string]any) string { + args, _ := call["args"].(map[string]any) + if args == nil { + if desc, ok := call["description"].(string); ok { + return desc + } + return "" + } + + switch toolName { + case "Bash": + if cmd, ok := args["command"].(string); ok { + return cmd + } + case "Read": + if p, ok := args["path"].(string); ok { + return p + } + case "Edit", "Write": + if p, ok := args["path"].(string); ok { + return p + } + if p, ok := args["filePath"].(string); ok { + return p + } + case "Grep": + if p, ok := args["pattern"].(string); ok { + return p + } + case "Glob": + if p, ok := args["pattern"].(string); ok { + return p + } + } + + if desc, ok := call["description"].(string); ok && desc != "" { + return desc + } + + b, _ := json.Marshal(args) + return string(b) +} + +func (cs *cursorSession) handleResult(raw map[string]any) { + var content string + if result, ok := raw["result"].(string); ok { + content = result + } + if sid, ok := raw["session_id"].(string); ok && sid != "" { + cs.chatID.Store(sid) + } + evt := core.Event{Type: core.EventResult, Content: content, SessionID: cs.CurrentSessionID(), Done: true} + select { + case cs.events <- evt: + case <-cs.ctx.Done(): + return + } +} + +// RespondPermission is a no-op — Cursor Agent permissions are handled via CLI default behavior or --force flag. +func (cs *cursorSession) RespondPermission(_ string, _ core.PermissionResult) error { + return nil +} + +func (cs *cursorSession) Events() <-chan core.Event { + return cs.events +} + +func (cs *cursorSession) CurrentSessionID() string { + v, _ := cs.chatID.Load().(string) + return v +} + +func (cs *cursorSession) Alive() bool { + return cs.alive.Load() +} + +func (cs *cursorSession) Close() error { + cs.alive.Store(false) + cs.cancel() + done := make(chan struct{}) + go func() { + cs.wg.Wait() + close(done) + }() + select { + case <-done: + close(cs.events) + case <-time.After(8 * time.Second): + slog.Warn("cursorSession: close timed out, abandoning wg.Wait") + } + return nil +} + +func truncateStr(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + return string([]rune(s)[:maxRunes]) + "..." +} diff --git a/agent/devin/devin.go b/agent/devin/devin.go new file mode 100644 index 0000000..33f92b2 --- /dev/null +++ b/agent/devin/devin.go @@ -0,0 +1,93 @@ +// Package devin integrates Devin CLI (https://cli.devin.ai/) as a +// first-class cc-connect agent. +// +// Devin speaks the Agent Client Protocol (ACP) over stdio via its +// `devin acp` subcommand, so the transport and session plumbing is +// shared with the generic agent/acp package. This package is a thin +// wrapper that: +// +// 1. Registers Devin under the stable config name `type = "devin"` +// (parallel to `claudecode`, `cursor`, `codex`, etc.), so minimal +// user config doesn't need to spell out the ACP command / args. +// 2. Pins Devin-specific defaults — binary name "devin", subcommand +// "acp", human-readable display name "Devin" — while leaving every +// underlying ACP option (mode, auth_method, env, work_dir, etc.) +// overridable from project config. +// 3. Reports Name() = "devin" so cc-connect's session store keys, +// audit logs, and /doctor output attribute activity to Devin +// rather than to the generic "acp" adapter. +// +// Authentication is delegated entirely to the local Devin CLI: after a +// one-time `devin auth login`, the spawned `devin acp` subprocess +// reads the credentials stored on disk, so cc-connect never needs to +// see or forward any API tokens. Windsurf Enterprise users can +// alternatively inject WINDSURF_API_KEY via the agent env option. +package devin + +import ( + "strings" + + "github.com/chenhg5/cc-connect/agent/acp" + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("devin", New) +} + +// Agent embeds *acp.Agent so it inherits StartSession, ListSessions, +// ModeSwitcher, AgentDoctorInfo, and all other optional capability +// interfaces implemented by the ACP adapter — only Name() is +// overridden so the engine identifies this as a Devin agent. +type Agent struct { + *acp.Agent +} + +// Name returns the stable agent type identifier used in config, +// session store keys, and audit logging. +func (a *Agent) Name() string { return "devin" } + +// New builds a Devin agent from project options. +// +// Option handling: +// - "command" defaults to "devin" (override only if you have the +// binary at a non-standard path; always use an absolute path when +// running under systemd / launchd where PATH is minimal). +// - "args" defaults to ["acp"]. +// - "display_name" defaults to "Devin". +// - All other ACP options (work_dir, mode, auth_method, env) are +// passed through unchanged to agent/acp. +func New(opts map[string]any) (core.Agent, error) { + a, err := acp.New(applyDevinDefaults(opts)) + if err != nil { + return nil, err + } + base, ok := a.(*acp.Agent) + if !ok { + // agent/acp.New always returns *acp.Agent today; if the + // concrete type ever changes, fall through with a plain + // wrapper rather than panicking. + return a, nil + } + return &Agent{Agent: base}, nil +} + +// applyDevinDefaults returns a new opts map with Devin-specific +// defaults filled in for any missing / blank fields. Extracted so +// unit tests can exercise the defaulting logic without requiring +// `devin` to be present in $PATH (which agent/acp.New would check). +func applyDevinDefaults(opts map[string]any) map[string]any { + if opts == nil { + opts = make(map[string]any) + } + if existing, _ := opts["command"].(string); strings.TrimSpace(existing) == "" { + opts["command"] = "devin" + } + if _, ok := opts["args"]; !ok { + opts["args"] = []string{"acp"} + } + if existing, _ := opts["display_name"].(string); strings.TrimSpace(existing) == "" { + opts["display_name"] = "Devin" + } + return opts +} diff --git a/agent/devin/devin_test.go b/agent/devin/devin_test.go new file mode 100644 index 0000000..113c66e --- /dev/null +++ b/agent/devin/devin_test.go @@ -0,0 +1,145 @@ +package devin + +import ( + "os/exec" + "testing" + + "github.com/chenhg5/cc-connect/agent/acp" +) + +// TestApplyDevinDefaults_FillsUnsetFields verifies the three Devin- +// specific defaults are applied when the user provides a minimal +// [projects.agent.options] block. This is the path most users hit — +// config.example.toml shows a bare `type = "devin"` section and we +// want that to just work. +func TestApplyDevinDefaults_FillsUnsetFields(t *testing.T) { + got := applyDevinDefaults(map[string]any{}) + if got["command"] != "devin" { + t.Errorf("command = %v, want devin", got["command"]) + } + args, ok := got["args"].([]string) + if !ok || len(args) != 1 || args[0] != "acp" { + t.Errorf("args = %v, want [acp]", got["args"]) + } + if got["display_name"] != "Devin" { + t.Errorf("display_name = %v, want Devin", got["display_name"]) + } +} + +// TestApplyDevinDefaults_UserOptsWin ensures we never stomp on +// explicit user config. Common reason to override `command`: absolute +// path for launchd / systemd deployments where ~/.local/bin isn't on +// $PATH. Common reason to override `display_name`: running multiple +// Devin instances against different Windsurf workspaces. +func TestApplyDevinDefaults_UserOptsWin(t *testing.T) { + got := applyDevinDefaults(map[string]any{ + "command": "/usr/local/bin/devin", + "args": []string{"acp", "--verbose"}, + "display_name": "Devin (staging)", + }) + if got["command"] != "/usr/local/bin/devin" { + t.Errorf("command was overwritten: %v", got["command"]) + } + args := got["args"].([]string) + if len(args) != 2 || args[1] != "--verbose" { + t.Errorf("args were overwritten: %v", got["args"]) + } + if got["display_name"] != "Devin (staging)" { + t.Errorf("display_name was overwritten: %v", got["display_name"]) + } +} + +// TestApplyDevinDefaults_BlankCommandGetsDefault covers a subtle TOML +// quirk: `command = ""` (explicit blank) should be treated as "use +// the default" rather than surfacing a cryptic "command is required" +// error. Matches how the rest of cc-connect treats whitespace-only +// string options. +func TestApplyDevinDefaults_BlankCommandGetsDefault(t *testing.T) { + got := applyDevinDefaults(map[string]any{"command": " "}) + if got["command"] != "devin" { + t.Errorf("command = %v, want devin (blank should fall through)", got["command"]) + } +} + +// TestApplyDevinDefaults_NilOpts guards against nil-map panics at +// registry level. core.CreateAgent may in principle pass nil if a +// project entry has no [projects.agent.options] table at all. +func TestApplyDevinDefaults_NilOpts(t *testing.T) { + got := applyDevinDefaults(nil) + if got == nil || got["command"] != "devin" { + t.Errorf("nil opts should yield defaults, got %v", got) + } +} + +// TestApplyDevinDefaults_PreservesOtherAcpOptions ensures pass-through +// of ACP-level knobs (mode, auth_method, env, work_dir) that the +// wrapper must not touch. These are handled by agent/acp. +func TestApplyDevinDefaults_PreservesOtherAcpOptions(t *testing.T) { + got := applyDevinDefaults(map[string]any{ + "work_dir": "/tmp/proj", + "mode": "plan", + "auth_method": "windsurf-api-key", + "env": map[string]string{"WINDSURF_API_KEY": "wk_xxx"}, + }) + if got["work_dir"] != "/tmp/proj" { + t.Errorf("work_dir lost: %v", got["work_dir"]) + } + if got["mode"] != "plan" { + t.Errorf("mode lost: %v", got["mode"]) + } + if got["auth_method"] != "windsurf-api-key" { + t.Errorf("auth_method lost: %v", got["auth_method"]) + } + if env, ok := got["env"].(map[string]string); !ok || env["WINDSURF_API_KEY"] != "wk_xxx" { + t.Errorf("env lost: %v", got["env"]) + } +} + +// TestNew_ReturnsDevinWrapper verifies the full New() → acp.New() +// path produces a *devin.Agent that shadows the embedded *acp.Agent's +// Name(). Uses `command: "true"` (a POSIX builtin guaranteed to be in +// PATH on both Linux and macOS, CI included) to bypass agent/acp's +// exec.LookPath check without requiring a real `devin` binary. +func TestNew_ReturnsDevinWrapper(t *testing.T) { + if _, err := exec.LookPath("true"); err != nil { + t.Skip("'true' not in PATH — unusual environment, skipping") + } + a, err := New(map[string]any{"command": "true"}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if got := a.Name(); got != "devin" { + t.Fatalf("Name() = %q, want devin (must shadow embedded acp.Name)", got) + } + wrapper, ok := a.(*Agent) + if !ok { + t.Fatalf("New() returned %T, want *devin.Agent", a) + } + // Sanity: the embedded acp.Agent is the backing implementation. + var _ *acp.Agent = wrapper.Agent + // Display name still reflects the Devin default even when command + // was overridden to "true". + if got := wrapper.CLIDisplayName(); got != "Devin" { + t.Fatalf("CLIDisplayName() = %q, want Devin", got) + } +} + +// TestNew_DisplayNameOverride locks in that a user-provided +// display_name reaches the embedded acp.Agent unchanged (relevant for +// multi-project setups where the bot's `/status` output needs to +// distinguish several concurrent Devin sessions). +func TestNew_DisplayNameOverride(t *testing.T) { + if _, err := exec.LookPath("true"); err != nil { + t.Skip("'true' not in PATH — skipping") + } + a, err := New(map[string]any{ + "command": "true", + "display_name": "Devin (prod)", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if got := a.(*Agent).CLIDisplayName(); got != "Devin (prod)" { + t.Fatalf("CLIDisplayName() = %q, want %q", got, "Devin (prod)") + } +} diff --git a/agent/gemini/gemini.go b/agent/gemini/gemini.go new file mode 100644 index 0000000..14363e6 --- /dev/null +++ b/agent/gemini/gemini.go @@ -0,0 +1,604 @@ +package gemini + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("gemini", New) +} + +// Agent drives the Gemini CLI in headless mode using -p --output-format stream-json. +// +// Modes (maps to Gemini CLI approval flags): +// - "default": standard approval mode (prompt for each tool use) +// - "auto_edit": auto-approve edit tools, ask for others +// - "yolo": auto-approve all tools (-y / --approval-mode yolo) +// - "plan": read-only plan mode (--approval-mode plan) +type Agent struct { + workDir string + model string + mode string + cmd string // CLI binary name, default "gemini" + timeout time.Duration + providers []core.ProviderConfig + activeIdx int + sessionEnv []string + mu sync.RWMutex +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + cmd, _ := opts["cmd"].(string) + if cmd == "" { + cmd = "gemini" + } + + var timeoutMins int64 + switch v := opts["timeout_mins"].(type) { + case int64: + timeoutMins = v + case int: + timeoutMins = int64(v) + case float64: + timeoutMins = int64(v) + default: + if v != nil { + slog.Debug("gemini: timeout_mins has unexpected type", "type", fmt.Sprintf("%T", v)) + } + } + var timeout time.Duration + if timeoutMins > 0 { + timeout = time.Duration(timeoutMins) * time.Minute + } + + if _, err := exec.LookPath(cmd); err != nil { + return nil, fmt.Errorf("gemini: %q CLI not found in PATH, install with: npm i -g @google/gemini-cli", cmd) + } + + return &Agent{ + workDir: workDir, + model: model, + mode: mode, + cmd: cmd, + timeout: timeout, + activeIdx: -1, + }, nil +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "yolo", "auto", "force", "bypasspermissions": + return "yolo" + case "auto_edit", "autoedit", "edit", "acceptedits": + return "auto_edit" + case "plan": + return "plan" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "gemini" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("gemini: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("gemini: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { + if models := a.configuredModels(); len(models) > 0 { + return models + } + if models := a.fetchModelsFromAPI(ctx); len(models) > 0 { + return models + } + // Matches Gemini CLI's own "Select Model" list. + return []core.ModelOption{ + {Name: "gemini-3.1-pro-preview", Desc: "Gemini 3.1 Pro Preview"}, + {Name: "gemini-3-flash-preview", Desc: "Gemini 3 Flash Preview"}, + {Name: "gemini-2.5-pro", Desc: "Gemini 2.5 Pro"}, + {Name: "gemini-2.5-flash", Desc: "Gemini 2.5 Flash"}, + {Name: "gemini-2.5-flash-lite", Desc: "Gemini 2.5 Flash Lite"}, + } +} + +func (a *Agent) fetchModelsFromAPI(ctx context.Context) []core.ModelOption { + apiKey := os.Getenv("GEMINI_API_KEY") + if apiKey == "" { + apiKey = os.Getenv("GOOGLE_API_KEY") + } + if apiKey == "" { + return nil + } + + url := "https://generativelanguage.googleapis.com/v1beta/models?key=" + apiKey + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + slog.Debug("gemini: failed to fetch models", "error", err) + return nil + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil + } + + var result struct { + Models []struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil + } + + var models []core.ModelOption + for _, m := range result.Models { + id := strings.TrimPrefix(m.Name, "models/") + if !strings.HasPrefix(id, "gemini-") { + continue + } + models = append(models, core.ModelOption{Name: id, Desc: m.DisplayName}) + } + sort.Slice(models, func(i, j int) bool { return models[i].Name > models[j].Name }) + return models +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + model := a.model + mode := a.mode + cmd := a.cmd + workDir := a.workDir + timeout := a.timeout + extraEnv := a.providerEnvLocked() + extraEnv = append(extraEnv, a.sessionEnv...) + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + if m := a.providers[a.activeIdx].Model; m != "" { + model = m + } + } + a.mu.Unlock() + + return newGeminiSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout) +} + +// ListSessions reads sessions from ~/.gemini/tmp//chats/. +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + return listGeminiSessions(a.workDir) +} + +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("gemini: cannot determine home dir: %w", err) + } + chatsDir := filepath.Join(homeDir, ".gemini", "tmp", geminiProjectSlug(a.workDir), "chats") + // Session files are named session--.json, not .json. + // Scan the directory to find the file containing the matching sessionId. + entries, err := os.ReadDir(chatsDir) + if err != nil { + return fmt.Errorf("session file not found: %s", sessionID) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + fpath := filepath.Join(chatsDir, entry.Name()) + data, err := os.ReadFile(fpath) + if err != nil { + continue + } + var sf struct { + SessionID string `json:"sessionId"` + } + if json.Unmarshal(data, &sf) == nil && sf.SessionID == sessionID { + return os.Remove(fpath) + } + } + return fmt.Errorf("session file not found: %s", sessionID) +} + +func (a *Agent) Stop() error { return nil } + +// ── ModeSwitcher ──────────────────────────────────────────────── + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("gemini: mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Prompt for approval on each tool use", DescZh: "每次工具调用都需要确认"}, + {Key: "auto_edit", Name: "Auto Edit", NameZh: "自动编辑", Desc: "Auto-approve edit tools, ask for others", DescZh: "编辑工具自动通过,其他仍需确认"}, + {Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"}, + {Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only plan mode, no execution", DescZh: "只读规划模式,不做修改"}, + } +} + +// ── CommandProvider implementation ──────────────────────────── + +func (a *Agent) CommandDirs() []string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + dirs := []string{filepath.Join(absDir, ".gemini", "commands")} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".gemini", "commands")) + } + return dirs +} + +// ── SkillProvider implementation ────────────────────────────── + +func (a *Agent) SkillDirs() []string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + dirs := []string{filepath.Join(absDir, ".gemini", "skills")} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".gemini", "skills")) + } + return dirs +} + +// ── ContextCompressor implementation ────────────────────────── +// Gemini CLI has no interactive compress/compact command. +// Return "" so engine reports "not supported" instead of sending +// a bogus "/compress" prompt to the model. + +func (a *Agent) CompressCommand() string { return "" } + +// ── MemoryFileProvider implementation ───────────────────────── + +func (a *Agent) ProjectMemoryFile() string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + return filepath.Join(absDir, "GEMINI.md") +} + +func (a *Agent) GlobalMemoryFile() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".gemini", "GEMINI.md") +} + +// ── ProviderSwitcher ──────────────────────────────────────────── + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.activeIdx = -1 + slog.Info("gemini: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("gemini: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + var env []string + if p.APIKey != "" { + env = append(env, "GEMINI_API_KEY="+p.APIKey) + } + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +// ── Session listing ───────────────────────────────────────────── + +// geminiProjectSlug looks up the directory name Gemini CLI uses under ~/.gemini/tmp/ +// for a given project path. It reads ~/.gemini/projects.json (the CLI's slug registry) +// and falls back to a slugified basename if the project isn't registered. +func geminiProjectSlug(workDir string) string { + abs, err := filepath.Abs(workDir) + if err != nil { + abs = workDir + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return slugify(filepath.Base(abs)) + } + + // Read the Gemini CLI project registry + data, err := os.ReadFile(filepath.Join(homeDir, ".gemini", "projects.json")) + if err == nil { + var registry struct { + Projects map[string]string `json:"projects"` + } + if json.Unmarshal(data, ®istry) == nil { + // Normalize path for lookup (Gemini CLI uses path.normalize) + normalized := filepath.Clean(abs) + if slug, ok := registry.Projects[normalized]; ok { + return slug + } + } + } + + // Fallback: replicate Gemini CLI's slugify logic + return slugify(filepath.Base(abs)) +} + +// slugify replicates the Gemini CLI's slug generation: +// lowercase, replace non-alphanumeric with hyphens, collapse consecutive hyphens. +func slugify(s string) string { + s = strings.ToLower(s) + var b strings.Builder + for _, r := range s { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + // Collapse consecutive hyphens and trim + result := b.String() + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + result = strings.Trim(result, "-") + if result == "" { + result = "project" + } + return result +} + +// sessionFile represents the JSON structure of a Gemini CLI session file. +type sessionFile struct { + SessionID string `json:"sessionId"` + ProjectHash string `json:"projectHash"` + StartTime time.Time `json:"startTime"` + LastUpdated time.Time `json:"lastUpdated"` + Messages []sessionMessage `json:"messages"` + Kind string `json:"kind"` +} + +// sessionMessage represents a message in the Gemini session file. +// The Content field is flexible: Gemini CLI can serialize it as either +// a plain string or an array of {text: "..."} parts. +type sessionMessage struct { + Type string `json:"type"` + RawContent json.RawMessage `json:"content"` +} + +// textContent extracts text from the flexible content field. +func (m *sessionMessage) textContent() string { + if len(m.RawContent) == 0 { + return "" + } + // Try as plain string first + var s string + if json.Unmarshal(m.RawContent, &s) == nil { + return s + } + // Try as array of {text: "..."} parts + var parts []struct { + Text string `json:"text"` + } + if json.Unmarshal(m.RawContent, &parts) == nil { + var texts []string + for _, p := range parts { + if p.Text != "" { + texts = append(texts, p.Text) + } + } + return strings.Join(texts, "\n") + } + return "" +} + +func listGeminiSessions(workDir string) ([]core.AgentSessionInfo, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("gemini: cannot determine home dir: %w", err) + } + + slug := geminiProjectSlug(workDir) + chatsDir := filepath.Join(homeDir, ".gemini", "tmp", slug, "chats") + + entries, err := os.ReadDir(chatsDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("gemini: read chats dir: %w", err) + } + + var sessions []core.AgentSessionInfo + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + data, err := os.ReadFile(filepath.Join(chatsDir, entry.Name())) + if err != nil { + continue + } + + var sf sessionFile + if json.Unmarshal(data, &sf) != nil || sf.SessionID == "" { + continue + } + + // Skip subagent sessions (internal agent-spawned sessions) + if sf.Kind == "subagent" { + continue + } + + // Skip sessions with no user messages + hasUserMsg := false + for _, msg := range sf.Messages { + if msg.Type == "user" { + hasUserMsg = true + break + } + } + if !hasUserMsg { + continue + } + + summary := extractSessionSummary(&sf) + if utf8.RuneCountInString(summary) > 60 { + summary = string([]rune(summary)[:60]) + "..." + } + + msgCount := len(sf.Messages) + modTime := sf.LastUpdated + if modTime.IsZero() { + modTime = sf.StartTime + } + + sessions = append(sessions, core.AgentSessionInfo{ + ID: sf.SessionID, + Summary: summary, + MessageCount: msgCount, + ModifiedAt: modTime, + }) + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + + return sessions, nil +} + +// extractSessionSummary picks the first meaningful user text as the session summary. +func extractSessionSummary(sf *sessionFile) string { + for _, msg := range sf.Messages { + if msg.Type != "user" { + continue + } + text := strings.TrimSpace(msg.textContent()) + if text == "" { + continue + } + for _, line := range strings.Split(text, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "{") && strings.HasSuffix(line, "}") { + continue + } + return line + } + } + if len(sf.SessionID) > 12 { + return sf.SessionID[:12] + "..." + } + return sf.SessionID +} diff --git a/agent/gemini/gemini_model_test.go b/agent/gemini/gemini_model_test.go new file mode 100644 index 0000000..a9ab7f4 --- /dev/null +++ b/agent/gemini/gemini_model_test.go @@ -0,0 +1,57 @@ +package gemini + +import ( + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestConfiguredModels_BoundaryConditions(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "first"}}}, + {Models: []core.ModelOption{{Name: "second"}}}, + }, + } + + tests := []struct { + name string + activeIdx int + wantNil bool + wantName string + }{ + {name: "negative index", activeIdx: -1, wantNil: true}, + {name: "out of range", activeIdx: 2, wantNil: true}, + {name: "valid index", activeIdx: 1, wantName: "second"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a.activeIdx = tt.activeIdx + got := a.configuredModels() + if tt.wantNil { + if got != nil { + t.Fatalf("configuredModels() = %v, want nil", got) + } + return + } + if len(got) != 1 || got[0].Name != tt.wantName { + t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName) + } + }) + } +} + +func TestGetModel_PrefersActiveProviderModel(t *testing.T) { + a := &Agent{ + model: "gemini-2.5-flash", + providers: []core.ProviderConfig{ + {Name: "google", Model: "gemini-2.5-pro"}, + }, + activeIdx: 0, + } + + if got := a.GetModel(); got != "gemini-2.5-pro" { + t.Fatalf("GetModel() = %q, want gemini-2.5-pro", got) + } +} diff --git a/agent/gemini/session.go b/agent/gemini/session.go new file mode 100644 index 0000000..6bbdad3 --- /dev/null +++ b/agent/gemini/session.go @@ -0,0 +1,698 @@ +package gemini + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +// geminiSession manages multi-turn conversations with the Gemini CLI. +// Each Send() launches a new `gemini -p - --output-format stream-json` process +// with --resume for conversation continuity. The prompt is passed via stdin +// (using -p - flag) to preserve newlines in multi-line messages. +type geminiSession struct { + cmd string + workDir string + model string + mode string + timeout time.Duration + extraEnv []string + events chan core.Event + chatID atomic.Value // stores string — Gemini session ID + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + + pendingMsgs []string // buffered assistant messages awaiting classification +} + +func newGeminiSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*geminiSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + gs := &geminiSession{ + cmd: cmd, + workDir: workDir, + model: model, + mode: mode, + timeout: timeout, + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: sessionCtx, + cancel: cancel, + } + gs.alive.Store(true) + + if resumeID != "" && resumeID != core.ContinueSession { + gs.chatID.Store(resumeID) + } + + return gs, nil +} + +func (gs *geminiSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) (err error) { + if !gs.alive.Load() { + return fmt.Errorf("session is closed") + } + + // Save images and files into the workspace so Gemini CLI tools can access them. + attachDir := filepath.Join(gs.workDir, ".cc-connect", "attachments") + if (len(images) > 0 || len(files) > 0) && os.MkdirAll(attachDir, 0o755) != nil { + attachDir = os.TempDir() + } + + var imageRefs []string + for i, img := range images { + ext := ".png" + switch img.MimeType { + case "image/jpeg": + ext = ".jpg" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + } + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(attachDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o644); err != nil { + slog.Warn("geminiSession: failed to save image", "error", err) + continue + } + imageRefs = append(imageRefs, fpath) + } + + var fileRefs []string + for i, f := range files { + fname := filepath.Base(f.FileName) + if fname == "" || fname == "." || fname == ".." { + fname = fmt.Sprintf("file_%d_%d", time.Now().UnixMilli(), i) + } + fpath := filepath.Join(attachDir, fname) + if err := os.WriteFile(fpath, f.Data, 0o644); err != nil { + slog.Warn("geminiSession: failed to save file", "error", err) + continue + } + fileRefs = append(fileRefs, fpath) + } + + chatID := gs.CurrentSessionID() + isResume := chatID != "" + + args := []string{ + "--output-format", "stream-json", + } + + switch gs.mode { + case "yolo": + args = append(args, "-y") + case "auto_edit": + args = append(args, "--approval-mode", "auto_edit") + case "plan": + args = append(args, "--approval-mode", "plan") + } + + if isResume { + args = append(args, "--resume", chatID) + } + if gs.model != "" { + args = append(args, "-m", gs.model) + } + + // Build prompt with explicit file path references so Gemini can find them. + fullPrompt := prompt + if len(imageRefs) > 0 { + if fullPrompt == "" { + fullPrompt = "Please analyze the attached image(s)." + } + fullPrompt += "\n\n[Attached images saved at: " + strings.Join(imageRefs, ", ") + "]" + } + if len(fileRefs) > 0 { + if fullPrompt == "" { + fullPrompt = "Please analyze the attached file(s)." + } + fullPrompt += "\n\n[Attached files saved at: " + strings.Join(fileRefs, ", ") + "]" + } + + // Pass prompt via stdin instead of -p flag to preserve newlines. + // The -p flag can truncate at newline characters in some Gemini CLI versions. + args = append(args, "-p", "-") + + // Add timeout for each turn to prevent hanging processes + var cancel context.CancelFunc + var ctx context.Context + if gs.timeout > 0 { + ctx, cancel = context.WithTimeout(gs.ctx, gs.timeout) + } else { + ctx, cancel = context.WithCancel(gs.ctx) + } + + // ensure cancel is called on early return errors + started := false + defer func() { + if !started { + cancel() + } + }() + + slog.Debug("geminiSession: launching", "resume", isResume, "args", core.RedactArgs(args)) + cmd := exec.CommandContext(ctx, gs.cmd, args...) + // Set a short WaitDelay to ensure I/O goroutines don't block for long after the context is done + cmd.WaitDelay = 1 * time.Second + cmd.Dir = gs.workDir + env := os.Environ() + if len(gs.extraEnv) > 0 { + env = core.MergeEnv(env, gs.extraEnv) + } + cmd.Env = env + cmd.Stdin = strings.NewReader(fullPrompt) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("geminiSession: stdout pipe: %w", err) + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + return fmt.Errorf("geminiSession: start: %w", err) + } + + started = true + gs.wg.Add(1) + go func() { + defer cancel() + gs.readLoop(ctx, cmd, stdout, &stderrBuf, append(imageRefs, fileRefs...)) + }() + + return nil +} + +func (gs *geminiSession) readLoop(ctx context.Context, cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer, tempImages []string) { + defer gs.wg.Done() + defer func() { + // Clean up temp image files + for _, f := range tempImages { + os.Remove(f) + } + if err := cmd.Wait(); err != nil { + stderrMsg := strings.TrimSpace(stderrBuf.String()) + if stderrMsg != "" { + slog.Error("geminiSession: process failed", "error", err, "stderr", stderrMsg) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + return + } + } + } + }() + + // Unblock scanner if context is canceled + go func() { + <-ctx.Done() + stdout.Close() + }() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + slog.Debug("geminiSession: raw", "line", truncate(line, 500)) + + var raw map[string]any + if err := json.Unmarshal([]byte(line), &raw); err != nil { + slog.Debug("geminiSession: non-JSON line", "line", line) + continue + } + + gs.handleEvent(raw) + } + + if err := scanner.Err(); err != nil { + slog.Error("geminiSession: scanner error", "error", err) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + return + } + } +} + +// Gemini CLI stream-json event types: +// +// init — session_id, model +// message — role (user/assistant), content, delta +// tool_use — tool_name, tool_id, parameters +// tool_result — tool_id, status, output, error +// error — severity, message +// result — status, stats (final event) +func (gs *geminiSession) handleEvent(raw map[string]any) { + eventType, _ := raw["type"].(string) + + switch eventType { + case "init": + gs.handleInit(raw) + case "message": + gs.handleMessage(raw) + case "tool_use": + gs.handleToolUse(raw) + case "tool_result": + gs.handleToolResult(raw) + case "error": + gs.handleError(raw) + case "result": + gs.handleResult(raw) + default: + slog.Debug("geminiSession: unhandled event", "type", eventType) + } +} + +func (gs *geminiSession) handleInit(raw map[string]any) { + sid, _ := raw["session_id"].(string) + model, _ := raw["model"].(string) + + if sid != "" { + gs.chatID.Store(sid) + slog.Debug("geminiSession: session init", "session_id", sid, "model", model) + + evt := core.Event{Type: core.EventText, SessionID: sid, Content: "", ToolName: model} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + return + } + } +} + +func (gs *geminiSession) handleMessage(raw map[string]any) { + role, _ := raw["role"].(string) + content, _ := raw["content"].(string) + + if role == "user" || content == "" { + return + } + + // Delta messages are incremental streaming fragments — emit immediately + // as EventText so engine's stream preview can update in real time. + // Non-delta messages (complete text) are buffered for later classification + // (thinking vs final text) based on what event follows. + delta, _ := raw["delta"].(bool) + if delta { + evt := core.Event{Type: core.EventText, Content: content} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + } + return + } + + gs.pendingMsgs = append(gs.pendingMsgs, content) +} + +func (gs *geminiSession) handleToolUse(raw map[string]any) { + gs.flushPendingAsThinking() + + toolName, _ := raw["tool_name"].(string) + toolID, _ := raw["tool_id"].(string) + params, _ := raw["parameters"].(map[string]any) + + input := formatToolParams(toolName, params) + + slog.Debug("geminiSession: tool_use", "tool", toolName, "id", toolID) + evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + return + } +} + +func (gs *geminiSession) handleToolResult(raw map[string]any) { + toolID, _ := raw["tool_id"].(string) + status, _ := raw["status"].(string) + output, _ := raw["output"].(string) + + slog.Debug("geminiSession: tool_result", "tool_id", toolID, "status", status) + + if status == "error" { + errObj, _ := raw["error"].(map[string]any) + if errObj != nil { + errMsg, _ := errObj["message"].(string) + if errMsg != "" { + output = "Error: " + errMsg + } + } + } + + if output != "" { + evt := core.Event{Type: core.EventToolResult, ToolName: toolID, Content: truncate(output, 500)} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + return + } + } +} + +func (gs *geminiSession) handleError(raw map[string]any) { + severity, _ := raw["severity"].(string) + message, _ := raw["message"].(string) + + if message != "" { + slog.Warn("geminiSession: error event", "severity", severity, "message", message) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("[%s] %s", severity, message)} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + return + } + } +} + +func (gs *geminiSession) handleResult(raw map[string]any) { + gs.flushPendingAsText() + + status, _ := raw["status"].(string) + + var errMsg string + if status == "error" { + errObj, _ := raw["error"].(map[string]any) + if errObj != nil { + errMsg, _ = errObj["message"].(string) + } + } + + sid := gs.CurrentSessionID() + + if errMsg != "" { + evt := core.Event{Type: core.EventResult, Content: errMsg, SessionID: sid, Done: true, Error: fmt.Errorf("%s", errMsg)} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + return + } + } else { + evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + return + } + } +} + +func (gs *geminiSession) flushPendingAsThinking() { + if len(gs.pendingMsgs) == 0 { + return + } + text := strings.Join(gs.pendingMsgs, "") + gs.pendingMsgs = gs.pendingMsgs[:0] + if text != "" { + evt := core.Event{Type: core.EventThinking, Content: text} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + } + } +} + +func (gs *geminiSession) flushPendingAsText() { + if len(gs.pendingMsgs) == 0 { + return + } + text := strings.Join(gs.pendingMsgs, "") + gs.pendingMsgs = gs.pendingMsgs[:0] + if text != "" { + evt := core.Event{Type: core.EventText, Content: text} + select { + case gs.events <- evt: + case <-gs.ctx.Done(): + } + } +} + +// RespondPermission is a no-op — Gemini CLI permissions are handled via -y / --approval-mode flags. +func (gs *geminiSession) RespondPermission(_ string, _ core.PermissionResult) error { + return nil +} + +func (gs *geminiSession) Events() <-chan core.Event { + return gs.events +} + +func (gs *geminiSession) CurrentSessionID() string { + v, _ := gs.chatID.Load().(string) + return v +} + +func (gs *geminiSession) Alive() bool { + return gs.alive.Load() +} + +func (gs *geminiSession) Close() error { + gs.alive.Store(false) + gs.cancel() + done := make(chan struct{}) + go func() { + gs.wg.Wait() + close(done) + }() + select { + case <-done: + close(gs.events) + case <-time.After(8 * time.Second): + slog.Warn("geminiSession: close timed out, abandoning wg.Wait") + } + return nil +} + +// formatToolParams extracts a human-readable summary from tool parameters. +func formatToolParams(toolName string, params map[string]any) string { + if params == nil { + return "" + } + + switch toolName { + case "shell", "run_shell_command", "Bash": + if cmd, ok := params["command"].(string); ok { + return cmd + } + case "write_file", "WriteFile": + fp, _ := params["file_path"].(string) + if fp == "" { + fp, _ = params["path"].(string) + } + if fp != "" { + if content, ok := params["content"].(string); ok && content != "" { + return "`" + fp + "`\n```\n" + content + "\n```" + } + return fp + } + case "replace", "ReplaceInFile": + fp, _ := params["file_path"].(string) + if fp == "" { + fp, _ = params["path"].(string) + } + if fp != "" { + old, _ := params["old_string"].(string) + new_, _ := params["new_string"].(string) + if old == "" { + old, _ = params["old_str"].(string) + new_, _ = params["new_str"].(string) + } + if old != "" || new_ != "" { + diff := computeLineDiff(old, new_) + return "`" + fp + "`\n```diff\n" + diff + "\n```" + } + return fp + } + case "read_file", "ReadFile": + if p, ok := params["file_path"].(string); ok { + return p + } + if p, ok := params["path"].(string); ok { + return p + } + case "list_directory", "ListDirectory": + if p, ok := params["dir_path"].(string); ok { + return p + } + if p, ok := params["path"].(string); ok { + return p + } + if p, ok := params["directory"].(string); ok { + return p + } + case "web_fetch", "WebFetch": + if p, ok := params["prompt"].(string); ok { + return p + } + if u, ok := params["url"].(string); ok { + return u + } + case "google_web_search", "GoogleWebSearch": + if q, ok := params["query"].(string); ok { + return q + } + case "activate_skill": + if n, ok := params["name"].(string); ok { + return n + } + case "search_code", "SearchCode", "Glob", "glob", "Grep", "grep_search": + if q, ok := params["query"].(string); ok { + return q + } + if p, ok := params["pattern"].(string); ok { + return p + } + case "save_memory": + if f, ok := params["fact"].(string); ok { + return f + } + case "ask_user": + if qs, ok := params["questions"].([]any); ok && len(qs) > 0 { + if q0, ok := qs[0].(map[string]any); ok { + if question, ok := q0["question"].(string); ok { + return question + } + } + } + case "enter_plan_mode": + if r, ok := params["reason"].(string); ok { + return r + } + case "exit_plan_mode": + if p, ok := params["plan_path"].(string); ok { + return p + } + } + + // Fallback: format as key: value pairs for readability + var parts []string + for k, v := range params { + switch val := v.(type) { + case string: + parts = append(parts, k+": "+val) + default: + j, _ := json.Marshal(val) + parts = append(parts, k+": "+string(j)) + } + } + return strings.Join(parts, ", ") +} + +// computeLineDiff computes a minimal unified-style diff between old and new text. +// It finds common prefix/suffix lines and shows only the changed lines with +// up to 1 line of surrounding context. Unchanged context lines are prefixed +// with " ", removed lines with "- ", and added lines with "+ ". +func computeLineDiff(old, new_ string) string { + oldLines := strings.Split(old, "\n") + newLines := strings.Split(new_, "\n") + + // Find common prefix lines + prefixLen := 0 + minLen := len(oldLines) + if len(newLines) < minLen { + minLen = len(newLines) + } + for prefixLen < minLen && oldLines[prefixLen] == newLines[prefixLen] { + prefixLen++ + } + + // Find common suffix lines (not overlapping with prefix) + suffixLen := 0 + for suffixLen < len(oldLines)-prefixLen && suffixLen < len(newLines)-prefixLen { + oi := len(oldLines) - 1 - suffixLen + ni := len(newLines) - 1 - suffixLen + if oldLines[oi] != newLines[ni] { + break + } + suffixLen++ + } + + // No actual changes + if prefixLen+suffixLen >= len(oldLines) && prefixLen+suffixLen >= len(newLines) { + return "" + } + + // If everything differs (no common lines), show full old/new + if prefixLen == 0 && suffixLen == 0 { + var sb strings.Builder + for _, l := range oldLines { + sb.WriteString("- " + l + "\n") + } + for _, l := range newLines { + sb.WriteString("+ " + l + "\n") + } + return strings.TrimRight(sb.String(), "\n") + } + + var sb strings.Builder + const contextN = 1 + + // Context: tail of common prefix + ctxStart := prefixLen - contextN + if ctxStart < 0 { + ctxStart = 0 + } + if ctxStart > 0 { + sb.WriteString(" ...\n") + } + for i := ctxStart; i < prefixLen; i++ { + sb.WriteString(" " + oldLines[i] + "\n") + } + + // Removed lines + for i := prefixLen; i < len(oldLines)-suffixLen; i++ { + sb.WriteString("- " + oldLines[i] + "\n") + } + + // Added lines + for i := prefixLen; i < len(newLines)-suffixLen; i++ { + sb.WriteString("+ " + newLines[i] + "\n") + } + + // Context: head of common suffix + suffStart := len(oldLines) - suffixLen + suffEnd := suffStart + contextN + if suffEnd > len(oldLines) { + suffEnd = len(oldLines) + } + for i := suffStart; i < suffEnd; i++ { + sb.WriteString(" " + oldLines[i] + "\n") + } + if suffEnd < len(oldLines) { + sb.WriteString(" ...") + } + + return strings.TrimRight(sb.String(), "\n") +} + +func truncate(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + return string([]rune(s)[:maxRunes]) + "..." +} diff --git a/agent/gemini/session_test.go b/agent/gemini/session_test.go new file mode 100644 index 0000000..9a800d7 --- /dev/null +++ b/agent/gemini/session_test.go @@ -0,0 +1,509 @@ +package gemini + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// sanitizeFileName mirrors the logic in geminiSession.Send for file name sanitization. +func sanitizeFileName(fileName string, index int) string { + fname := filepath.Base(fileName) + if fname == "" || fname == "." || fname == ".." { + fname = fmt.Sprintf("cc-connect-file-%d", index) + } + return fname +} + +func TestSanitizeFileName(t *testing.T) { + tests := []struct { + name string + input string + index int + wantSafe bool + want string + }{ + {"normal file", "report.pdf", 0, true, "report.pdf"}, + {"path traversal", "../../etc/passwd", 0, true, "passwd"}, + {"deep traversal", "../../../tmp/evil.sh", 1, true, "evil.sh"}, + {"absolute path", "/etc/shadow", 0, true, "shadow"}, + {"empty name", "", 2, true, "cc-connect-file-2"}, + {"dot only", ".", 3, true, "cc-connect-file-3"}, + {"double dot", "..", 4, true, "cc-connect-file-4"}, + {"subdir file", "subdir/file.txt", 0, true, "file.txt"}, + {"slash path", "dir/subdir/evil.dll", 0, true, "evil.dll"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitizeFileName(tt.input, tt.index) + if tt.want != "" && got != tt.want { + t.Errorf("sanitizeFileName(%q, %d) = %q, want %q", tt.input, tt.index, got, tt.want) + } + if strings.Contains(got, "..") || strings.Contains(got, "/") { + t.Errorf("sanitizeFileName(%q, %d) = %q — still contains path traversal chars", tt.input, tt.index, got) + } + }) + } +} + +// drainEvents reads all events from the channel until it blocks for the given timeout. +func drainEvents(ch <-chan core.Event, timeout time.Duration) []core.Event { + var events []core.Event + for { + select { + case evt, ok := <-ch: + if !ok { + return events + } + events = append(events, evt) + case <-time.After(timeout): + return events + } + } +} + +func TestHandleMessage_DeltaEmitsEventTextImmediately(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + // Delta message should emit EventText immediately + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "Hello ", + "delta": true, + }) + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "world!", + "delta": true, + }) + + events := drainEvents(gs.events, 50*time.Millisecond) + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Type != core.EventText || events[0].Content != "Hello " { + t.Errorf("event 0: expected EventText 'Hello ', got %v %q", events[0].Type, events[0].Content) + } + if events[1].Type != core.EventText || events[1].Content != "world!" { + t.Errorf("event 1: expected EventText 'world!', got %v %q", events[1].Type, events[1].Content) + } + + // No pending messages should be buffered + if len(gs.pendingMsgs) != 0 { + t.Errorf("expected 0 pending messages, got %d", len(gs.pendingMsgs)) + } +} + +func TestHandleMessage_NonDeltaBuffered(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + // Non-delta message should be buffered (no immediate event) + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "Thinking about this...", + }) + + events := drainEvents(gs.events, 50*time.Millisecond) + if len(events) != 0 { + t.Fatalf("expected 0 immediate events for non-delta message, got %d", len(events)) + } + if len(gs.pendingMsgs) != 1 || gs.pendingMsgs[0] != "Thinking about this..." { + t.Errorf("expected 1 pending message, got %v", gs.pendingMsgs) + } +} + +func TestHandleMessage_NonDeltaFlushedAsThinkingOnToolUse(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + // Buffer a non-delta message + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "Let me check...", + }) + + // tool_use should flush it as thinking + gs.handleEvent(map[string]any{ + "type": "tool_use", + "tool_name": "shell", + "tool_id": "t1", + "parameters": map[string]any{ + "command": "ls", + }, + }) + + events := drainEvents(gs.events, 50*time.Millisecond) + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Type != core.EventThinking || events[0].Content != "Let me check..." { + t.Errorf("event 0: expected EventThinking, got %v %q", events[0].Type, events[0].Content) + } + if events[1].Type != core.EventToolUse { + t.Errorf("event 1: expected EventToolUse, got %v", events[1].Type) + } +} + +func TestHandleMessage_NonDeltaFlushedAsTextOnResult(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + // Buffer a non-delta message + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "Here is the result.", + }) + + // result should flush it as text + gs.handleEvent(map[string]any{ + "type": "result", + "status": "success", + }) + + events := drainEvents(gs.events, 50*time.Millisecond) + if len(events) != 2 { + t.Fatalf("expected 2 events, got %d", len(events)) + } + if events[0].Type != core.EventText || events[0].Content != "Here is the result." { + t.Errorf("event 0: expected EventText, got %v %q", events[0].Type, events[0].Content) + } + if events[1].Type != core.EventResult { + t.Errorf("event 1: expected EventResult, got %v", events[1].Type) + } +} + +func TestHandleMessage_UserMessagesIgnored(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + gs.handleEvent(map[string]any{ + "type": "message", + "role": "user", + "content": "Hello agent", + "delta": true, + }) + + events := drainEvents(gs.events, 50*time.Millisecond) + if len(events) != 0 { + t.Fatalf("expected 0 events for user message, got %d", len(events)) + } +} + +func TestHandleMessage_EmptyContentIgnored(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "", + "delta": true, + }) + + events := drainEvents(gs.events, 50*time.Millisecond) + if len(events) != 0 { + t.Fatalf("expected 0 events for empty content, got %d", len(events)) + } +} + +func TestHandleMessage_MixedDeltaAndNonDelta(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + // Simulate a realistic Gemini CLI output sequence: + // 1. non-delta thinking message + // 2. tool_use flushes thinking + // 3. tool_result + // 4. delta streaming responses + // 5. result + + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "Let me look at the files.", + }) + gs.handleEvent(map[string]any{ + "type": "tool_use", + "tool_name": "shell", + "tool_id": "t1", + "parameters": map[string]any{"command": "ls"}, + }) + gs.handleEvent(map[string]any{ + "type": "tool_result", + "tool_id": "t1", + "status": "success", + "output": "file1.txt\nfile2.txt", + }) + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "Here are ", + "delta": true, + }) + gs.handleEvent(map[string]any{ + "type": "message", + "role": "assistant", + "content": "the files.", + "delta": true, + }) + gs.handleEvent(map[string]any{ + "type": "result", + "status": "success", + }) + + events := drainEvents(gs.events, 50*time.Millisecond) + + // Expected sequence: EventThinking, EventToolUse, EventToolResult, EventText, EventText, EventResult + if len(events) != 6 { + var types []string + for _, e := range events { + types = append(types, string(e.Type)) + } + t.Fatalf("expected 6 events, got %d: %v", len(events), types) + } + + expects := []struct { + typ core.EventType + content string + }{ + {core.EventThinking, "Let me look at the files."}, + {core.EventToolUse, ""}, + {core.EventToolResult, ""}, + {core.EventText, "Here are "}, + {core.EventText, "the files."}, + {core.EventResult, ""}, + } + + for i, exp := range expects { + if events[i].Type != exp.typ { + t.Errorf("event %d: expected type %v, got %v", i, exp.typ, events[i].Type) + } + if exp.content != "" && events[i].Content != exp.content { + t.Errorf("event %d: expected content %q, got %q", i, exp.content, events[i].Content) + } + } +} + +func TestHandleInit_StoresSessionID(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + gs.handleEvent(map[string]any{ + "type": "init", + "session_id": "abc123", + "model": "gemini-2.0-flash", + }) + + if sid := gs.CurrentSessionID(); sid != "abc123" { + t.Errorf("expected session_id abc123, got %q", sid) + } + + events := drainEvents(gs.events, 50*time.Millisecond) + if len(events) != 1 || events[0].Type != core.EventText { + t.Errorf("expected 1 EventText from init, got %v", events) + } +} + +func TestHandleError_EmitsEventError(t *testing.T) { + gs := &geminiSession{ + events: make(chan core.Event, 64), + ctx: context.Background(), + } + + gs.handleEvent(map[string]any{ + "type": "error", + "severity": "error", + "message": "something broke", + }) + + events := drainEvents(gs.events, 50*time.Millisecond) + if len(events) != 1 { + t.Fatalf("expected 1 event, got %d", len(events)) + } + if events[0].Type != core.EventError { + t.Errorf("expected EventError, got %v", events[0].Type) + } + if !strings.Contains(events[0].Error.Error(), "something broke") { + t.Errorf("error should contain message, got %v", events[0].Error) + } +} + +func TestFormatToolParams(t *testing.T) { + tests := []struct { + name string + toolName string + params map[string]any + want string + }{ + {"shell command", "shell", map[string]any{"command": "ls -la"}, "ls -la"}, + {"Bash command", "Bash", map[string]any{"command": "echo hello"}, "echo hello"}, + {"write file path only", "write_file", map[string]any{"file_path": "/tmp/test.txt"}, "/tmp/test.txt"}, + {"write file with content", "write_file", map[string]any{"file_path": "/tmp/test.txt", "content": "hello world"}, "`/tmp/test.txt`\n```\nhello world\n```"}, + {"write file full content", "write_file", map[string]any{"file_path": "/tmp/test.txt", "content": strings.Repeat("x", 200)}, "`/tmp/test.txt`\n```\n" + strings.Repeat("x", 200) + "\n```"}, + {"read file path", "read_file", map[string]any{"path": "/tmp/test.txt"}, "/tmp/test.txt"}, + {"replace single line", "replace", map[string]any{"file_path": "/tmp/test.go", "old_string": "foo", "new_string": "bar"}, "`/tmp/test.go`\n```diff\n- foo\n+ bar\n```"}, + {"replace with context", "replace", map[string]any{"file_path": "/tmp/test.go", "old_string": "func foo() {\n return 1\n}", "new_string": "func foo() {\n return 2\n}"}, "`/tmp/test.go`\n```diff\n func foo() {\n- return 1\n+ return 2\n }\n```"}, + {"list directory path", "list_directory", map[string]any{"path": "/home/user"}, "/home/user"}, + {"list directory dir_path", "list_directory", map[string]any{"dir_path": "/home/user"}, "/home/user"}, + {"web fetch prompt", "web_fetch", map[string]any{"prompt": "https://example.com fetch this page"}, "https://example.com fetch this page"}, + {"web fetch url fallback", "web_fetch", map[string]any{"url": "https://example.com"}, "https://example.com"}, + {"search query", "google_web_search", map[string]any{"query": "golang testing"}, "golang testing"}, + {"activate skill", "activate_skill", map[string]any{"name": "obsidian-markdown"}, "obsidian-markdown"}, + {"search code", "search_code", map[string]any{"query": "func main"}, "func main"}, + {"Grep pattern", "Grep", map[string]any{"pattern": "TODO"}, "TODO"}, + {"grep_search pattern", "grep_search", map[string]any{"pattern": "TODO"}, "TODO"}, + {"glob lowercase", "glob", map[string]any{"pattern": "*.go"}, "*.go"}, + {"save memory", "save_memory", map[string]any{"fact": "user prefers dark mode"}, "user prefers dark mode"}, + {"ask user", "ask_user", map[string]any{"questions": []any{map[string]any{"question": "Which DB?", "header": "DB", "type": "choice"}}}, "Which DB?"}, + {"enter plan mode", "enter_plan_mode", map[string]any{"reason": "complex task"}, "complex task"}, + {"exit plan mode", "exit_plan_mode", map[string]any{"plan_path": "/tmp/plan.md"}, "/tmp/plan.md"}, + {"nil params", "shell", nil, ""}, + {"unknown single key", "custom_tool", map[string]any{"key": "value"}, "key: value"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatToolParams(tt.toolName, tt.params) + if got != tt.want { + t.Errorf("formatToolParams(%q, %v) = %q, want %q", tt.toolName, tt.params, got, tt.want) + } + }) + } +} + +func TestSlugify(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"cc-connect", "cc-connect"}, + {"Daily", "daily"}, + {"My Project", "my-project"}, + {"hello_world", "hello-world"}, + {"Test.123", "test-123"}, + {"---weird---", "weird"}, + {"", "project"}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := slugify(tt.input) + if got != tt.want { + t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestSessionMessage_TextContent(t *testing.T) { + // Test plain string content + m1 := sessionMessage{Type: "user", RawContent: json.RawMessage(`"hello world"`)} + if got := m1.textContent(); got != "hello world" { + t.Errorf("string content: got %q, want %q", got, "hello world") + } + + // Test array of parts content + m2 := sessionMessage{Type: "user", RawContent: json.RawMessage(`[{"text":"line 1"},{"text":"line 2"}]`)} + if got := m2.textContent(); got != "line 1\nline 2" { + t.Errorf("array content: got %q, want %q", got, "line 1\nline 2") + } + + // Test empty content + m3 := sessionMessage{Type: "user", RawContent: nil} + if got := m3.textContent(); got != "" { + t.Errorf("nil content: got %q, want empty", got) + } +} + +func TestComputeLineDiff(t *testing.T) { + tests := []struct { + name string + old string + new_ string + want string + }{ + { + "single line fully different", + "foo", "bar", + "- foo\n+ bar", + }, + { + "common prefix and suffix", + "func foo() {\n return 1\n}", + "func foo() {\n return 2\n}", + " func foo() {\n- return 1\n+ return 2\n }", + }, + { + "large context ellipsis", + "a\nb\nc\nd\nold\ne\nf\ng\nh", + "a\nb\nc\nd\nnew\ne\nf\ng\nh", + " ...\n d\n- old\n+ new\n e\n ...", + }, + { + "common prefix only", + "header\nold1\nold2", + "header\nnew1", + " header\n- old1\n- old2\n+ new1", + }, + { + "common suffix only", + "old1\nfooter", + "new1\nnew2\nfooter", + "- old1\n+ new1\n+ new2\n footer", + }, + { + "identical", + "same\nlines", + "same\nlines", + "", // prefixLen covers all lines, no diff + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := computeLineDiff(tt.old, tt.new_) + if got != tt.want { + t.Errorf("computeLineDiff:\n old=%q\n new=%q\n got=%q\n want=%q", tt.old, tt.new_, got, tt.want) + } + }) + } +} + +func TestGeminiSession_ContinueSessionTreatedAsFresh(t *testing.T) { + s, err := newGeminiSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0) + if err != nil { + t.Fatalf("newGeminiSession: %v", err) + } + defer s.Close() + + if got := s.CurrentSessionID(); got != "" { + t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got) + } +} diff --git a/agent/iflow/iflow.go b/agent/iflow/iflow.go new file mode 100644 index 0000000..165d668 --- /dev/null +++ b/agent/iflow/iflow.go @@ -0,0 +1,480 @@ +package iflow + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("iflow", New) +} + +// Agent drives iFlow CLI one turn at a time using interactive `iflow -i` +// inside a PTY, then reconstructs streaming events from the transcript JSONL. +// +// Modes (maps to iFlow CLI flags): +// - "default": manual approval mode (--default) +// - "auto-edit": auto-edit mode (--autoEdit) +// - "plan": read-only planning mode (--plan) +// - "yolo": auto-approve all tool calls (--yolo) +type Agent struct { + workDir string + model string + mode string + cmd string + toolTimeoutSec int + providers []core.ProviderConfig + activeIdx int + sessionEnv []string + mu sync.RWMutex +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + cmd, _ := opts["cmd"].(string) + if cmd == "" { + cmd = "iflow" + } + + if _, err := exec.LookPath(cmd); err != nil { + return nil, fmt.Errorf("iflow: %q CLI not found in PATH, install with: npm i -g @iflow-ai/iflow-cli", cmd) + } + + var toolTimeoutSec int + switch v := opts["tool_timeout_secs"].(type) { + case int64: + toolTimeoutSec = int(v) + case int: + toolTimeoutSec = v + case float64: + toolTimeoutSec = int(v) + } + + return &Agent{ + workDir: workDir, + model: model, + mode: mode, + cmd: cmd, + toolTimeoutSec: toolTimeoutSec, + activeIdx: -1, + }, nil +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "auto-edit", "auto_edit", "autoedit", "edit": + return "auto-edit" + case "plan": + return "plan" + case "yolo", "force", "auto", "bypasspermissions": + return "yolo" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "iflow" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("iflow: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("iflow: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption { + if models := a.configuredModels(); len(models) > 0 { + return models + } + return []core.ModelOption{ + {Name: "Qwen3-Coder", Desc: "Qwen3 Coder"}, + {Name: "Kimi-K2.5", Desc: "Kimi K2.5"}, + {Name: "DeepSeek-v3", Desc: "DeepSeek v3"}, + } +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + model := a.model + mode := a.mode + cmd := a.cmd + workDir := a.workDir + toolTimeoutSec := a.toolTimeoutSec + extraEnv := a.providerEnvLocked() + extraEnv = append(extraEnv, a.sessionEnv...) + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + if m := a.providers[a.activeIdx].Model; m != "" { + model = m + } + } + a.mu.Unlock() + + return newIFlowSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, toolTimeoutSec) +} + +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + return listIFlowSessions(workDir) +} + +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + if sessionID == "" { + return fmt.Errorf("session id is required") + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("iflow: cannot determine home dir: %w", err) + } + + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absDir := iflowResolvedWorkDir(workDir) + + candidates := []string{ + filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(absDir), sessionID+".jsonl"), + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return os.Remove(p) + } + } + + matches, _ := filepath.Glob(filepath.Join(homeDir, ".iflow", "projects", "*", sessionID+".jsonl")) + if len(matches) == 0 { + return fmt.Errorf("session file not found: %s", sessionID) + } + return os.Remove(matches[0]) +} + +func (a *Agent) Stop() error { return nil } + +// -- ModeSwitcher -- + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("iflow: mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Manual approval mode", DescZh: "手动审批模式"}, + {Key: "auto-edit", Name: "Auto Edit", NameZh: "自动编辑", Desc: "Auto-edit mode", DescZh: "自动编辑模式"}, + {Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only planning mode", DescZh: "只读规划模式"}, + {Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"}, + } +} + +// -- ContextCompressor -- + +func (a *Agent) CompressCommand() string { return "/compress" } + +// -- MemoryFileProvider -- + +func (a *Agent) ProjectMemoryFile() string { + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + return filepath.Join(absDir, "IFLOW.md") +} + +func (a *Agent) GlobalMemoryFile() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".iflow", "IFLOW.md") +} + +// -- ProviderSwitcher -- + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.activeIdx = -1 + slog.Info("iflow: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("iflow: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + var env []string + if p.APIKey != "" { + env = append(env, + "IFLOW_API_KEY="+p.APIKey, + "IFLOW_apiKey="+p.APIKey, + ) + } + if p.BaseURL != "" { + env = append(env, + "IFLOW_BASE_URL="+p.BaseURL, + "IFLOW_baseUrl="+p.BaseURL, + ) + } + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +// -- Session listing helpers -- + +type iflowTranscriptLine struct { + SessionID string `json:"sessionId"` + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + Message struct { + Role string `json:"role"` + Content any `json:"content"` + } `json:"message"` +} + +func listIFlowSessions(workDir string) ([]core.AgentSessionInfo, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("iflow: cannot determine home dir: %w", err) + } + + absDir := iflowResolvedWorkDir(workDir) + + sessionDir := filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(absDir)) + entries, err := os.ReadDir(sessionDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("iflow: read sessions dir: %w", err) + } + + var sessions []core.AgentSessionInfo + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasPrefix(name, "session-") || !strings.HasSuffix(name, ".jsonl") { + continue + } + + path := filepath.Join(sessionDir, name) + sid, summary, msgCount, modifiedAt := parseIFlowSessionFile(path) + if sid == "" { + sid = strings.TrimSuffix(name, ".jsonl") + } + if summary == "" { + summary = sid + if utf8.RuneCountInString(summary) > 12 { + summary = string([]rune(summary)[:12]) + "..." + } + } + if modifiedAt.IsZero() { + if fi, err := e.Info(); err == nil { + modifiedAt = fi.ModTime() + } + } + + sessions = append(sessions, core.AgentSessionInfo{ + ID: sid, + Summary: summary, + MessageCount: msgCount, + ModifiedAt: modifiedAt, + }) + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + return sessions, nil +} + +func parseIFlowSessionFile(path string) (sid, summary string, msgCount int, modifiedAt time.Time) { + f, err := os.Open(path) + if err != nil { + return "", "", 0, time.Time{} + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + msgCount++ + + var item iflowTranscriptLine + if err := json.Unmarshal([]byte(line), &item); err != nil { + continue + } + if sid == "" && item.SessionID != "" { + sid = item.SessionID + } + if !item.Timestamp.IsZero() && item.Timestamp.After(modifiedAt) { + modifiedAt = item.Timestamp + } + if summary == "" && item.Type == "user" { + if text := extractIFlowContentText(item.Message.Content); text != "" { + summary = firstNonEmptyLine(text) + if utf8.RuneCountInString(summary) > 60 { + summary = string([]rune(summary)[:60]) + "..." + } + } + } + } + + return sid, summary, msgCount, modifiedAt +} + +func extractIFlowContentText(content any) string { + switch v := content.(type) { + case string: + return strings.TrimSpace(v) + case []any: + for _, it := range v { + m, ok := it.(map[string]any) + if !ok { + continue + } + if t, _ := m["text"].(string); t != "" { + return strings.TrimSpace(t) + } + } + } + return "" +} + +func firstNonEmptyLine(s string) string { + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + +func iflowProjectKey(absDir string) string { + if absDir == "" { + return "" + } + key := filepath.Clean(absDir) + key = strings.ReplaceAll(key, "\\", "-") + key = strings.ReplaceAll(key, "/", "-") + key = strings.ReplaceAll(key, ":", "-") + return key +} + +func iflowResolvedWorkDir(workDir string) string { + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + if resolved, err := filepath.EvalSymlinks(absDir); err == nil && resolved != "" { + absDir = resolved + } + return absDir +} diff --git a/agent/iflow/iflow_integration_test.go b/agent/iflow/iflow_integration_test.go new file mode 100644 index 0000000..012ceb1 --- /dev/null +++ b/agent/iflow/iflow_integration_test.go @@ -0,0 +1,94 @@ +package iflow + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func TestIFlowSessionIntegration(t *testing.T) { + if os.Getenv("IFLOW_INTEGRATION") == "" { + t.Skip("set IFLOW_INTEGRATION=1 to run") + } + if _, err := exec.LookPath("iflow"); err != nil { + t.Skip("iflow CLI not found in PATH") + } + + a, err := New(map[string]any{"work_dir": "/tmp"}) + if err != nil { + t.Fatalf("New: %v", err) + } + + sess, err := a.StartSession(context.Background(), "") + if err != nil { + t.Fatalf("StartSession: %v", err) + } + defer sess.Close() + + if err := sess.Send("Reply exactly READY", nil, nil); err != nil { + t.Fatalf("Send #1: %v", err) + } + res1 := waitForResult(t, sess.Events()) + if !strings.Contains(strings.ToUpper(res1.Content), "READY") { + t.Fatalf("unexpected first result: %q", res1.Content) + } + sid := sess.CurrentSessionID() + if sid == "" { + t.Fatal("expected session id after first send") + } + + if err := sess.Send("Reply exactly RESUMED", nil, nil); err != nil { + t.Fatalf("Send #2: %v", err) + } + res2 := waitForResult(t, sess.Events()) + if !strings.Contains(strings.ToUpper(res2.Content), "RESUMED") { + t.Fatalf("unexpected second result: %q", res2.Content) + } + if sid2 := sess.CurrentSessionID(); sid2 != sid { + t.Fatalf("session id changed: %q -> %q", sid, sid2) + } + + resumed, err := a.StartSession(context.Background(), sid) + if err != nil { + t.Fatalf("StartSession resume: %v", err) + } + defer resumed.Close() + if err := resumed.Send("Reply exactly CONTINUED", nil, nil); err != nil { + t.Fatalf("Send resume: %v", err) + } + res3 := waitForResult(t, resumed.Events()) + if !strings.Contains(strings.ToUpper(res3.Content), "CONTINUED") { + t.Fatalf("unexpected resumed result: %q", res3.Content) + } + if sid3 := resumed.CurrentSessionID(); sid3 != sid { + t.Fatalf("resumed session id mismatch: want %q, got %q", sid, sid3) + } +} + +func waitForResult(t *testing.T, ch <-chan core.Event) core.Event { + t.Helper() + timeout := time.After(90 * time.Second) + for { + select { + case ev, ok := <-ch: + if !ok { + t.Fatal("events channel closed") + } + switch ev.Type { + case core.EventError: + t.Fatalf("event error: %v", ev.Error) + case core.EventResult: + fmt.Printf("[RESULT] sid=%s content=%s\n", ev.SessionID, ev.Content) + return ev + } + case <-timeout: + t.Fatal("timeout waiting for result") + } + } +} diff --git a/agent/iflow/iflow_test.go b/agent/iflow/iflow_test.go new file mode 100644 index 0000000..370a011 --- /dev/null +++ b/agent/iflow/iflow_test.go @@ -0,0 +1,211 @@ +package iflow + +import ( + "context" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +func TestNormalizeMode(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", "default"}, + {"default", "default"}, + {"AUTO-EDIT", "auto-edit"}, + {"auto_edit", "auto-edit"}, + {"edit", "auto-edit"}, + {"plan", "plan"}, + {"yolo", "yolo"}, + {"force", "yolo"}, + {"unknown", "default"}, + } + for _, tc := range cases { + if got := normalizeMode(tc.in); got != tc.want { + t.Fatalf("normalizeMode(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestProviderEnvLocked(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + { + Name: "custom", + APIKey: "k1", + BaseURL: "https://example.com/v1", + Env: map[string]string{ + "FOO": "bar", + }, + }, + }, + activeIdx: 0, + } + + got := a.providerEnvLocked() + wantSubset := []string{ + "IFLOW_API_KEY=k1", + "IFLOW_apiKey=k1", + "IFLOW_BASE_URL=https://example.com/v1", + "IFLOW_baseUrl=https://example.com/v1", + "FOO=bar", + } + + for _, item := range wantSubset { + if !contains(got, item) { + t.Fatalf("providerEnvLocked() missing %q; got=%v", item, got) + } + } +} + +func TestIFlowProjectKey(t *testing.T) { + path := "/Users/test/project" + got := iflowProjectKey(path) + if got != "-Users-test-project" { + t.Fatalf("iflowProjectKey(%q) = %q", path, got) + } + + if got := iflowProjectKey(""); got != "" { + t.Fatalf("iflowProjectKey(\"\") = %q, want empty", got) + } +} + +func TestIFlowResolvedWorkDir(t *testing.T) { + base := t.TempDir() + realDir := filepath.Join(base, "real") + linkDir := filepath.Join(base, "link") + if err := os.Mkdir(realDir, 0o755); err != nil { + t.Fatalf("Mkdir: %v", err) + } + if err := os.Symlink(realDir, linkDir); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + + want, err := filepath.EvalSymlinks(realDir) + if err != nil { + t.Fatalf("EvalSymlinks(realDir): %v", err) + } + + if got := iflowResolvedWorkDir(linkDir); got != want { + t.Fatalf("iflowResolvedWorkDir(%q) = %q, want %q", linkDir, got, want) + } +} + +func TestExtractIFlowContentText(t *testing.T) { + if got := extractIFlowContentText("hello"); got != "hello" { + t.Fatalf("extractIFlowContentText string = %q", got) + } + + arr := []any{map[string]any{"type": "text", "text": "from-array"}} + if got := extractIFlowContentText(arr); got != "from-array" { + t.Fatalf("extractIFlowContentText array = %q", got) + } + + if got := extractIFlowContentText(123); got != "" { + t.Fatalf("extractIFlowContentText unexpected = %q", got) + } +} + +func TestPermissionModesKeys(t *testing.T) { + a := &Agent{} + modes := a.PermissionModes() + got := make([]string, 0, len(modes)) + for _, m := range modes { + got = append(got, m.Key) + } + want := []string{"default", "auto-edit", "plan", "yolo"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("PermissionModes keys = %v, want %v", got, want) + } +} + +func TestConfiguredModels_BoundaryConditions(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "first"}}}, + {Models: []core.ModelOption{{Name: "second"}}}, + }, + } + + tests := []struct { + name string + activeIdx int + wantNil bool + wantName string + }{ + {name: "negative index", activeIdx: -1, wantNil: true}, + {name: "out of range", activeIdx: 2, wantNil: true}, + {name: "valid index", activeIdx: 1, wantName: "second"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a.activeIdx = tt.activeIdx + got := a.configuredModels() + if tt.wantNil { + if got != nil { + t.Fatalf("configuredModels() = %v, want nil", got) + } + return + } + if len(got) != 1 || got[0].Name != tt.wantName { + t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName) + } + }) + } +} + +func contains(list []string, target string) bool { + for _, item := range list { + if item == target { + return true + } + } + return false +} + +// TestIFlowAgent_WorkDirRaceFreeReaders pins the bug where ListSessions, +// DeleteSession, and ProjectMemoryFile read a.workDir without holding +// a.mu, while SetWorkDir mutates it under the lock. Running this with +// -race flags the data race; with the fix the race detector stays quiet. +func TestIFlowAgent_WorkDirRaceFreeReaders(t *testing.T) { + dir := t.TempDir() + a := &Agent{workDir: dir} + + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if i%2 == 0 { + a.SetWorkDir(filepath.Join(dir, "a")) + } else { + a.SetWorkDir(filepath.Join(dir, "b")) + } + }(i) + } + for i := 0; i < 20; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _, _ = a.ListSessions(context.Background()) + }() + wg.Add(1) + go func() { + defer wg.Done() + _ = a.DeleteSession(context.Background(), "no-such-session") + }() + wg.Add(1) + go func() { + defer wg.Done() + _ = a.ProjectMemoryFile() + }() + } + wg.Wait() +} diff --git a/agent/iflow/session.go b/agent/iflow/session.go new file mode 100644 index 0000000..97fa838 --- /dev/null +++ b/agent/iflow/session.go @@ -0,0 +1,905 @@ +package iflow + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" + "github.com/creack/pty" +) + +var ( + sessionIDRe = regexp.MustCompile(`"session-id"\s*:\s*"([^"]+)"`) + ansiCSIRe = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) + ansiOSCRe = regexp.MustCompile(`\x1b\][^\a]*(?:\a|\x1b\\)`) +) + +const ( + iflowTurnIdle = 900 * time.Millisecond + iflowTranscriptPoll = 200 * time.Millisecond +) + +var iflowPendingToolTimeout = 180 * time.Second +var iflowPendingToolTimeoutDefaultMode = 6 * time.Second + +// iflowSession manages multi-turn conversations with iFlow CLI. +// Each Send() launches a fresh interactive `iflow -i` process inside a PTY, +// then tails the transcript JSONL to recover structured assistant/tool events. +type iflowSession struct { + cmd string + workDir string + model string + mode string + toolTimeoutSec int + extraEnv []string + events chan core.Event + sessionID atomic.Value // stores string + sentOnce atomic.Bool + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + turnActive atomic.Bool +} + +type iflowTurn struct { + cancel context.CancelFunc + startedAt time.Time + mode string + pendingTimeout time.Duration + sessionDir string + transcriptPath string + offset int64 + partial string + processDone chan struct{} + + mu sync.Mutex + pendingToolIDs map[string]struct{} + pendingTools map[string]iflowToolUse + seenToolIDs map[string]struct{} + doneToolIDs map[string]struct{} + pendingTimer *time.Timer + resultTimer *time.Timer + resultText string + toolFallback []string + awaitingTool bool + resultReady bool + resultSent bool +} + +type iflowToolUse struct { + ID string + Name string + Input any +} + +type iflowToolResult struct { + ID string + Output string +} + +func newIFlowSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, toolTimeoutSec int) (*iflowSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + s := &iflowSession{ + cmd: cmd, + workDir: workDir, + model: model, + mode: mode, + toolTimeoutSec: toolTimeoutSec, + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: sessionCtx, + cancel: cancel, + } + s.alive.Store(true) + + if resumeID != "" && resumeID != core.ContinueSession { + s.sessionID.Store(resumeID) + s.sentOnce.Store(true) + } + + return s, nil +} + +func (s *iflowSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if len(images) > 0 { + slog.Warn("iflowSession: images are not supported, ignoring") + } + if len(files) > 0 { + filePaths := core.SaveFilesToDisk(s.workDir, files) + prompt = core.AppendFileRefs(prompt, filePaths) + } + if !s.alive.Load() { + return fmt.Errorf("session is closed") + } + if !s.turnActive.CompareAndSwap(false, true) { + return fmt.Errorf("iflow session is busy") + } + + turnCtx, turnCancel := context.WithCancel(s.ctx) + turn := &iflowTurn{ + cancel: turnCancel, + startedAt: time.Now(), + mode: s.mode, + pendingTimeout: s.pendingToolTimeout(), + processDone: make(chan struct{}), + pendingToolIDs: make(map[string]struct{}), + pendingTools: make(map[string]iflowToolUse), + seenToolIDs: make(map[string]struct{}), + doneToolIDs: make(map[string]struct{}), + } + + defer func() { + if !s.turnActive.Load() { + turnCancel() + } + }() + + sessionDir, err := iflowSessionDir(s.workDir) + if err != nil { + s.turnActive.Store(false) + return fmt.Errorf("iflowSession: resolve session dir: %w", err) + } + turn.sessionDir = sessionDir + + args := make([]string, 0, 16) + if s.model != "" { + args = append(args, "-m", s.model) + } + + switch s.mode { + case "yolo": + args = append(args, "--yolo") + case "plan": + args = append(args, "--plan") + case "auto-edit": + args = append(args, "--autoEdit") + default: + args = append(args, "--default") + } + + sid := s.CurrentSessionID() + if sid != "" { + args = append(args, "-r", sid) + turn.transcriptPath = filepath.Join(sessionDir, sid+".jsonl") + turn.offset = fileSize(turn.transcriptPath) + } else if s.sentOnce.Load() { + args = append(args, "-c") + } + + args = append(args, "-i", prompt) + slog.Debug("iflowSession: launching interactive turn", "resume", sid != "", "args", core.RedactArgs(args)) + + cmd := exec.CommandContext(turnCtx, s.cmd, args...) + cmd.Dir = s.workDir + env := os.Environ() + if len(s.extraEnv) > 0 { + env = core.MergeEnv(env, s.extraEnv) + } + cmd.Env = env + + ptmx, err := pty.Start(cmd) + if err != nil { + s.turnActive.Store(false) + return fmt.Errorf("iflowSession: start pty: %w", err) + } + + s.sentOnce.Store(true) + s.wg.Add(1) + go s.readLoop(turn, cmd, ptmx) + return nil +} + +func (s *iflowSession) readLoop(turn *iflowTurn, cmd *exec.Cmd, ptmx *os.File) { + defer s.wg.Done() + defer s.turnActive.Store(false) + defer turn.cancel() + defer ptmx.Close() + + var termBuf bytes.Buffer + drainDone := make(chan struct{}) + go func() { + _, _ = io.Copy(&termBuf, ptmx) + close(drainDone) + }() + + watchDone := make(chan struct{}) + go func() { + s.watchTranscript(turn) + close(watchDone) + }() + + waitErr := cmd.Wait() + close(turn.processDone) + _ = ptmx.Close() + <-drainDone + <-watchDone + turn.stopResultTimer() + + // Clear busy state before emitting events so callers can Send() immediately + // after receiving the event. The defer above serves as a safety net. + s.turnActive.Store(false) + + termText := strings.TrimSpace(stripANSI(termBuf.String())) + if turn.readyForResult() { + turn.markResultSent() + s.emitEvent(core.Event{ + Type: core.EventResult, + Content: turn.finalContent(), + SessionID: s.CurrentSessionID(), + Done: true, + }) + return + } + + if turn.resultWasSent() { + return + } + + if waitErr != nil { + if s.ctx.Err() != nil || errors.Is(waitErr, context.Canceled) { + return + } + s.emitEvent(core.Event{Type: core.EventError, Error: summarizeIFlowError(termText, waitErr)}) + return + } + + if termText != "" && isIFlowAPIFailure(termText) { + s.emitEvent(core.Event{Type: core.EventError, Error: summarizeIFlowError(termText, nil)}) + return + } + + s.emitEvent(core.Event{ + Type: core.EventResult, + Content: turn.finalContent(), + SessionID: s.CurrentSessionID(), + Done: true, + }) +} + +func (s *iflowSession) watchTranscript(turn *iflowTurn) { + ticker := time.NewTicker(iflowTranscriptPoll) + defer ticker.Stop() + + processDone := false + + for { + if turn.transcriptPath == "" { + if path := findIFlowTranscriptPath(turn.sessionDir, turn.startedAt); path != "" { + turn.transcriptPath = path + turn.offset = 0 + } + } + + if turn.transcriptPath != "" { + if err := s.consumeTranscript(turn); err != nil { + slog.Debug("iflowSession: transcript poll failed", "path", turn.transcriptPath, "error", err) + } + } + + if turn.resultWasSent() || processDone { + return + } + + select { + case <-s.ctx.Done(): + return + case <-turn.processDone: + processDone = true + case <-ticker.C: + } + } +} + +func (s *iflowSession) pendingToolTimeout() time.Duration { + if s.toolTimeoutSec > 0 { + return time.Duration(s.toolTimeoutSec) * time.Second + } + if strings.EqualFold(s.mode, "default") { + return iflowPendingToolTimeoutDefaultMode + } + return iflowPendingToolTimeout +} + +func (s *iflowSession) consumeTranscript(turn *iflowTurn) error { + f, err := os.Open(turn.transcriptPath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return err + } + if fi.Size() < turn.offset { + turn.offset = fi.Size() + turn.partial = "" + } + if _, err := f.Seek(turn.offset, io.SeekStart); err != nil { + return err + } + + chunk, err := io.ReadAll(f) + if err != nil { + return err + } + if len(chunk) == 0 { + return nil + } + turn.offset += int64(len(chunk)) + + data := turn.partial + string(chunk) + lines := strings.Split(data, "\n") + if !strings.HasSuffix(data, "\n") { + turn.partial = lines[len(lines)-1] + lines = lines[:len(lines)-1] + } else { + turn.partial = "" + } + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + s.handleTranscriptLine(turn, line) + } + return nil +} + +func (s *iflowSession) handleTranscriptLine(turn *iflowTurn, line string) { + var item iflowTranscriptLine + if err := json.Unmarshal([]byte(line), &item); err != nil { + slog.Debug("iflowSession: invalid transcript line", "error", err) + return + } + + if item.SessionID != "" { + s.sessionID.Store(item.SessionID) + } + + switch item.Type { + case "assistant": + texts, toolUses := extractIFlowAssistantEvents(item.Message.Content) + if len(toolUses) > 0 { + newTools := turn.addPendingTools(toolUses) + for _, tool := range newTools { + s.emitEvent(core.Event{ + Type: core.EventToolUse, + ToolName: tool.Name, + ToolInput: summarizeIFlowToolInput(tool.Input), + }) + } + } + for _, text := range texts { + turn.appendText(text) + s.emitEvent(core.Event{Type: core.EventText, Content: text, SessionID: s.CurrentSessionID()}) + } + if len(texts) > 0 && !turn.hasPendingTools() { + turn.scheduleResult(s) + } + + case "user": + toolResults := extractIFlowToolResults(item.Message.Content) + _ = turn.completeTools(toolResults) + } +} + +func iflowSessionDir(workDir string) (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(homeDir, ".iflow", "projects", iflowProjectKey(iflowResolvedWorkDir(workDir))), nil +} + +func findIFlowTranscriptPath(sessionDir string, startedAt time.Time) string { + matches, err := filepath.Glob(filepath.Join(sessionDir, "session-*.jsonl")) + if err != nil { + return "" + } + + cutoff := startedAt.Add(-2 * time.Second) + var best string + var bestMod time.Time + for _, path := range matches { + fi, err := os.Stat(path) + if err != nil || fi.IsDir() { + continue + } + if fi.ModTime().Before(cutoff) { + continue + } + if best == "" || fi.ModTime().After(bestMod) { + best = path + bestMod = fi.ModTime() + } + } + return best +} + +func fileSize(path string) int64 { + fi, err := os.Stat(path) + if err != nil { + return 0 + } + return fi.Size() +} + +func extractIFlowAssistantEvents(content any) ([]string, []iflowToolUse) { + switch v := content.(type) { + case string: + text := strings.TrimSpace(v) + if text == "" { + return nil, nil + } + return []string{text}, nil + + case []any: + var texts []string + var tools []iflowToolUse + for _, raw := range v { + item, ok := raw.(map[string]any) + if !ok { + continue + } + switch itemType, _ := item["type"].(string); itemType { + case "text": + if text, _ := item["text"].(string); strings.TrimSpace(text) != "" { + texts = append(texts, strings.TrimSpace(text)) + } + case "tool_use": + name, _ := item["name"].(string) + if name == "" { + continue + } + toolID, _ := item["id"].(string) + tools = append(tools, iflowToolUse{ + ID: toolID, + Name: name, + Input: item["input"], + }) + } + } + return texts, tools + } + + return nil, nil +} + +func extractIFlowToolResults(content any) []iflowToolResult { + items, ok := content.([]any) + if !ok { + return nil + } + + var results []iflowToolResult + for _, raw := range items { + item, ok := raw.(map[string]any) + if !ok { + continue + } + if itemType, _ := item["type"].(string); itemType != "tool_result" { + continue + } + if toolID, _ := item["tool_use_id"].(string); toolID != "" { + results = append(results, iflowToolResult{ + ID: toolID, + Output: summarizeIFlowToolResult(item["content"]), + }) + } + } + return results +} + +func summarizeIFlowToolInput(input any) string { + m, ok := input.(map[string]any) + if !ok { + return "" + } + + for _, key := range []string{ + "absolute_path", + "path", + "file_path", + "command", + "query", + "pattern", + "prompt", + "url", + } { + if v, _ := m[key].(string); v != "" { + return truncateRunes(v, 300) + } + } + + b, err := json.Marshal(m) + if err != nil { + return "" + } + return truncateRunes(string(b), 300) +} + +func summarizeIFlowToolResult(content any) string { + m, ok := content.(map[string]any) + if !ok { + return "" + } + + for _, path := range [][]string{ + {"functionResponse", "response", "output"}, + {"responseParts", "functionResponse", "response", "output"}, + } { + if v := nestedString(m, path...); v != "" { + return truncateRunes(strings.TrimSpace(v), 2000) + } + } + + if v := nestedString(m, "resultDisplay"); v != "" { + return truncateRunes(strings.TrimSpace(v), 2000) + } + if v := nestedString(m, "output"); v != "" { + return truncateRunes(strings.TrimSpace(v), 2000) + } + + b, err := json.Marshal(m) + if err != nil { + return "" + } + return truncateRunes(string(b), 2000) +} + +func nestedString(v any, path ...string) string { + cur := v + for _, key := range path { + m, ok := cur.(map[string]any) + if !ok { + return "" + } + cur, ok = m[key] + if !ok { + return "" + } + } + s, _ := cur.(string) + return s +} + +func truncateRunes(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + return string([]rune(s)[:maxRunes]) + "..." +} + +func (t *iflowTurn) addPendingTools(tools []iflowToolUse) []iflowToolUse { + t.mu.Lock() + defer t.mu.Unlock() + + var added []iflowToolUse + for _, tool := range tools { + if tool.ID == "" { + continue + } + if _, ok := t.doneToolIDs[tool.ID]; ok { + continue + } + if _, ok := t.seenToolIDs[tool.ID]; ok { + continue + } + t.seenToolIDs[tool.ID] = struct{}{} + t.pendingToolIDs[tool.ID] = struct{}{} + t.pendingTools[tool.ID] = tool + added = append(added, tool) + } + if len(added) > 0 { + timeout := t.pendingTimeout + if timeout <= 0 { + timeout = iflowPendingToolTimeout + } + if t.pendingTimer != nil { + t.pendingTimer.Stop() + t.pendingTimer = nil + } + t.pendingTimer = time.AfterFunc(timeout, func() { + t.mu.Lock() + if t.resultSent || len(t.pendingToolIDs) == 0 { + t.pendingTimer = nil + t.mu.Unlock() + return + } + + var names []string + for _, tool := range t.pendingTools { + if tool.Name != "" { + names = append(names, tool.Name) + } + } + msg := "Tool call timed out before completion." + if len(names) > 0 { + msg += " Pending: " + strings.Join(names, ", ") + "." + } + if strings.EqualFold(t.mode, "default") { + msg += " Default mode requires interactive approval; use /mode yolo or /mode auto-edit for tool calls." + } else { + msg += " The tool appears stalled." + } + + if t.resultText == "" { + t.resultText = msg + } else { + t.resultText = t.resultText + "\n\n" + msg + } + t.awaitingTool = false + t.resultReady = true + t.pendingToolIDs = make(map[string]struct{}) + t.pendingTools = make(map[string]iflowToolUse) + t.pendingTimer = nil + t.mu.Unlock() + + t.cancel() + }) + + if t.resultTimer != nil { + t.resultTimer.Stop() + t.resultTimer = nil + } + t.awaitingTool = false + t.toolFallback = nil + } + return added +} + +func (t *iflowTurn) appendText(text string) { + t.mu.Lock() + defer t.mu.Unlock() + t.awaitingTool = false + t.resultText += text +} + +func (t *iflowTurn) completeTools(results []iflowToolResult) bool { + t.mu.Lock() + defer t.mu.Unlock() + + changed := false + for _, result := range results { + if _, ok := t.doneToolIDs[result.ID]; ok { + continue + } + delete(t.pendingToolIDs, result.ID) + delete(t.pendingTools, result.ID) + t.doneToolIDs[result.ID] = struct{}{} + if result.Output != "" { + t.toolFallback = append(t.toolFallback, result.Output) + } + changed = true + } + if len(t.pendingToolIDs) == 0 && t.pendingTimer != nil { + t.pendingTimer.Stop() + t.pendingTimer = nil + } else if changed && len(t.pendingToolIDs) > 0 && t.pendingTimer != nil { + t.pendingTimer.Reset(t.pendingTimeout) + } + if len(results) == 0 || len(t.pendingToolIDs) > 0 { + return false + } + t.awaitingTool = true + return true +} + +func (t *iflowTurn) hasPendingTools() bool { + t.mu.Lock() + defer t.mu.Unlock() + return len(t.pendingToolIDs) > 0 +} + +func (t *iflowTurn) scheduleResult(s *iflowSession) { + t.mu.Lock() + defer t.mu.Unlock() + + if t.resultSent || len(t.pendingToolIDs) > 0 { + return + } + if t.pendingTimer != nil { + t.pendingTimer.Stop() + t.pendingTimer = nil + } + if t.resultTimer != nil { + t.resultTimer.Stop() + t.resultTimer = nil + } + + t.resultTimer = time.AfterFunc(iflowTurnIdle, func() { + t.mu.Lock() + if t.resultSent || len(t.pendingToolIDs) > 0 { + t.resultTimer = nil + t.mu.Unlock() + return + } + t.resultReady = true + t.resultTimer = nil + t.mu.Unlock() + + t.cancel() + }) +} + +func (t *iflowTurn) stopResultTimer() { + t.mu.Lock() + defer t.mu.Unlock() + + if t.resultTimer != nil { + t.resultTimer.Stop() + t.resultTimer = nil + } + if t.pendingTimer != nil { + t.pendingTimer.Stop() + t.pendingTimer = nil + } +} + +func (t *iflowTurn) resultWasSent() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.resultSent +} + +func (t *iflowTurn) readyForResult() bool { + t.mu.Lock() + defer t.mu.Unlock() + return t.resultReady +} + +func (t *iflowTurn) markResultSent() { + t.mu.Lock() + defer t.mu.Unlock() + t.resultSent = true +} + +func (t *iflowTurn) finalContent() string { + t.mu.Lock() + defer t.mu.Unlock() + if !t.awaitingTool || len(t.toolFallback) == 0 { + return t.resultText + } + + fallback := strings.Join(t.toolFallback, "\n\n") + if t.resultText == "" { + return fallback + } + if strings.Contains(t.resultText, fallback) { + return t.resultText + } + return t.resultText + "\n\n" + fallback +} + +func (s *iflowSession) emitEvent(evt core.Event) { + select { + case s.events <- evt: + case <-s.ctx.Done(): + } +} + +func readExecutionInfoSessionID(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + var payload struct { + SessionID string `json:"session-id"` + } + if err := json.Unmarshal(b, &payload); err != nil { + return "", err + } + return strings.TrimSpace(payload.SessionID), nil +} + +func extractSessionIDFromExecutionInfo(stderrText string) string { + m := sessionIDRe.FindStringSubmatch(stderrText) + if len(m) == 2 { + return strings.TrimSpace(m[1]) + } + return "" +} + +func stripANSI(s string) string { + s = ansiOSCRe.ReplaceAllString(s, "") + s = ansiCSIRe.ReplaceAllString(s, "") + s = strings.ReplaceAll(s, "\r", "") + return s +} + +func isIFlowAPIFailure(stderrText string) bool { + if stderrText == "" { + return false + } + lower := strings.ToLower(stderrText) + if strings.Contains(lower, "error when talking to iflow api") { + return true + } + if strings.Contains(lower, "generate data error") { + return true + } + if strings.Contains(lower, "retrying with backoff") && strings.Contains(lower, "fetch failed") { + return true + } + return false +} + +func summarizeIFlowError(stderrText string, waitErr error) error { + if stderrText != "" { + for _, line := range strings.Split(stderrText, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if strings.HasPrefix(line, "") || strings.HasPrefix(line, "") { + continue + } + if strings.HasPrefix(line, "{") || strings.HasPrefix(line, "}") || strings.HasPrefix(line, "\"") { + continue + } + if utf8.RuneCountInString(line) > 300 { + line = string([]rune(line)[:300]) + "..." + } + return fmt.Errorf("%s", line) + } + } + if waitErr != nil { + return fmt.Errorf("iflow process failed: %w", waitErr) + } + return fmt.Errorf("iflow API request failed") +} + +func (s *iflowSession) RespondPermission(_ string, _ core.PermissionResult) error { + return nil +} + +func (s *iflowSession) Events() <-chan core.Event { + return s.events +} + +func (s *iflowSession) CurrentSessionID() string { + v, _ := s.sessionID.Load().(string) + return v +} + +func (s *iflowSession) Alive() bool { + return s.alive.Load() +} + +func (s *iflowSession) Close() error { + s.alive.Store(false) + s.cancel() + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + select { + case <-done: + close(s.events) + case <-time.After(8 * time.Second): + slog.Warn("iflowSession: close timed out, abandoning wg.Wait") + } + return nil +} diff --git a/agent/iflow/session_test.go b/agent/iflow/session_test.go new file mode 100644 index 0000000..2fa13f4 --- /dev/null +++ b/agent/iflow/session_test.go @@ -0,0 +1,442 @@ +package iflow + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func TestReadExecutionInfoSessionID(t *testing.T) { + f, err := os.CreateTemp("", "iflow-exec-info-*.json") + if err != nil { + t.Fatalf("CreateTemp: %v", err) + } + defer os.Remove(f.Name()) + + if _, err := f.WriteString(`{"session-id":"session-123"}`); err != nil { + t.Fatalf("WriteString: %v", err) + } + f.Close() + + sid, err := readExecutionInfoSessionID(f.Name()) + if err != nil { + t.Fatalf("readExecutionInfoSessionID: %v", err) + } + if sid != "session-123" { + t.Fatalf("session id = %q, want session-123", sid) + } +} + +func TestExtractSessionIDFromExecutionInfo(t *testing.T) { + stderr := ` +{ + "session-id": "session-abc", + "conversation-id": "cid" +} +` + if got := extractSessionIDFromExecutionInfo(stderr); got != "session-abc" { + t.Fatalf("extractSessionIDFromExecutionInfo = %q", got) + } +} + +func TestIsIFlowAPIFailure(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"", false}, + {"Error when talking to iFlow API", true}, + {"Attempt 1 failed. Retrying with backoff... Error: generate data error: fetch failed", true}, + {"normal log only", false}, + } + + for _, tc := range cases { + if got := isIFlowAPIFailure(tc.in); got != tc.want { + t.Fatalf("isIFlowAPIFailure(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} + +func TestSummarizeIFlowError(t *testing.T) { + err := summarizeIFlowError("Error when talking to iFlow API\n\n", nil) + if err == nil || err.Error() != "Error when talking to iFlow API" { + t.Fatalf("unexpected error summary: %v", err) + } +} + +func TestExtractIFlowAssistantEvents(t *testing.T) { + content := []any{ + map[string]any{"type": "text", "text": "checking"}, + map[string]any{ + "type": "tool_use", + "id": "read_file:1", + "name": "read_file", + "input": map[string]any{"absolute_path": "/tmp/demo.txt"}, + }, + } + + texts, tools := extractIFlowAssistantEvents(content) + if len(texts) != 1 || texts[0] != "checking" { + t.Fatalf("texts = %#v", texts) + } + if len(tools) != 1 { + t.Fatalf("tools = %#v", tools) + } + if tools[0].ID != "read_file:1" || tools[0].Name != "read_file" { + t.Fatalf("tool = %#v", tools[0]) + } + if got := summarizeIFlowToolInput(tools[0].Input); got != "/tmp/demo.txt" { + t.Fatalf("summarizeIFlowToolInput = %q", got) + } +} + +func TestExtractIFlowToolResults(t *testing.T) { + content := []any{ + map[string]any{ + "type": "tool_result", + "tool_use_id": "read_file:1", + "content": map[string]any{ + "functionResponse": map[string]any{ + "response": map[string]any{ + "output": "alpha", + }, + }, + }, + }, + map[string]any{ + "type": "tool_result", + "tool_use_id": "glob:2", + "content": map[string]any{ + "responseParts": map[string]any{ + "functionResponse": map[string]any{ + "response": map[string]any{ + "output": "beta", + }, + }, + }, + }, + }, + } + + results := extractIFlowToolResults(content) + if len(results) != 2 { + t.Fatalf("results = %#v", results) + } + if results[0].ID != "read_file:1" || results[0].Output != "alpha" { + t.Fatalf("result[0] = %#v", results[0]) + } + if results[1].ID != "glob:2" || results[1].Output != "beta" { + t.Fatalf("result[1] = %#v", results[1]) + } +} + +func TestSummarizeIFlowToolResultFallback(t *testing.T) { + content := map[string]any{ + "resultDisplay": "Search results returned.", + } + if got := summarizeIFlowToolResult(content); got != "Search results returned." { + t.Fatalf("summarizeIFlowToolResult = %q", got) + } +} + +func TestStripANSI(t *testing.T) { + in := "\x1b]2;iFlow\x07\x1b[2K\rHello\x1b[?25l" + if got := stripANSI(in); got != "Hello" { + t.Fatalf("stripANSI = %q", got) + } +} + +func TestFindIFlowTranscriptPath(t *testing.T) { + dir := t.TempDir() + oldPath := filepath.Join(dir, "session-old.jsonl") + newPath := filepath.Join(dir, "session-new.jsonl") + if err := os.WriteFile(oldPath, []byte("old\n"), 0o644); err != nil { + t.Fatalf("WriteFile old: %v", err) + } + if err := os.WriteFile(newPath, []byte("new\n"), 0o644); err != nil { + t.Fatalf("WriteFile new: %v", err) + } + + startedAt := time.Now() + oldTime := startedAt.Add(-5 * time.Second) + newTime := startedAt.Add(1 * time.Second) + if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil { + t.Fatalf("Chtimes old: %v", err) + } + if err := os.Chtimes(newPath, newTime, newTime); err != nil { + t.Fatalf("Chtimes new: %v", err) + } + + if got := findIFlowTranscriptPath(dir, startedAt); got != newPath { + t.Fatalf("findIFlowTranscriptPath = %q, want %q", got, newPath) + } +} + +func TestIFlowTurnFinalContentFallsBackToToolResult(t *testing.T) { + turn := &iflowTurn{ + resultText: "我来查一下。", + toolFallback: []string{"天气结果"}, + awaitingTool: true, + } + + if got := turn.finalContent(); got != "我来查一下。\n\n天气结果" { + t.Fatalf("finalContent = %q", got) + } +} + +func TestIFlowTurnIgnoresDuplicateToolUseAfterToolResult(t *testing.T) { + turn := &iflowTurn{ + pendingToolIDs: make(map[string]struct{}), + pendingTools: make(map[string]iflowToolUse), + seenToolIDs: make(map[string]struct{}), + doneToolIDs: make(map[string]struct{}), + } + + added := turn.addPendingTools([]iflowToolUse{{ID: "web_search:0", Name: "web_search"}}) + if len(added) != 1 || !turn.hasPendingTools() { + t.Fatalf("first add = %#v pending=%v", added, turn.hasPendingTools()) + } + + if !turn.completeTools([]iflowToolResult{{ID: "web_search:0", Output: "search result"}}) { + t.Fatal("expected tool completion to finish pending set") + } + if turn.hasPendingTools() { + t.Fatal("expected no pending tools after completion") + } + if !turn.awaitingTool { + t.Fatal("expected awaitingTool after tool completion") + } + + added = turn.addPendingTools([]iflowToolUse{{ID: "web_search:0", Name: "web_search"}}) + if len(added) != 0 { + t.Fatalf("duplicate add = %#v", added) + } + if turn.hasPendingTools() { + t.Fatal("duplicate tool_use should not recreate pending tool state") + } + if !turn.awaitingTool { + t.Fatal("duplicate tool_use should not clear awaitingTool") + } + + turn.appendText("北京今天多云") + if turn.awaitingTool { + t.Fatal("assistant text should clear awaitingTool") + } + if got := turn.finalContent(); got != "北京今天多云" { + t.Fatalf("finalContent = %q", got) + } +} + +func TestIFlowTurnScheduleResultReplacesFallbackTimer(t *testing.T) { + turn := &iflowTurn{ + cancel: func() {}, + pendingToolIDs: make(map[string]struct{}), + pendingTools: make(map[string]iflowToolUse), + awaitingTool: false, + } + turn.resultTimer = time.AfterFunc(time.Hour, func() {}) + defer turn.stopResultTimer() + + turn.scheduleResult(nil) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if turn.readyForResult() { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("result timer did not fire; callback was not replaced") +} + +func TestIFlowTurnPendingToolTimeoutReleasesTurn(t *testing.T) { + oldTimeout := iflowPendingToolTimeout + iflowPendingToolTimeout = 50 * time.Millisecond + defer func() { iflowPendingToolTimeout = oldTimeout }() + + turn := &iflowTurn{ + cancel: func() {}, + pendingTimeout: iflowPendingToolTimeout, + pendingToolIDs: make(map[string]struct{}), + pendingTools: make(map[string]iflowToolUse), + seenToolIDs: make(map[string]struct{}), + doneToolIDs: make(map[string]struct{}), + } + defer turn.stopResultTimer() + + added := turn.addPendingTools([]iflowToolUse{{ID: "run_shell_command:1", Name: "run_shell_command"}}) + if len(added) != 1 { + t.Fatalf("added = %#v", added) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if turn.readyForResult() { + content := turn.finalContent() + if !strings.Contains(content, "timed out") || !strings.Contains(content, "run_shell_command") { + t.Fatalf("finalContent = %q", content) + } + if turn.hasPendingTools() { + t.Fatal("pending tools should be cleared after timeout") + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("pending tool timeout did not release turn") +} + +func TestIFlowTurnTimerResetsOnPartialToolCompletion(t *testing.T) { + timeout := 100 * time.Millisecond + + var cancelled atomic.Bool + turn := &iflowTurn{ + cancel: func() { cancelled.Store(true) }, + pendingTimeout: timeout, + pendingToolIDs: make(map[string]struct{}), + pendingTools: make(map[string]iflowToolUse), + seenToolIDs: make(map[string]struct{}), + doneToolIDs: make(map[string]struct{}), + } + defer turn.stopResultTimer() + + turn.addPendingTools([]iflowToolUse{ + {ID: "write_file:1", Name: "write_file"}, + {ID: "run_shell_command:2", Name: "run_shell_command"}, + }) + + // Wait 70ms (>50% of timeout), then complete one tool + time.Sleep(70 * time.Millisecond) + turn.completeTools([]iflowToolResult{{ID: "write_file:1", Output: "ok"}}) + + // Timer was reset — wait another 70ms; should NOT have timed out yet + time.Sleep(70 * time.Millisecond) + if turn.readyForResult() { + t.Fatal("timer should have been reset; should not be ready yet") + } + + // Now wait for the full reset timeout to expire + time.Sleep(50 * time.Millisecond) + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if turn.readyForResult() { + if !cancelled.Load() { + t.Fatal("expected cancel to be called") + } + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("timer did not fire after reset") +} + +func TestIFlowSessionCustomToolTimeout(t *testing.T) { + sess, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "yolo", "", nil, 300) + if err != nil { + t.Fatalf("newIFlowSession: %v", err) + } + defer sess.Close() + if got := sess.pendingToolTimeout(); got != 300*time.Second { + t.Fatalf("pendingToolTimeout = %v, want 300s", got) + } +} + +func TestIFlowSessionDefaultToolTimeout(t *testing.T) { + sess, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "yolo", "", nil, 0) + if err != nil { + t.Fatalf("newIFlowSession: %v", err) + } + defer sess.Close() + if got := sess.pendingToolTimeout(); got != iflowPendingToolTimeout { + t.Fatalf("pendingToolTimeout = %v, want %v", got, iflowPendingToolTimeout) + } +} + +func TestIFlowSessionPendingToolTimeoutClearsBusyState(t *testing.T) { + oldTimeout := iflowPendingToolTimeout + oldDefaultTimeout := iflowPendingToolTimeoutDefaultMode + iflowPendingToolTimeout = 80 * time.Millisecond + iflowPendingToolTimeoutDefaultMode = 80 * time.Millisecond + defer func() { iflowPendingToolTimeout = oldTimeout }() + defer func() { iflowPendingToolTimeoutDefaultMode = oldDefaultTimeout }() + + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + + workDir := filepath.Join(t.TempDir(), "work") + if err := os.MkdirAll(workDir, 0o755); err != nil { + t.Fatalf("MkdirAll workDir: %v", err) + } + + projectKey := iflowProjectKey(iflowResolvedWorkDir(workDir)) + t.Setenv("IFLOW_TEST_PROJECT_KEY", projectKey) + + cmdPath := filepath.Join(t.TempDir(), "fake-iflow.sh") + script := `#!/bin/sh +set -eu +sid="session-test" +session_dir="$HOME/.iflow/projects/$IFLOW_TEST_PROJECT_KEY" +mkdir -p "$session_dir" +transcript="$session_dir/$sid.jsonl" +cat >>"$transcript" <<'EOF' +{"sessionId":"session-test","type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"run_shell_command:1","name":"run_shell_command","input":{"command":"ls -la"}}]}} +{"sessionId":"session-test","type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"hello"}]}} +EOF +while :; do sleep 1; done +` + if err := os.WriteFile(cmdPath, []byte(script), 0o755); err != nil { + t.Fatalf("WriteFile fake iflow: %v", err) + } + + sess, err := newIFlowSession(context.Background(), cmdPath, workDir, "", "default", "", nil, 0) + if err != nil { + t.Fatalf("newIFlowSession: %v", err) + } + defer sess.Close() + + if err := sess.Send("执行ls", nil, nil); err != nil { + t.Fatalf("Send #1: %v", err) + } + + deadline := time.After(5 * time.Second) + gotResult := false + for !gotResult { + select { + case ev := <-sess.Events(): + if ev.Type != "result" { + continue + } + gotResult = true + if !strings.Contains(ev.Content, "timed out") { + t.Fatalf("result content = %q", ev.Content) + } + case <-deadline: + t.Fatal("timeout waiting for result") + } + } + + if err := sess.Send("第二条消息", nil, nil); err != nil { + if strings.Contains(err.Error(), "busy") { + t.Fatalf("session still busy after timeout result: %v", err) + } + t.Fatalf("Send #2 failed: %v", err) + } +} + +func TestIFlowSession_ContinueSessionTreatedAsFresh(t *testing.T) { + s, err := newIFlowSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil, 0) + if err != nil { + t.Fatalf("newIFlowSession: %v", err) + } + defer s.Close() + + if got := s.CurrentSessionID(); got != "" { + t.Errorf("ContinueSession should be treated as fresh: sessionID = %q, want empty", got) + } +} diff --git a/agent/kimi/kimi.go b/agent/kimi/kimi.go new file mode 100644 index 0000000..b9cb4d2 --- /dev/null +++ b/agent/kimi/kimi.go @@ -0,0 +1,461 @@ +package kimi + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("kimi", New) +} + +// Agent drives Kimi Code CLI using --print --output-format stream-json. +// +// Modes: +// - "default": standard mode (note: --print implicitly enables --yolo) +// - "yolo": auto-approve all tool calls +// - "plan": read-only plan mode +// - "quiet": alias for --quiet (print + text + final-message-only) +type Agent struct { + workDir string + model string + mode string + cmd string // CLI binary name, default "kimi" + timeout time.Duration + providers []core.ProviderConfig + activeIdx int // -1 = no provider set + sessionEnv []string + mu sync.RWMutex +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + cmd, _ := opts["cmd"].(string) + if cmd == "" { + cmd = "kimi" + } + + var timeoutMins int64 + switch v := opts["timeout_mins"].(type) { + case int64: + timeoutMins = v + case int: + timeoutMins = int64(v) + case float64: + timeoutMins = int64(v) + default: + if v != nil { + slog.Debug("kimi: timeout_mins has unexpected type", "type", fmt.Sprintf("%T", v)) + } + } + var timeout time.Duration + if timeoutMins > 0 { + timeout = time.Duration(timeoutMins) * time.Minute + } + + if _, err := exec.LookPath(cmd); err != nil { + return nil, fmt.Errorf("kimi: %q CLI not found in PATH, install with: pip install kimi-cli", cmd) + } + + return &Agent{ + workDir: workDir, + model: model, + mode: mode, + cmd: cmd, + timeout: timeout, + activeIdx: -1, + }, nil +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "yolo", "force", "bypass", "auto": + return "yolo" + case "plan": + return "plan" + case "quiet": + return "quiet" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "kimi" } +func (a *Agent) CLIBinaryName() string { return "kimi" } +func (a *Agent) CLIDisplayName() string { return "Kimi" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("kimi: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("kimi: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { + if models := a.configuredModels(); len(models) > 0 { + return models + } + return []core.ModelOption{ + {Name: "kimi-k2-0711-preview", Desc: "Kimi K2 (most capable)"}, + {Name: "kimi-k2-0711", Desc: "Kimi K2"}, + {Name: "kimi-k2-5-preview", Desc: "Kimi K2.5 (balanced)"}, + {Name: "kimi-k2-5", Desc: "Kimi K2.5"}, + } +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + model := a.model + mode := a.mode + cmd := a.cmd + workDir := a.workDir + timeout := a.timeout + extraEnv := a.providerEnvLocked() + extraEnv = append(extraEnv, a.sessionEnv...) + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + if m := a.providers[a.activeIdx].Model; m != "" { + model = m + } + } + a.mu.Unlock() + + return newKimiSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv, timeout) +} + +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + return listKimiSessions(a.workDir) +} + +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + path := findKimiSessionDir(sessionID) + if path == "" { + return fmt.Errorf("session not found: %s", sessionID) + } + return os.RemoveAll(path) +} + +func (a *Agent) Stop() error { return nil } + +// ── ModeSwitcher ──────────────────────────────────────────────── + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("kimi: mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Standard mode (print output)", DescZh: "标准模式(打印输出)"}, + {Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"}, + {Key: "plan", Name: "Plan", NameZh: "规划模式", Desc: "Read-only plan mode, no execution", DescZh: "只读规划模式,不做修改"}, + {Key: "quiet", Name: "Quiet", NameZh: "静默", Desc: "Quiet mode (final message only)", DescZh: "静默模式(仅最终消息)"}, + } +} + +// ── SkillProvider implementation ────────────────────────────── + +func (a *Agent) SkillDirs() []string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + dirs := []string{filepath.Join(absDir, ".kimi", "skills")} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".kimi", "skills")) + } + return dirs +} + +// ── ContextCompressor implementation ────────────────────────── + +func (a *Agent) CompressCommand() string { return "" } + +// ── MemoryFileProvider implementation ───────────────────────── + +func (a *Agent) ProjectMemoryFile() string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + return filepath.Join(absDir, "AGENTS.md") +} + +func (a *Agent) GlobalMemoryFile() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".kimi", "AGENTS.md") +} + +// ── ProviderSwitcher ──────────────────────────────────────────── + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.activeIdx = -1 + slog.Info("kimi: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("kimi: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + var env []string + if p.APIKey != "" { + env = append(env, "KIMI_API_KEY="+p.APIKey) + } + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +// ── Session listing ───────────────────────────────────────────── + +func kimiSessionsBaseDir() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".kimi", "sessions") +} + +func listKimiSessions(workDir string) ([]core.AgentSessionInfo, error) { + absWorkDir, err := filepath.Abs(workDir) + if err != nil { + absWorkDir = workDir + } + + sessionsBase := kimiSessionsBaseDir() + if sessionsBase == "" { + return nil, nil + } + + entries, err := os.ReadDir(sessionsBase) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("kimi: read sessions dir: %w", err) + } + + var sessions []core.AgentSessionInfo + for _, entry := range entries { + if !entry.IsDir() { + continue + } + projectDir := filepath.Join(sessionsBase, entry.Name()) + sessionEntries, err := os.ReadDir(projectDir) + if err != nil { + continue + } + for _, se := range sessionEntries { + if !se.IsDir() { + continue + } + sessionDir := filepath.Join(projectDir, se.Name()) + info := parseKimiSessionDir(sessionDir, absWorkDir) + if info != nil { + sessions = append(sessions, *info) + } + } + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + + return sessions, nil +} + +func parseKimiSessionDir(sessionDir, filterWorkDir string) *core.AgentSessionInfo { + statePath := filepath.Join(sessionDir, "state.json") + stateData, err := os.ReadFile(statePath) + if err != nil { + return nil + } + + var state struct { + CustomTitle string `json:"custom_title"` + Archived bool `json:"archived"` + } + if json.Unmarshal(stateData, &state) != nil { + return nil + } + if state.Archived { + return nil + } + + sessionID := filepath.Base(sessionDir) + + info, err := os.Stat(sessionDir) + if err != nil { + return nil + } + + msgCount := 0 + summary := "" + contextPath := filepath.Join(sessionDir, "context.jsonl") + if f, err := os.Open(contextPath); err == nil { + defer f.Close() + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 256*1024), 256*1024) + for scanner.Scan() { + var entry struct { + Role string `json:"role"` + Content string `json:"content"` + } + if json.Unmarshal(scanner.Bytes(), &entry) != nil { + continue + } + if entry.Role == "user" || entry.Role == "assistant" { + msgCount++ + if entry.Role == "user" && entry.Content != "" && summary == "" { + summary = strings.TrimSpace(entry.Content) + } + } + } + } + + _ = filterWorkDir // Kimi does not store cwd in session metadata; list all sessions. + + if summary == "" { + summary = state.CustomTitle + } + if utf8.RuneCountInString(summary) > 60 { + summary = string([]rune(summary)[:60]) + "..." + } + + return &core.AgentSessionInfo{ + ID: sessionID, + Summary: summary, + MessageCount: msgCount, + ModifiedAt: info.ModTime(), + } +} + +func findKimiSessionDir(sessionID string) string { + sessionsBase := kimiSessionsBaseDir() + if sessionsBase == "" { + return "" + } + + entries, err := os.ReadDir(sessionsBase) + if err != nil { + return "" + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + projectDir := filepath.Join(sessionsBase, entry.Name()) + sessionEntries, err := os.ReadDir(projectDir) + if err != nil { + continue + } + for _, se := range sessionEntries { + if !se.IsDir() { + continue + } + if se.Name() == sessionID { + return filepath.Join(projectDir, se.Name()) + } + } + } + return "" +} diff --git a/agent/kimi/kimi_test.go b/agent/kimi/kimi_test.go new file mode 100644 index 0000000..4164c4e --- /dev/null +++ b/agent/kimi/kimi_test.go @@ -0,0 +1,163 @@ +package kimi + +import ( + "context" + "os/exec" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func skipUnlessKimiAvailable(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("kimi"); err != nil { + t.Skipf("kimi CLI not in PATH, skipping: %v", err) + } +} + +func TestNormalizeMode(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"default", "default"}, + {"DEFAULT", "default"}, + {"yolo", "yolo"}, + {"YOLO", "yolo"}, + {"force", "yolo"}, + {"bypass", "yolo"}, + {"auto", "yolo"}, + {"plan", "plan"}, + {"quiet", "quiet"}, + {"", "default"}, + {"unknown", "default"}, + } + + for _, c := range cases { + assert.Equal(t, c.expected, normalizeMode(c.input), "input: %s", c.input) + } +} + +func TestAgentNew(t *testing.T) { + skipUnlessKimiAvailable(t) + agentInf, err := New(map[string]any{ + "work_dir": "/tmp", + "model": "kimi-k2", + "mode": "yolo", + "timeout_mins": 15, + }) + require.NoError(t, err) + require.NotNil(t, agentInf) + + a := agentInf.(*Agent) + assert.Equal(t, "kimi", a.Name()) + assert.Equal(t, "/tmp", a.GetWorkDir()) + assert.Equal(t, "yolo", a.GetMode()) + assert.Equal(t, "kimi-k2", a.GetModel()) +} + +// TestAgentFields verifies Name/WorkDir/Mode/Model without requiring +// the kimi CLI on PATH — constructs the struct directly. +func TestAgentFields(t *testing.T) { + a := &Agent{ + workDir: "/tmp", + model: "kimi-k2", + mode: "yolo", + cmd: "kimi", + activeIdx: -1, + } + assert.Equal(t, "kimi", a.Name()) + assert.Equal(t, "Kimi", a.CLIDisplayName()) + assert.Equal(t, "kimi", a.CLIBinaryName()) + assert.Equal(t, "/tmp", a.GetWorkDir()) + assert.Equal(t, "yolo", a.GetMode()) + assert.Equal(t, "kimi-k2", a.GetModel()) +} + +func TestAgentSetters(t *testing.T) { + a := &Agent{workDir: "/tmp", mode: "default", activeIdx: -1} + + a.SetWorkDir("/new/path") + assert.Equal(t, "/new/path", a.GetWorkDir()) + + a.SetModel("kimi-k2-5") + assert.Equal(t, "kimi-k2-5", a.GetModel()) + + a.SetMode("plan") + assert.Equal(t, "plan", a.GetMode()) +} + +func TestAgentPermissionModes(t *testing.T) { + a := &Agent{} + + modes := a.PermissionModes() + require.Len(t, modes, 4) + assert.Equal(t, "default", modes[0].Key) + assert.Equal(t, "yolo", modes[1].Key) + assert.Equal(t, "plan", modes[2].Key) + assert.Equal(t, "quiet", modes[3].Key) +} + +func TestAgentProviderSwitcher(t *testing.T) { + a := &Agent{workDir: "/tmp", activeIdx: -1} + + providers := []core.ProviderConfig{ + {Name: "moonshot", APIKey: "sk-123"}, + {Name: "custom", BaseURL: "https://api.example.com"}, + } + a.SetProviders(providers) + + assert.False(t, a.SetActiveProvider("missing")) + assert.True(t, a.SetActiveProvider("moonshot")) + assert.Equal(t, "moonshot", a.GetActiveProvider().Name) + + list := a.ListProviders() + require.Len(t, list, 2) + assert.Equal(t, "moonshot", list[0].Name) +} + +func TestAgentStartSession(t *testing.T) { + skipUnlessKimiAvailable(t) + agentInf, err := New(map[string]any{ + "work_dir": "/tmp", + "model": "kimi-k2", + "mode": "default", + "timeout_mins": 10, + }) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + session, err := agentInf.StartSession(ctx, "test-session-id") + require.NoError(t, err) + require.NotNil(t, session) + assert.True(t, session.Alive()) + assert.Equal(t, "test-session-id", session.CurrentSessionID()) + + err = session.Close() + assert.NoError(t, err) + assert.False(t, session.Alive()) +} + +func TestAgentMemoryAndSkill(t *testing.T) { + a := &Agent{workDir: "/tmp/my-project", activeIdx: -1} + + assert.Equal(t, "/tmp/my-project/AGENTS.md", a.ProjectMemoryFile()) + assert.NotEmpty(t, a.GlobalMemoryFile()) + + skillDirs := a.SkillDirs() + require.Len(t, skillDirs, 2) + assert.Contains(t, skillDirs[0], ".kimi/skills") + assert.Contains(t, skillDirs[1], ".kimi/skills") +} + +func TestAgentAvailableModels(t *testing.T) { + a := &Agent{workDir: "/tmp", activeIdx: -1} + + models := a.AvailableModels(context.Background()) + require.True(t, len(models) > 0) +} diff --git a/agent/kimi/session.go b/agent/kimi/session.go new file mode 100644 index 0000000..05354ba --- /dev/null +++ b/agent/kimi/session.go @@ -0,0 +1,476 @@ +package kimi + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +// kimSession manages multi-turn conversations with the Kimi CLI. +// Each Send() launches a new `kimi --print --output-format stream-json` process +// with --resume for conversation continuity. +type kimiSession struct { + cmd string + workDir string + model string + mode string + timeout time.Duration + extraEnv []string + events chan core.Event + sessionID atomic.Value // stores string — Kimi session ID + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + + pendingMsgs []string // buffered assistant text messages +} + +func newKimiSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string, timeout time.Duration) (*kimiSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + ks := &kimiSession{ + cmd: cmd, + workDir: workDir, + model: model, + mode: mode, + timeout: timeout, + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: sessionCtx, + cancel: cancel, + } + ks.alive.Store(true) + + if resumeID != "" && resumeID != core.ContinueSession { + ks.sessionID.Store(resumeID) + } + + return ks, nil +} + +func (ks *kimiSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if !ks.alive.Load() { + return fmt.Errorf("session is closed") + } + + // Save images and files into the workspace so Kimi CLI can access them. + attachDir := filepath.Join(ks.workDir, ".cc-connect", "attachments") + if (len(images) > 0 || len(files) > 0) && os.MkdirAll(attachDir, 0o755) != nil { + attachDir = os.TempDir() + } + + var imageRefs []string + for i, img := range images { + ext := ".png" + switch img.MimeType { + case "image/jpeg": + ext = ".jpg" + case "image/gif": + ext = ".gif" + case "image/webp": + ext = ".webp" + } + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(attachDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o644); err != nil { + slog.Warn("kimiSession: failed to save image", "error", err) + continue + } + imageRefs = append(imageRefs, fpath) + } + + var fileRefs []string + for i, f := range files { + fname := filepath.Base(f.FileName) + if fname == "" || fname == "." || fname == ".." { + fname = fmt.Sprintf("file_%d_%d", time.Now().UnixMilli(), i) + } + fpath := filepath.Join(attachDir, fname) + if err := os.WriteFile(fpath, f.Data, 0o644); err != nil { + slog.Warn("kimiSession: failed to save file", "error", err) + continue + } + fileRefs = append(fileRefs, fpath) + } + + fullPrompt := prompt + if len(imageRefs) > 0 { + if fullPrompt == "" { + fullPrompt = "Please analyze the attached image(s)." + } + fullPrompt += "\n\n[Attached images saved at: " + strings.Join(imageRefs, ", ") + "]" + } + if len(fileRefs) > 0 { + if fullPrompt == "" { + fullPrompt = "Please analyze the attached file(s)." + } + fullPrompt += "\n\n[Attached files saved at: " + strings.Join(fileRefs, ", ") + "]" + } + + args := []string{ + "--print", + "--output-format", "stream-json", + } + + switch ks.mode { + case "plan": + args = append(args, "--plan") + case "quiet": + args = append(args, "--quiet") + } + + sid := ks.CurrentSessionID() + if sid != "" { + args = append(args, "--resume", sid) + } + if ks.model != "" { + args = append(args, "--model", ks.model) + } + if ks.workDir != "" { + args = append(args, "--work-dir", ks.workDir) + } + + args = append(args, "--prompt", fullPrompt) + + var cancel context.CancelFunc + var ctx context.Context + if ks.timeout > 0 { + ctx, cancel = context.WithTimeout(ks.ctx, ks.timeout) + } else { + ctx, cancel = context.WithCancel(ks.ctx) + } + + started := false + defer func() { + if !started { + cancel() + } + }() + + slog.Debug("kimiSession: launching", "resume", sid != "", "args", core.RedactArgs(args)) + cmd := exec.CommandContext(ctx, ks.cmd, args...) + cmd.WaitDelay = 1 * time.Second + cmd.Dir = ks.workDir + env := os.Environ() + if len(ks.extraEnv) > 0 { + env = core.MergeEnv(env, ks.extraEnv) + } + cmd.Env = env + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("kimiSession: stdout pipe: %w", err) + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + + if err := cmd.Start(); err != nil { + return fmt.Errorf("kimiSession: start: %w", err) + } + + started = true + ks.wg.Add(1) + go func() { + defer cancel() + ks.readLoop(ctx, cmd, stdout, &stderrBuf, append(imageRefs, fileRefs...)) + }() + + return nil +} + +func (ks *kimiSession) readLoop(ctx context.Context, cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer, tempFiles []string) { + defer ks.wg.Done() + defer func() { + for _, f := range tempFiles { + os.Remove(f) + } + }() + + go func() { + <-ctx.Done() + stdout.Close() + }() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + var scanErr error + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + slog.Debug("kimiSession: raw", "line", truncate(line, 500)) + + // Kimi prints a non-JSON line at the end: "To resume this session: kimi -r " + if strings.HasPrefix(line, "To resume this session:") { + if id := extractResumeSessionID(line); id != "" { + ks.sessionID.Store(id) + slog.Debug("kimiSession: session id updated", "session_id", id) + } + continue + } + + var raw map[string]any + if err := json.Unmarshal([]byte(line), &raw); err != nil { + slog.Debug("kimiSession: non-JSON line", "line", line) + continue + } + + ks.handleEvent(raw) + } + scanErr = scanner.Err() + + // Wait for process exit before sending any terminal event so the engine + // never sees EventError after EventResult from the same turn. + waitErr := cmd.Wait() + + // Kimi writes "To resume this session: kimi -r " to stderr (not stdout), + // so the scanner above never sees it. Extract it from the captured stderr + // buffer before emitting EventResult so the next turn can pass --resume. + for _, line := range strings.Split(stderrBuf.String(), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "To resume this session:") { + if id := extractResumeSessionID(line); id != "" { + ks.sessionID.Store(id) + slog.Debug("kimiSession: session id from stderr", "session_id", id) + } + break + } + } + + if scanErr != nil { + slog.Error("kimiSession: scanner error", "error", scanErr) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", scanErr)} + select { + case ks.events <- evt: + case <-ks.ctx.Done(): + return + } + } + + if waitErr != nil { + stderrMsg := strings.TrimSpace(stderrBuf.String()) + if stderrMsg != "" { + slog.Error("kimiSession: process failed", "error", waitErr, "stderr", stderrMsg) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)} + select { + case ks.events <- evt: + case <-ks.ctx.Done(): + return + } + return + } + } + + // Flush any remaining pending messages as text and send result event. + ks.flushPendingAsText() + evt := core.Event{Type: core.EventResult, SessionID: ks.CurrentSessionID(), Done: true} + select { + case ks.events <- evt: + case <-ks.ctx.Done(): + } +} + +func extractResumeSessionID(line string) string { + // Format: "To resume this session: kimi -r " + parts := strings.Fields(line) + for i, p := range parts { + if p == "-r" && i+1 < len(parts) { + return parts[i+1] + } + } + return "" +} + +// Kimi CLI stream-json message roles: +// - "assistant": content (think + text), tool_calls +// - "tool": content (tool execution result), tool_call_id +func (ks *kimiSession) handleEvent(raw map[string]any) { + role, _ := raw["role"].(string) + + switch role { + case "assistant": + ks.handleAssistant(raw) + case "tool": + ks.handleTool(raw) + default: + slog.Debug("kimiSession: unhandled role", "role", role) + } +} + +func (ks *kimiSession) handleAssistant(raw map[string]any) { + content, _ := raw["content"].([]any) + for _, item := range content { + block, ok := item.(map[string]any) + if !ok { + continue + } + blockType, _ := block["type"].(string) + switch blockType { + case "think", "thinking": + if think, ok := block["think"].(string); ok && think != "" { + evt := core.Event{Type: core.EventThinking, Content: think} + select { + case ks.events <- evt: + case <-ks.ctx.Done(): + return + } + } + case "text": + if text, ok := block["text"].(string); ok && text != "" { + ks.pendingMsgs = append(ks.pendingMsgs, text) + } + } + } + + // Handle tool_calls + toolCalls, _ := raw["tool_calls"].([]any) + if len(toolCalls) > 0 { + ks.flushPendingAsThinking() + for _, tc := range toolCalls { + tcMap, ok := tc.(map[string]any) + if !ok { + continue + } + funcBlock, _ := tcMap["function"].(map[string]any) + toolName, _ := funcBlock["name"].(string) + args, _ := funcBlock["arguments"].(string) + toolID, _ := tcMap["id"].(string) + + slog.Debug("kimiSession: tool_call", "tool", toolName, "id", toolID) + evt := core.Event{ + Type: core.EventToolUse, + ToolName: toolName, + ToolInput: truncate(strings.TrimSpace(args), 500), + RequestID: toolID, + } + select { + case ks.events <- evt: + case <-ks.ctx.Done(): + return + } + } + } +} + +func (ks *kimiSession) handleTool(raw map[string]any) { + toolCallID, _ := raw["tool_call_id"].(string) + content, _ := raw["content"].([]any) + var outputParts []string + for _, item := range content { + block, ok := item.(map[string]any) + if !ok { + continue + } + blockType, _ := block["type"].(string) + if blockType == "text" { + if text, ok := block["text"].(string); ok { + outputParts = append(outputParts, text) + } + } + } + output := strings.Join(outputParts, "") + + if output != "" { + slog.Debug("kimiSession: tool result", "tool_call_id", toolCallID) + evt := core.Event{ + Type: core.EventToolResult, + ToolName: toolCallID, + ToolResult: truncate(strings.TrimSpace(output), 500), + } + select { + case ks.events <- evt: + case <-ks.ctx.Done(): + return + } + } +} + +func (ks *kimiSession) flushPendingAsThinking() { + if len(ks.pendingMsgs) == 0 { + return + } + text := strings.Join(ks.pendingMsgs, "") + ks.pendingMsgs = ks.pendingMsgs[:0] + if text != "" { + evt := core.Event{Type: core.EventThinking, Content: text} + select { + case ks.events <- evt: + case <-ks.ctx.Done(): + } + } +} + +func (ks *kimiSession) flushPendingAsText() { + if len(ks.pendingMsgs) == 0 { + return + } + text := strings.Join(ks.pendingMsgs, "") + ks.pendingMsgs = ks.pendingMsgs[:0] + if text != "" { + evt := core.Event{Type: core.EventText, Content: text} + select { + case ks.events <- evt: + case <-ks.ctx.Done(): + } + } +} + +// RespondPermission is a no-op — Kimi CLI permissions are handled via --print (implicit --yolo). +func (ks *kimiSession) RespondPermission(_ string, _ core.PermissionResult) error { + return nil +} + +func (ks *kimiSession) Events() <-chan core.Event { + return ks.events +} + +func (ks *kimiSession) CurrentSessionID() string { + v, _ := ks.sessionID.Load().(string) + return v +} + +func (ks *kimiSession) Alive() bool { + return ks.alive.Load() +} + +func (ks *kimiSession) Close() error { + ks.alive.Store(false) + ks.cancel() + done := make(chan struct{}) + go func() { + ks.wg.Wait() + close(done) + }() + select { + case <-done: + close(ks.events) + case <-time.After(8 * time.Second): + slog.Warn("kimiSession: close timed out, abandoning wg.Wait") + } + return nil +} + +func truncate(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + return string([]rune(s)[:maxRunes]) + "..." +} diff --git a/agent/kimi/session_test.go b/agent/kimi/session_test.go new file mode 100644 index 0000000..4560b34 --- /dev/null +++ b/agent/kimi/session_test.go @@ -0,0 +1,177 @@ +package kimi + +import ( + "context" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewKimiSession(t *testing.T) { + ctx := context.Background() + ks, err := newKimiSession(ctx, "kimi", "/tmp", "kimi-k2", "default", "resume-123", nil, 0) + require.NoError(t, err) + require.NotNil(t, ks) + assert.True(t, ks.Alive()) + assert.Equal(t, "resume-123", ks.CurrentSessionID()) + + err = ks.Close() + assert.NoError(t, err) + assert.False(t, ks.Alive()) +} + +func TestExtractResumeSessionID(t *testing.T) { + cases := []struct { + input string + expected string + }{ + {"To resume this session: kimi -r e3690555-60eb-4d50-874b-e3647e9cee5b", "e3690555-60eb-4d50-874b-e3647e9cee5b"}, + {"To resume this session: kimi --resume abc-def", ""}, + {"To resume this session: no-id-here", ""}, + {"random text", ""}, + } + + for _, c := range cases { + assert.Equal(t, c.expected, extractResumeSessionID(c.input), "input: %s", c.input) + } +} + +func TestHandleAssistantWithText(t *testing.T) { + ctx := context.Background() + ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0) + defer ks.Close() + + ks.handleEvent(map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{"type": "text", "text": "Hello!"}, + }, + }) + + // pendingMsgs should buffer the text + assert.Len(t, ks.pendingMsgs, 1) + assert.Equal(t, "Hello!", ks.pendingMsgs[0]) +} + +func TestHandleAssistantWithThink(t *testing.T) { + ctx := context.Background() + ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0) + defer ks.Close() + + ks.handleEvent(map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{"type": "think", "think": "Let me think..."}, + map[string]any{"type": "text", "text": "Done!"}, + }, + }) + + events := drainEvents(ks.events, 2) + require.Len(t, events, 1) + assert.Equal(t, core.EventThinking, events[0].Type) + assert.Equal(t, "Let me think...", events[0].Content) + assert.Equal(t, "Done!", ks.pendingMsgs[0]) +} + +func TestHandleAssistantWithToolCalls(t *testing.T) { + ctx := context.Background() + ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0) + defer ks.Close() + + ks.handleEvent(map[string]any{ + "role": "assistant", + "content": []any{ + map[string]any{"type": "text", "text": "I will run a command"}, + }, + "tool_calls": []any{ + map[string]any{ + "id": "tool_abc", + "function": map[string]any{ + "name": "Shell", + "arguments": `{"command":"echo hello"}`, + }, + }, + }, + }) + + events := drainEvents(ks.events, 3) + require.Len(t, events, 2) + assert.Equal(t, core.EventThinking, events[0].Type) + assert.Equal(t, "I will run a command", events[0].Content) + assert.Equal(t, core.EventToolUse, events[1].Type) + assert.Equal(t, "Shell", events[1].ToolName) + assert.Equal(t, `{"command":"echo hello"}`, events[1].ToolInput) + assert.Equal(t, "tool_abc", events[1].RequestID) +} + +func TestHandleTool(t *testing.T) { + ctx := context.Background() + ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0) + defer ks.Close() + + ks.handleEvent(map[string]any{ + "role": "tool", + "tool_call_id": "tool_abc", + "content": []any{ + map[string]any{"type": "text", "text": "hello\n"}, + }, + }) + + events := drainEvents(ks.events, 1) + require.Len(t, events, 1) + assert.Equal(t, core.EventToolResult, events[0].Type) + assert.Equal(t, "tool_abc", events[0].ToolName) + assert.Contains(t, events[0].ToolResult, "hello") +} + +func TestFlushPendingAsText(t *testing.T) { + ctx := context.Background() + ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0) + defer ks.Close() + + ks.pendingMsgs = []string{"Hello", " ", "world"} + ks.flushPendingAsText() + + events := drainEvents(ks.events, 1) + require.Len(t, events, 1) + assert.Equal(t, core.EventText, events[0].Type) + assert.Equal(t, "Hello world", events[0].Content) + assert.Empty(t, ks.pendingMsgs) +} + +func TestFlushPendingAsThinking(t *testing.T) { + ctx := context.Background() + ks, _ := newKimiSession(ctx, "kimi", "/tmp", "", "default", "", nil, 0) + defer ks.Close() + + ks.pendingMsgs = []string{"Thinking..."} + ks.flushPendingAsThinking() + + events := drainEvents(ks.events, 1) + require.Len(t, events, 1) + assert.Equal(t, core.EventThinking, events[0].Type) + assert.Equal(t, "Thinking...", events[0].Content) +} + +func TestTruncate(t *testing.T) { + assert.Equal(t, "hello", truncate("hello", 10)) + assert.Equal(t, "hello world", truncate("hello world", 11)) + assert.Equal(t, "hello worl...", truncate("hello world", 10)) +} + +func drainEvents(ch <-chan core.Event, max int) []core.Event { + var events []core.Event + timeout := time.After(500 * time.Millisecond) + for i := 0; i < max; i++ { + select { + case evt := <-ch: + events = append(events, evt) + case <-timeout: + return events + } + } + return events +} diff --git a/agent/opencode/opencode.go b/agent/opencode/opencode.go new file mode 100644 index 0000000..1008ff8 --- /dev/null +++ b/agent/opencode/opencode.go @@ -0,0 +1,717 @@ +package opencode + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("opencode", New) +} + +// Agent drives the OpenCode CLI in headless mode using `opencode run --format json`. +// +// Modes: +// - "default": standard mode +// - "yolo": auto mode (opencode run is auto by default in non-interactive mode) +type Agent struct { + workDir string + model string + mode string + cmd string // CLI binary name, default "opencode" + providers []core.ProviderConfig + activeIdx int + sessionEnv []string + modelCachePath string + persistentModelCache *opencodePersistentModelCache + refreshingModelCache bool + mu sync.RWMutex +} + +type opencodePersistentModelCache struct { + Models []core.ModelOption `json:"models"` + UpdatedAt time.Time `json:"updated_at"` + ProviderKey string `json:"provider_key,omitempty"` + ContextKey string `json:"context_key,omitempty"` +} + +type opencodeModelDiscoverySnapshot struct { + cmd string + workDir string + providerEnv []string + providerKey string + cachePath string +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + cmd, _ := opts["cmd"].(string) + if cmd == "" { + cmd = "opencode" + } + ccDataDir, _ := opts["cc_data_dir"].(string) + ccProject, _ := opts["cc_project"].(string) + modelCachePath := opencodeProjectModelCachePath(ccDataDir, ccProject) + persistentModelCache, err := loadOpencodePersistentModelCache(modelCachePath) + if err != nil { + slog.Warn("opencode: load persistent model cache failed", "path", modelCachePath, "err", err) + } + + if _, err := exec.LookPath(cmd); err != nil { + return nil, fmt.Errorf("opencode: %q CLI not found in PATH, install from: https://github.com/opencode-ai/opencode", cmd) + } + + return &Agent{ + workDir: workDir, + model: model, + mode: mode, + cmd: cmd, + activeIdx: -1, + modelCachePath: modelCachePath, + persistentModelCache: persistentModelCache, + }, nil +} + +func opencodeProjectModelCachePath(dataDir, project string) string { + if dataDir == "" || project == "" { + return "" + } + prefix := sanitizeProjectCacheComponent(project) + if prefix == "" { + prefix = "project" + } + hash := sha256.Sum256([]byte(project)) + fileName := fmt.Sprintf("%s-%s.opencode-models.json", prefix, hex.EncodeToString(hash[:8])) + return filepath.Join(dataDir, "projects", fileName) +} + +func sanitizeProjectCacheComponent(project string) string { + project = strings.TrimSpace(strings.ToLower(project)) + if project == "" { + return "" + } + + var b strings.Builder + b.Grow(len(project)) + lastDash := false + for _, r := range project { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + return strings.Trim(b.String(), "-") +} + +func loadOpencodePersistentModelCache(path string) (*opencodePersistentModelCache, error) { + if path == "" { + return nil, nil + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var cache opencodePersistentModelCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, err + } + cache.Models = normalizeModelOptions(cache.Models) + if len(cache.Models) == 0 { + return nil, nil + } + return &cache, nil +} + +func normalizeModelOptions(models []core.ModelOption) []core.ModelOption { + if len(models) == 0 { + return nil + } + + seen := make(map[string]struct{}, len(models)) + normalized := make([]core.ModelOption, 0, len(models)) + for _, model := range models { + model.Name = strings.TrimSpace(model.Name) + if model.Name == "" { + continue + } + if _, dup := seen[model.Name]; dup { + continue + } + seen[model.Name] = struct{}{} + normalized = append(normalized, model) + } + if len(normalized) == 0 { + return nil + } + + sort.Slice(normalized, func(i, j int) bool { + return normalized[i].Name < normalized[j].Name + }) + return normalized +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "yolo", "auto", "force", "bypasspermissions": + return "yolo" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "opencode" } + +func (a *Agent) SetWorkDir(dir string) { + a.mu.Lock() + defer a.mu.Unlock() + a.workDir = dir + slog.Info("opencode: work_dir changed", "work_dir", dir) +} + +func (a *Agent) GetWorkDir() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.workDir +} + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("opencode: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return core.GetProviderModel(a.providers, a.activeIdx, a.model) +} + +func (a *Agent) configuredModels() []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + return core.GetProviderModels(a.providers, a.activeIdx) +} + +func (a *Agent) configuredModelsForSnapshot(snapshot opencodeModelDiscoverySnapshot) []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + if len(a.providers) == 0 { + return nil + } + for i := range a.providers { + if providerCacheKey(a.providers[i]) != snapshot.providerKey { + continue + } + return core.GetProviderModels(a.providers, i) + } + return nil +} + +func (a *Agent) activeProviderKeyLocked() string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return "" + } + return providerCacheKey(a.providers[a.activeIdx]) +} + +func providerCacheKey(p core.ProviderConfig) string { + h := sha256.New() + mustWriteProviderSignaturePart(h, "name", p.Name) + mustWriteProviderSignaturePart(h, "base_url", p.BaseURL) + mustWriteProviderSignaturePart(h, "model", p.Model) + mustWriteProviderSignaturePart(h, "api_key", p.APIKey) + keys := make([]string, 0, len(p.Env)) + for k := range p.Env { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + mustWriteProviderSignaturePart(h, "env:"+k, p.Env[k]) + } + return hex.EncodeToString(h.Sum(nil)[:16]) +} + +func mustWriteProviderSignaturePart(w io.Writer, key, value string) { + if err := writeProviderSignaturePart(w, key, value); err != nil { + panic(fmt.Sprintf("write provider signature: %v", err)) + } +} + +func writeProviderSignaturePart(w io.Writer, key, value string) error { + if _, err := io.WriteString(w, key); err != nil { + return err + } + if _, err := io.WriteString(w, "\x00"); err != nil { + return err + } + if _, err := io.WriteString(w, value); err != nil { + return err + } + if _, err := io.WriteString(w, "\x00"); err != nil { + return err + } + return nil +} + +func (a *Agent) activeProviderKey() string { + a.mu.RLock() + defer a.mu.RUnlock() + return a.activeProviderKeyLocked() +} + +func (a *Agent) modelDiscoverySnapshot() opencodeModelDiscoverySnapshot { + a.mu.RLock() + defer a.mu.RUnlock() + return opencodeModelDiscoverySnapshot{ + cmd: a.cmd, + workDir: a.workDir, + providerEnv: append([]string(nil), a.providerEnvLocked()...), + providerKey: a.activeProviderKeyLocked(), + cachePath: a.modelCachePath, + } +} + +func modelDiscoveryContextKey(snapshot opencodeModelDiscoverySnapshot) string { + h := sha256.New() + mustWriteProviderSignaturePart(h, "provider_key", snapshot.providerKey) + mustWriteProviderSignaturePart(h, "work_dir", snapshot.workDir) + return hex.EncodeToString(h.Sum(nil)[:16]) +} + +func (a *Agent) persistentModelsForSnapshot(snapshot opencodeModelDiscoverySnapshot) []core.ModelOption { + a.mu.RLock() + defer a.mu.RUnlock() + if a.persistentModelCache == nil || len(a.persistentModelCache.Models) == 0 { + return nil + } + if a.persistentModelCache.ProviderKey != snapshot.providerKey { + return nil + } + if a.persistentModelCache.ContextKey != "" && a.persistentModelCache.ContextKey != modelDiscoveryContextKey(snapshot) { + return nil + } + if a.persistentModelCache.ContextKey == "" && snapshot.workDir != "" { + return nil + } + models := make([]core.ModelOption, len(a.persistentModelCache.Models)) + copy(models, a.persistentModelCache.Models) + return models +} + +func (a *Agent) persistentModels() []core.ModelOption { + return a.persistentModelsForSnapshot(a.modelDiscoverySnapshot()) +} + +func (a *Agent) startPersistentModelRefresh(snapshot opencodeModelDiscoverySnapshot, allowColdStart bool) { + a.mu.Lock() + hasPersistentModels := a.persistentModelCache != nil && len(a.persistentModelCache.Models) > 0 + if (!allowColdStart && !hasPersistentModels) || a.refreshingModelCache { + a.mu.Unlock() + return + } + a.refreshingModelCache = true + a.mu.Unlock() + + go func() { + defer func() { + a.mu.Lock() + a.refreshingModelCache = false + a.mu.Unlock() + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + models := a.discoverModelsWithSnapshot(ctx, snapshot) + if len(models) == 0 { + return + } + if err := a.storePersistentModelCache(snapshot, models); err != nil { + slog.Warn("opencode: update persistent model cache failed", "path", snapshot.cachePath, "err", err) + } + }() +} + +func (a *Agent) StartInitialModelRefresh() { + a.startPersistentModelRefresh(a.modelDiscoverySnapshot(), true) +} + +func (a *Agent) storePersistentModelCache(snapshot opencodeModelDiscoverySnapshot, models []core.ModelOption) error { + models = normalizeModelOptions(models) + if len(models) == 0 { + return nil + } + + cache := &opencodePersistentModelCache{ + Models: models, + UpdatedAt: time.Now(), + ProviderKey: snapshot.providerKey, + ContextKey: modelDiscoveryContextKey(snapshot), + } + + if snapshot.cachePath != "" { + if err := os.MkdirAll(filepath.Dir(snapshot.cachePath), 0o755); err != nil { + return err + } + data, err := json.Marshal(cache) + if err != nil { + return err + } + if err := core.AtomicWriteFile(snapshot.cachePath, data, 0o644); err != nil { + return err + } + } + + a.mu.Lock() + a.persistentModelCache = cache + a.mu.Unlock() + return nil +} + +func (a *Agent) discoverModelsWithSnapshot(ctx context.Context, snapshot opencodeModelDiscoverySnapshot) []core.ModelOption { + c := exec.CommandContext(ctx, snapshot.cmd, "models") + c.Dir = snapshot.workDir + if len(snapshot.providerEnv) > 0 { + c.Env = append(os.Environ(), snapshot.providerEnv...) + } + out, err := c.Output() + if err != nil { + slog.Debug("opencode: discoverModels failed", "err", err) + return nil + } + + var models []core.ModelOption + for _, line := range strings.Split(string(out), "\n") { + models = append(models, core.ModelOption{Name: line}) + } + + models = normalizeModelOptions(models) + if len(models) == 0 { + slog.Debug("opencode: discoverModels: no models in output") + return nil + } + return models +} + +func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption { + snapshot := a.modelDiscoverySnapshot() + if models := a.persistentModelsForSnapshot(snapshot); len(models) > 0 { + a.startPersistentModelRefresh(snapshot, false) + return models + } + if models := a.discoverModelsWithSnapshot(ctx, snapshot); len(models) > 0 { + if err := a.storePersistentModelCache(snapshot, models); err != nil { + slog.Warn("opencode: persist discovered model cache failed", "path", a.modelCachePath, "err", err) + } + return models + } + if models := a.configuredModelsForSnapshot(snapshot); len(models) > 0 { + return models + } + return []core.ModelOption{ + {Name: "anthropic/claude-sonnet-4-20250514", Desc: "Claude Sonnet 4 (default)"}, + {Name: "anthropic/claude-opus-4-20250514", Desc: "Claude Opus 4"}, + {Name: "openai/gpt-4o", Desc: "GPT-4o"}, + {Name: "openai/o3", Desc: "OpenAI o3"}, + } +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + model := a.model + mode := a.mode + cmd := a.cmd + workDir := a.workDir + extraEnv := a.providerEnvLocked() + extraEnv = append(extraEnv, a.sessionEnv...) + if a.activeIdx >= 0 && a.activeIdx < len(a.providers) { + if m := a.providers[a.activeIdx].Model; m != "" { + model = m + } + } + a.mu.Unlock() + + return newOpencodeSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv) +} + +// ListSessions runs `opencode session list` and parses the JSON output. +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + a.mu.RLock() + cmd := a.cmd + workDir := a.workDir + a.mu.RUnlock() + return listOpencodeSessions(cmd, workDir) +} + +func (a *Agent) Stop() error { return nil } + +// DeleteSession implements core.SessionDeleter via `opencode session delete `. +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + a.mu.RLock() + cmd := a.cmd + workDir := a.workDir + a.mu.RUnlock() + + c := exec.Command(cmd, "session", "delete", sessionID) + c.Dir = workDir + if out, err := c.CombinedOutput(); err != nil { + return fmt.Errorf("opencode: delete session %s: %w: %s", sessionID, err, strings.TrimSpace(string(out))) + } + return nil +} + +// -- ModeSwitcher -- + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("opencode: mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Standard mode", DescZh: "标准模式"}, + {Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"}, + } +} + +// -- ContextCompressor -- + +func (a *Agent) CompressCommand() string { return "/compact" } + +// -- MemoryFileProvider -- + +func (a *Agent) ProjectMemoryFile() string { + a.mu.RLock() + workDir := a.workDir + a.mu.RUnlock() + absDir, err := filepath.Abs(workDir) + if err != nil { + absDir = workDir + } + return filepath.Join(absDir, "OPENCODE.md") +} + +func (a *Agent) GlobalMemoryFile() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".opencode", "OPENCODE.md") +} + +// -- ProviderSwitcher -- + +func (a *Agent) SetProviders(providers []core.ProviderConfig) { + a.mu.Lock() + defer a.mu.Unlock() + a.providers = providers +} + +func (a *Agent) SetActiveProvider(name string) bool { + a.mu.Lock() + defer a.mu.Unlock() + if name == "" { + a.activeIdx = -1 + slog.Info("opencode: provider cleared") + return true + } + for i, p := range a.providers { + if p.Name == name { + a.activeIdx = i + slog.Info("opencode: provider switched", "provider", name) + return true + } + } + return false +} + +func (a *Agent) GetActiveProvider() *core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + return &p +} + +func (a *Agent) ListProviders() []core.ProviderConfig { + a.mu.Lock() + defer a.mu.Unlock() + result := make([]core.ProviderConfig, len(a.providers)) + copy(result, a.providers) + return result +} + +func (a *Agent) providerEnvLocked() []string { + if a.activeIdx < 0 || a.activeIdx >= len(a.providers) { + return nil + } + p := a.providers[a.activeIdx] + var env []string + if p.APIKey != "" { + env = append(env, "ANTHROPIC_API_KEY="+p.APIKey) + } + for k, v := range p.Env { + env = append(env, k+"="+v) + } + return env +} + +// -- Session listing -- + +// opencodeSessionEntry represents a session from `opencode session list` output. +type opencodeSessionEntry struct { + ID string `json:"id"` + Title string `json:"title"` + Updated int64 `json:"updated"` // Unix timestamp in milliseconds + Created int64 `json:"created"` +} + +func listOpencodeSessions(cmd, workDir string) ([]core.AgentSessionInfo, error) { + c := exec.Command(cmd, "session", "list", "--format", "json") + c.Dir = workDir + + out, err := c.Output() + if err != nil { + return nil, fmt.Errorf("opencode: session list: %w", err) + } + + var entries []opencodeSessionEntry + if err := json.Unmarshal(out, &entries); err != nil { + return nil, fmt.Errorf("opencode: parse session list: %w", err) + } + + msgCounts := querySessionMessageCounts() + + var sessions []core.AgentSessionInfo + for _, e := range entries { + sessions = append(sessions, core.AgentSessionInfo{ + ID: e.ID, + Summary: e.Title, + MessageCount: msgCounts[e.ID], + ModifiedAt: time.UnixMilli(e.Updated), + }) + } + + return sessions, nil +} + +// querySessionMessageCounts uses the sqlite3 CLI to read message counts from +// OpenCode's local database. Returns an empty map on any failure. +func querySessionMessageCounts() map[string]int { + dbPath := opencodeDBPath() + if dbPath == "" { + return nil + } + if _, err := os.Stat(dbPath); err != nil { + return nil + } + sqlite3, err := exec.LookPath("sqlite3") + if err != nil { + slog.Warn("opencode: sqlite3 CLI not found, message counts unavailable", "err", err) + return nil + } + + out, err := exec.Command(sqlite3, dbPath, + "SELECT session_id, COUNT(*) FROM message GROUP BY session_id").Output() + if err != nil { + slog.Warn("opencode: sqlite3 query failed", "db_path", dbPath, "err", err) + return nil + } + + counts := make(map[string]int) + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.SplitN(line, "|", 2) + if len(parts) != 2 { + continue + } + var n int + if _, err := fmt.Sscanf(parts[1], "%d", &n); err == nil { + counts[parts[0]] = n + } + } + return counts +} + +func opencodeDBPath() string { + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "opencode", "opencode.db") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".local", "share", "opencode", "opencode.db") +} + +func (a *Agent) GetSessionTitle(sessionID string) string { + return querySessionTitle(sessionID) +} + +func querySessionTitle(sessionID string) string { + dbPath := opencodeDBPath() + if dbPath == "" { + return "" + } + if _, err := os.Stat(dbPath); err != nil { + return "" + } + sqlite3, err := exec.LookPath("sqlite3") + if err != nil { + return "" + } + escaped := strings.ReplaceAll(sessionID, "'", "''") + query := fmt.Sprintf("SELECT title FROM session WHERE id = '%s' LIMIT 1", escaped) + out, err := exec.Command(sqlite3, dbPath, query).Output() + if err != nil { + return "" + } + title := strings.TrimSpace(string(out)) + return title +} diff --git a/agent/opencode/opencode_model_test.go b/agent/opencode/opencode_model_test.go new file mode 100644 index 0000000..3b5b178 --- /dev/null +++ b/agent/opencode/opencode_model_test.go @@ -0,0 +1,1229 @@ +package opencode + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +type errWriter struct{} + +func (errWriter) Write(_ []byte) (int, error) { + return 0, errors.New("write failed") +} + +// writeFakeModelsBin writes a temporary shell script that acts as a fake CLI. +// When invoked with "models", it prints lines to stdout. +// When exitCode != 0, the script exits immediately with that code. +func writeFakeModelsBin(t *testing.T, lines []string, exitCode int) string { + t.Helper() + tmpDir := t.TempDir() + name := filepath.Join(tmpDir, "fake-opencode") + + var body strings.Builder + body.WriteString("#!/bin/sh\n") + if exitCode != 0 { + fmt.Fprintf(&body, "exit %d\n", exitCode) + } else { + body.WriteString("if [ \"$1\" = \"models\" ]; then\n") + for _, line := range lines { + fmt.Fprintf(&body, "printf '%%s\\n' '%s'\n", line) + } + body.WriteString("fi\n") + } + + if err := os.WriteFile(name, []byte(body.String()), 0755); err != nil { + t.Fatal(err) + } + return name +} + +func TestWriteProviderSignaturePart_PropagatesWriterError(t *testing.T) { + err := writeProviderSignaturePart(errWriter{}, "name", "value") + if err == nil { + t.Fatal("writeProviderSignaturePart() error = nil, want write failure") + } + if !strings.Contains(err.Error(), "write failed") { + t.Fatalf("writeProviderSignaturePart() error = %v, want write failure", err) + } +} + +func writePersistentModelCache(t *testing.T, cachePath string, models []core.ModelOption, updatedAt time.Time) string { + t.Helper() + + type persistentModelCache struct { + Models []core.ModelOption `json:"models"` + UpdatedAt time.Time `json:"updated_at"` + ContextKey string `json:"context_key,omitempty"` + } + + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + t.Fatal(err) + } + + defaultSnapshot := opencodeModelDiscoverySnapshot{workDir: "."} + data, err := json.Marshal(persistentModelCache{Models: models, UpdatedAt: updatedAt, ContextKey: modelDiscoveryContextKey(defaultSnapshot)}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cachePath, data, 0o644); err != nil { + t.Fatal(err) + } + return cachePath +} + +func writePersistentModelCacheWithSnapshot(t *testing.T, cachePath string, snapshot opencodeModelDiscoverySnapshot, models []core.ModelOption, updatedAt time.Time) string { + t.Helper() + + type persistentModelCache struct { + Models []core.ModelOption `json:"models"` + UpdatedAt time.Time `json:"updated_at"` + ProviderKey string `json:"provider_key,omitempty"` + ContextKey string `json:"context_key,omitempty"` + } + + if err := os.MkdirAll(filepath.Dir(cachePath), 0o755); err != nil { + t.Fatal(err) + } + + data, err := json.Marshal(persistentModelCache{ + Models: models, + UpdatedAt: updatedAt, + ProviderKey: snapshot.providerKey, + ContextKey: modelDiscoveryContextKey(snapshot), + }) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cachePath, data, 0o644); err != nil { + t.Fatal(err) + } + return cachePath +} + +func writeBlockingModelsBin(t *testing.T, gatePath string, lines []string) string { + t.Helper() + tmpDir := t.TempDir() + name := filepath.Join(tmpDir, "fake-opencode") + + var body strings.Builder + body.WriteString("#!/bin/sh\n") + body.WriteString("if [ \"$1\" = \"models\" ]; then\n") + if gatePath != "" { + fmt.Fprintf(&body, " while [ ! -f '%s' ]; do\n", gatePath) + body.WriteString(" sleep 0.01\n") + body.WriteString(" done\n") + } + for _, line := range lines { + fmt.Fprintf(&body, " printf '%%s\\n' '%s'\n", line) + } + body.WriteString("fi\n") + + if err := os.WriteFile(name, []byte(body.String()), 0o755); err != nil { + t.Fatal(err) + } + return name +} + +func writeCountingModelsBin(t *testing.T, countPath, gatePath string, lines []string, requireEnvKey string, exitCode int) string { + t.Helper() + tmpDir := t.TempDir() + name := filepath.Join(tmpDir, "fake-opencode") + + var body strings.Builder + body.WriteString("#!/bin/sh\n") + body.WriteString("if [ \"$1\" = \"models\" ]; then\n") + if countPath != "" { + fmt.Fprintf(&body, " count=0\n if [ -f '%s' ]; then count=$(cat '%s'); fi\n", countPath, countPath) + fmt.Fprintf(&body, " count=$((count + 1))\n printf '%%s' \"$count\" > '%s'\n", countPath) + } + if gatePath != "" { + fmt.Fprintf(&body, " while [ ! -f '%s' ]; do\n", gatePath) + body.WriteString(" sleep 0.01\n") + body.WriteString(" done\n") + } + if requireEnvKey != "" { + fmt.Fprintf(&body, " if [ -z \"$%s\" ]; then exit 0; fi\n", requireEnvKey) + } + if exitCode != 0 { + fmt.Fprintf(&body, " exit %d\n", exitCode) + } else { + for _, line := range lines { + fmt.Fprintf(&body, " printf '%%s\\n' '%s'\n", line) + } + } + body.WriteString("fi\n") + + if err := os.WriteFile(name, []byte(body.String()), 0o755); err != nil { + t.Fatal(err) + } + return name +} + +func waitForModelsInPersistentCache(t *testing.T, cachePath string, want []string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + cache, err := loadOpencodePersistentModelCache(cachePath) + if err == nil && cache != nil && len(cache.Models) == len(want) { + match := true + for i, model := range cache.Models { + if model.Name != want[i] { + match = false + break + } + } + if match { + return + } + } + time.Sleep(10 * time.Millisecond) + } + + cache, err := loadOpencodePersistentModelCache(cachePath) + if err != nil { + t.Fatalf("loadOpencodePersistentModelCache(%q) error = %v", cachePath, err) + } + t.Fatalf("persistent cache models = %v, want %v", cache, want) +} + +func waitForFileContent(t *testing.T, path, want string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + data, err := os.ReadFile(path) + if err == nil && strings.TrimSpace(string(data)) == want { + return + } + time.Sleep(10 * time.Millisecond) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", path, err) + } + t.Fatalf("file %q content = %q, want %q", path, strings.TrimSpace(string(data)), want) +} + +func readPersistentModelCachePayload(t *testing.T, cachePath string) map[string]any { + t.Helper() + data, err := os.ReadFile(cachePath) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", cachePath, err) + } + var payload map[string]any + if err := json.Unmarshal(data, &payload); err != nil { + t.Fatalf("json.Unmarshal(%q) error = %v", cachePath, err) + } + return payload +} + +func providerCacheKeyOf(t *testing.T, a *Agent) string { + t.Helper() + key := a.activeProviderKey() + if key == "" { + t.Fatal("activeProviderKey() = empty, want signature") + } + if strings.Contains(key, "provider-") || strings.Contains(key, "present") || strings.Contains(key, "secret") { + t.Fatalf("activeProviderKey() leaked raw provider data: %q", key) + } + return key +} + +func TestConfiguredModels_BoundaryConditions(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "first"}}}, + {Models: []core.ModelOption{{Name: "second"}}}, + }, + } + + tests := []struct { + name string + activeIdx int + wantNil bool + wantName string + }{ + {name: "negative index", activeIdx: -1, wantNil: true}, + {name: "out of range", activeIdx: 2, wantNil: true}, + {name: "valid index", activeIdx: 1, wantName: "second"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a.activeIdx = tt.activeIdx + got := a.configuredModels() + if tt.wantNil { + if got != nil { + t.Fatalf("configuredModels() = %v, want nil", got) + } + return + } + if len(got) != 1 || got[0].Name != tt.wantName { + t.Fatalf("configuredModels() = %v, want %q", got, tt.wantName) + } + }) + } +} + +func TestNormalizeMode(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"yolo", "yolo"}, + {"YOLO", "yolo"}, + {"auto", "yolo"}, + {"AUTO", "yolo"}, + {"force", "yolo"}, + {"bypasspermissions", "yolo"}, + {"default", "default"}, + {"DEFAULT", "default"}, + {"", "default"}, + {"unknown", "default"}, + {" yolo ", "yolo"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := normalizeMode(tt.input) + if got != tt.expected { + t.Errorf("normalizeMode(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestAgent_Name(t *testing.T) { + a := &Agent{} + if got := a.Name(); got != "opencode" { + t.Errorf("Name() = %q, want %q", got, "opencode") + } +} + +func TestAgent_SetModel(t *testing.T) { + a := &Agent{} + a.SetModel("gpt-4") + if got := a.GetModel(); got != "gpt-4" { + t.Errorf("GetModel() = %q, want %q", got, "gpt-4") + } +} + +func TestAgent_SetMode(t *testing.T) { + a := &Agent{} + a.SetMode("yolo") + if got := a.GetMode(); got != "yolo" { + t.Errorf("GetMode() = %q, want %q", got, "yolo") + } +} + +func TestAgent_GetActiveProvider(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Name: "openai"}, + {Name: "anthropic"}, + }, + activeIdx: 1, + } + got := a.GetActiveProvider() + if got == nil { + t.Fatal("GetActiveProvider() returned nil") + } + if got.Name != "anthropic" { + t.Errorf("GetActiveProvider().Name = %q, want %q", got.Name, "anthropic") + } +} + +func TestAgent_GetActiveProvider_NoActive(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Name: "openai"}, + }, + activeIdx: -1, + } + if got := a.GetActiveProvider(); got != nil { + t.Errorf("GetActiveProvider() = %v, want nil", got) + } +} + +func TestAgent_ListProviders(t *testing.T) { + providers := []core.ProviderConfig{ + {Name: "openai"}, + {Name: "anthropic"}, + } + a := &Agent{providers: providers} + got := a.ListProviders() + if len(got) != 2 { + t.Errorf("ListProviders() returned %d providers, want 2", len(got)) + } +} + +func TestAgent_SetActiveProvider(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Name: "openai"}, + {Name: "anthropic"}, + }, + } + if !a.SetActiveProvider("anthropic") { + t.Error("SetActiveProvider(\"anthropic\") returned false") + } + if got := a.GetActiveProvider(); got == nil || got.Name != "anthropic" { + t.Errorf("GetActiveProvider().Name = %q, want %q", got.Name, "anthropic") + } +} + +func TestAgent_SetActiveProvider_Invalid(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Name: "openai"}, + }, + } + if a.SetActiveProvider("nonexistent") { + t.Error("SetActiveProvider(\"nonexistent\") returned true, want false") + } +} + +// ---------- dynamic discovery tests ---------- + +// TestAvailableModels_UsesDynamicDiscovery verifies that AvailableModels returns +// the model list produced by `opencode models` when it succeeds. +func TestAvailableModels_UsesDynamicDiscovery(t *testing.T) { + bin := writeFakeModelsBin(t, []string{"anthropic/claude-3-5-sonnet", "openai/gpt-4o"}, 0) + a := &Agent{cmd: bin, activeIdx: -1} + + got := a.AvailableModels(context.Background()) + if len(got) != 2 { + t.Fatalf("AvailableModels() = %v (len %d), want 2 models", got, len(got)) + } + // results must be sorted + if got[0].Name != "anthropic/claude-3-5-sonnet" { + t.Errorf("got[0].Name = %q, want %q", got[0].Name, "anthropic/claude-3-5-sonnet") + } + if got[1].Name != "openai/gpt-4o" { + t.Errorf("got[1].Name = %q, want %q", got[1].Name, "openai/gpt-4o") + } +} + +// TestAvailableModels_DynamicTakesPriorityOverConfigured verifies discovery beats +// provider-configured models. +func TestAvailableModels_DynamicTakesPriorityOverConfigured(t *testing.T) { + bin := writeFakeModelsBin(t, []string{"discovered/model"}, 0) + a := &Agent{ + cmd: bin, + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "configured/model"}}}, + }, + activeIdx: 0, + } + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "discovered/model" { + t.Errorf("AvailableModels() = %v, want [discovered/model]", got) + } +} + +// TestAvailableModels_FallsBackToConfiguredOnDiscoveryFail verifies fallback to +// provider-configured models when `opencode models` exits non-zero. +func TestAvailableModels_FallsBackToConfiguredOnDiscoveryFail(t *testing.T) { + bin := writeFakeModelsBin(t, nil, 1) // exits with error + a := &Agent{ + cmd: bin, + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "configured-model"}}}, + }, + activeIdx: 0, + } + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "configured-model" { + t.Errorf("AvailableModels() = %v, want [configured-model]", got) + } +} + +// TestAvailableModels_FallsBackToBuiltinWhenBothUnavailable verifies the final +// fallback to the hardcoded built-in model list. +func TestAvailableModels_FallsBackToBuiltinWhenBothUnavailable(t *testing.T) { + bin := writeFakeModelsBin(t, nil, 1) + a := &Agent{cmd: bin, activeIdx: -1} + + got := a.AvailableModels(context.Background()) + if len(got) == 0 { + t.Fatal("AvailableModels() returned empty list, want built-in fallback") + } + found := false + for _, m := range got { + if m.Name == "anthropic/claude-sonnet-4-20250514" { + found = true + break + } + } + if !found { + t.Errorf("AvailableModels() built-in fallback missing expected model; got: %v", got) + } +} + +// TestAvailableModels_DeduplicatesDiscoveredModels verifies that duplicate model +// names from the CLI output appear only once. +func TestAvailableModels_DeduplicatesDiscoveredModels(t *testing.T) { + bin := writeFakeModelsBin(t, []string{"openai/gpt-4o", "openai/gpt-4o", "anthropic/claude"}, 0) + a := &Agent{cmd: bin, activeIdx: -1} + + got := a.AvailableModels(context.Background()) + if len(got) != 2 { + t.Fatalf("AvailableModels() = %v (len %d), want 2 after dedup", got, len(got)) + } +} + +// TestAvailableModels_SortsDiscoveredModels verifies lexicographic sort order. +func TestAvailableModels_SortsDiscoveredModels(t *testing.T) { + bin := writeFakeModelsBin(t, []string{"z-model", "a-model", "m-model"}, 0) + a := &Agent{cmd: bin, activeIdx: -1} + + got := a.AvailableModels(context.Background()) + if len(got) != 3 { + t.Fatalf("AvailableModels() = %v, want 3 models", got) + } + names := make([]string, len(got)) + for i, m := range got { + names[i] = m.Name + } + sorted := append([]string(nil), names...) + sort.Strings(sorted) + for i := range names { + if names[i] != sorted[i] { + t.Errorf("AvailableModels() not sorted: got %v", names) + break + } + } +} + +// TestAvailableModels_EmptyDiscoveryOutputFallsBackToConfigured verifies that an +// exit-0 but empty-output binary still triggers the fallback chain. +func TestAvailableModels_EmptyDiscoveryOutputFallsBackToConfigured(t *testing.T) { + bin := writeFakeModelsBin(t, []string{}, 0) // exits 0 but no output + a := &Agent{ + cmd: bin, + providers: []core.ProviderConfig{ + {Models: []core.ModelOption{{Name: "fallback-model"}}}, + }, + activeIdx: 0, + } + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "fallback-model" { + t.Errorf("AvailableModels() empty discovery = %v, want [fallback-model]", got) + } +} + +func TestAvailableModels_ConfiguredFallbackUsesSnapshot(t *testing.T) { + a := &Agent{ + providers: []core.ProviderConfig{ + {Name: "provider-a", Models: []core.ModelOption{{Name: "configured-a"}}}, + {Name: "provider-b", Models: []core.ModelOption{{Name: "configured-b"}}}, + }, + activeIdx: 0, + } + + snapshot := a.modelDiscoverySnapshot() + if !a.SetActiveProvider("provider-b") { + t.Fatal("SetActiveProvider(provider-b) = false, want true") + } + + got := a.configuredModelsForSnapshot(snapshot) + if len(got) != 1 || got[0].Name != "configured-a" { + t.Fatalf("configuredModelsForSnapshot() = %v, want [configured-a]", got) + } +} + +// TestAvailableModels_CustomCmdUsedForDiscovery verifies that a.cmd (not the +// literal string "opencode") is used when running the models sub-command. +func TestAvailableModels_CustomCmdUsedForDiscovery(t *testing.T) { + tmpDir := t.TempDir() + customBin := filepath.Join(tmpDir, "my-ai-cli") + script := "#!/bin/sh\nif [ \"$1\" = \"models\" ]; then\nprintf '%s\\n' 'custom/model-a'\nfi\n" + if err := os.WriteFile(customBin, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + a := &Agent{cmd: customBin, activeIdx: -1} + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "custom/model-a" { + t.Errorf("AvailableModels() with custom cmd = %v, want [custom/model-a]", got) + } +} + +func TestProjectModelCachePath_SanitizesProjectName(t *testing.T) { + got := opencodeProjectModelCachePath("/tmp/data", " ../team/demo project:alpha?beta ") + if filepath.Dir(got) != filepath.Join("/tmp/data", "projects") { + t.Fatalf("opencodeProjectModelCachePath() dir = %q, want %q", filepath.Dir(got), filepath.Join("/tmp/data", "projects")) + } + base := filepath.Base(got) + if !strings.HasPrefix(base, "team-demo-project-alpha-beta-") { + t.Fatalf("opencodeProjectModelCachePath() base = %q, want sanitized prefix", base) + } + if !strings.HasSuffix(base, ".opencode-models.json") { + t.Fatalf("opencodeProjectModelCachePath() base = %q, want .opencode-models.json suffix", base) + } + hashSuffix := strings.TrimSuffix(strings.TrimPrefix(base, "team-demo-project-alpha-beta-"), ".opencode-models.json") + if len(hashSuffix) != 16 { + t.Fatalf("opencodeProjectModelCachePath() hash suffix = %q, want 16 hex chars", hashSuffix) + } +} + +func TestProjectModelCachePath_DistinguishesSanitizeCollisions(t *testing.T) { + pathA := opencodeProjectModelCachePath("/tmp/data", "team/demo") + pathB := opencodeProjectModelCachePath("/tmp/data", "team-demo") + if pathA == pathB { + t.Fatalf("opencodeProjectModelCachePath() collision: %q == %q", pathA, pathB) + } +} + +func TestLoadPersistentModelCache_NormalizesModels(t *testing.T) { + cachePath := opencodeProjectModelCachePath(t.TempDir(), "demo") + writePersistentModelCache(t, cachePath, []core.ModelOption{ + {Name: " z-model ", Desc: "z"}, + {Name: ""}, + {Name: " "}, + {Name: "a-model", Desc: "first"}, + {Name: "a-model", Desc: "duplicate"}, + {Name: " m-model", Desc: "m"}, + }, time.Now()) + + cache, err := loadOpencodePersistentModelCache(cachePath) + if err != nil { + t.Fatalf("loadOpencodePersistentModelCache() error = %v", err) + } + if cache == nil { + t.Fatal("loadOpencodePersistentModelCache() = nil, want cache") + } + + got := cache.Models + if len(got) != 3 { + t.Fatalf("normalized cache models len = %d, want 3: %v", len(got), got) + } + want := []string{"a-model", "m-model", "z-model"} + for i, model := range got { + if model.Name != want[i] { + t.Fatalf("normalized cache models[%d] = %q, want %q (all: %v)", i, model.Name, want[i], got) + } + } + if got[0].Desc != "first" { + t.Fatalf("normalized cache kept desc %q, want %q", got[0].Desc, "first") + } +} + +func TestNew_SurfacesPersistentModelCacheViaAvailableModels(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + writePersistentModelCache(t, cachePath, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + fakeEmptyBin := writeFakeModelsBin(t, []string{}, 0) + + agent, err := New(map[string]any{ + "cmd": fakeEmptyBin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + switcher, ok := agent.(core.ModelSwitcher) + if !ok { + t.Fatalf("New() agent does not implement core.ModelSwitcher") + } + + got := switcher.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "cached/model" { + t.Fatalf("AvailableModels() = %v, want [cached/model]", got) + } +} + +func TestAvailableModels_PrefersPersistentCacheOverDiscoveredModels(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + writePersistentModelCache(t, cachePath, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + bin := writeFakeModelsBin(t, []string{"fresh/model"}, 0) + + agent, err := New(map[string]any{ + "cmd": bin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + switcher, ok := agent.(core.ModelSwitcher) + if !ok { + t.Fatalf("New() agent does not implement core.ModelSwitcher") + } + + got := switcher.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "cached/model" { + t.Fatalf("AvailableModels() = %v, want [cached/model]", got) + } +} + +func TestAvailableModels_ReturnsPersistentCacheWhenDiscoveryFails(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + writePersistentModelCache(t, cachePath, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + failingBin := writeFakeModelsBin(t, nil, 1) + + agent, err := New(map[string]any{ + "cmd": failingBin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + switcher, ok := agent.(core.ModelSwitcher) + if !ok { + t.Fatalf("New() agent does not implement core.ModelSwitcher") + } + + got := switcher.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "cached/model" { + t.Fatalf("AvailableModels() = %v, want [cached/model]", got) + } +} + +func TestAvailableModels_PersistsDiscoveryOnColdStart(t *testing.T) { + dataDir := t.TempDir() + bin := writeFakeModelsBin(t, []string{"fresh/model", "second/model"}, 0) + + agent, err := New(map[string]any{ + "cmd": bin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := agent.(*Agent) + + got := a.AvailableModels(context.Background()) + if len(got) != 2 || got[0].Name != "fresh/model" || got[1].Name != "second/model" { + t.Fatalf("AvailableModels() = %v, want discovered models", got) + } + + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + cache, err := loadOpencodePersistentModelCache(cachePath) + if err != nil { + t.Fatalf("loadOpencodePersistentModelCache(%q) error = %v", cachePath, err) + } + if cache == nil { + t.Fatalf("loadOpencodePersistentModelCache(%q) = nil, want persisted cache", cachePath) + } + if len(cache.Models) != 2 || cache.Models[0].Name != "fresh/model" || cache.Models[1].Name != "second/model" { + t.Fatalf("persisted cache models = %v, want discovered models", cache.Models) + } + + inMemory := a.persistentModels() + if len(inMemory) != 2 || inMemory[0].Name != "fresh/model" || inMemory[1].Name != "second/model" { + t.Fatalf("in-memory persistent models = %v, want discovered models", inMemory) + } +} + +func TestAvailableModels_BackgroundRefreshUpdatesDiskCache(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + writePersistentModelCache(t, cachePath, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + gatePath := filepath.Join(t.TempDir(), "refresh-ready") + bin := writeBlockingModelsBin(t, gatePath, []string{"fresh/model", "second/model"}) + + agent, err := New(map[string]any{ + "cmd": bin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + switcher, ok := agent.(core.ModelSwitcher) + if !ok { + t.Fatalf("New() agent does not implement core.ModelSwitcher") + } + + got := switcher.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "cached/model" { + t.Fatalf("AvailableModels() = %v, want immediate cached result", got) + } + + if err := os.WriteFile(gatePath, []byte("ok"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", gatePath, err) + } + + waitForModelsInPersistentCache(t, cachePath, []string{"fresh/model", "second/model"}) + + got = switcher.AvailableModels(context.Background()) + if len(got) != 2 || got[0].Name != "fresh/model" || got[1].Name != "second/model" { + t.Fatalf("AvailableModels() after refresh = %v, want refreshed cache", got) + } +} + +func TestAvailableModels_BackgroundRefreshFailurePreservesCache(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + writePersistentModelCache(t, cachePath, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + countPath := filepath.Join(t.TempDir(), "refresh-count") + gatePath := filepath.Join(t.TempDir(), "refresh-ready") + bin := writeCountingModelsBin(t, countPath, gatePath, nil, "", 1) + + agent, err := New(map[string]any{ + "cmd": bin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := agent.(*Agent) + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "cached/model" { + t.Fatalf("AvailableModels() = %v, want immediate cached result", got) + } + if err := os.WriteFile(gatePath, []byte("ok"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", gatePath, err) + } + waitForFileContent(t, countPath, "1") + + cache, err := loadOpencodePersistentModelCache(cachePath) + if err != nil { + t.Fatalf("loadOpencodePersistentModelCache(%q) error = %v", cachePath, err) + } + if cache == nil || len(cache.Models) != 1 || cache.Models[0].Name != "cached/model" { + t.Fatalf("persistent cache after failed refresh = %v, want cached/model preserved", cache) + } + inMemory := a.persistentModels() + if len(inMemory) != 1 || inMemory[0].Name != "cached/model" { + t.Fatalf("in-memory cache after failed refresh = %v, want cached/model preserved", inMemory) + } +} + +func TestAvailableModels_BackgroundRefreshSingleFlight(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + writePersistentModelCache(t, cachePath, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + countPath := filepath.Join(t.TempDir(), "refresh-count") + gatePath := filepath.Join(t.TempDir(), "refresh-ready") + bin := writeCountingModelsBin(t, countPath, gatePath, []string{"fresh/model"}, "", 0) + + agent, err := New(map[string]any{ + "cmd": bin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := agent.(*Agent) + + for i := 0; i < 5; i++ { + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "cached/model" { + t.Fatalf("AvailableModels() call %d = %v, want cached/model", i, got) + } + } + waitForFileContent(t, countPath, "1") + if err := os.WriteFile(gatePath, []byte("ok"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", gatePath, err) + } + waitForModelsInPersistentCache(t, cachePath, []string{"fresh/model"}) + data, err := os.ReadFile(countPath) + if err != nil { + t.Fatalf("os.ReadFile(%q) error = %v", countPath, err) + } + if strings.TrimSpace(string(data)) != "1" { + t.Fatalf("refresh count = %q, want 1", strings.TrimSpace(string(data))) + } +} + +func TestStartInitialModelRefresh_UsesCurrentProviderWiring(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + writePersistentModelCache(t, cachePath, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + countPath := filepath.Join(t.TempDir(), "refresh-count") + gatePath := filepath.Join(t.TempDir(), "refresh-ready") + bin := writeCountingModelsBin(t, countPath, gatePath, []string{"provider/model"}, "MODEL_DISCOVERY_TOKEN", 0) + + agent, err := New(map[string]any{ + "cmd": bin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := agent.(*Agent) + a.SetProviders([]core.ProviderConfig{{ + Name: "provider-a", + Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present"}, + }}) + if !a.SetActiveProvider("provider-a") { + t.Fatal("SetActiveProvider(provider-a) = false, want true") + } + + a.StartInitialModelRefresh() + waitForFileContent(t, countPath, "1") + if err := os.WriteFile(gatePath, []byte("ok"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", gatePath, err) + } + waitForModelsInPersistentCache(t, cachePath, []string{"provider/model"}) +} + +func TestStartInitialModelRefresh_PrewarmsColdStartCacheAfterProviderWiring(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + countPath := filepath.Join(t.TempDir(), "refresh-count") + gatePath := filepath.Join(t.TempDir(), "refresh-ready") + bin := writeCountingModelsBin(t, countPath, gatePath, []string{"provider/model"}, "MODEL_DISCOVERY_TOKEN", 0) + + agent, err := New(map[string]any{ + "cmd": bin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := agent.(*Agent) + a.SetProviders([]core.ProviderConfig{{ + Name: "provider-a", + Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present"}, + }}) + if !a.SetActiveProvider("provider-a") { + t.Fatal("SetActiveProvider(provider-a) = false, want true") + } + if cache, err := loadOpencodePersistentModelCache(cachePath); err != nil { + t.Fatalf("loadOpencodePersistentModelCache(%q) error = %v", cachePath, err) + } else if cache != nil { + t.Fatalf("persistent cache before prewarm = %v, want nil", cache) + } + + a.StartInitialModelRefresh() + waitForFileContent(t, countPath, "1") + if err := os.WriteFile(gatePath, []byte("ok"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", gatePath, err) + } + waitForModelsInPersistentCache(t, cachePath, []string{"provider/model"}) +} + +func TestAvailableModels_DiscoveryUsesProviderEnv(t *testing.T) { + countPath := filepath.Join(t.TempDir(), "refresh-count") + bin := writeCountingModelsBin(t, countPath, "", []string{"provider/model"}, "MODEL_DISCOVERY_TOKEN", 0) + a := &Agent{ + cmd: bin, + workDir: t.TempDir(), + providers: []core.ProviderConfig{{ + Name: "provider-a", + Model: "provider/model", + Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present"}, + }}, + activeIdx: 0, + } + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "provider/model" { + t.Fatalf("AvailableModels() = %v, want provider/model discovered via provider env", got) + } +} + +func TestAvailableModels_PersistsProviderKeyOnColdStartDiscovery(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + bin := writeCountingModelsBin(t, "", "", []string{"provider/model"}, "MODEL_DISCOVERY_TOKEN", 0) + a := &Agent{ + cmd: bin, + workDir: t.TempDir(), + modelCachePath: cachePath, + providers: []core.ProviderConfig{{ + Name: "provider-a", + Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present"}, + }}, + activeIdx: 0, + } + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "provider/model" { + t.Fatalf("AvailableModels() = %v, want provider/model", got) + } + wantKey := providerCacheKeyOf(t, a) + payload := readPersistentModelCachePayload(t, cachePath) + if payload["provider_key"] != wantKey { + t.Fatalf("provider_key = %v, want %q", payload["provider_key"], wantKey) + } +} + +func TestAvailableModels_IgnoresPersistentCacheForProviderMismatch(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + bin := writeFakeModelsBin(t, []string{"fresh/provider-b"}, 0) + + agent, err := New(map[string]any{ + "cmd": bin, + "cc_data_dir": dataDir, + "cc_project": "demo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := agent.(*Agent) + a.SetProviders([]core.ProviderConfig{{Name: "provider-a"}, {Name: "provider-b"}}) + providerASnapshot := func() opencodeModelDiscoverySnapshot { + a.activeIdx = 0 + providerCacheKeyOf(t, a) + return a.modelDiscoverySnapshot() + }() + writePersistentModelCacheWithSnapshot(t, cachePath, providerASnapshot, []core.ModelOption{{Name: "cached/provider-a"}}, time.Now()) + a.persistentModelCache = &opencodePersistentModelCache{Models: []core.ModelOption{{Name: "cached/provider-a"}}, UpdatedAt: time.Now(), ProviderKey: providerASnapshot.providerKey, ContextKey: modelDiscoveryContextKey(providerASnapshot)} + if !a.SetActiveProvider("provider-b") { + t.Fatal("SetActiveProvider(provider-b) = false, want true") + } + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "fresh/provider-b" { + t.Fatalf("AvailableModels() = %v, want provider-mismatched cache ignored", got) + } + cache, err := loadOpencodePersistentModelCache(cachePath) + if err != nil { + t.Fatalf("loadOpencodePersistentModelCache(%q) error = %v", cachePath, err) + } + if cache == nil || len(cache.Models) != 1 || cache.Models[0].Name != "fresh/provider-b" { + t.Fatalf("persistent cache after provider mismatch refresh = %v, want fresh/provider-b", cache) + } +} + +func TestAvailableModels_BackgroundRefreshPersistsProviderKey(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + gatePath := filepath.Join(t.TempDir(), "refresh-ready") + bin := writeCountingModelsBin(t, "", gatePath, []string{"fresh/model"}, "MODEL_DISCOVERY_TOKEN", 0) + + a := &Agent{ + cmd: bin, + workDir: t.TempDir(), + modelCachePath: cachePath, + providers: []core.ProviderConfig{{ + Name: "provider-a", + Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present"}, + }}, + activeIdx: 0, + } + providerAKey := providerCacheKeyOf(t, a) + providerASnapshot := a.modelDiscoverySnapshot() + writePersistentModelCacheWithSnapshot(t, cachePath, providerASnapshot, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + a.persistentModelCache = &opencodePersistentModelCache{Models: []core.ModelOption{{Name: "cached/model"}}, UpdatedAt: time.Now(), ProviderKey: providerAKey, ContextKey: modelDiscoveryContextKey(providerASnapshot)} + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "cached/model" { + t.Fatalf("AvailableModels() = %v, want cached/model", got) + } + if err := os.WriteFile(gatePath, []byte("ok"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", gatePath, err) + } + waitForModelsInPersistentCache(t, cachePath, []string{"fresh/model"}) + payload := readPersistentModelCachePayload(t, cachePath) + if payload["provider_key"] != providerAKey { + t.Fatalf("provider_key = %v, want %q", payload["provider_key"], providerAKey) + } +} + +func TestAvailableModels_BackgroundRefreshUsesProviderSnapshot(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + countPath := filepath.Join(t.TempDir(), "refresh-count") + gatePath := filepath.Join(t.TempDir(), "refresh-ready") + bin := writeCountingModelsBin(t, countPath, gatePath, []string{"provider-a/model"}, "MODEL_DISCOVERY_TOKEN", 0) + a := &Agent{ + cmd: bin, + workDir: t.TempDir(), + modelCachePath: cachePath, + providers: []core.ProviderConfig{ + {Name: "provider-a", Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present"}}, + {Name: "provider-b", Env: map[string]string{}}, + }, + activeIdx: 0, + } + providerAKey := providerCacheKeyOf(t, a) + providerASnapshot := a.modelDiscoverySnapshot() + writePersistentModelCacheWithSnapshot(t, cachePath, providerASnapshot, []core.ModelOption{{Name: "cached/model"}}, time.Now()) + a.persistentModelCache = &opencodePersistentModelCache{Models: []core.ModelOption{{Name: "cached/model"}}, UpdatedAt: time.Now(), ProviderKey: providerAKey, ContextKey: modelDiscoveryContextKey(providerASnapshot)} + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "cached/model" { + t.Fatalf("AvailableModels() = %v, want cached/model", got) + } + waitForFileContent(t, countPath, "1") + if !a.SetActiveProvider("provider-b") { + t.Fatal("SetActiveProvider(provider-b) = false, want true") + } + if err := os.WriteFile(gatePath, []byte("ok"), 0o644); err != nil { + t.Fatalf("os.WriteFile(%q) error = %v", gatePath, err) + } + waitForModelsInPersistentCache(t, cachePath, []string{"provider-a/model"}) + payload := readPersistentModelCachePayload(t, cachePath) + if payload["provider_key"] != providerAKey { + t.Fatalf("provider_key after provider switch = %v, want %q snapshot", payload["provider_key"], providerAKey) + } +} + +func TestAvailableModels_IgnoresPersistentCacheForSameProviderNameDifferentConfig(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + bin := writeCountingModelsBin(t, "", "", []string{"fresh/provider-a"}, "MODEL_DISCOVERY_TOKEN", 0) + a := &Agent{ + cmd: bin, + workDir: t.TempDir(), + modelCachePath: cachePath, + providers: []core.ProviderConfig{{ + Name: "provider-a", + APIKey: "new-secret", + BaseURL: "https://new.example", + Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present", "EXTRA": "new"}, + }}, + activeIdx: 0, + } + stale := &Agent{providers: []core.ProviderConfig{{Name: "provider-a", APIKey: "old-secret", BaseURL: "https://old.example", Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "old", "EXTRA": "old"}}}, activeIdx: 0} + staleKey := providerCacheKeyOf(t, stale) + staleSnapshot := stale.modelDiscoverySnapshot() + writePersistentModelCacheWithSnapshot(t, cachePath, staleSnapshot, []core.ModelOption{{Name: "cached/stale"}}, time.Now()) + a.persistentModelCache = &opencodePersistentModelCache{Models: []core.ModelOption{{Name: "cached/stale"}}, UpdatedAt: time.Now(), ProviderKey: staleKey, ContextKey: modelDiscoveryContextKey(staleSnapshot)} + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "fresh/provider-a" { + t.Fatalf("AvailableModels() = %v, want stale cache ignored for changed config", got) + } + payload := readPersistentModelCachePayload(t, cachePath) + wantKey := providerCacheKeyOf(t, a) + if payload["provider_key"] != wantKey { + t.Fatalf("provider_key = %v, want %q", payload["provider_key"], wantKey) + } +} + +func TestAvailableModels_IgnoresPersistentCacheForWorkDirMismatch(t *testing.T) { + dataDir := t.TempDir() + cachePath := opencodeProjectModelCachePath(dataDir, "demo") + countPath := filepath.Join(t.TempDir(), "refresh-count") + workDirA := filepath.Join(t.TempDir(), "workspace-a") + workDirB := filepath.Join(t.TempDir(), "workspace-b") + if err := os.MkdirAll(workDirA, 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", workDirA, err) + } + if err := os.MkdirAll(workDirB, 0o755); err != nil { + t.Fatalf("os.MkdirAll(%q) error = %v", workDirB, err) + } + bin := writeCountingModelsBin(t, countPath, "", []string{"fresh/workspace-b"}, "MODEL_DISCOVERY_TOKEN", 0) + a := &Agent{ + cmd: bin, + workDir: workDirB, + modelCachePath: cachePath, + providers: []core.ProviderConfig{{ + Name: "provider-a", + Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present"}, + }}, + activeIdx: 0, + } + stale := &Agent{ + workDir: workDirA, + providers: []core.ProviderConfig{{ + Name: "provider-a", + Env: map[string]string{"MODEL_DISCOVERY_TOKEN": "present"}, + }}, + activeIdx: 0, + } + staleSnapshot := stale.modelDiscoverySnapshot() + writePersistentModelCacheWithSnapshot(t, cachePath, staleSnapshot, []core.ModelOption{{Name: "cached/workspace-a"}}, time.Now()) + a.persistentModelCache = &opencodePersistentModelCache{ + Models: []core.ModelOption{{Name: "cached/workspace-a"}}, + UpdatedAt: time.Now(), + ProviderKey: staleSnapshot.providerKey, + ContextKey: modelDiscoveryContextKey(staleSnapshot), + } + + got := a.AvailableModels(context.Background()) + if len(got) != 1 || got[0].Name != "fresh/workspace-b" { + t.Fatalf("AvailableModels() = %v, want stale cache ignored for changed workdir", got) + } + waitForFileContent(t, countPath, "1") + payload := readPersistentModelCachePayload(t, cachePath) + if payload["provider_key"] != staleSnapshot.providerKey { + t.Fatalf("provider_key = %v, want %q", payload["provider_key"], staleSnapshot.providerKey) + } +} + +// ---------- DeleteSession tests ---------- + +// writeFakeDeleteBin writes a temporary shell script that acts as a fake opencode CLI. +// When invoked with "session delete ", it either succeeds (exitCode=0) or fails. +// If wantID is non-empty the script validates the session ID matches. +func writeFakeDeleteBin(t *testing.T, wantID string, exitCode int, stderr string) string { + t.Helper() + tmpDir := t.TempDir() + name := filepath.Join(tmpDir, "fake-opencode") + + var body strings.Builder + body.WriteString("#!/bin/sh\n") + body.WriteString("if [ \"$1\" = \"session\" ] && [ \"$2\" = \"delete\" ]; then\n") + if wantID != "" { + fmt.Fprintf(&body, " if [ \"$3\" != \"%s\" ]; then\n", wantID) + fmt.Fprintf(&body, " printf 'unexpected session id: %%s\\n' \"$3\" >&2\n") + body.WriteString(" exit 1\n") + body.WriteString(" fi\n") + } + if stderr != "" { + fmt.Fprintf(&body, " printf '%s\\n' >&2\n", stderr) + } + fmt.Fprintf(&body, " exit %d\n", exitCode) + body.WriteString("fi\n") + body.WriteString("exit 0\n") + + if err := os.WriteFile(name, []byte(body.String()), 0755); err != nil { + t.Fatal(err) + } + return name +} + +// TestDeleteSession_Success verifies that DeleteSession calls +// `opencode session delete ` and returns nil on success. +func TestDeleteSession_Success(t *testing.T) { + sessionID := "ses_abc123" + bin := writeFakeDeleteBin(t, sessionID, 0, "") + a := &Agent{cmd: bin, workDir: t.TempDir()} + + if err := a.DeleteSession(context.Background(), sessionID); err != nil { + t.Fatalf("DeleteSession() unexpected error: %v", err) + } +} + +// TestDeleteSession_CLIError verifies that DeleteSession propagates CLI failures. +func TestDeleteSession_CLIError(t *testing.T) { + bin := writeFakeDeleteBin(t, "", 1, "session not found") + a := &Agent{cmd: bin, workDir: t.TempDir()} + + err := a.DeleteSession(context.Background(), "ses_missing") + if err == nil { + t.Fatal("DeleteSession() expected error, got nil") + } + if !strings.Contains(err.Error(), "ses_missing") { + t.Errorf("error %q should mention the session ID", err.Error()) + } +} + +// TestDeleteSession_ImplementsInterface is a compile-time check that Agent +// satisfies core.SessionDeleter. +var _ core.SessionDeleter = (*Agent)(nil) + +// ---------- interface / compile-time checks ---------- + +// verify Agent implements core.Agent +var _ core.Agent = (*Agent)(nil) diff --git a/agent/opencode/opencode_workdir_race_test.go b/agent/opencode/opencode_workdir_race_test.go new file mode 100644 index 0000000..24306ea --- /dev/null +++ b/agent/opencode/opencode_workdir_race_test.go @@ -0,0 +1,48 @@ +package opencode + +import ( + "context" + "path/filepath" + "sync" + "testing" +) + +// TestOpencodeAgent_WorkDirRaceFreeReaders pins the bug where +// ListSessions and ProjectMemoryFile read a.workDir (and a.cmd, in +// ListSessions' case) without holding a.mu, while SetWorkDir writes +// a.workDir under the lock. Run with -race to detect the data race; +// with the production fix the test stays clean. +func TestOpencodeAgent_WorkDirRaceFreeReaders(t *testing.T) { + dir := t.TempDir() + a := &Agent{workDir: dir, cmd: "opencode"} + + var wg sync.WaitGroup + for i := 0; i < 30; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + if i%2 == 0 { + a.SetWorkDir(filepath.Join(dir, "a")) + } else { + a.SetWorkDir(filepath.Join(dir, "b")) + } + }(i) + } + for i := 0; i < 30; i++ { + wg.Add(1) + go func() { + defer wg.Done() + // ListSessions execs `opencode session list`; with a + // non-existent binary it returns an error immediately. + // The race detector still observes the unlocked field + // reads before the exec attempt. + _, _ = a.ListSessions(context.Background()) + }() + wg.Add(1) + go func() { + defer wg.Done() + _ = a.ProjectMemoryFile() + }() + } + wg.Wait() +} diff --git a/agent/opencode/session.go b/agent/opencode/session.go new file mode 100644 index 0000000..01848c5 --- /dev/null +++ b/agent/opencode/session.go @@ -0,0 +1,524 @@ +package opencode + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "time" + "unicode/utf8" + + "github.com/chenhg5/cc-connect/core" +) + +// opencodeSession manages multi-turn conversations with the OpenCode CLI. +// Each Send() launches a new `opencode run --format json` process +// with --session for conversation continuity. +type opencodeSession struct { + cmd string + workDir string + model string + mode string + extraEnv []string + events chan core.Event + chatID atomic.Value // stores string — OpenCode session ID + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + alive atomic.Bool + expectingContinue atomic.Bool // true when compaction_continue received, waiting for next step + resultSent atomic.Bool // true when EventResult has been sent for this turn +} + +func newOpencodeSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*opencodeSession, error) { + sessionCtx, cancel := context.WithCancel(ctx) + + s := &opencodeSession{ + cmd: cmd, + workDir: workDir, + model: model, + mode: mode, + extraEnv: extraEnv, + events: make(chan core.Event, 64), + ctx: sessionCtx, + cancel: cancel, + } + s.alive.Store(true) + + if resumeID != "" && resumeID != core.ContinueSession { + s.chatID.Store(resumeID) + } + + return s, nil +} + +func (s *opencodeSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + if len(files) > 0 { + filePaths := core.SaveFilesToDisk(s.workDir, files) + prompt = core.AppendFileRefs(prompt, filePaths) + } + prompt, imagePaths, err := s.stageImages(prompt, images) + if err != nil { + return err + } + if !s.alive.Load() { + return fmt.Errorf("session is closed") + } + + s.resultSent.Store(false) + s.expectingContinue.Store(false) + + chatID := s.CurrentSessionID() + isResume := chatID != "" + + args := s.buildRunArgs(prompt, imagePaths, chatID) + + slog.Debug("opencodeSession: launching", "resume", isResume, "args", core.RedactArgs(args)) + + cmd := exec.CommandContext(s.ctx, s.cmd, args...) + cmd.Dir = s.workDir + env := os.Environ() + if len(s.extraEnv) > 0 { + env = core.MergeEnv(env, s.extraEnv) + } + cmd.Env = env + + stdout, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("opencodeSession: stdout pipe: %w", err) + } + + var stderrBuf bytes.Buffer + cmd.Stderr = &stderrBuf + cmd.Stdin = strings.NewReader(prompt) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("opencodeSession: start: %w", err) + } + + s.wg.Add(1) + go s.readLoop(cmd, stdout, &stderrBuf) + + return nil +} + +func (s *opencodeSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) { + if len(images) == 0 { + return prompt, nil, nil + } + + imgDir := filepath.Join(s.workDir, ".cc-connect", "images") + if err := os.MkdirAll(imgDir, 0o755); err != nil { + return "", nil, fmt.Errorf("opencodeSession: create image dir: %w", err) + } + + imagePaths := make([]string, 0, len(images)) + for i, img := range images { + ext := opencodeImageExt(img.MimeType) + fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext) + fpath := filepath.Join(imgDir, fname) + if err := os.WriteFile(fpath, img.Data, 0o644); err != nil { + return "", nil, fmt.Errorf("opencodeSession: save image: %w", err) + } + imagePaths = append(imagePaths, fpath) + } + + if prompt == "" { + prompt = "Please analyze the attached image(s)." + } + + return prompt, imagePaths, nil +} + +func opencodeImageExt(mimeType string) string { + switch mimeType { + case "image/jpeg": + return ".jpg" + case "image/gif": + return ".gif" + case "image/webp": + return ".webp" + default: + return ".png" + } +} + +func (s *opencodeSession) buildRunArgs(prompt string, imagePaths []string, chatID string) []string { + args := []string{"run", "--format", "json"} + + if chatID != "" { + args = append(args, "--session", chatID) + } + if s.model != "" { + args = append(args, "--model", s.model) + } + if s.workDir != "" { + args = append(args, "--dir", s.workDir) + } + + // Enable thinking blocks. + args = append(args, "--thinking") + + // In yolo/auto mode, skip permission prompts entirely so headless + // runs don't get stuck with auto-rejected external-directory ops. + if s.mode == "yolo" { + args = append(args, "--dangerously-skip-permissions") + } + + for _, imagePath := range imagePaths { + if imagePath == "" { + continue + } + args = append(args, "--file", imagePath) + } + + return args +} + +func (s *opencodeSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) { + defer s.wg.Done() + defer func() { _ = cmd.Wait() }() + + scanner := bufio.NewScanner(stdout) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) + + for scanner.Scan() { + line := scanner.Text() + if line == "" { + continue + } + + var raw map[string]any + if err := json.Unmarshal([]byte(line), &raw); err != nil { + slog.Debug("opencodeSession: non-JSON line", "line", line) + continue + } + + s.handleEvent(raw) + } + + if err := scanner.Err(); err != nil { + slog.Error("opencodeSession: scanner error", "error", err) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)} + select { + case s.events <- evt: + case <-s.ctx.Done(): + return + } + return + } + + stderrMsg := stderrBuf.String() + if stderrMsg != "" { + slog.Error("opencodeSession: process error", "stderr", truncate(stderrMsg, 500)) + if strings.Contains(stderrMsg, "Session not found") { + s.chatID.Store("") + slog.Warn("opencodeSession: cleared stale session ID") + } + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)} + select { + case s.events <- evt: + case <-s.ctx.Done(): + } + return + } + + // Check if we received compaction_continue before readLoop ended. + // If so, OpenCode will continue with a new turn - do NOT send EventResult. + // The subsequent process will send its own EventResult when it finishes. + if s.expectingContinue.Load() { + slog.Info("opencodeSession: readLoop ended after compaction_continue, skipping EventResult", "session_id", s.CurrentSessionID()) + s.expectingContinue.Store(false) + return + } + + slog.Debug("opencodeSession: readLoop complete, sending fallback EventResult", "session_id", s.CurrentSessionID()) + s.sendEventResult() +} + +// OpenCode NDJSON event structure: +// +// { "type": "text|tool_use|reasoning|step_start|step_finish", +// "part": { "type": "text|tool|reasoning|step-start|step-finish", ... } } +func (s *opencodeSession) handleEvent(raw map[string]any) { + eventType, _ := raw["type"].(string) + + switch eventType { + case "text": + s.handleText(raw) + case "tool_use": + s.handleToolUse(raw) + case "reasoning": + s.handleReasoning(raw) + case "step_start": + s.handleStepStart(raw) + case "step_finish": + s.handleStepFinish(raw) + case "error": + s.handleError(raw) + default: + b, _ := json.Marshal(raw) + slog.Debug("opencodeSession: unhandled event", "type", eventType, "raw", string(b)) + } +} + +func (s *opencodeSession) handleText(raw map[string]any) { + part, _ := raw["part"].(map[string]any) + if part == nil { + return + } + text, _ := part["text"].(string) + + // Extract metadata and synthetic flags to identify compaction_continue + metadata, _ := part["metadata"].(map[string]any) + synthetic, _ := part["synthetic"].(bool) + + // Check for compaction_continue: this is OpenCode's auto-continuation signal. + // When received, we should NOT send EventText to engine, but mark that we expect + // a continuation (next step_start will start a new turn without EventResult). + if synthetic && metadata != nil { + if cc, ok := metadata["compaction_continue"].(bool); ok && cc { + slog.Info("opencodeSession: compaction_continue detected, marking expectingContinue", "session_id", s.CurrentSessionID()) + s.expectingContinue.Store(true) + // Do NOT send EventText - this is internal continuation signal + return + } + } + + if text != "" { + evt := core.Event{Type: core.EventText, Content: text, Metadata: metadata, Synthetic: synthetic} + select { + case s.events <- evt: + case <-s.ctx.Done(): + return + } + } +} + +func (s *opencodeSession) handleToolUse(raw map[string]any) { + part, _ := raw["part"].(map[string]any) + if part == nil { + return + } + + toolName, _ := part["tool"].(string) + + state, _ := part["state"].(map[string]any) + status := "" + if state != nil { + status, _ = state["status"].(string) + } + + // Extract tool input summary for display + input := extractToolInput(state) + + if status == "completed" { + // OpenCode bundles call + result in one event; emit both for UI. + useEvt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input} + select { + case s.events <- useEvt: + case <-s.ctx.Done(): + return + } + + output, _ := state["output"].(string) + resultEvt := core.Event{Type: core.EventToolResult, ToolName: toolName, Content: truncate(output, 500)} + select { + case s.events <- resultEvt: + case <-s.ctx.Done(): + return + } + } else { + evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input} + select { + case s.events <- evt: + case <-s.ctx.Done(): + return + } + } +} + +func extractToolInput(state map[string]any) string { + if state == nil { + return "" + } + // Prefer title as a concise description (e.g. "List files in current directory") + if title, ok := state["title"].(string); ok && title != "" { + return title + } + switch input := state["input"].(type) { + case string: + return input + case map[string]any: + // Use "description" or "command" fields if available + if desc, ok := input["description"].(string); ok && desc != "" { + return desc + } + if cmd, ok := input["command"].(string); ok && cmd != "" { + return cmd + } + b, _ := json.Marshal(input) + return truncate(string(b), 200) + } + return "" +} + +func (s *opencodeSession) handleReasoning(raw map[string]any) { + part, _ := raw["part"].(map[string]any) + if part == nil { + return + } + text, _ := part["text"].(string) + if text != "" { + evt := core.Event{Type: core.EventThinking, Content: text} + select { + case s.events <- evt: + case <-s.ctx.Done(): + return + } + } +} + +func (s *opencodeSession) handleError(raw map[string]any) { + errMsg := extractErrorMessage(raw) + slog.Error("opencodeSession: agent error", "error", errMsg) + evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", errMsg)} + select { + case s.events <- evt: + case <-s.ctx.Done(): + return + } +} + +// extractErrorMessage tries to pull a human-readable message from various +// OpenCode error JSON shapes. +func extractErrorMessage(raw map[string]any) string { + // Shape: {"error": {"data": {"message": "..."}, "name": "..."}} + if errObj, ok := raw["error"].(map[string]any); ok { + if data, ok := errObj["data"].(map[string]any); ok { + if msg, ok := data["message"].(string); ok && msg != "" { + name, _ := errObj["name"].(string) + if name != "" { + return name + ": " + msg + } + return msg + } + } + if msg, ok := errObj["message"].(string); ok && msg != "" { + return msg + } + if name, ok := errObj["name"].(string); ok && name != "" { + return name + } + } + // Shape: {"error": "string message"} + if errStr, ok := raw["error"].(string); ok && errStr != "" { + return errStr + } + // Shape: {"part": {"error": "...", "message": "..."}} + if part, ok := raw["part"].(map[string]any); ok { + if msg, ok := part["error"].(string); ok && msg != "" { + return msg + } + if msg, ok := part["message"].(string); ok && msg != "" { + return msg + } + } + if msg, ok := raw["message"].(string); ok && msg != "" { + return msg + } + b, _ := json.Marshal(raw) + return string(b) +} + +func (s *opencodeSession) handleStepStart(raw map[string]any) { + sessionID, _ := raw["sessionID"].(string) + if sessionID == "" { + part, _ := raw["part"].(map[string]any) + if part != nil { + sessionID, _ = part["sessionID"].(string) + } + } + if sessionID != "" { + s.chatID.Store(sessionID) + slog.Debug("opencodeSession: session started", "session_id", sessionID) + } +} + +func (s *opencodeSession) handleStepFinish(raw map[string]any) { + part, _ := raw["part"].(map[string]any) + reason := "" + if part != nil { + reason, _ = part["reason"].(string) + } + slog.Debug("opencodeSession: step finished", "reason", reason, "session_id", s.CurrentSessionID()) + + if reason == "stop" { + s.sendEventResult() + } +} + +func (s *opencodeSession) sendEventResult() { + if s.resultSent.Load() { + slog.Debug("opencodeSession: EventResult already sent, skipping", "session_id", s.CurrentSessionID()) + return + } + s.resultSent.Store(true) + sid := s.CurrentSessionID() + evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true} + select { + case s.events <- evt: + case <-s.ctx.Done(): + } +} + +// RespondPermission is a no-op — OpenCode handles permissions internally. +func (s *opencodeSession) RespondPermission(_ string, _ core.PermissionResult) error { + return nil +} + +func (s *opencodeSession) Events() <-chan core.Event { + return s.events +} + +func (s *opencodeSession) CurrentSessionID() string { + v, _ := s.chatID.Load().(string) + return v +} + +func (s *opencodeSession) Alive() bool { + return s.alive.Load() +} + +func (s *opencodeSession) Close() error { + s.alive.Store(false) + s.cancel() + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + select { + case <-done: + close(s.events) + case <-time.After(8 * time.Second): + slog.Warn("opencodeSession: close timed out, abandoning wg.Wait") + } + return nil +} + +func truncate(s string, maxRunes int) string { + if utf8.RuneCountInString(s) <= maxRunes { + return s + } + return string([]rune(s)[:maxRunes]) + "..." +} diff --git a/agent/opencode/session_test.go b/agent/opencode/session_test.go new file mode 100644 index 0000000..6f94828 --- /dev/null +++ b/agent/opencode/session_test.go @@ -0,0 +1,246 @@ +package opencode + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "sync/atomic" + "testing" + + "github.com/chenhg5/cc-connect/core" +) + +// TestOpencodeSessionEntry_Unmarshal verifies that OpenCode's +// `session list --format json` output can be correctly parsed. +// +// OpenCode returns `updated` and `created` as Unix timestamps in +// milliseconds (int64), not strings. This test prevents regression +// of the unmarshal error: +// +// json: cannot unmarshal number into Go struct field opencodeSessionEntry.updated of type string +func TestOpencodeSessionEntry_Unmarshal(t *testing.T) { + jsonData := `[ + { + "id": "ses_2eb11bb11ffeYwQZOj25mlmGMc", + "title": "Test Session", + "updated": 1774174646445, + "created": 1774172652782, + "projectId": "b80385ead03e8b450bdb2016d434aad318f93c16", + "directory": "/path/to/project" + } +]` + + var entries []opencodeSessionEntry + if err := json.Unmarshal([]byte(jsonData), &entries); err != nil { + t.Fatalf("Failed to unmarshal OpenCode session list: %v", err) + } + + if len(entries) != 1 { + t.Fatalf("Expected 1 entry, got %d", len(entries)) + } + + e := entries[0] + if e.ID != "ses_2eb11bb11ffeYwQZOj25mlmGMc" { + t.Errorf("ID = %q, want %q", e.ID, "ses_2eb11bb11ffeYwQZOj25mlmGMc") + } + if e.Title != "Test Session" { + t.Errorf("Title = %q, want %q", e.Title, "Test Session") + } + if e.Updated != 1774174646445 { + t.Errorf("Updated = %d, want %d", e.Updated, 1774174646445) + } + if e.Created != 1774172652782 { + t.Errorf("Created = %d, want %d", e.Created, 1774172652782) + } +} + +// TestNewOpencodeSession_ContinueSessionTreatedAsFresh verifies that +// the ContinueSession sentinel (__continue__) is not passed as a literal +// session ID to the CLI. This was fixed in PR #249. +func TestNewOpencodeSession_ContinueSessionTreatedAsFresh(t *testing.T) { + s, err := newOpencodeSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil) + if err != nil { + t.Fatalf("newOpencodeSession: %v", err) + } + defer s.Close() + + if got := s.CurrentSessionID(); got != "" { + t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got) + } +} + +func TestOpencodeSessionStageImages(t *testing.T) { + dir := t.TempDir() + s := &opencodeSession{workDir: dir} + + prompt, imagePaths, err := s.stageImages("", []core.ImageAttachment{ + {MimeType: "image/jpeg", Data: []byte{0xff, 0xd8, 0xff}}, + {MimeType: "image/webp", Data: []byte("webp")}, + }) + if err != nil { + t.Fatalf("stageImages: %v", err) + } + if prompt != "Please analyze the attached image(s)." { + t.Fatalf("prompt = %q", prompt) + } + if len(imagePaths) != 2 { + t.Fatalf("imagePaths len = %d, want 2", len(imagePaths)) + } + if filepath.Ext(imagePaths[0]) != ".jpg" { + t.Fatalf("first ext = %q, want .jpg", filepath.Ext(imagePaths[0])) + } + if filepath.Ext(imagePaths[1]) != ".webp" { + t.Fatalf("second ext = %q, want .webp", filepath.Ext(imagePaths[1])) + } + for _, path := range imagePaths { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected staged image %s: %v", path, err) + } + } +} + +func TestOpencodeSessionBuildRunArgsIncludesImagesAsFiles(t *testing.T) { + s := &opencodeSession{workDir: "/repo", model: "provider/model"} + + got := s.buildRunArgs("describe these images", []string{"/tmp/a.png", "/tmp/b.jpg"}, "ses_123") + want := []string{ + "run", "--format", "json", + "--session", "ses_123", + "--model", "provider/model", + "--dir", "/repo", + "--thinking", + "--file", "/tmp/a.png", + "--file", "/tmp/b.jpg", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("args = %#v, want %#v", got, want) + } +} + +// TestHandleStepStart_SessionIDFromTopLevel verifies that handleStepStart +// prefers the sessionID from the top-level JSON field when both top-level +// and part-level sessionID are present. This matches OpenCode's stdout format. +func TestHandleStepStart_SessionIDFromTopLevel(t *testing.T) { + jsonData := `{"type":"step_start","sessionID":"ses_top_level","part":{"sessionID":"ses_part_level"}}` + + var raw map[string]any + if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + s := &opencodeSession{} + s.handleStepStart(raw) + + if got := s.CurrentSessionID(); got != "ses_top_level" { + t.Errorf("sessionID = %q, want %q (should prefer top-level)", got, "ses_top_level") + } +} + +// TestHandleStepStart_SessionIDFromPart verifies that handleStepStart +// falls back to the sessionID inside part when top-level sessionID is absent. +func TestHandleStepStart_SessionIDFromPart(t *testing.T) { + jsonData := `{"type":"step_start","part":{"sessionID":"ses_part_level"}}` + + var raw map[string]any + if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + s := &opencodeSession{} + s.handleStepStart(raw) + + if got := s.CurrentSessionID(); got != "ses_part_level" { + t.Errorf("sessionID = %q, want %q (should fallback to part)", got, "ses_part_level") + } +} + +// TestHandleStepStopSendsEventResult verifies that handleStepFinish sends +// an EventResult when reason="stop", signaling turn completion to the engine. +func TestHandleStepStopSendsEventResult(t *testing.T) { + jsonData := `{"type":"step_finish","part":{"reason":"stop"}}` + + var raw map[string]any + if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := &opencodeSession{events: make(chan core.Event, 1), ctx: ctx} + s.handleStepFinish(raw) + + select { + case evt := <-s.events: + if evt.Type != core.EventResult { + t.Errorf("event type = %q, want EventResult", evt.Type) + } + if !evt.Done { + t.Errorf("event.Done = false, want true") + } + default: + t.Error("expected EventResult to be sent when reason=stop") + } +} + +// TestHandleStepToolCallsNoEventResult verifies that handleStepFinish does NOT +// send EventResult when reason="tool-calls", allowing the agent to continue +// with subsequent tool execution steps. +func TestHandleStepToolCallsNoEventResult(t *testing.T) { + jsonData := `{"type":"step_finish","part":{"reason":"tool-calls"}}` + + var raw map[string]any + if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := &opencodeSession{events: make(chan core.Event, 1), ctx: ctx} + s.handleStepFinish(raw) + + select { + case evt := <-s.events: + t.Errorf("unexpected event sent when reason=tool-calls: %v", evt) + default: + } +} + +// TestHandleStepDuplicateEventResultPrevented verifies that calling +// handleStepFinish multiple times with reason="stop" only sends one +// EventResult, preventing duplicate completion signals to the engine. +func TestHandleStepDuplicateEventResultPrevented(t *testing.T) { + jsonData := `{"type":"step_finish","part":{"reason":"stop"}}` + + var raw map[string]any + if err := json.Unmarshal([]byte(jsonData), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + s := &opencodeSession{ + events: make(chan core.Event, 2), + ctx: ctx, + resultSent: atomic.Bool{}, + } + + s.handleStepFinish(raw) + s.handleStepFinish(raw) + + count := 0 + for len(s.events) > 0 { + evt := <-s.events + if evt.Type == core.EventResult { + count++ + } + } + + if count != 1 { + t.Errorf("EventResult count = %d, want 1 (duplicate should be prevented)", count) + } +} + +// verify Agent implements core.Agent +var _ core.Agent = (*Agent)(nil) diff --git a/agent/pi/pi.go b/agent/pi/pi.go new file mode 100644 index 0000000..b54163f --- /dev/null +++ b/agent/pi/pi.go @@ -0,0 +1,411 @@ +package pi + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + + "github.com/chenhg5/cc-connect/core" +) + +func init() { + core.RegisterAgent("pi", New) +} + +// Agent drives the pi coding agent CLI (`pi --mode json --no-input`). +type Agent struct { + cmd string // path to pi binary + workDir string + model string + mode string // "default" | "yolo" + thinking string // reasoning effort: off, minimal, low, medium, high, xhigh + sessionEnv []string + mu sync.Mutex +} + +func New(opts map[string]any) (core.Agent, error) { + workDir, _ := opts["work_dir"].(string) + if workDir == "" { + workDir = "." + } + model, _ := opts["model"].(string) + mode, _ := opts["mode"].(string) + mode = normalizeMode(mode) + + cmd, _ := opts["cmd"].(string) + if cmd == "" { + cmd = "pi" + } + + if _, err := exec.LookPath(cmd); err != nil { + return nil, fmt.Errorf("pi: '%s' not found in PATH, install with: npm install -g @mariozechner/pi-coding-agent", cmd) + } + + return &Agent{ + cmd: cmd, + workDir: workDir, + model: model, + mode: mode, + }, nil +} + +func normalizeMode(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "yolo", "bypass", "auto-approve": + return "yolo" + default: + return "default" + } +} + +func (a *Agent) Name() string { return "pi" } +func (a *Agent) CLIBinaryName() string { return "pi" } +func (a *Agent) CLIDisplayName() string { return "Pi" } + +func (a *Agent) SetModel(model string) { + a.mu.Lock() + defer a.mu.Unlock() + a.model = model + slog.Info("pi: model changed", "model", model) +} + +func (a *Agent) GetModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.model +} + +func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption { + return nil // Pi uses its own model registry; no static list here. +} + +func (a *Agent) SetSessionEnv(env []string) { + a.mu.Lock() + defer a.mu.Unlock() + a.sessionEnv = env +} + +func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + mode := a.mode + model := a.model + thinking := a.thinking + extraEnv := append([]string{}, a.sessionEnv...) + a.mu.Unlock() + return newPiSession(ctx, a.cmd, a.workDir, model, mode, thinking, sessionID, extraEnv) +} + +func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + sessDir := piSessionDir(a.workDir) + if sessDir == "" { + return nil, nil + } + + entries, err := os.ReadDir(sessDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("pi: read session dir: %w", err) + } + + var sessions []core.AgentSessionInfo + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") { + continue + } + + info, err := entry.Info() + if err != nil { + continue + } + + sessionID, summary, msgCount := scanPiSession(filepath.Join(sessDir, name)) + if sessionID == "" { + continue + } + + sessions = append(sessions, core.AgentSessionInfo{ + ID: sessionID, + Summary: summary, + MessageCount: msgCount, + ModifiedAt: info.ModTime(), + }) + } + + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].ModifiedAt.After(sessions[j].ModifiedAt) + }) + + return sessions, nil +} + +func (a *Agent) DeleteSession(_ context.Context, sessionID string) error { + sessDir := piSessionDir(a.workDir) + if sessDir == "" { + return fmt.Errorf("pi: cannot determine session directory") + } + + path := findSessionFile(sessDir, sessionID) + if path == "" { + return fmt.Errorf("pi: session %q not found", sessionID) + } + return os.Remove(path) +} + +func (a *Agent) Stop() error { return nil } + +// ── ModeSwitcher ───────────────────────────────────────────── + +func (a *Agent) SetMode(mode string) { + a.mu.Lock() + defer a.mu.Unlock() + a.mode = normalizeMode(mode) + slog.Info("pi: mode changed", "mode", a.mode) +} + +func (a *Agent) GetMode() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.mode +} + +func (a *Agent) PermissionModes() []core.PermissionModeInfo { + return []core.PermissionModeInfo{ + {Key: "default", Name: "Default", NameZh: "默认", Desc: "Standard permissions", DescZh: "标准权限模式"}, + {Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"}, + } +} + +// ── MemoryFileProvider ─────────────────────────────────────── + +func (a *Agent) ProjectMemoryFile() string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + return filepath.Join(absDir, "AGENTS.md") +} + +func (a *Agent) GlobalMemoryFile() string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(homeDir, ".pi", "AGENTS.md") +} + +// ── ReasoningEffortSwitcher ────────────────────────────────── + +func (a *Agent) SetReasoningEffort(effort string) { + a.mu.Lock() + defer a.mu.Unlock() + a.thinking = effort + slog.Info("pi: thinking level changed", "level", effort) +} + +func (a *Agent) GetReasoningEffort() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.thinking +} + +func (a *Agent) AvailableReasoningEfforts() []string { + return []string{"off", "minimal", "low", "medium", "high", "xhigh"} +} + +// ── GetWorkDir (for /status display) ───────────────────────── + +func (a *Agent) GetWorkDir() string { return a.workDir } + +// ── HistoryProvider ────────────────────────────────────────── + +func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) { + sessDir := piSessionDir(a.workDir) + if sessDir == "" { + return nil, nil + } + + sessFile := findSessionFile(sessDir, sessionID) + if sessFile == "" { + return nil, nil + } + + return readPiHistory(sessFile, limit) +} + +// ── SkillProvider ──────────────────────────────────────────── + +func (a *Agent) SkillDirs() []string { + absDir, err := filepath.Abs(a.workDir) + if err != nil { + absDir = a.workDir + } + dirs := []string{filepath.Join(absDir, ".pi", "skills")} + if home, err := os.UserHomeDir(); err == nil { + dirs = append(dirs, filepath.Join(home, ".pi", "skills")) + } + return dirs +} + +// ── Session helpers ────────────────────────────────────────── + +// findSessionFile locates the .jsonl file for a given session UUID in sessDir. +// Session files are named: _.jsonl — this function extracts +// the UUID portion and matches exactly to avoid partial-match vulnerabilities. +func findSessionFile(sessDir, sessionID string) string { + entries, err := os.ReadDir(sessDir) + if err != nil { + return "" + } + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") { + continue + } + // Extract UUID: strip .jsonl, then take everything after the last "_". + base := strings.TrimSuffix(name, ".jsonl") + if idx := strings.LastIndex(base, "_"); idx >= 0 { + if base[idx+1:] == sessionID { + return filepath.Join(sessDir, name) + } + } + } + return "" +} + +// piSessionDir returns the pi session directory for the given workDir. +// Pi encodes the absolute path as: replace "/" with "-", wrap with "--". +// e.g. /home/user/project → --home-user-project-- +func piSessionDir(workDir string) string { + homeDir, err := os.UserHomeDir() + if err != nil { + return "" + } + absDir, err := filepath.Abs(workDir) + if err != nil { + return "" + } + encoded := "--" + strings.ReplaceAll(strings.TrimPrefix(absDir, "/"), "/", "-") + "--" + return filepath.Join(homeDir, ".pi", "agent", "sessions", encoded) +} + +// scanPiSession reads a pi session .jsonl file and extracts the session ID, +// a summary (first user message), and a message count. +func scanPiSession(path string) (sessionID, summary string, msgCount int) { + f, err := os.Open(path) + if err != nil { + return "", "", 0 + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1*1024*1024) + + for scanner.Scan() { + var entry map[string]any + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + switch entry["type"] { + case "session": + if id, ok := entry["id"].(string); ok { + sessionID = id + } + case "message": + msg, _ := entry["message"].(map[string]any) + if msg == nil { + continue + } + role, _ := msg["role"].(string) + if role == "user" || role == "assistant" { + msgCount++ + } + // Use first user message as summary. + if role == "user" && summary == "" { + content, _ := msg["content"].([]any) + for _, c := range content { + item, _ := c.(map[string]any) + if item != nil { + if text, ok := item["text"].(string); ok && text != "" { + summary = text + runes := []rune(summary) + if len(runes) > 80 { + summary = string(runes[:80]) + "..." + } + break + } + } + } + } + } + } + if err := scanner.Err(); err != nil { + slog.Warn("pi: scan session error", "path", path, "error", err) + } + return +} + +// readPiHistory reads user/assistant messages from a pi session file. +func readPiHistory(path string, limit int) ([]core.HistoryEntry, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), 1*1024*1024) + + var all []core.HistoryEntry + for scanner.Scan() { + var entry map[string]any + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + continue + } + if entry["type"] != "message" { + continue + } + msg, _ := entry["message"].(map[string]any) + if msg == nil { + continue + } + role, _ := msg["role"].(string) + if role != "user" && role != "assistant" { + continue + } + + var text string + content, _ := msg["content"].([]any) + for _, c := range content { + item, _ := c.(map[string]any) + if item != nil { + if t, ok := item["text"].(string); ok && t != "" { + text = t + break + } + } + } + if text == "" { + continue + } + all = append(all, core.HistoryEntry{Role: role, Content: text}) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("pi: read history: %w", err) + } + + if limit > 0 && len(all) > limit { + all = all[len(all)-limit:] + } + return all, nil +} diff --git a/agent/pi/pi_test.go b/agent/pi/pi_test.go new file mode 100644 index 0000000..ba61fe4 --- /dev/null +++ b/agent/pi/pi_test.go @@ -0,0 +1,1267 @@ +package pi + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// ── normalizeMode ──────────────────────────────────────────── + +func TestNormalizeMode(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"", "default"}, + {"default", "default"}, + {"yolo", "yolo"}, + {"YOLO", "yolo"}, + {"bypass", "yolo"}, + {"auto-approve", "yolo"}, + {" Yolo ", "yolo"}, + {" Bypass ", "yolo"}, + {"unknown", "default"}, + {"something", "default"}, + } + for _, tt := range tests { + if got := normalizeMode(tt.in); got != tt.want { + t.Errorf("normalizeMode(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +// ── Agent constructor ──────────────────────────────────────── + +func TestNew_DefaultValues(t *testing.T) { + // Use a command that exists on all systems. + ag, err := New(map[string]any{"cmd": "echo"}) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := ag.(*Agent) + if a.workDir != "." { + t.Errorf("workDir = %q, want \".\"", a.workDir) + } + if a.model != "" { + t.Errorf("model = %q, want empty", a.model) + } + if a.mode != "default" { + t.Errorf("mode = %q, want \"default\"", a.mode) + } + if a.cmd != "echo" { + t.Errorf("cmd = %q, want \"echo\"", a.cmd) + } +} + +func TestNew_CustomOptions(t *testing.T) { + ag, err := New(map[string]any{ + "cmd": "echo", + "work_dir": "/tmp", + "model": "qwen3.5-plus", + "mode": "yolo", + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + a := ag.(*Agent) + if a.workDir != "/tmp" { + t.Errorf("workDir = %q", a.workDir) + } + if a.model != "qwen3.5-plus" { + t.Errorf("model = %q", a.model) + } + if a.mode != "yolo" { + t.Errorf("mode = %q", a.mode) + } +} + +func TestNew_CmdNotFound(t *testing.T) { + _, err := New(map[string]any{"cmd": "nonexistent-binary-xyz-12345"}) + if err == nil { + t.Fatal("expected error for missing binary") + } + if !strings.Contains(err.Error(), "not found in PATH") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestNew_DefaultCmd(t *testing.T) { + // When cmd is not specified, it defaults to "pi". + // This will fail if pi is not installed, which is expected in CI. + _, err := New(map[string]any{"cmd": ""}) + if err == nil { + // pi is installed — verify the cmd was set + return + } + if !strings.Contains(err.Error(), "'pi' not found") { + t.Errorf("unexpected error: %v", err) + } +} + +// ── Agent interface methods ────────────────────────────────── + +func TestAgent_NameAndDisplay(t *testing.T) { + a := &Agent{} + if a.Name() != "pi" { + t.Errorf("Name() = %q", a.Name()) + } + if a.CLIBinaryName() != "pi" { + t.Errorf("CLIBinaryName() = %q", a.CLIBinaryName()) + } + if a.CLIDisplayName() != "Pi" { + t.Errorf("CLIDisplayName() = %q", a.CLIDisplayName()) + } +} + +func TestAgent_ModelGetSet(t *testing.T) { + a := &Agent{} + if a.GetModel() != "" { + t.Errorf("initial model = %q", a.GetModel()) + } + a.SetModel("gpt-4o") + if a.GetModel() != "gpt-4o" { + t.Errorf("after SetModel = %q", a.GetModel()) + } +} + +func TestAgent_ModeGetSet(t *testing.T) { + a := &Agent{mode: "default"} + if a.GetMode() != "default" { + t.Errorf("initial mode = %q", a.GetMode()) + } + a.SetMode("yolo") + if a.GetMode() != "yolo" { + t.Errorf("after SetMode(yolo) = %q", a.GetMode()) + } + a.SetMode("bypass") + if a.GetMode() != "yolo" { + t.Errorf("after SetMode(bypass) = %q", a.GetMode()) + } + a.SetMode("unknown") + if a.GetMode() != "default" { + t.Errorf("after SetMode(unknown) = %q", a.GetMode()) + } +} + +func TestAgent_AvailableModels(t *testing.T) { + a := &Agent{} + if models := a.AvailableModels(context.Background()); models != nil { + t.Errorf("AvailableModels() = %v, want nil", models) + } +} + +func TestAgent_SetSessionEnv(t *testing.T) { + a := &Agent{} + a.SetSessionEnv([]string{"FOO=bar", "BAZ=qux"}) + a.mu.Lock() + defer a.mu.Unlock() + if len(a.sessionEnv) != 2 { + t.Errorf("sessionEnv len = %d, want 2", len(a.sessionEnv)) + } +} + +func TestAgent_ListSessions(t *testing.T) { + a := &Agent{} + sessions, err := a.ListSessions(context.Background()) + if err != nil { + t.Errorf("ListSessions() error = %v", err) + } + if sessions != nil { + t.Errorf("ListSessions() = %v, want nil", sessions) + } +} + +func TestAgent_Stop(t *testing.T) { + a := &Agent{} + if err := a.Stop(); err != nil { + t.Errorf("Stop() error = %v", err) + } +} + +func TestAgent_PermissionModes(t *testing.T) { + a := &Agent{} + modes := a.PermissionModes() + if len(modes) != 2 { + t.Fatalf("PermissionModes() len = %d, want 2", len(modes)) + } + if modes[0].Key != "default" { + t.Errorf("modes[0].Key = %q", modes[0].Key) + } + if modes[1].Key != "yolo" { + t.Errorf("modes[1].Key = %q", modes[1].Key) + } +} + +func TestAgent_MemoryFiles(t *testing.T) { + a := &Agent{workDir: "/tmp/test-project"} + proj := a.ProjectMemoryFile() + if !strings.HasSuffix(proj, "AGENTS.md") { + t.Errorf("ProjectMemoryFile() = %q, want suffix AGENTS.md", proj) + } + if !strings.Contains(proj, "test-project") { + t.Errorf("ProjectMemoryFile() = %q, want to contain work_dir", proj) + } + + global := a.GlobalMemoryFile() + if !strings.HasSuffix(global, filepath.Join(".pi", "AGENTS.md")) { + t.Errorf("GlobalMemoryFile() = %q", global) + } +} + +func TestAgent_StartSession(t *testing.T) { + a := &Agent{cmd: "echo", workDir: "/tmp", model: "test-model", mode: "yolo"} + a.SetSessionEnv([]string{"TEST_VAR=1"}) + + sess, err := a.StartSession(context.Background(), "resume-123") + if err != nil { + t.Fatalf("StartSession() error = %v", err) + } + defer sess.Close() + + ps := sess.(*piSession) + if ps.cmd != "echo" { + t.Errorf("cmd = %q", ps.cmd) + } + if ps.model != "test-model" { + t.Errorf("model = %q", ps.model) + } + if ps.mode != "yolo" { + t.Errorf("mode = %q", ps.mode) + } + if ps.CurrentSessionID() != "resume-123" { + t.Errorf("sessionID = %q, want resume-123", ps.CurrentSessionID()) + } + if !ps.Alive() { + t.Error("session should be alive") + } +} + +// ── extractToolInput ───────────────────────────────────────── + +func TestExtractToolInput(t *testing.T) { + tests := []struct { + name string + item map[string]any + want string + }{ + {"nil arguments", map[string]any{}, ""}, + {"description", map[string]any{"arguments": map[string]any{"description": "List files"}}, "List files"}, + {"command", map[string]any{"arguments": map[string]any{"command": "ls -la"}}, "ls -la"}, + {"file_path", map[string]any{"arguments": map[string]any{"file_path": "/tmp/foo.go"}}, "/tmp/foo.go"}, + {"pattern", map[string]any{"arguments": map[string]any{"pattern": "*.go"}}, "*.go"}, + {"query", map[string]any{"arguments": map[string]any{"query": "find errors"}}, "find errors"}, + {"description takes priority", map[string]any{"arguments": map[string]any{ + "description": "desc", "command": "cmd", + }}, "desc"}, + {"fallback to json", map[string]any{"arguments": map[string]any{"foo": "bar"}}, `{"foo":"bar"}`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractToolInput(tt.item) + if got != tt.want { + t.Errorf("extractToolInput() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestExtractToolInput_LongFallbackTruncated(t *testing.T) { + longVal := strings.Repeat("x", 300) + item := map[string]any{"arguments": map[string]any{"data": longVal}} + got := extractToolInput(item) + if len(got) > 210 { // 200 + "..." + t.Errorf("expected truncated output, got len=%d", len(got)) + } + if !strings.HasSuffix(got, "...") { + t.Error("expected '...' suffix") + } +} + +// ── truncStr ───────────────────────────────────────────────── + +func TestTruncStr(t *testing.T) { + tests := []struct { + in string + max int + want string + wantSame bool + }{ + {"hello", 10, "hello", true}, + {"hello", 5, "hello", true}, + {"hello", 3, "hel...", false}, + {"", 5, "", true}, + {"日本語テスト", 3, "日本語...", false}, + {"日本語テスト", 10, "日本語テスト", true}, + } + for _, tt := range tests { + got := truncStr(tt.in, tt.max) + if got != tt.want { + t.Errorf("truncStr(%q, %d) = %q, want %q", tt.in, tt.max, got, tt.want) + } + } +} + +// ── saveImagesToDisk ───────────────────────────────────────── + +func TestSaveImagesToDisk(t *testing.T) { + attachDir := filepath.Join(t.TempDir(), ".cc-connect", "attachments", "pi-test") + images := []core.ImageAttachment{ + {MimeType: "image/png", Data: []byte("png-data"), FileName: "test.png"}, + {MimeType: "image/jpeg", Data: []byte("jpg-data")}, + {MimeType: "image/gif", Data: []byte("gif-data")}, + {MimeType: "image/webp", Data: []byte("webp-data")}, + {MimeType: "image/bmp", Data: []byte("bmp-data")}, // unknown mime → .png default + } + + paths := saveImagesToDisk(attachDir, images) + if len(paths) != 5 { + t.Fatalf("got %d paths, want 5", len(paths)) + } + + // First file should use the provided filename. + if filepath.Base(paths[0]) != "test.png" { + t.Errorf("paths[0] base = %q, want test.png", filepath.Base(paths[0])) + } + + // Check extensions of auto-named files. + if !strings.HasSuffix(paths[1], ".jpg") { + t.Errorf("jpeg path = %q, want .jpg suffix", paths[1]) + } + if !strings.HasSuffix(paths[2], ".gif") { + t.Errorf("gif path = %q, want .gif suffix", paths[2]) + } + if !strings.HasSuffix(paths[3], ".webp") { + t.Errorf("webp path = %q, want .webp suffix", paths[3]) + } + if !strings.HasSuffix(paths[4], ".png") { + t.Errorf("unknown mime path = %q, want .png suffix", paths[4]) + } + + // Verify file contents. + data, err := os.ReadFile(paths[0]) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(data) != "png-data" { + t.Errorf("file content = %q", data) + } +} + +func TestSaveImagesToDisk_Empty(t *testing.T) { + paths := saveImagesToDisk(t.TempDir(), nil) + if len(paths) != 0 { + t.Errorf("expected empty, got %d", len(paths)) + } +} + +// TestSaveImagesToDisk_RejectsPathTraversal is a regression test for a path +// traversal vulnerability in saveImagesToDisk: the user-supplied +// ImageAttachment.FileName (sourced from IM upload metadata) was passed +// directly to filepath.Join, so a malicious uploader could escape the +// attachments directory by using `../` segments. This mirrors the same +// issue and fix in core.SaveFilesToDisk. +func TestSaveImagesToDisk_RejectsPathTraversal(t *testing.T) { + workDir := t.TempDir() + attachDir := filepath.Join(workDir, ".cc-connect", "attachments") + + images := []core.ImageAttachment{ + // Two levels up — escapes attachments/ and .cc-connect/. + {MimeType: "image/png", Data: []byte("payload"), FileName: "../../escape.png"}, + // Three levels up — would land outside workDir entirely. + {MimeType: "image/png", Data: []byte("payload"), FileName: "../../../way-up.png"}, + // Windows-style separators must also be stripped on Linux. + {MimeType: "image/png", Data: []byte("payload"), FileName: `..\..\winescape.png`}, + // Plain name still works. + {MimeType: "image/png", Data: []byte("payload"), FileName: "ok.png"}, + // "." sanitizes to empty so the generated-name fallback kicks in, + // not a write to the attachments directory itself. + {MimeType: "image/png", Data: []byte("payload"), FileName: "."}, + } + + paths := saveImagesToDisk(attachDir, images) + + // Every returned path must live inside attachDir. + for _, p := range paths { + if !strings.HasPrefix(p, attachDir+string(filepath.Separator)) { + t.Errorf("saveImagesToDisk wrote outside attachments dir: %q (attachDir=%q)", p, attachDir) + } + } + + // Walk workDir and assert no file exists outside attachDir. + if err := filepath.Walk(workDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() { + return nil + } + if !strings.HasPrefix(path, attachDir+string(filepath.Separator)) { + t.Errorf("found stray attachment outside attachments dir: %q", path) + } + return nil + }); err != nil { + t.Fatalf("walk: %v", err) + } + + // Sanity: legitimate "ok.png" must still have been saved. + if _, err := os.Stat(filepath.Join(attachDir, "ok.png")); err != nil { + t.Errorf("legitimate ok.png not saved: %v", err) + } +} + +func TestSanitizePiAttachmentName(t *testing.T) { + tests := []struct { + in, want string + }{ + {"image.png", "image.png"}, + {"subdir/file.png", "file.png"}, + {"../../escape.png", "escape.png"}, + {`..\..\winescape.png`, "winescape.png"}, + {"/etc/passwd", "passwd"}, + {"..", ""}, + {".", ""}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + got := sanitizePiAttachmentName(tt.in) + if got != tt.want { + t.Errorf("sanitizePiAttachmentName(%q) = %q, want %q", tt.in, got, tt.want) + } + }) + } +} + +// ── cleanAttachments ───────────────────────────────────────── + +func TestCleanAttachments(t *testing.T) { + tmpDir := t.TempDir() + attachDir := filepath.Join(tmpDir, ".cc-connect", "attachments") + os.MkdirAll(attachDir, 0o755) + + // Create some files. + os.WriteFile(filepath.Join(attachDir, "old1.png"), []byte("data"), 0o644) + os.WriteFile(filepath.Join(attachDir, "old2.jpg"), []byte("data"), 0o644) + + // Verify files exist. + entries, _ := os.ReadDir(attachDir) + if len(entries) != 2 { + t.Fatalf("expected 2 files, got %d", len(entries)) + } + + cleanAttachments(attachDir) + + // Directory should be removed. + if _, err := os.Stat(attachDir); !os.IsNotExist(err) { + t.Errorf("expected attachments dir removed after clean, got err=%v", err) + } +} + +func TestCleanAttachments_NonexistentDir(t *testing.T) { + // Should not panic or error on non-existent directory. + cleanAttachments("/nonexistent/path/xyz") +} + +func TestPiSessionAttachmentDirsAreIsolated(t *testing.T) { + workDir := t.TempDir() + s1, err := newPiSession(context.Background(), "pi", workDir, "", "", "", "", nil) + if err != nil { + t.Fatal(err) + } + s2, err := newPiSession(context.Background(), "pi", workDir, "", "", "", "", nil) + if err != nil { + t.Fatal(err) + } + if s1.attachDir == s2.attachDir { + t.Fatalf("expected distinct attachment dirs, got %q", s1.attachDir) + } + + if err := os.MkdirAll(s1.attachDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(s2.attachDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(s1.attachDir, "one.txt"), []byte("one"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(s2.attachDir, "two.txt"), []byte("two"), 0o644); err != nil { + t.Fatal(err) + } + + cleanAttachments(s1.attachDir) + + if _, err := os.Stat(filepath.Join(s2.attachDir, "two.txt")); err != nil { + t.Fatalf("cleaning one session removed another session's attachment: %v", err) + } +} + +// ── handleEvent ────────────────────────────────────────────── + +func newTestSession() *piSession { + ctx, cancel := context.WithCancel(context.Background()) + s := &piSession{ + events: make(chan core.Event, 64), + ctx: ctx, + cancel: cancel, + } + s.alive.Store(true) + return s +} + +func drainEvents(s *piSession) []core.Event { + var evts []core.Event + for { + select { + case e := <-s.events: + evts = append(evts, e) + default: + return evts + } + } +} + +func TestHandleEvent_Session(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{"type": "session", "id": "sess-abc-123"}) + if s.CurrentSessionID() != "sess-abc-123" { + t.Errorf("sessionID = %q", s.CurrentSessionID()) + } +} + +func TestHandleEvent_SessionEmptyID(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{"type": "session", "id": ""}) + if s.CurrentSessionID() != "" { + t.Errorf("sessionID = %q, want empty", s.CurrentSessionID()) + } +} + +func TestHandleEvent_SessionNoID(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{"type": "session"}) + if s.CurrentSessionID() != "" { + t.Errorf("sessionID = %q, want empty", s.CurrentSessionID()) + } +} + +func TestHandleEvent_LifecycleEventsNoOp(t *testing.T) { + s := newTestSession() + defer s.cancel() + + for _, evType := range []string{"agent_start", "agent_end", "turn_start", "turn_end", "message_start"} { + s.handleEvent(map[string]any{"type": evType}) + } + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events, got %d", len(evts)) + } +} + +func TestHandleEvent_UnhandledType(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{"type": "unknown_event"}) + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events, got %d", len(evts)) + } +} + +// ── handleMessageUpdate: text_delta ────────────────────────── + +func TestHandleMessageUpdate_TextDelta(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "text_delta", + "delta": "Hello world", + }, + }) + + evts := drainEvents(s) + if len(evts) != 1 { + t.Fatalf("got %d events, want 1", len(evts)) + } + if evts[0].Type != core.EventText || evts[0].Content != "Hello world" { + t.Errorf("event = %+v", evts[0]) + } +} + +func TestHandleMessageUpdate_TextDeltaEmpty(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "text_delta", + "delta": "", + }, + }) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events for empty delta, got %d", len(evts)) + } +} + +// ── handleMessageUpdate: thinking accumulation ─────────────── + +func TestHandleMessageUpdate_ThinkingAccumulation(t *testing.T) { + s := newTestSession() + defer s.cancel() + + // Multiple thinking deltas should be accumulated. + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{"type": "thinking_delta", "delta": "Let me "}, + }) + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{"type": "thinking_delta", "delta": "think about "}, + }) + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{"type": "thinking_delta", "delta": "this."}, + }) + + // No events should be emitted yet. + evts := drainEvents(s) + if len(evts) != 0 { + t.Fatalf("expected no events before thinking_end, got %d", len(evts)) + } + + // thinking_end triggers the accumulated event. + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{"type": "thinking_end"}, + }) + + evts = drainEvents(s) + if len(evts) != 1 { + t.Fatalf("got %d events, want 1", len(evts)) + } + if evts[0].Type != core.EventThinking { + t.Errorf("type = %s, want EventThinking", evts[0].Type) + } + if evts[0].Content != "Let me think about this." { + t.Errorf("content = %q", evts[0].Content) + } +} + +func TestHandleMessageUpdate_ThinkingEndEmpty(t *testing.T) { + s := newTestSession() + defer s.cancel() + + // thinking_end with no prior deltas should not emit. + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{"type": "thinking_end"}, + }) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events for empty thinking, got %d", len(evts)) + } +} + +func TestHandleMessageUpdate_ThinkingDeltaEmpty(t *testing.T) { + s := newTestSession() + defer s.cancel() + + // Empty deltas should not grow the buffer. + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{"type": "thinking_delta", "delta": ""}, + }) + + if s.thinkingBuf.Len() != 0 { + t.Errorf("thinkingBuf.Len() = %d, want 0", s.thinkingBuf.Len()) + } +} + +// ── handleMessageUpdate: toolcall_end ──────────────────────── + +func TestHandleMessageUpdate_ToolcallEnd(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "toolcall_end", + "contentIndex": float64(1), + "message": map[string]any{ + "content": []any{ + map[string]any{"type": "thinking", "thinking": "..."}, + map[string]any{ + "type": "toolCall", + "name": "bash", + "arguments": map[string]any{"command": "ls -la"}, + }, + }, + }, + }, + }) + + evts := drainEvents(s) + if len(evts) != 1 { + t.Fatalf("got %d events, want 1", len(evts)) + } + if evts[0].Type != core.EventToolUse { + t.Errorf("type = %s", evts[0].Type) + } + if evts[0].ToolName != "bash" { + t.Errorf("toolName = %q", evts[0].ToolName) + } + if evts[0].ToolInput != "ls -la" { + t.Errorf("toolInput = %q", evts[0].ToolInput) + } +} + +func TestHandleMessageUpdate_ToolcallEnd_UsesPartialFallback(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "toolcall_end", + "contentIndex": float64(0), + "partial": map[string]any{ + "content": []any{ + map[string]any{ + "type": "toolCall", + "name": "read", + "arguments": map[string]any{"file_path": "/tmp/foo.txt"}, + }, + }, + }, + }, + }) + + evts := drainEvents(s) + if len(evts) != 1 { + t.Fatalf("got %d events, want 1", len(evts)) + } + if evts[0].ToolName != "read" || evts[0].ToolInput != "/tmp/foo.txt" { + t.Errorf("event = %+v", evts[0]) + } +} + +func TestHandleMessageUpdate_ToolcallEnd_NonToolCallItem(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "toolcall_end", + "contentIndex": float64(0), + "message": map[string]any{ + "content": []any{ + map[string]any{"type": "text", "text": "hello"}, + }, + }, + }, + }) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events for non-toolCall item, got %d", len(evts)) + } +} + +func TestHandleMessageUpdate_ToolcallEnd_OutOfBoundsIndex(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "toolcall_end", + "contentIndex": float64(5), + "message": map[string]any{ + "content": []any{ + map[string]any{"type": "toolCall", "name": "bash"}, + }, + }, + }, + }) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events for out-of-bounds index, got %d", len(evts)) + } +} + +func TestHandleMessageUpdate_ToolcallEnd_NilMessage(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "toolcall_end", + // no "message" or "partial" + }, + }) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events, got %d", len(evts)) + } +} + +func TestHandleMessageUpdate_NilAssistantEvent(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{"type": "message_update"}) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events, got %d", len(evts)) + } +} + +func TestHandleMessageUpdate_UnknownSubType(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "some_unknown_delta", + }, + }) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events for unknown sub-type, got %d", len(evts)) + } +} + +// ── handleMessageEnd ───────────────────────────────────────── + +func TestHandleMessageEnd_ToolResult(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "toolResult", + "toolName": "bash", + "content": []any{ + map[string]any{"type": "text", "text": "file1.go\nfile2.go"}, + }, + }, + }) + + evts := drainEvents(s) + if len(evts) != 1 { + t.Fatalf("got %d events, want 1", len(evts)) + } + if evts[0].Type != core.EventToolResult { + t.Errorf("type = %s", evts[0].Type) + } + if evts[0].ToolName != "bash" { + t.Errorf("toolName = %q", evts[0].ToolName) + } + if evts[0].Content != "file1.go\nfile2.go" { + t.Errorf("content = %q", evts[0].Content) + } +} + +func TestHandleMessageEnd_ToolResultLongOutput(t *testing.T) { + s := newTestSession() + defer s.cancel() + + longOutput := strings.Repeat("x", 600) + s.handleEvent(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "toolResult", + "toolName": "bash", + "content": []any{ + map[string]any{"type": "text", "text": longOutput}, + }, + }, + }) + + evts := drainEvents(s) + if len(evts) != 1 { + t.Fatalf("got %d events, want 1", len(evts)) + } + if len(evts[0].Content) > 510 { + t.Errorf("content should be truncated, got len=%d", len(evts[0].Content)) + } +} + +func TestHandleMessageEnd_ToolResultEmptyContent(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "toolResult", + "toolName": "bash", + "content": []any{}, + }, + }) + + evts := drainEvents(s) + if len(evts) != 1 { + t.Fatalf("got %d events, want 1", len(evts)) + } + if evts[0].Content != "" { + t.Errorf("content = %q, want empty", evts[0].Content) + } +} + +func TestHandleMessageEnd_AssistantError(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "assistant", + "errorMessage": "400 model not supported", + }, + }) + + evts := drainEvents(s) + if len(evts) != 1 { + t.Fatalf("got %d events, want 1", len(evts)) + } + if evts[0].Type != core.EventError { + t.Errorf("type = %s", evts[0].Type) + } + if evts[0].Error == nil || !strings.Contains(evts[0].Error.Error(), "400") { + t.Errorf("error = %v", evts[0].Error) + } +} + +func TestHandleMessageEnd_AssistantNoError(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "assistant", + }, + }) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events for assistant without error, got %d", len(evts)) + } +} + +func TestHandleMessageEnd_NilMessage(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{"type": "message_end"}) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events, got %d", len(evts)) + } +} + +func TestHandleMessageEnd_UserRole(t *testing.T) { + s := newTestSession() + defer s.cancel() + + s.handleEvent(map[string]any{ + "type": "message_end", + "message": map[string]any{ + "role": "user", + }, + }) + + evts := drainEvents(s) + if len(evts) != 0 { + t.Errorf("expected no events for user role, got %d", len(evts)) + } +} + +// ── piSession lifecycle ────────────────────────────────────── + +func TestPiSession_NewWithResumeID(t *testing.T) { + s, err := newPiSession(context.Background(), "echo", "/tmp", "model", "default", "", "resume-id", nil) + if err != nil { + t.Fatalf("newPiSession: %v", err) + } + defer s.Close() + + if s.CurrentSessionID() != "resume-id" { + t.Errorf("sessionID = %q", s.CurrentSessionID()) + } +} + +func TestPiSession_ContinueSessionTreatedAsFresh(t *testing.T) { + // ContinueSession ("__continue__") is a sentinel used by the engine to tell + // Claude Code to pick up the latest CLI session via --continue. Agents that + // don't support --continue must treat it as "" (fresh session), otherwise + // they pass the literal "__continue__" as a session ID which always fails. + s, err := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", core.ContinueSession, nil) + if err != nil { + t.Fatalf("newPiSession: %v", err) + } + defer s.Close() + + if got := s.CurrentSessionID(); got != "" { + t.Errorf("ContinueSession should be treated as fresh: sessionID = %q, want empty", got) + } +} + +func TestPiSession_NewWithoutResumeID(t *testing.T) { + s, err := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil) + if err != nil { + t.Fatalf("newPiSession: %v", err) + } + defer s.Close() + + if s.CurrentSessionID() != "" { + t.Errorf("sessionID = %q, want empty", s.CurrentSessionID()) + } +} + +func TestPiSession_SendWhenClosed(t *testing.T) { + s, _ := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil) + s.Close() + + err := s.Send("hello", nil, nil) + if err == nil || !strings.Contains(err.Error(), "closed") { + t.Errorf("expected 'closed' error, got %v", err) + } +} + +func TestPiSession_RespondPermission(t *testing.T) { + s := newTestSession() + defer s.cancel() + + if err := s.RespondPermission("id", core.PermissionResult{}); err != nil { + t.Errorf("RespondPermission() error = %v", err) + } +} + +func TestPiSession_Events(t *testing.T) { + s := newTestSession() + defer s.cancel() + + ch := s.Events() + if ch == nil { + t.Fatal("Events() returned nil") + } +} + +func TestPiSession_Close(t *testing.T) { + s, _ := newPiSession(context.Background(), "echo", "/tmp", "", "default", "", "", nil) + + if err := s.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + if s.Alive() { + t.Error("session should not be alive after Close()") + } +} + +// ── Full event stream simulation ───────────────────────────── + +func TestHandleEvent_FullConversation(t *testing.T) { + s := newTestSession() + defer s.cancel() + + // Simulate a full pi conversation: session → thinking → text → tool → tool result → text → done + events := []map[string]any{ + {"type": "session", "id": "conv-123"}, + {"type": "agent_start"}, + {"type": "turn_start"}, + {"type": "message_start"}, + {"type": "message_update", "assistantMessageEvent": map[string]any{ + "type": "thinking_delta", "delta": "I need to ", + }}, + {"type": "message_update", "assistantMessageEvent": map[string]any{ + "type": "thinking_delta", "delta": "list files.", + }}, + {"type": "message_update", "assistantMessageEvent": map[string]any{ + "type": "thinking_end", + }}, + {"type": "message_update", "assistantMessageEvent": map[string]any{ + "type": "toolcall_end", + "contentIndex": float64(1), + "message": map[string]any{ + "content": []any{ + map[string]any{"type": "thinking"}, + map[string]any{"type": "toolCall", "name": "bash", "arguments": map[string]any{"command": "ls"}}, + }, + }, + }}, + {"type": "message_end", "message": map[string]any{ + "role": "assistant", "stopReason": "toolUse", + }}, + {"type": "message_end", "message": map[string]any{ + "role": "toolResult", "toolName": "bash", + "content": []any{map[string]any{"type": "text", "text": "file1.go"}}, + }}, + {"type": "turn_end"}, + {"type": "turn_start"}, + {"type": "message_update", "assistantMessageEvent": map[string]any{ + "type": "text_delta", "delta": "Here are your files.", + }}, + {"type": "message_end", "message": map[string]any{ + "role": "assistant", "stopReason": "stop", + }}, + {"type": "turn_end"}, + {"type": "agent_end"}, + } + + for _, ev := range events { + s.handleEvent(ev) + } + + evts := drainEvents(s) + + // Expected: thinking, tool_use, tool_result, text + if len(evts) != 4 { + var types []string + for _, e := range evts { + types = append(types, string(e.Type)) + } + t.Fatalf("got %d events %v, want 4", len(evts), types) + } + + if evts[0].Type != core.EventThinking || evts[0].Content != "I need to list files." { + t.Errorf("evts[0] = %+v", evts[0]) + } + if evts[1].Type != core.EventToolUse || evts[1].ToolName != "bash" { + t.Errorf("evts[1] = %+v", evts[1]) + } + if evts[2].Type != core.EventToolResult || evts[2].Content != "file1.go" { + t.Errorf("evts[2] = %+v", evts[2]) + } + if evts[3].Type != core.EventText || evts[3].Content != "Here are your files." { + t.Errorf("evts[3] = %+v", evts[3]) + } + + if s.CurrentSessionID() != "conv-123" { + t.Errorf("sessionID = %q", s.CurrentSessionID()) + } +} + +// ── readLoop with real process ─────────────────────────────── + +func TestPiSession_ReadLoopWithEcho(t *testing.T) { + // Use sh -c to simulate pi JSON output on stdout. + sessionEvent := map[string]any{"type": "session", "id": "echo-sess"} + textEvent := map[string]any{ + "type": "message_update", + "assistantMessageEvent": map[string]any{ + "type": "text_delta", + "delta": "hi", + }, + } + line1, _ := json.Marshal(sessionEvent) + line2, _ := json.Marshal(textEvent) + + s, err := newPiSession(context.Background(), "sh", "/tmp", "", "default", "", "", nil) + if err != nil { + t.Fatalf("newPiSession: %v", err) + } + + // sh -c 'echo ...; echo ...' will output our JSON lines. + // We need to override how Send builds args. Instead, call readLoop directly. + // Actually, just use Send with sh -c and craft the prompt as the script. + script := "echo '" + string(line1) + "'; echo '" + string(line2) + "'" + + // Manually build the command since Send adds extra flags for pi. + s.cmd = "sh" + s.model = "" // prevent --model flag + + // Directly test readLoop via Send by crafting args that sh understands. + // Send will run: sh --mode json -p & \"quotes\"", + } +} + +// TestImageAttachment creates a test image attachment. +func TestImageAttachment(mimeType, filename string, data []byte) core.ImageAttachment { + return core.ImageAttachment{ + MimeType: mimeType, + Data: data, + FileName: filename, + } +} + +// TestFileAttachment creates a test file attachment. +func TestFileAttachment(mimeType, filename string, data []byte) core.FileAttachment { + return core.FileAttachment{ + MimeType: mimeType, + Data: data, + FileName: filename, + } +} + +// TestAudioAttachment creates a test audio attachment. +func TestAudioAttachment(mimeType, format string, data []byte, duration int) *core.AudioAttachment { + return &core.AudioAttachment{ + MimeType: mimeType, + Data: data, + Format: format, + Duration: duration, + } +} + +// TestEvent creates a test event. +func TestEvent(eventType core.EventType, content string) core.Event { + return core.Event{ + Type: eventType, + Content: content, + Done: eventType == core.EventResult || eventType == core.EventError, + } +} + +// TestTextEvent creates a text event. +func TestTextEvent(content string) core.Event { + return TestEvent(core.EventText, content) +} + +// TestResultEvent creates a result event. +func TestResultEvent(content string) core.Event { + return TestEvent(core.EventResult, content) +} + +// TestErrorEvent creates an error event. +func TestErrorEvent(err error) core.Event { + return core.Event{ + Type: core.EventError, + Done: true, + Error: err, + } +} + +// TestPermissionRequestEvent creates a permission request event. +func TestPermissionRequestEvent(requestID, toolName, toolInput string) core.Event { + return core.Event{ + Type: core.EventPermissionRequest, + ToolName: toolName, + ToolInput: toolInput, + RequestID: requestID, + } +} + +// TestToolUseEvent creates a tool use event. +func TestToolUseEvent(toolName, toolInput string) core.Event { + return core.Event{ + Type: core.EventToolUse, + ToolName: toolName, + ToolInput: toolInput, + } +} + +// TestThinkingEvent creates a thinking event. +func TestThinkingEvent(content string) core.Event { + return TestEvent(core.EventThinking, content) +} + +// TestHistoryEntry creates a test history entry. +func TestHistoryEntry(role, content string) core.HistoryEntry { + return core.HistoryEntry{ + Role: role, + Content: content, + Timestamp: time.Now(), + } +} + +// TestAgentSessionInfo creates a test agent session info. +func TestAgentSessionInfo(id, summary string, messageCount int) core.AgentSessionInfo { + return core.AgentSessionInfo{ + ID: id, + Summary: summary, + MessageCount: messageCount, + ModifiedAt: time.Now(), + } +} + +// TestPermissionResult creates a test permission result. +func TestPermissionResultAllow() core.PermissionResult { + return core.PermissionResult{ + Behavior: "allow", + } +} + +func TestPermissionResultDeny(message string) core.PermissionResult { + return core.PermissionResult{ + Behavior: "deny", + Message: message, + } +} + +// TestProviderConfig creates a test provider config. +func TestProviderConfig(name, apiKey, baseURL, model string) core.ProviderConfig { + return core.ProviderConfig{ + Name: name, + APIKey: apiKey, + BaseURL: baseURL, + Model: model, + } +} + +// TestModelOption creates a test model option. +func TestModelOption(name, desc, alias string) core.ModelOption { + return core.ModelOption{ + Name: name, + Desc: desc, + Alias: alias, + } +} + +// BatchTestMessages creates multiple test messages for batch testing. +func BatchTestMessages(count int) []*core.Message { + messages := make([]*core.Message, count) + for i := 0; i < count; i++ { + messages[i] = TestMessageWithContent(fmt.Sprintf("Test message %d", i)) + } + return messages +} diff --git a/tests/mocks/fake/response.go b/tests/mocks/fake/response.go new file mode 100644 index 0000000..ab248d2 --- /dev/null +++ b/tests/mocks/fake/response.go @@ -0,0 +1,151 @@ +package fake + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// TestUsageReport creates a test usage report. +func TestUsageReport(provider, accountID, email string) *core.UsageReport { + return &core.UsageReport{ + Provider: provider, + AccountID: accountID, + UserID: "test-user", + Email: email, + Plan: "pro", + Buckets: []core.UsageBucket{ + { + Name: "Standard Requests", + Allowed: true, + LimitReached: false, + Windows: []core.UsageWindow{ + {UsedPercent: 45, WindowSeconds: 3600}, + }, + }, + }, + Credits: &core.UsageCredits{ + HasCredits: true, + Unlimited: false, + Balance: "$12.50", + }, + } +} + +// TestPermissionModeInfo creates a test permission mode info. +func TestPermissionModeInfo(key, name, nameZh, desc, descZh string) core.PermissionModeInfo { + return core.PermissionModeInfo{ + Key: key, + Name: name, + NameZh: nameZh, + Desc: desc, + DescZh: descZh, + } +} + +// TestCard creates a test card using the CardBuilder. +func TestCard() *core.Card { + return core.NewCard(). + Title("Test Card", "blue"). + Markdown("**Test content**"). + Build() +} + +// TestCardWithTitle creates a card with a specific title. +func TestCardWithTitle(title string) *core.Card { + return core.NewCard(). + Title(title, "blue"). + Markdown("**Content**"). + Build() +} + +// TestCardWithButtons creates a card with buttons. +func TestCardWithButtons(buttons ...core.CardButton) *core.Card { + return core.NewCard(). + Title("Test Card", "blue"). + Markdown("Select an option:"). + Buttons(buttons...). + Build() +} + +// TestMessageHandler is a simple message handler for testing. +type TestMessageHandler struct { + mu sync.Mutex + Messages []*core.Message +} + +func NewTestMessageHandler() *TestMessageHandler { + return &TestMessageHandler{ + Messages: make([]*core.Message, 0), + } +} + +func (h *TestMessageHandler) Handle(p core.Platform, msg *core.Message) { + h.mu.Lock() + defer h.mu.Unlock() + h.Messages = append(h.Messages, msg) +} + +func (h *TestMessageHandler) GetMessages() []*core.Message { + h.mu.Lock() + defer h.mu.Unlock() + cp := make([]*core.Message, len(h.Messages)) + copy(cp, h.Messages) + return cp +} + +func (h *TestMessageHandler) Clear() { + h.mu.Lock() + defer h.mu.Unlock() + h.Messages = h.Messages[:0] +} + +// TestDedupeItem creates a test deduplication item. +type TestDedupeItem struct { + key string + expiration time.Time +} + +func NewTestDedupeItem(key string, ttl time.Duration) *TestDedupeItem { + return &TestDedupeItem{ + key: key, + expiration: time.Now().Add(ttl), + } +} + +// TestRateLimiterToken creates a test rate limiter token bucket state. +type TestRateLimiterToken struct{} + +// TestCronJob creates a test cron job. +func TestCronJob(id, desc, prompt string, cronExpr string) *core.CronJob { + enabled := true + return &core.CronJob{ + ID: id, + Description: desc, + Prompt: prompt, + CronExpr: cronExpr, + Enabled: enabled, + } +} + +// TestAgentSessionInfoList creates a list of test agent session info. +func TestAgentSessionInfoList(count int) []core.AgentSessionInfo { + sessions := make([]core.AgentSessionInfo, count) + for i := 0; i < count; i++ { + sessions[i] = core.AgentSessionInfo{ + ID: fmt.Sprintf("session-%d", i), + Summary: fmt.Sprintf("Test session %d", i), + MessageCount: (i + 1) * 10, + ModifiedAt: time.Now().Add(-time.Duration(i) * time.Hour), + } + } + return sessions +} + +// TestContext returns a context with timeout for testing. +func TestContext(timeout time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), timeout) +} diff --git a/tests/mocks/fake/session.go b/tests/mocks/fake/session.go new file mode 100644 index 0000000..c3c4b70 --- /dev/null +++ b/tests/mocks/fake/session.go @@ -0,0 +1,226 @@ +package fake + +import ( + "context" + "io" + "sync" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +// FakeAgentSession is a fake implementation of AgentSession for testing. +// It simulates agent behavior without calling real CLI tools. +type FakeAgentSession struct { + mu sync.RWMutex + sessionID string + promptQueue []string + events []core.Event + closed bool + alive bool + responseDelay time.Duration + responses []string + responseIdx int +} + +func NewFakeAgentSession(sessionID string) *FakeAgentSession { + return &FakeAgentSession{ + sessionID: sessionID, + alive: true, + events: make([]core.Event, 0), + promptQueue: make([]string, 0), + } +} + +// SetResponseDelay sets a delay before sending responses (for timeout testing). +func (s *FakeAgentSession) SetResponseDelay(delay time.Duration) *FakeAgentSession { + s.responseDelay = delay + return s +} + +// SetResponses sets predefined responses to return. +func (s *FakeAgentSession) SetResponses(responses ...string) *FakeAgentSession { + s.responses = responses + return s +} + +// AddTextEvent adds a text event to the event stream. +func (s *FakeAgentSession) AddTextEvent(content string) *FakeAgentSession { + s.events = append(s.events, TestTextEvent(content)) + return s +} + +// AddResultEvent adds a result event to the event stream. +func (s *FakeAgentSession) AddResultEvent(content string) *FakeAgentSession { + s.events = append(s.events, TestResultEvent(content)) + return s +} + +// AddErrorEvent adds an error event to the event stream. +func (s *FakeAgentSession) AddErrorEvent(err error) *FakeAgentSession { + s.events = append(s.events, TestErrorEvent(err)) + return s +} + +// AddThinkingEvent adds a thinking event to the event stream. +func (s *FakeAgentSession) AddThinkingEvent(content string) *FakeAgentSession { + s.events = append(s.events, TestThinkingEvent(content)) + return s +} + +// AddPermissionRequest adds a permission request event. +func (s *FakeAgentSession) AddPermissionRequest(requestID, toolName, toolInput string) *FakeAgentSession { + s.events = append(s.events, TestPermissionRequestEvent(requestID, toolName, toolInput)) + return s +} + +func (s *FakeAgentSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return io.ErrClosedPipe + } + + s.promptQueue = append(s.promptQueue, prompt) + + if len(s.events) == 0 { + if len(s.responses) > 0 && s.responseIdx < len(s.responses) { + resp := s.responses[s.responseIdx] + s.responseIdx++ + s.events = append(s.events, TestTextEvent(resp), TestResultEvent(resp)) + } else { + s.events = append(s.events, TestTextEvent("Processing: "+prompt), TestResultEvent("Done")) + } + } + delay := s.responseDelay + s.mu.Unlock() + + if delay > 0 { + time.Sleep(delay) + } + + return nil +} + +func (s *FakeAgentSession) RespondPermission(requestID string, result core.PermissionResult) error { + s.mu.Lock() + defer s.mu.Unlock() + s.events = append(s.events, core.Event{ + Type: core.EventToolResult, + Content: "Permission " + result.Behavior, + }) + return nil +} + +func (s *FakeAgentSession) Events() <-chan core.Event { + s.mu.RLock() + defer s.mu.RUnlock() + + needsDone := len(s.events) == 0 || !s.events[len(s.events)-1].Done + bufSize := len(s.events) + if needsDone { + bufSize++ + } + ch := make(chan core.Event, bufSize) + for _, e := range s.events { + ch <- e + } + if needsDone { + ch <- core.Event{Type: core.EventResult, Done: true} + } + close(ch) + return ch +} + +func (s *FakeAgentSession) CurrentSessionID() string { + return s.sessionID +} + +func (s *FakeAgentSession) Alive() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.alive && !s.closed +} + +func (s *FakeAgentSession) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + s.alive = false + s.closed = true + return nil +} + +// GetPrompts returns all prompts sent to this session (for verification). +func (s *FakeAgentSession) GetPrompts() []string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.promptQueue +} + +// FakeAgent is a fake implementation of Agent for testing. +type FakeAgent struct { + name string + sessionID string + session *FakeAgentSession + preConfiguredSession *FakeAgentSession // session from NewFakeAgentWithSession + sessions []core.AgentSessionInfo + stopped bool +} + +func NewFakeAgent(name string) *FakeAgent { + return &FakeAgent{ + name: name, + sessionID: "fake-session-001", + sessions: []core.AgentSessionInfo{ + {ID: "fake-session-001", Summary: "Test session"}, + }, + } +} + +func (a *FakeAgent) Name() string { + return a.name +} + +func (a *FakeAgent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + a.sessionID = sessionID + // Return pre-configured session on first call (from NewFakeAgentWithSession) + // then create fresh sessions for subsequent calls + if a.preConfiguredSession != nil { + a.session = a.preConfiguredSession + a.preConfiguredSession = nil + return a.session, nil + } + a.session = NewFakeAgentSession(sessionID) + return a.session, nil +} + +func (a *FakeAgent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) { + return a.sessions, nil +} + +func (a *FakeAgent) Stop() error { + a.stopped = true + if a.session != nil { + a.session.Close() + } + return nil +} + +// GetSession returns the current fake session. +func (a *FakeAgent) GetSession() *FakeAgentSession { + return a.session +} + +// NewFakeAgentWithSession creates a fake agent with a pre-configured session. +// The pre-configured session is returned on the first StartSession call. +// Subsequent StartSession calls create fresh sessions (simulating real agent behavior). +func NewFakeAgentWithSession(name, sessionID string, session *FakeAgentSession) *FakeAgent { + return &FakeAgent{ + name: name, + preConfiguredSession: session, + sessionID: sessionID, + sessions: []core.AgentSessionInfo{ + {ID: sessionID, Summary: "Test session"}, + }, + } +} diff --git a/tests/mocks/mock_agent.go b/tests/mocks/mock_agent.go new file mode 100644 index 0000000..64bb8f8 --- /dev/null +++ b/tests/mocks/mock_agent.go @@ -0,0 +1,396 @@ +package mocks + +import ( + "context" + "io" + + "github.com/chenhg5/cc-connect/core" + "github.com/stretchr/testify/mock" +) + +// MockAgent is a mock implementation of the core.Agent interface. +type MockAgent struct { + mock.Mock +} + +func (m *MockAgent) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *MockAgent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) { + args := m.Called(ctx, sessionID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(core.AgentSession), args.Error(1) +} + +func (m *MockAgent) ListSessions(ctx context.Context) ([]core.AgentSessionInfo, error) { + args := m.Called(ctx) + return args.Get(0).([]core.AgentSessionInfo), args.Error(1) +} + +func (m *MockAgent) Stop() error { + args := m.Called() + return args.Error(0) +} + +// MockAgentSession is a mock implementation of the core.AgentSession interface. +type MockAgentSession struct { + mock.Mock +} + +func (m *MockAgentSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + args := m.Called(prompt, images, files) + return args.Error(0) +} + +func (m *MockAgentSession) RespondPermission(requestID string, result core.PermissionResult) error { + args := m.Called(requestID, result) + return args.Error(0) +} + +func (m *MockAgentSession) Events() <-chan core.Event { + args := m.Called() + return args.Get(0).(<-chan core.Event) +} + +func (m *MockAgentSession) CurrentSessionID() string { + args := m.Called() + return args.String(0) +} + +func (m *MockAgentSession) Alive() bool { + args := m.Called() + return args.Bool(0) +} + +func (m *MockAgentSession) Close() error { + args := m.Called() + return args.Error(0) +} + +// MockAgentWithProviders is a mock agent that also implements ProviderSwitcher. +type MockAgentWithProviders struct { + *MockAgent +} + +func (m *MockAgentWithProviders) SetProviders(providers []core.ProviderConfig) { + m.Called(providers) +} + +func (m *MockAgentWithProviders) SetActiveProvider(name string) bool { + args := m.Called(name) + return args.Bool(0) +} + +func (m *MockAgentWithProviders) GetActiveProvider() *core.ProviderConfig { + args := m.Called() + if args.Get(0) == nil { + return nil + } + return args.Get(0).(*core.ProviderConfig) +} + +func (m *MockAgentWithProviders) ListProviders() []core.ProviderConfig { + args := m.Called() + return args.Get(0).([]core.ProviderConfig) +} + +// MockAgentWithModel is a mock agent that also implements ModelSwitcher. +type MockAgentWithModel struct { + *MockAgent +} + +func (m *MockAgentWithModel) SetModel(model string) { + m.Called(model) +} + +func (m *MockAgentWithModel) GetModel() string { + args := m.Called() + return args.String(0) +} + +func (m *MockAgentWithModel) AvailableModels(ctx context.Context) []core.ModelOption { + args := m.Called(ctx) + return args.Get(0).([]core.ModelOption) +} + +// MockAgentWithMode is a mock agent that also implements ModeSwitcher. +type MockAgentWithMode struct { + *MockAgent +} + +func (m *MockAgentWithMode) SetMode(mode string) { + m.Called(mode) +} + +func (m *MockAgentWithMode) GetMode() string { + args := m.Called() + return args.String(0) +} + +func (m *MockAgentWithMode) PermissionModes() []core.PermissionModeInfo { + args := m.Called() + return args.Get(0).([]core.PermissionModeInfo) +} + +// MockAgentWithToolAuth is a mock agent that also implements ToolAuthorizer. +type MockAgentWithToolAuth struct { + *MockAgent +} + +func (m *MockAgentWithToolAuth) AddAllowedTools(tools ...string) error { + args := m.Called(tools) + return args.Error(0) +} + +func (m *MockAgentWithToolAuth) GetAllowedTools() []string { + args := m.Called() + return args.Get(0).([]string) +} + +// MockAgentWithHistory is a mock agent that also implements HistoryProvider. +type MockAgentWithHistory struct { + *MockAgent +} + +func (m *MockAgentWithHistory) GetSessionHistory(ctx context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) { + args := m.Called(ctx, sessionID, limit) + return args.Get(0).([]core.HistoryEntry), args.Error(1) +} + +// MockAgentWithUsage is a mock agent that also implements UsageReporter. +type MockAgentWithUsage struct { + *MockAgent +} + +func (m *MockAgentWithUsage) GetUsage(ctx context.Context) (*core.UsageReport, error) { + args := m.Called(ctx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*core.UsageReport), args.Error(1) +} + +// MockAgentWithMemory is a mock agent that also implements MemoryFileProvider. +type MockAgentWithMemory struct { + *MockAgent +} + +func (m *MockAgentWithMemory) ProjectMemoryFile() string { + args := m.Called() + return args.String(0) +} + +func (m *MockAgentWithMemory) GlobalMemoryFile() string { + args := m.Called() + return args.String(0) +} + +// MockAgentWithWorkDir is a mock agent that also implements WorkDirSwitcher. +type MockAgentWithWorkDir struct { + *MockAgent +} + +func (m *MockAgentWithWorkDir) SetWorkDir(dir string) { + m.Called(dir) +} + +func (m *MockAgentWithWorkDir) GetWorkDir() string { + args := m.Called() + return args.String(0) +} + +// MockAgentWithSkill is a mock agent that also implements SkillProvider. +type MockAgentWithSkill struct { + *MockAgent +} + +func (m *MockAgentWithSkill) SkillDirs() []string { + args := m.Called() + return args.Get(0).([]string) +} + +// MockAgentWithCommand is a mock agent that also implements CommandProvider. +type MockAgentWithCommand struct { + *MockAgent +} + +func (m *MockAgentWithCommand) CommandDirs() []string { + args := m.Called() + return args.Get(0).([]string) +} + +// MockAgentWithContextCompressor is a mock agent that also implements ContextCompressor. +type MockAgentWithContextCompressor struct { + *MockAgent +} + +func (m *MockAgentWithContextCompressor) CompressCommand() string { + args := m.Called() + return args.String(0) +} + +// MockAgentWithReasoning is a mock agent that also implements ReasoningEffortSwitcher. +type MockAgentWithReasoning struct { + *MockAgent +} + +func (m *MockAgentWithReasoning) SetReasoningEffort(effort string) { + m.Called(effort) +} + +func (m *MockAgentWithReasoning) GetReasoningEffort() string { + args := m.Called() + return args.String(0) +} + +func (m *MockAgentWithReasoning) AvailableReasoningEfforts() []string { + args := m.Called() + return args.Get(0).([]string) +} + +// MockAgentWithSessionDeleter is a mock agent that also implements SessionDeleter. +type MockAgentWithSessionDeleter struct { + *MockAgent +} + +func (m *MockAgentWithSessionDeleter) DeleteSession(ctx context.Context, sessionID string) error { + args := m.Called(ctx, sessionID) + return args.Error(0) +} + +// MockAgentWithSystemPrompt is a mock agent that also implements SystemPromptSupporter. +type MockAgentWithSystemPrompt struct { + *MockAgent +} + +func (m *MockAgentWithSystemPrompt) HasSystemPromptSupport() bool { + args := m.Called() + return args.Bool(0) +} + +// MockAgentWithPlatformPrompt is a mock agent that also implements PlatformPromptInjector. +type MockAgentWithPlatformPrompt struct { + *MockAgent +} + +func (m *MockAgentWithPlatformPrompt) SetPlatformPrompt(prompt string) { + m.Called(prompt) +} + +// MockAgentWithSessionEnv is a mock agent that also implements SessionEnvInjector. +type MockAgentWithSessionEnv struct { + *MockAgent +} + +func (m *MockAgentWithSessionEnv) SetSessionEnv(env []string) { + m.Called(env) +} + +// MockAgentFull implements all optional interfaces for comprehensive testing. +type MockAgentFull struct { + *MockAgent + *MockAgentWithProviders + *MockAgentWithModel + *MockAgentWithMode + *MockAgentWithToolAuth + *MockAgentWithHistory + *MockAgentWithUsage + *MockAgentWithMemory + *MockAgentWithWorkDir + *MockAgentWithSkill + *MockAgentWithCommand + *MockAgentWithContextCompressor + *MockAgentWithReasoning + *MockAgentWithSessionDeleter + *MockAgentWithSystemPrompt + *MockAgentWithPlatformPrompt + *MockAgentWithSessionEnv +} + +func NewMockAgentFull(name string) *MockAgentFull { + m := &MockAgentFull{ + MockAgent: new(MockAgent), + MockAgentWithProviders: new(MockAgentWithProviders), + MockAgentWithModel: new(MockAgentWithModel), + MockAgentWithMode: new(MockAgentWithMode), + MockAgentWithToolAuth: new(MockAgentWithToolAuth), + MockAgentWithHistory: new(MockAgentWithHistory), + MockAgentWithUsage: new(MockAgentWithUsage), + MockAgentWithMemory: new(MockAgentWithMemory), + MockAgentWithWorkDir: new(MockAgentWithWorkDir), + MockAgentWithSkill: new(MockAgentWithSkill), + MockAgentWithCommand: new(MockAgentWithCommand), + MockAgentWithContextCompressor: new(MockAgentWithContextCompressor), + MockAgentWithReasoning: new(MockAgentWithReasoning), + MockAgentWithSessionDeleter: new(MockAgentWithSessionDeleter), + MockAgentWithSystemPrompt: new(MockAgentWithSystemPrompt), + MockAgentWithPlatformPrompt: new(MockAgentWithPlatformPrompt), + MockAgentWithSessionEnv: new(MockAgentWithSessionEnv), + } + m.MockAgent.On("Name").Return(name) + return m +} + +// EventIterator is a helper for simulating agent events in tests. +type EventIterator struct { + events []core.Event + index int +} + +func NewEventIterator(events []core.Event) *EventIterator { + return &EventIterator{events: events, index: 0} +} + +func (e *EventIterator) Next() (core.Event, bool) { + if e.index >= len(e.events) { + return core.Event{}, false + } + event := e.events[e.index] + e.index++ + return event, true +} + +func (e *EventIterator) EventChannel() <-chan core.Event { + ch := make(chan core.Event, len(e.events)) + for _, event := range e.events { + ch <- event + } + close(ch) + return ch +} + +// NewMockAgentSessionWithEvents creates a mock session that emits predefined events. +func NewMockAgentSessionWithEvents(sessionID string, events []core.Event) *MockAgentSession { + m := new(MockAgentSession) + m.On("CurrentSessionID").Return(sessionID) + m.On("Alive").Return(true) + m.On("Events").Return(NewEventIterator(events).EventChannel()) + m.On("Close").Return(nil) + m.On("Send", mock.Anything, mock.Anything, mock.Anything).Return(nil) + return m +} + +// MockEventReader implements io.Reader for testing streaming scenarios. +type MockEventReader struct { + events []core.Event + index int +} + +func NewMockEventReader(events []core.Event) *MockEventReader { + return &MockEventReader{events: events, index: 0} +} + +func (r *MockEventReader) Read(p []byte) (n int, err error) { + if r.index >= len(r.events) { + return 0, io.EOF + } + event := r.events[r.index] + r.index++ + data := []byte(event.Content) + copy(p, data) + return len(data), nil +} diff --git a/tests/mocks/mock_platform.go b/tests/mocks/mock_platform.go new file mode 100644 index 0000000..7ef4711 --- /dev/null +++ b/tests/mocks/mock_platform.go @@ -0,0 +1,50 @@ +// Package mocks provides mock implementations for testing cc-connect components. +package mocks + +import ( + "context" + + "github.com/chenhg5/cc-connect/core" + "github.com/stretchr/testify/mock" +) + +// MockPlatform is a mock implementation of the core.Platform interface. +type MockPlatform struct { + mock.Mock +} + +func (m *MockPlatform) Name() string { + args := m.Called() + return args.String(0) +} + +func (m *MockPlatform) Start(handler core.MessageHandler) error { + args := m.Called(handler) + return args.Error(0) +} + +func (m *MockPlatform) Reply(ctx context.Context, replyCtx any, content string) error { + args := m.Called(ctx, replyCtx, content) + return args.Error(0) +} + +func (m *MockPlatform) Send(ctx context.Context, replyCtx any, content string) error { + args := m.Called(ctx, replyCtx, content) + return args.Error(0) +} + +func (m *MockPlatform) Stop() error { + args := m.Called() + return args.Error(0) +} + +// MockPlatformWithReplyCtxReconstructor is a mock platform that also implements +// ReplyContextReconstructor for testing cron job scenarios. +type MockPlatformWithReplyCtxReconstructor struct { + *MockPlatform +} + +func (m *MockPlatformWithReplyCtxReconstructor) ReconstructReplyCtx(sessionKey string) (any, error) { + args := m.Called(sessionKey) + return args.Get(0), args.Error(1) +} diff --git a/tests/performance/bench_test.go b/tests/performance/bench_test.go new file mode 100644 index 0000000..3d76780 --- /dev/null +++ b/tests/performance/bench_test.go @@ -0,0 +1,323 @@ +//go:build performance + +// Package performance contains benchmark tests for cc-connect. +// These tests measure latency, throughput, and resource usage. +// +// Run with: go test -bench=. -benchmem -tags=performance ./tests/performance/... +package performance + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" + "github.com/chenhg5/cc-connect/tests/mocks/fake" +) + +// --------------------------------------------------------------------------- +// T-400: Single Message Latency +// --------------------------------------------------------------------------- + +func Benchmark_SingleMessageLatency(b *testing.B) { + ctx := context.Background() + agent := fake.NewFakeAgent("bench-agent") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sess, _ := agent.StartSession(ctx, "bench-session") + sess.Send("Hello", nil, nil) + for e := range sess.Events() { + if e.Done { + break + } + } + } +} + +// --------------------------------------------------------------------------- +// T-401: Concurrent Throughput +// --------------------------------------------------------------------------- + +func Benchmark_ConcurrentThroughput(b *testing.B) { + ctx := context.Background() + numAgents := 10 + + // Create multiple agents with sessions + agents := make([]struct { + agent *fake.FakeAgent + sess core.AgentSession + }, numAgents) + for i := 0; i < numAgents; i++ { + agent := fake.NewFakeAgent("bench-agent") + sess, _ := agent.StartSession(ctx, "bench-session") + agents[i] = struct { + agent *fake.FakeAgent + sess core.AgentSession + }{agent: agent, sess: sess} + } + + var totalMessages int64 + + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + idx := 0 + for pb.Next() { + a := &agents[idx%numAgents] + a.sess.Send("Concurrent message", nil, nil) + atomic.AddInt64(&totalMessages, 1) + idx++ + } + }) + + b.StopTimer() + msgsPerSec := float64(totalMessages) / b.Elapsed().Seconds() + b.ReportMetric(msgsPerSec, "msgs/sec") +} + +// --------------------------------------------------------------------------- +// T-402: Session Switch Latency +// --------------------------------------------------------------------------- + +func Benchmark_SessionSwitch(b *testing.B) { + ctx := context.Background() + agent := fake.NewFakeAgent("bench-agent") + + // Pre-create multiple sessions + const numSessions = 10 + sessions := make([]core.AgentSession, numSessions) + for i := 0; i < numSessions; i++ { + sess, _ := agent.StartSession(ctx, "session-switch") + sessions[i] = sess + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sess := sessions[i%numSessions] + sess.Send("Switch test", nil, nil) + } +} + +// --------------------------------------------------------------------------- +// T-403: Memory Usage During Message Processing +// --------------------------------------------------------------------------- + +func Benchmark_MemoryUsage(b *testing.B) { + ctx := context.Background() + agent := fake.NewFakeAgent("bench-agent") + sess, _ := agent.StartSession(ctx, "bench-session") + + b.ResetTimer() + b.ReportMetric(float64(b.N)*32, "bytes/op") // baseline estimate + + for i := 0; i < b.N; i++ { + sess.Send("Memory test message", nil, nil) + // Consume events + for e := range sess.Events() { + if e.Done { + break + } + } + } +} + +// --------------------------------------------------------------------------- +// T-404: Rate Limiter Performance +// --------------------------------------------------------------------------- + +func Benchmark_RateLimiter(b *testing.B) { + rl := core.NewRateLimiter(1000, time.Second) + defer rl.Stop() + + b.ResetTimer() + for i := 0; i < b.N; i++ { + rl.Allow("bench-user") + } +} + +// --------------------------------------------------------------------------- +// T-405: Message Deduplication Performance +// --------------------------------------------------------------------------- + +func Benchmark_MessageDedup(b *testing.B) { + dedup := &core.MessageDedup{} + + b.ResetTimer() + for i := 0; i < b.N; i++ { + msgID := "bench-msg" + dedup.IsDuplicate(msgID) + } +} + +// --------------------------------------------------------------------------- +// T-406: Command Registry Lookup +// --------------------------------------------------------------------------- + +func Benchmark_CommandRegistryLookup(b *testing.B) { + registry := core.NewCommandRegistry() + + // Add commands + for i := 0; i < 50; i++ { + registry.Add("cmd", "Command", "{{1}}", "", "", "bench") + } + registry.Add("target", "Target command", "{{1}}", "", "", "bench") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + registry.Resolve("target") + } +} + +// --------------------------------------------------------------------------- +// T-407: Card Rendering Performance +// --------------------------------------------------------------------------- + +func Benchmark_CardRendering(b *testing.B) { + b.ResetTimer() + for i := 0; i < b.N; i++ { + card := core.NewCard(). + Title("Benchmark Card", "blue"). + Markdown("## Section\nSome content here."). + Markdown("- Item 1\n- Item 2\n- Item 3"). + Divider(). + Buttons( + core.PrimaryBtn("Confirm", "act:/confirm"), + core.DefaultBtn("Cancel", "act:/cancel"), + core.DangerBtn("Delete", "act:/delete"), + ). + Note("Footnote text"). + Build() + + _ = card.RenderText() + _ = card.CollectButtons() + } +} + +// --------------------------------------------------------------------------- +// T-408: Cron Store Operations +// --------------------------------------------------------------------------- + +func Benchmark_CronStoreOperations(b *testing.B) { + tmpDir := b.TempDir() + store, _ := core.NewCronStore(tmpDir) + + // Pre-populate + for i := 0; i < 20; i++ { + store.Add(&core.CronJob{ + ID: "bench-cron", + Description: "Bench", + Prompt: "Run", + CronExpr: "0 9 * * *", + Enabled: true, + }) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + jobs := store.List() + _ = len(jobs) + store.SetEnabled("bench-cron", true) + } +} + +// --------------------------------------------------------------------------- +// T-409: Session Creation Overhead +// --------------------------------------------------------------------------- + +func Benchmark_SessionCreation(b *testing.B) { + ctx := context.Background() + agent := fake.NewFakeAgent("bench-agent") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = agent.StartSession(ctx, "bench-session") + } +} + +// --------------------------------------------------------------------------- +// T-410: Session Send/Receive Overhead +// --------------------------------------------------------------------------- + +func Benchmark_SessionSendReceive(b *testing.B) { + ctx := context.Background() + agent := fake.NewFakeAgent("bench-agent") + sess, _ := agent.StartSession(ctx, "bench-session") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + sess.Send("Bench message", nil, nil) + for e := range sess.Events() { + if e.Done { + break + } + } + } +} + +// --------------------------------------------------------------------------- +// T-411: Role Manager Resolution +// --------------------------------------------------------------------------- + +func Benchmark_RoleManagerResolution(b *testing.B) { + manager := core.NewUserRoleManager() + manager.Configure("viewer", []core.RoleInput{ + {Name: "admin", UserIDs: []string{"admin1", "admin2"}}, + {Name: "developer", UserIDs: []string{"dev1", "dev2"}}, + {Name: "viewer", UserIDs: []string{"*"}}, + }) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + manager.ResolveRole("admin1") + } +} + +// --------------------------------------------------------------------------- +// T-412: Concurrent Rate Limiter Access +// --------------------------------------------------------------------------- + +func Benchmark_ConcurrentRateLimiter(b *testing.B) { + rl := core.NewRateLimiter(10000, time.Second) + defer rl.Stop() + + var counter int64 + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + if rl.Allow("bench-user") { + atomic.AddInt64(&counter, 1) + } + } + }) + b.ReportMetric(float64(counter), "allowed") +} + +// --------------------------------------------------------------------------- +// T-413: Multi-Agent Coordination +// --------------------------------------------------------------------------- + +func Benchmark_MultiAgentCoordination(b *testing.B) { + ctx := context.Background() + numAgents := 5 + + agents := make([]*fake.FakeAgent, numAgents) + for i := 0; i < numAgents; i++ { + agents[i] = fake.NewFakeAgent("bench-agent") + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + var wg sync.WaitGroup + for j := 0; j < numAgents; j++ { + wg.Add(1) + go func(a *fake.FakeAgent) { + defer wg.Done() + sess, _ := a.StartSession(ctx, "coord-session") + sess.Send("Coordinated message", nil, nil) + }(agents[j]) + } + wg.Wait() + } +} diff --git a/tests/release_local/config_matrix/config_matrix_test.go b/tests/release_local/config_matrix/config_matrix_test.go new file mode 100644 index 0000000..a1f6d36 --- /dev/null +++ b/tests/release_local/config_matrix/config_matrix_test.go @@ -0,0 +1,257 @@ +package config_matrix + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/chenhg5/cc-connect/config" +) + +func writeConfig(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func baseProjectTOML(extra string) string { + return ` +data_dir = "` + filepath.ToSlash(os.TempDir()) + `/cc-connect-release-test" +` + extra + ` + +[[projects]] +name = "release" + +[projects.agent] +type = "claudecode" +work_dir = "/tmp/cc-connect-release-work" + +[[projects.platforms]] +type = "feishu" +app_id = "cli_release" +app_secret = "secret" +` +} + +func TestReleaseConfig_ProjectDisplayOverridesGlobalFromLoadedConfig(t *testing.T) { + path := writeConfig(t, ` +attachment_send = "off" + +[display] +mode = "quiet" +card_mode = "rich" +thinking_messages = true +tool_messages = false + +[[projects]] +name = "release" +reset_on_idle_mins = 0 + +[projects.display] +mode = "full" +card_mode = "legacy" +thinking_messages = false +tool_messages = true +thinking_max_len = 111 +tool_max_len = 222 + +[projects.agent] +type = "claudecode" +work_dir = "/tmp/cc-connect-release-work" + +[[projects.platforms]] +type = "feishu" +app_id = "cli_release" +app_secret = "secret" +`) + + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.AttachmentSend != "off" { + t.Fatalf("AttachmentSend = %q, want off", cfg.AttachmentSend) + } + if len(cfg.Projects) != 1 { + t.Fatalf("projects = %d, want 1", len(cfg.Projects)) + } + proj := &cfg.Projects[0] + if proj.ResetOnIdleMins == nil || *proj.ResetOnIdleMins != 0 { + t.Fatalf("ResetOnIdleMins = %#v, want explicit 0", proj.ResetOnIdleMins) + } + + mode, thinking, tools, thinkingMax, toolMax, _, _ := config.EffectiveDisplay(cfg, proj) + if mode != config.DisplayModeFull { + t.Fatalf("mode = %q, want project full override", mode) + } + if thinking { + t.Fatal("thinking_messages = true, want project override false") + } + if !tools { + t.Fatal("tool_messages = false, want project override true") + } + if thinkingMax != 111 || toolMax != 222 { + t.Fatalf("max lens = %d/%d, want 111/222", thinkingMax, toolMax) + } + if got := config.EffectiveCardMode(cfg, proj); got != "legacy" { + t.Fatalf("card mode = %q, want project legacy override", got) + } +} + +func TestReleaseConfig_DefaultsKeepAttachmentsAndFullDisplayEnabled(t *testing.T) { + path := writeConfig(t, baseProjectTOML("")) + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.AttachmentSend != "on" { + t.Fatalf("AttachmentSend = %q, want default on", cfg.AttachmentSend) + } + mode, thinking, tools, _, _, _, _ := config.EffectiveDisplay(cfg, &cfg.Projects[0]) + if mode != config.DisplayModeFull || !thinking || !tools { + t.Fatalf("display = mode:%s thinking:%v tools:%v, want full/true/true", mode, thinking, tools) + } + if got := config.EffectiveCardMode(cfg, &cfg.Projects[0]); got != "legacy" { + t.Fatalf("card mode = %q, want default legacy", got) + } +} + +func TestReleaseConfig_BehaviorControlSwitchesParseFromLoadedConfig(t *testing.T) { + path := writeConfig(t, ` +[stream_preview] +enabled = false +disabled_platforms = ["feishu", "telegram"] +interval_ms = 250 +min_delta_chars = 12 +max_chars = 777 + +[[projects]] +name = "release" +show_context_indicator = false +reply_footer = false +disabled_commands = ["restart", "shell"] + +[projects.display] +mode = "quiet" +card_mode = "rich" +thinking_messages = false +tool_messages = false + +[projects.agent] +type = "claudecode" +work_dir = "/tmp/cc-connect-release-work" + +[[projects.platforms]] +type = "feishu" +app_id = "cli_release" +app_secret = "secret" +`) + + cfg, err := config.Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.StreamPreview.Enabled == nil || *cfg.StreamPreview.Enabled { + t.Fatalf("stream_preview.enabled = %#v, want false", cfg.StreamPreview.Enabled) + } + if got := strings.Join(cfg.StreamPreview.DisabledPlatforms, ","); got != "feishu,telegram" { + t.Fatalf("stream_preview.disabled_platforms = %#v", cfg.StreamPreview.DisabledPlatforms) + } + if cfg.StreamPreview.IntervalMs == nil || *cfg.StreamPreview.IntervalMs != 250 { + t.Fatalf("stream_preview.interval_ms = %#v, want 250", cfg.StreamPreview.IntervalMs) + } + if cfg.StreamPreview.MinDeltaChars == nil || *cfg.StreamPreview.MinDeltaChars != 12 { + t.Fatalf("stream_preview.min_delta_chars = %#v, want 12", cfg.StreamPreview.MinDeltaChars) + } + if cfg.StreamPreview.MaxChars == nil || *cfg.StreamPreview.MaxChars != 777 { + t.Fatalf("stream_preview.max_chars = %#v, want 777", cfg.StreamPreview.MaxChars) + } + + proj := &cfg.Projects[0] + if proj.ShowContextIndicator == nil || *proj.ShowContextIndicator { + t.Fatalf("show_context_indicator = %#v, want false", proj.ShowContextIndicator) + } + if proj.ReplyFooter == nil || *proj.ReplyFooter { + t.Fatalf("reply_footer = %#v, want false", proj.ReplyFooter) + } + if strings.Join(proj.DisabledCommands, ",") != "restart,shell" { + t.Fatalf("disabled_commands = %#v", proj.DisabledCommands) + } + mode, thinking, tools, _, _, _, _ := config.EffectiveDisplay(cfg, proj) + if mode != config.DisplayModeQuiet || thinking || tools { + t.Fatalf("display = mode:%s thinking:%v tools:%v, want quiet/false/false", mode, thinking, tools) + } + if got := config.EffectiveCardMode(cfg, proj); got != "rich" { + t.Fatalf("card mode = %q, want rich", got) + } +} + +func TestReleaseConfig_InvalidCriticalOptionsFailFast(t *testing.T) { + tests := []struct { + name string + toml string + wantErr string + }{ + { + name: "invalid attachment send", + toml: baseProjectTOML(` +attachment_send = "maybe" +`), + wantErr: `attachment_send must be "on" or "off"`, + }, + { + name: "invalid project display mode", + toml: ` +[[projects]] +name = "release" + +[projects.display] +mode = "verbose" + +[projects.agent] +type = "claudecode" +work_dir = "/tmp/cc-connect-release-work" + +[[projects.platforms]] +type = "feishu" +app_id = "cli_release" +app_secret = "secret" +`, + wantErr: `projects[0].display.mode must be "full", "compact", or "quiet"`, + }, + { + name: "negative reset on idle", + toml: ` +[[projects]] +name = "release" +reset_on_idle_mins = -1 + +[projects.agent] +type = "claudecode" +work_dir = "/tmp/cc-connect-release-work" + +[[projects.platforms]] +type = "feishu" +app_id = "cli_release" +app_secret = "secret" +`, + wantErr: "reset_on_idle_mins must be >= 0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := config.Load(writeConfig(t, tt.toml)) + if err == nil { + t.Fatal("Load() error = nil, want validation error") + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("Load() error = %q, want contains %q", err.Error(), tt.wantErr) + } + }) + } +} diff --git a/tests/release_local/engine_matrix/engine_matrix_test.go b/tests/release_local/engine_matrix/engine_matrix_test.go new file mode 100644 index 0000000..e4e42b0 --- /dev/null +++ b/tests/release_local/engine_matrix/engine_matrix_test.go @@ -0,0 +1,302 @@ +package engine_matrix + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +type promptRecord struct { + sessionID string + prompt string +} + +type matrixAgent struct { + mu sync.Mutex + sessions []*matrixSession + list []core.AgentSessionInfo + records []promptRecord +} + +func newMatrixAgent() *matrixAgent { + return &matrixAgent{} +} + +func (a *matrixAgent) Name() string { return "matrix-agent" } + +func (a *matrixAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) { + a.mu.Lock() + defer a.mu.Unlock() + if sessionID == "" { + sessionID = fmt.Sprintf("agent-session-%d", len(a.sessions)+1) + } + session := &matrixSession{agent: a, id: sessionID, alive: true, events: make(chan core.Event, 32)} + a.sessions = append(a.sessions, session) + a.list = append(a.list, core.AgentSessionInfo{ + ID: sessionID, + Summary: "release matrix session", + MessageCount: 1, + ModifiedAt: time.Now(), + }) + return session, nil +} + +func (a *matrixAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + a.mu.Lock() + defer a.mu.Unlock() + return append([]core.AgentSessionInfo(nil), a.list...), nil +} + +func (a *matrixAgent) Stop() error { + a.mu.Lock() + sessions := append([]*matrixSession(nil), a.sessions...) + a.mu.Unlock() + for _, session := range sessions { + _ = session.Close() + } + return nil +} + +func (a *matrixAgent) addRecord(sessionID, prompt string) { + a.mu.Lock() + defer a.mu.Unlock() + a.records = append(a.records, promptRecord{sessionID: sessionID, prompt: prompt}) +} + +func (a *matrixAgent) waitRecords(t *testing.T, n int) []promptRecord { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + a.mu.Lock() + if len(a.records) >= n { + out := append([]promptRecord(nil), a.records...) + a.mu.Unlock() + return out + } + a.mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + a.mu.Lock() + defer a.mu.Unlock() + t.Fatalf("timeout waiting for %d prompts, got %d: %#v", n, len(a.records), a.records) + return nil +} + +func (a *matrixAgent) recordCount() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.records) +} + +type matrixSession struct { + mu sync.Mutex + agent *matrixAgent + id string + alive bool + events chan core.Event + counter int +} + +func (s *matrixSession) Send(prompt string, _ []core.ImageAttachment, _ []core.FileAttachment) error { + s.mu.Lock() + id := s.id + s.counter++ + count := s.counter + s.mu.Unlock() + + s.agent.addRecord(id, prompt) + s.events <- core.Event{Type: core.EventResult, Content: "matrix response " + id + " #" + strconv.Itoa(count), Done: true} + return nil +} + +func (s *matrixSession) Events() <-chan core.Event { return s.events } +func (s *matrixSession) RespondPermission(string, core.PermissionResult) error { + return nil +} +func (s *matrixSession) CurrentSessionID() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.id +} +func (s *matrixSession) Alive() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.alive +} +func (s *matrixSession) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.alive { + return nil + } + s.alive = false + close(s.events) + return nil +} + +type matrixPlatform struct { + mu sync.Mutex + texts []string +} + +func (p *matrixPlatform) Name() string { return "matrix" } +func (p *matrixPlatform) Start(core.MessageHandler) error { + return nil +} +func (p *matrixPlatform) Stop() error { return nil } +func (p *matrixPlatform) Reply(_ context.Context, replyCtx any, content string) error { + return p.Send(context.Background(), replyCtx, content) +} +func (p *matrixPlatform) Send(_ context.Context, _ any, content string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.texts = append(p.texts, content) + return nil +} +func (p *matrixPlatform) clear() { + p.mu.Lock() + defer p.mu.Unlock() + p.texts = nil +} +func (p *matrixPlatform) snapshot() []string { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.texts...) +} +func (p *matrixPlatform) waitTextContaining(t *testing.T, substr string) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + for _, text := range p.snapshot() { + if strings.Contains(strings.ToLower(text), strings.ToLower(substr)) { + return text + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("timeout waiting for %q, got %#v", substr, p.snapshot()) + return "" +} + +func newMatrixEngine(t *testing.T) (*core.Engine, *matrixAgent, *matrixPlatform) { + t.Helper() + agent := newMatrixAgent() + platform := &matrixPlatform{} + engine := core.NewEngine("release-core", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish) + t.Cleanup(func() { + engine.Stop() + _ = agent.Stop() + }) + return engine, agent, platform +} + +func matrixMessage(content string) *core.Message { + return &core.Message{ + SessionKey: "matrix:chat-1:user-1", + Platform: "matrix", + UserID: "user-1", + UserName: "Release Tester", + ChatName: "Release Room", + Content: content, + ReplyCtx: "reply-ctx", + } +} + +func receive(engine *core.Engine, platform *matrixPlatform, content string) { + engine.ReceiveMessage(platform, matrixMessage(content)) +} + +func TestSessionLifecycleCommandsThroughReceiveMessage(t *testing.T) { + engine, agent, platform := newMatrixEngine(t) + + receive(engine, platform, "first user turn") + agent.waitRecords(t, 1) + platform.waitTextContaining(t, "matrix response") + platform.clear() + + receive(engine, platform, "/new release-named") + platform.waitTextContaining(t, "release-named") + platform.clear() + + receive(engine, platform, "second user turn") + agent.waitRecords(t, 2) + platform.waitTextContaining(t, "matrix response") + platform.clear() + + receive(engine, platform, "/list") + platform.waitTextContaining(t, "release-named") + platform.clear() + + receive(engine, platform, "/name renamed-release") + platform.waitTextContaining(t, "renamed-release") + platform.clear() + + receive(engine, platform, "/list") + platform.waitTextContaining(t, "renamed-release") + platform.clear() + + receive(engine, platform, "/current") + platform.waitTextContaining(t, "session") + platform.clear() + + receive(engine, platform, "/status") + platform.waitTextContaining(t, "user-1") +} + +func TestAliasDisabledCommandAndBannedWordsThroughReceiveMessage(t *testing.T) { + engine, agent, platform := newMatrixEngine(t) + engine.AddAlias("帮助", "/whoami") + + receive(engine, platform, "帮助") + platform.waitTextContaining(t, "user-1") + if got := agent.recordCount(); got != 0 { + t.Fatalf("alias to command should not reach agent, got %d prompts", got) + } + platform.clear() + + engine.SetDisabledCommands([]string{"whoami"}) + receive(engine, platform, "/whoami") + platform.waitTextContaining(t, "disabled") + if got := agent.recordCount(); got != 0 { + t.Fatalf("disabled command should not reach agent, got %d prompts", got) + } + platform.clear() + + engine.SetBannedWords([]string{"forbidden"}) + receive(engine, platform, "this contains forbidden content") + platform.waitTextContaining(t, "blocked") + if got := agent.recordCount(); got != 0 { + t.Fatalf("banned message should not reach agent, got %d prompts", got) + } +} + +func TestCustomPromptCommandThroughReceiveMessage(t *testing.T) { + engine, agent, platform := newMatrixEngine(t) + engine.AddCommand("daily", "Daily summary", "Summarize release status for {{1}}", "", "", "release-test") + + receive(engine, platform, "/daily beta") + + records := agent.waitRecords(t, 1) + if !strings.Contains(records[0].prompt, "Summarize release status for beta") { + t.Fatalf("custom command prompt = %q", records[0].prompt) + } + platform.waitTextContaining(t, "matrix response") +} + +func TestUnknownSlashCommandNotifiesThenFallsThroughToAgent(t *testing.T) { + engine, agent, platform := newMatrixEngine(t) + + receive(engine, platform, "/not-a-command keep this request") + + platform.waitTextContaining(t, "forwarding") + records := agent.waitRecords(t, 1) + if !strings.Contains(records[0].prompt, "/not-a-command keep this request") { + t.Fatalf("unknown slash command should fall through to agent, got prompt %q", records[0].prompt) + } +} diff --git a/tests/release_local/media_pipeline/media_pipeline_test.go b/tests/release_local/media_pipeline/media_pipeline_test.go new file mode 100644 index 0000000..0f3ff4c --- /dev/null +++ b/tests/release_local/media_pipeline/media_pipeline_test.go @@ -0,0 +1,448 @@ +package media_pipeline + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +type sendRecord struct { + prompt string + images []core.ImageAttachment + files []core.FileAttachment +} + +type recordingAgent struct { + session *recordingSession +} + +func newRecordingAgent() *recordingAgent { + return &recordingAgent{session: newRecordingSession()} +} + +func (a *recordingAgent) Name() string { return "recording-agent" } + +func (a *recordingAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) { + a.session.setID(sessionID) + return a.session, nil +} + +func (a *recordingAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { + return nil, nil +} + +func (a *recordingAgent) Stop() error { + return a.session.Close() +} + +type recordingSession struct { + mu sync.Mutex + id string + alive bool + records []sendRecord + events chan core.Event + blockFirst bool + blocked bool +} + +func newRecordingSession() *recordingSession { + return &recordingSession{alive: true, events: make(chan core.Event, 16)} +} + +func (s *recordingSession) setID(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.id = id +} + +func (s *recordingSession) blockFirstResult() { + s.mu.Lock() + defer s.mu.Unlock() + s.blockFirst = true +} + +func (s *recordingSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.alive { + return errors.New("session closed") + } + rec := sendRecord{ + prompt: prompt, + images: append([]core.ImageAttachment(nil), images...), + files: append([]core.FileAttachment(nil), files...), + } + s.records = append(s.records, rec) + if !(s.blockFirst && len(s.records) == 1) { + s.events <- core.Event{Type: core.EventResult, Content: "media ok", Done: true} + } else { + s.blocked = true + } + return nil +} + +func (s *recordingSession) Events() <-chan core.Event { + return s.events +} + +func (s *recordingSession) RespondPermission(string, core.PermissionResult) error { + return nil +} + +func (s *recordingSession) CurrentSessionID() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.id +} + +func (s *recordingSession) Alive() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.alive +} + +func (s *recordingSession) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.alive { + return nil + } + s.alive = false + close(s.events) + return nil +} + +func (s *recordingSession) releaseFirstResult(content string) { + s.releaseFirstEvent(core.Event{Type: core.EventResult, Content: content, Done: true}) +} + +func (s *recordingSession) releaseFirstEvent(event core.Event) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.blocked { + return + } + s.events <- event + s.blocked = false +} + +func (s *recordingSession) waitRecords(t *testing.T, n int) []sendRecord { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + s.mu.Lock() + if len(s.records) >= n { + out := append([]sendRecord(nil), s.records...) + s.mu.Unlock() + return out + } + s.mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + s.mu.Lock() + defer s.mu.Unlock() + t.Fatalf("timeout waiting for %d Send calls, got %d: %#v", n, len(s.records), s.records) + return nil +} + +type mediaPlatform struct { + mu sync.Mutex + texts []string + images []core.ImageAttachment + files []core.FileAttachment + replyCtx []any +} + +func (p *mediaPlatform) Name() string { return "media" } +func (p *mediaPlatform) Start(core.MessageHandler) error { + return nil +} +func (p *mediaPlatform) Stop() error { return nil } +func (p *mediaPlatform) Reply(_ context.Context, replyCtx any, content string) error { + return p.Send(context.Background(), replyCtx, content) +} +func (p *mediaPlatform) Send(_ context.Context, replyCtx any, content string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.texts = append(p.texts, content) + p.replyCtx = append(p.replyCtx, replyCtx) + return nil +} +func (p *mediaPlatform) SendImage(_ context.Context, replyCtx any, img core.ImageAttachment) error { + p.mu.Lock() + defer p.mu.Unlock() + p.images = append(p.images, img) + p.replyCtx = append(p.replyCtx, replyCtx) + return nil +} +func (p *mediaPlatform) SendFile(_ context.Context, replyCtx any, file core.FileAttachment) error { + p.mu.Lock() + defer p.mu.Unlock() + p.files = append(p.files, file) + p.replyCtx = append(p.replyCtx, replyCtx) + return nil +} + +func (p *mediaPlatform) snapshot() (texts []string, images []core.ImageAttachment, files []core.FileAttachment, replyCtx []any) { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.texts...), + append([]core.ImageAttachment(nil), p.images...), + append([]core.FileAttachment(nil), p.files...), + append([]any(nil), p.replyCtx...) +} + +func (p *mediaPlatform) waitTextContaining(t *testing.T, substr string) string { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + texts, _, _, _ := p.snapshot() + for _, text := range texts { + if strings.Contains(strings.ToLower(text), strings.ToLower(substr)) { + return text + } + } + time.Sleep(10 * time.Millisecond) + } + texts, _, _, _ := p.snapshot() + t.Fatalf("timeout waiting for text containing %q, got %#v", substr, texts) + return "" +} + +func newMediaEngine(t *testing.T) (*core.Engine, *recordingAgent, *mediaPlatform) { + t.Helper() + agent := newRecordingAgent() + platform := &mediaPlatform{} + engine := core.NewEngine("release-media", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish) + t.Cleanup(func() { + engine.Stop() + _ = agent.Stop() + }) + return engine, agent, platform +} + +func mediaMessage(content string) *core.Message { + return &core.Message{ + SessionKey: "media:chat-1:user-1", + Platform: "media", + UserID: "user-1", + UserName: "tester", + Content: content, + ReplyCtx: "reply-ctx-1", + } +} + +func TestInboundImagesAndFilesReachAgentThroughEngine(t *testing.T) { + engine, agent, platform := newMediaEngine(t) + msg := mediaMessage("analyze these attachments") + msg.Images = []core.ImageAttachment{{MimeType: "image/png", FileName: "screenshot.png", Data: []byte("png-bytes")}} + msg.Files = []core.FileAttachment{{MimeType: "application/pdf", FileName: "spec.pdf", Data: []byte("%PDF")}} + + engine.ReceiveMessage(platform, msg) + + records := agent.session.waitRecords(t, 1) + if !strings.Contains(records[0].prompt, "analyze these attachments") { + t.Fatalf("prompt = %q, want user content", records[0].prompt) + } + if len(records[0].images) != 1 || records[0].images[0].FileName != "screenshot.png" || string(records[0].images[0].Data) != "png-bytes" { + t.Fatalf("images not preserved: %#v", records[0].images) + } + if len(records[0].files) != 1 || records[0].files[0].FileName != "spec.pdf" || string(records[0].files[0].Data) != "%PDF" { + t.Fatalf("files not preserved: %#v", records[0].files) + } + platform.waitTextContaining(t, "media ok") +} + +func TestAttachmentOnlyMessageReachesAgent(t *testing.T) { + engine, agent, platform := newMediaEngine(t) + msg := mediaMessage("") + msg.Images = []core.ImageAttachment{{MimeType: "image/jpeg", FileName: "photo.jpg", Data: []byte("jpeg-bytes")}} + + engine.ReceiveMessage(platform, msg) + + records := agent.session.waitRecords(t, 1) + if len(records[0].images) != 1 || records[0].images[0].MimeType != "image/jpeg" { + t.Fatalf("attachment-only image not delivered: %#v", records[0].images) + } + platform.waitTextContaining(t, "media ok") +} + +func TestQueuedMessagePreservesFiles(t *testing.T) { + engine, agent, platform := newMediaEngine(t) + agent.session.blockFirstResult() + + first := mediaMessage("start long task") + engine.ReceiveMessage(platform, first) + agent.session.waitRecords(t, 1) + + queued := mediaMessage("please also inspect this file") + queued.MessageID = "queued-msg" + queued.Files = []core.FileAttachment{{MimeType: "text/plain", FileName: "queued.txt", Data: []byte("queued-file")}} + engine.ReceiveMessage(platform, queued) + + platform.waitTextContaining(t, "process after") + agent.session.releaseFirstResult("first done") + + records := agent.session.waitRecords(t, 2) + if !strings.Contains(records[1].prompt, "please also inspect this file") { + t.Fatalf("queued prompt = %q", records[1].prompt) + } + if len(records[1].files) != 1 || records[1].files[0].FileName != "queued.txt" || string(records[1].files[0].Data) != "queued-file" { + t.Fatalf("queued file not preserved: %#v", records[1].files) + } +} + +func TestSendToSessionWithAttachmentsDeliversTextImagesAndFiles(t *testing.T) { + engine, agent, platform := newMediaEngine(t) + msg := mediaMessage("establish active session") + engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + platform.waitTextContaining(t, "media ok") + + err := engine.SendToSessionWithAttachments( + msg.SessionKey, + "delivery ready", + []core.ImageAttachment{{MimeType: "image/png", FileName: "chart.png", Data: []byte("chart")}}, + []core.FileAttachment{{MimeType: "text/plain", FileName: "report.txt", Data: []byte("report")}}, + ) + if err != nil { + t.Fatalf("SendToSessionWithAttachments() error = %v", err) + } + + texts, images, files, replyCtx := platform.snapshot() + if !containsText(texts, "delivery ready") { + t.Fatalf("texts = %#v, want delivery message", texts) + } + if len(images) != 1 || images[0].FileName != "chart.png" || string(images[0].Data) != "chart" { + t.Fatalf("images = %#v", images) + } + if len(files) != 1 || files[0].FileName != "report.txt" || string(files[0].Data) != "report" { + t.Fatalf("files = %#v", files) + } + for _, ctx := range replyCtx { + if ctx != "reply-ctx-1" { + t.Fatalf("reply context = %#v, want original reply context", replyCtx) + } + } +} + +func TestSendToSessionWithAttachmentsDoesNotDuplicateEchoedFinalTextWithContextIndicator(t *testing.T) { + engine, agent, platform := newMediaEngine(t) + agent.session.blockFirstResult() + + msg := mediaMessage("start long task") + engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + sideText := "delivery ready" + err := engine.SendToSessionWithAttachments( + msg.SessionKey, + sideText, + nil, + []core.FileAttachment{{MimeType: "text/plain", FileName: "report.txt", Data: []byte("report")}}, + ) + if err != nil { + t.Fatalf("SendToSessionWithAttachments() error = %v", err) + } + + agent.session.releaseFirstEvent(core.Event{ + Type: core.EventResult, + Content: sideText, + InputTokens: 52000, + Done: true, + }) + + deadline := time.Now().Add(300 * time.Millisecond) + var lastTexts []string + for time.Now().Before(deadline) { + texts, _, _, _ := platform.snapshot() + lastTexts = texts + count := 0 + for _, text := range texts { + if strings.Contains(text, sideText) { + count++ + } + if strings.Contains(text, "[ctx:") { + t.Fatalf("unexpected duplicate context indicator reply: %#v", texts) + } + } + if count > 1 { + t.Fatalf("texts = %#v, want no duplicate delivery message", texts) + } + time.Sleep(10 * time.Millisecond) + } + count := 0 + for _, text := range lastTexts { + if strings.Contains(text, sideText) { + count++ + } + } + if count != 1 { + t.Fatalf("texts = %#v, want exactly one side-channel delivery message", lastTexts) + } +} + +func TestSendToSessionWithAttachmentsRespectsDisabledAttachmentSend(t *testing.T) { + engine, agent, platform := newMediaEngine(t) + msg := mediaMessage("establish active session") + engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + platform.waitTextContaining(t, "media ok") + + engine.SetAttachmentSendEnabled(false) + err := engine.SendToSessionWithAttachments( + msg.SessionKey, + "should not send", + nil, + []core.FileAttachment{{MimeType: "text/plain", FileName: "blocked.txt", Data: []byte("blocked")}}, + ) + if !errors.Is(err, core.ErrAttachmentSendDisabled) { + t.Fatalf("err = %v, want ErrAttachmentSendDisabled", err) + } + texts, images, files, _ := platform.snapshot() + if containsText(texts, "should not send") || len(images) != 0 || len(files) != 0 { + t.Fatalf("disabled attachment send leaked output: texts=%#v images=%#v files=%#v", texts, images, files) + } +} + +func TestSendToSessionWithAttachmentsRequiresSessionWhenMultipleSessionsHaveAttachments(t *testing.T) { + engine, agent, platform := newMediaEngine(t) + first := mediaMessage("first") + first.SessionKey = "media:chat-1:user-1" + second := mediaMessage("second") + second.SessionKey = "media:chat-1:user-2" + second.ReplyCtx = "reply-ctx-2" + + engine.ReceiveMessage(platform, first) + engine.ReceiveMessage(platform, second) + agent.session.waitRecords(t, 2) + platform.waitTextContaining(t, "media ok") + + err := engine.SendToSessionWithAttachments( + "", + "ambiguous", + []core.ImageAttachment{{MimeType: "image/png", FileName: "ambiguous.png", Data: []byte("img")}}, + nil, + ) + if err == nil || !strings.Contains(err.Error(), "multiple active sessions") { + t.Fatalf("err = %v, want multiple active sessions error", err) + } + texts, images, files, _ := platform.snapshot() + if containsText(texts, "ambiguous") || len(images) != 0 || len(files) != 0 { + t.Fatalf("ambiguous attachment send leaked output: texts=%#v images=%#v files=%#v", texts, images, files) + } +} + +func containsText(texts []string, want string) bool { + for _, text := range texts { + if strings.Contains(text, want) { + return true + } + } + return false +} diff --git a/tests/release_local/turn_contract/turn_contract_test.go b/tests/release_local/turn_contract/turn_contract_test.go new file mode 100644 index 0000000..aa95584 --- /dev/null +++ b/tests/release_local/turn_contract/turn_contract_test.go @@ -0,0 +1,1040 @@ +package turn_contract + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/chenhg5/cc-connect/core" +) + +type turnRecord struct { + prompt string + images []core.ImageAttachment + files []core.FileAttachment +} + +type turnAgent struct { + session *turnSession + model string + workDir string +} + +func newTurnAgent() *turnAgent { + return &turnAgent{session: newTurnSession()} +} + +func (a *turnAgent) Name() string { return "turn-agent" } +func (a *turnAgent) GetModel() string { + return strings.TrimSpace(a.model) +} +func (a *turnAgent) GetWorkDir() string { + return strings.TrimSpace(a.workDir) +} + +func (a *turnAgent) StartSession(_ context.Context, sessionID string) (core.AgentSession, error) { + a.session.setID(sessionID) + return a.session, nil +} + +func (a *turnAgent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) { return nil, nil } +func (a *turnAgent) Stop() error { return a.session.Close() } + +type turnSession struct { + mu sync.Mutex + id string + alive bool + records []turnRecord + events chan core.Event + blockFirst bool + blocked bool + result core.Event + permCalls []permissionCall +} + +type permissionCall struct { + requestID string + result core.PermissionResult +} + +func newTurnSession() *turnSession { + return &turnSession{ + alive: true, + events: make(chan core.Event, 32), + result: core.Event{Type: core.EventResult, Content: "turn ok", Done: true}, + } +} + +func (s *turnSession) setID(id string) { + s.mu.Lock() + defer s.mu.Unlock() + s.id = id +} + +func (s *turnSession) setResult(event core.Event) { + s.mu.Lock() + defer s.mu.Unlock() + s.result = event +} + +func (s *turnSession) blockFirstResult() { + s.mu.Lock() + defer s.mu.Unlock() + s.blockFirst = true +} + +func (s *turnSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.alive { + return errors.New("session closed") + } + s.records = append(s.records, turnRecord{ + prompt: prompt, + images: append([]core.ImageAttachment(nil), images...), + files: append([]core.FileAttachment(nil), files...), + }) + if s.blockFirst && len(s.records) == 1 { + s.blocked = true + return nil + } + s.events <- s.result + return nil +} + +func (s *turnSession) Events() <-chan core.Event { return s.events } +func (s *turnSession) RespondPermission(requestID string, result core.PermissionResult) error { + s.mu.Lock() + defer s.mu.Unlock() + s.permCalls = append(s.permCalls, permissionCall{requestID: requestID, result: result}) + return nil +} +func (s *turnSession) CurrentSessionID() string { + s.mu.Lock() + defer s.mu.Unlock() + return s.id +} +func (s *turnSession) Alive() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.alive +} +func (s *turnSession) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if !s.alive { + return nil + } + s.alive = false + close(s.events) + return nil +} + +func (s *turnSession) releaseFirstResult(event core.Event) { + s.mu.Lock() + defer s.mu.Unlock() + if !s.blocked { + return + } + s.events <- event + s.blocked = false +} + +func (s *turnSession) emit(event core.Event) { + s.events <- event +} + +func (s *turnSession) permissionCalls() []permissionCall { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]permissionCall, len(s.permCalls)) + copy(out, s.permCalls) + return out +} + +func (s *turnSession) waitRecords(t *testing.T, n int) []turnRecord { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + s.mu.Lock() + if len(s.records) >= n { + out := append([]turnRecord(nil), s.records...) + s.mu.Unlock() + return out + } + s.mu.Unlock() + time.Sleep(10 * time.Millisecond) + } + s.mu.Lock() + defer s.mu.Unlock() + t.Fatalf("timeout waiting for %d agent sends, got %d: %#v", n, len(s.records), s.records) + return nil +} + +type turnPlatform struct { + mu sync.Mutex + texts []string + images []core.ImageAttachment + files []core.FileAttachment + replyCtx []any + buttons [][][]core.ButtonOption +} + +func (p *turnPlatform) Name() string { return "turn" } +func (p *turnPlatform) Start(core.MessageHandler) error { + return nil +} +func (p *turnPlatform) Stop() error { return nil } +func (p *turnPlatform) Reply(_ context.Context, replyCtx any, content string) error { + return p.Send(context.Background(), replyCtx, content) +} +func (p *turnPlatform) Send(_ context.Context, replyCtx any, content string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.texts = append(p.texts, content) + p.replyCtx = append(p.replyCtx, replyCtx) + return nil +} +func (p *turnPlatform) SendWithButtons(_ context.Context, replyCtx any, content string, buttons [][]core.ButtonOption) error { + p.mu.Lock() + defer p.mu.Unlock() + p.texts = append(p.texts, content) + p.replyCtx = append(p.replyCtx, replyCtx) + p.buttons = append(p.buttons, buttons) + return nil +} +func (p *turnPlatform) SendImage(_ context.Context, replyCtx any, img core.ImageAttachment) error { + p.mu.Lock() + defer p.mu.Unlock() + p.images = append(p.images, img) + p.replyCtx = append(p.replyCtx, replyCtx) + return nil +} +func (p *turnPlatform) SendFile(_ context.Context, replyCtx any, file core.FileAttachment) error { + p.mu.Lock() + defer p.mu.Unlock() + p.files = append(p.files, file) + p.replyCtx = append(p.replyCtx, replyCtx) + return nil +} + +func (p *turnPlatform) snapshot() (texts []string, images []core.ImageAttachment, files []core.FileAttachment, replyCtx []any) { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.texts...), + append([]core.ImageAttachment(nil), p.images...), + append([]core.FileAttachment(nil), p.files...), + append([]any(nil), p.replyCtx...) +} + +func (p *turnPlatform) waitTextContaining(t *testing.T, substr string) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + texts, _, _, _ := p.snapshot() + for _, text := range texts { + if strings.Contains(text, substr) { + return + } + } + time.Sleep(10 * time.Millisecond) + } + texts, _, _, _ := p.snapshot() + t.Fatalf("timeout waiting for text containing %q, got %#v", substr, texts) +} + +func newTurnEngine(t *testing.T) (*core.Engine, *turnAgent, *turnPlatform) { + t.Helper() + agent := newTurnAgent() + platform := &turnPlatform{} + engine := core.NewEngine("release-turn", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish) + t.Cleanup(func() { + engine.Stop() + _ = agent.Stop() + }) + return engine, agent, platform +} + +func turnMessage(content string) *core.Message { + return &core.Message{ + SessionKey: "turn:chat-1:user-1", + Platform: "turn", + UserID: "user-1", + UserName: "tester", + Content: content, + ReplyCtx: "reply-ctx-1", + } +} + +func TestBasicUserTurnContractAcrossInputModalities(t *testing.T) { + tests := []struct { + name string + content string + images []core.ImageAttachment + files []core.FileAttachment + wantPrompt string + }{ + {name: "text", content: "plain request", wantPrompt: "plain request"}, + {name: "image_only", images: []core.ImageAttachment{{MimeType: "image/png", FileName: "chart.png", Data: []byte("png")}}}, + {name: "file_only", files: []core.FileAttachment{{MimeType: "text/plain", FileName: "notes.txt", Data: []byte("notes")}}}, + { + name: "text_image_file", + content: "inspect these", + images: []core.ImageAttachment{{MimeType: "image/jpeg", FileName: "photo.jpg", Data: []byte("jpg")}}, + files: []core.FileAttachment{{MimeType: "application/pdf", FileName: "spec.pdf", Data: []byte("pdf")}}, + wantPrompt: "inspect these", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + agent.session.setResult(core.Event{Type: core.EventResult, Content: "final answer", InputTokens: 52000, Done: true}) + + msg := turnMessage(tt.content) + msg.Images = tt.images + msg.Files = tt.files + engine.ReceiveMessage(platform, msg) + + records := agent.session.waitRecords(t, 1) + if tt.wantPrompt != "" && !strings.Contains(records[0].prompt, tt.wantPrompt) { + t.Fatalf("prompt = %q, want %q", records[0].prompt, tt.wantPrompt) + } + if len(records[0].images) != len(tt.images) { + t.Fatalf("images = %#v, want %#v", records[0].images, tt.images) + } + if len(records[0].files) != len(tt.files) { + t.Fatalf("files = %#v, want %#v", records[0].files, tt.files) + } + + platform.waitTextContaining(t, "final answer") + texts, _, _, _ := platform.snapshot() + if len(texts) != 1 { + t.Fatalf("texts = %#v, want exactly one final reply", texts) + } + if strings.Count(texts[0], "[ctx:") != 1 { + t.Fatalf("final reply = %q, want exactly one context indicator", texts[0]) + } + }) + } +} + +func TestSideChannelEchoContractAcrossOutboundModalities(t *testing.T) { + tests := []struct { + name string + images []core.ImageAttachment + files []core.FileAttachment + }{ + {name: "text_only"}, + {name: "text_image", images: []core.ImageAttachment{{MimeType: "image/png", FileName: "chart.png", Data: []byte("png")}}}, + {name: "text_file", files: []core.FileAttachment{{MimeType: "text/plain", FileName: "report.txt", Data: []byte("report")}}}, + { + name: "text_image_file", + images: []core.ImageAttachment{{MimeType: "image/png", FileName: "chart.png", Data: []byte("png")}}, + files: []core.FileAttachment{{MimeType: "application/pdf", FileName: "report.pdf", Data: []byte("pdf")}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + agent.session.blockFirstResult() + + msg := turnMessage("start work") + engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + sideText := "delivery ready" + if err := engine.SendToSessionWithAttachments(msg.SessionKey, sideText, tt.images, tt.files); err != nil { + t.Fatalf("SendToSessionWithAttachments() error = %v", err) + } + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: sideText, InputTokens: 52000, Done: true}) + assertStableSideChannelOnly(t, platform, sideText) + }) + } +} + +func TestSideChannelDifferentFinalContract(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + agent.session.blockFirstResult() + + msg := turnMessage("start work") + engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + sideText := "delivery ready" + if err := engine.SendToSessionWithAttachments(msg.SessionKey, sideText, nil, nil); err != nil { + t.Fatalf("SendToSessionWithAttachments() error = %v", err) + } + + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: "separate final answer", InputTokens: 52000, Done: true}) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + texts, _, _, _ := platform.snapshot() + if len(texts) >= 2 { + if !containsText(texts, sideText) || !containsText(texts, "separate final answer") { + t.Fatalf("texts = %#v, want side-channel and distinct final reply", texts) + } + return + } + time.Sleep(10 * time.Millisecond) + } + texts, _, _, _ := platform.snapshot() + t.Fatalf("texts = %#v, want side-channel plus distinct final reply", texts) +} + +func TestThinkingAndToolEventsContract(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + engine.SetDisplayConfig(core.DisplayCfg{ + Mode: "full", + ThinkingMessages: true, + ToolMessages: true, + ThinkingMaxLen: 300, + ToolMaxLen: 500, + }) + agent.session.blockFirstResult() + + msg := turnMessage("run a tool") + go engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + agent.session.emit(core.Event{Type: core.EventThinking, Content: "planning the command"}) + agent.session.emit(core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: "echo tool-output"}) + agent.session.emit(core.Event{Type: core.EventToolResult, ToolName: "Bash", ToolResult: "tool-output", ToolStatus: "completed"}) + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: "final answer", InputTokens: 52000, Done: true}) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + texts, _, _, _ := platform.snapshot() + joined := strings.Join(texts, "\n") + if strings.Contains(joined, "planning the command") && + strings.Contains(joined, "Bash") && + strings.Contains(joined, "tool-output") && + strings.Contains(joined, "final answer") { + if countContaining(texts, "final answer") != 1 { + t.Fatalf("texts = %#v, want exactly one final answer", texts) + } + return + } + time.Sleep(10 * time.Millisecond) + } + texts, _, _, _ := platform.snapshot() + t.Fatalf("texts = %#v, want thinking, tool use/result, and final answer", texts) +} + +func TestHiddenToolEventsContractKeepsFinalAndHidesToolDetails(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + engine.SetDisplayConfig(core.DisplayCfg{ + Mode: "full", + ThinkingMessages: true, + ToolMessages: false, + ThinkingMaxLen: 300, + ToolMaxLen: 500, + }) + agent.session.blockFirstResult() + + msg := turnMessage("run a hidden tool") + go engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + agent.session.emit(core.Event{Type: core.EventThinking, Content: "planning hidden work"}) + agent.session.emit(core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: "cat secret.txt"}) + agent.session.emit(core.Event{Type: core.EventToolResult, ToolName: "Bash", ToolResult: "secret-output", ToolStatus: "completed"}) + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: "final answer", InputTokens: 52000, Done: true}) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + texts, _, _, _ := platform.snapshot() + joined := strings.Join(texts, "\n") + if strings.Contains(joined, "final answer") { + if strings.Contains(joined, "Bash") || strings.Contains(joined, "cat secret.txt") || strings.Contains(joined, "secret-output") { + t.Fatalf("hidden tool details leaked to platform: %#v", texts) + } + if countContaining(texts, "final answer") != 1 { + t.Fatalf("texts = %#v, want exactly one final answer", texts) + } + return + } + time.Sleep(10 * time.Millisecond) + } + texts, _, _, _ := platform.snapshot() + t.Fatalf("texts = %#v, want final answer even when tool messages are hidden", texts) +} + +func TestPermissionInteractionContractWhileAgentSendIsBlocked(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + agent.session.blockFirstResult() + + msg := turnMessage("write a file") + go engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + agent.session.emit(core.Event{ + Type: core.EventPermissionRequest, + RequestID: "req-write", + ToolName: "write_file", + ToolInput: "/tmp/contract.txt", + ToolInputRaw: map[string]any{"path": "/tmp/contract.txt"}, + }) + platform.waitTextContaining(t, "write_file") + + engine.ReceiveMessage(platform, &core.Message{ + SessionKey: msg.SessionKey, + Platform: msg.Platform, + UserID: msg.UserID, + UserName: msg.UserName, + Content: "allow", + ReplyCtx: "reply-ctx-allow", + }) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + calls := agent.session.permissionCalls() + if len(calls) > 0 { + if len(calls) != 1 { + t.Fatalf("permission calls = %#v, want exactly one", calls) + } + if calls[0].requestID != "req-write" || calls[0].result.Behavior != "allow" { + t.Fatalf("permission call = %#v, want allow for req-write", calls[0]) + } + break + } + time.Sleep(10 * time.Millisecond) + } + if len(agent.session.permissionCalls()) != 1 { + t.Fatalf("permission calls = %#v, want one allow response", agent.session.permissionCalls()) + } + + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: "write complete", InputTokens: 52000, Done: true}) + platform.waitTextContaining(t, "write complete") + + records := agent.session.waitRecords(t, 1) + if len(records) != 1 { + t.Fatalf("agent sends = %#v, permission response should not start a second user turn", records) + } + texts, _, _, _ := platform.snapshot() + if countContaining(texts, "write complete") != 1 { + t.Fatalf("texts = %#v, want exactly one final write completion", texts) + } +} + +func TestStreamingPreviewFinalizationContractExposesDuplicateFinalSend(t *testing.T) { + agent := newTurnAgent() + platform := &previewLifecyclePlatform{} + engine := core.NewEngine("release-preview", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish) + t.Cleanup(func() { + engine.Stop() + _ = agent.Stop() + }) + agent.session.blockFirstResult() + + msg := turnMessage("produce a long direct response") + go engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + previewText := strings.Repeat("preview content ", 20) + agent.session.emit(core.Event{Type: core.EventText, Content: previewText}) + platform.waitPreviewStarts(t, 1) + + agent.session.releaseFirstResult(core.Event{ + Type: core.EventResult, + Content: previewText, + InputTokens: 52000, + Done: true, + }) + + platform.waitPreviewUpdates(t, 1) + texts, starts, updates, deletes := platform.snapshotPreviewLifecycle() + if len(texts) != 0 || len(starts) != 1 || len(updates) == 0 || len(deletes) != 0 { + t.Fatalf( + "streaming preview finalization violated: normal final text was sent separately while preview remained active\ntexts=%#v\npreview_starts=%#v\npreview_updates=%#v\npreview_deletes=%#v", + texts, starts, updates, deletes, + ) + } +} + +func TestStreamingPreviewConfigurationMatrix(t *testing.T) { + tests := []struct { + name string + cfg core.StreamPreviewCfg + wantPreview bool + }{ + { + name: "enabled_keeps_preview_in_place", + cfg: core.StreamPreviewCfg{ + Enabled: true, + IntervalMs: 1, + MinDeltaChars: 1, + MaxChars: 5000, + }, + wantPreview: true, + }, + { + name: "disabled_globally_sends_final_once", + cfg: core.StreamPreviewCfg{ + Enabled: false, + IntervalMs: 1, + MinDeltaChars: 1, + MaxChars: 5000, + }, + }, + { + name: "disabled_for_platform_sends_final_once", + cfg: core.StreamPreviewCfg{ + Enabled: true, + DisabledPlatforms: []string{"feishu"}, + IntervalMs: 1, + MinDeltaChars: 1, + MaxChars: 5000, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent := newTurnAgent() + platform := &previewLifecyclePlatform{} + engine := core.NewEngine("release-preview-matrix", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish) + engine.SetStreamPreviewCfg(tt.cfg) + t.Cleanup(func() { + engine.Stop() + _ = agent.Stop() + }) + agent.session.blockFirstResult() + + msg := turnMessage("streaming config matrix") + go engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + body := strings.Repeat("stream body ", 20) + agent.session.emit(core.Event{Type: core.EventText, Content: body}) + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: body, InputTokens: 52000, Done: true}) + + if tt.wantPreview { + platform.waitPreviewUpdates(t, 1) + } else { + platform.waitSentTexts(t, 1) + } + + texts, starts, updates, deletes := platform.snapshotPreviewLifecycle() + if tt.wantPreview { + if len(texts) != 0 || len(starts) != 1 || len(updates) == 0 || len(deletes) != 0 { + t.Fatalf("preview lifecycle = texts:%#v starts:%#v updates:%#v deletes:%#v, want in-place preview finalize", texts, starts, updates, deletes) + } + if !strings.Contains(updates[len(updates)-1], "[ctx:") { + t.Fatalf("final preview update = %q, want context indicator", updates[len(updates)-1]) + } + return + } + if len(starts) != 0 || len(updates) != 0 || len(deletes) != 0 { + t.Fatalf("preview lifecycle = starts:%#v updates:%#v deletes:%#v, want no preview when disabled", starts, updates, deletes) + } + if len(texts) != 1 || !strings.Contains(texts[0], strings.TrimSpace(body)) || strings.Count(texts[0], "[ctx:") != 1 { + t.Fatalf("texts = %#v, want one final send with context indicator", texts) + } + }) + } +} + +func TestStreamingPreviewMaxCharsOnlyTruncatesIntermediatePreview(t *testing.T) { + agent := newTurnAgent() + platform := &previewLifecyclePlatform{} + engine := core.NewEngine("release-preview-maxchars", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish) + engine.SetStreamPreviewCfg(core.StreamPreviewCfg{ + Enabled: true, + IntervalMs: 1, + MinDeltaChars: 1, + MaxChars: 20, + }) + t.Cleanup(func() { + engine.Stop() + _ = agent.Stop() + }) + agent.session.blockFirstResult() + + msg := turnMessage("stream max chars") + go engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + body := strings.Repeat("full final body ", 30) + agent.session.emit(core.Event{Type: core.EventText, Content: body}) + platform.waitPreviewStarts(t, 1) + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: body, InputTokens: 52000, Done: true}) + platform.waitPreviewUpdates(t, 1) + + _, starts, updates, _ := platform.snapshotPreviewLifecycle() + if !strings.Contains(starts[0], "…") { + t.Fatalf("initial preview = %q, want max_chars truncation marker", starts[0]) + } + final := updates[len(updates)-1] + if !strings.Contains(final, strings.TrimSpace(body)) { + t.Fatalf("final preview update = %q, want full untruncated final body", final) + } +} + +func TestReplyMetadataConfigurationMatrix(t *testing.T) { + tests := []struct { + name string + showCtx bool + showFooter bool + want []string + forbid []string + }{ + { + name: "context_and_footer_on_share_one_line", + showCtx: true, + showFooter: true, + want: []string{"answer", "*[ctx: ~14%] · glm-5.1 · …/tmp/release-agent*"}, + }, + { + name: "context_off_footer_on_keeps_model_footer", + showCtx: false, + showFooter: true, + want: []string{"answer", "*glm-5.1 · …/tmp/release-agent*"}, + forbid: []string{"[ctx:"}, + }, + { + name: "context_on_footer_off_uses_legacy_context_suffix", + showCtx: true, + showFooter: false, + want: []string{"answer\n[ctx: ~14%]"}, + forbid: []string{"glm-5.1", "/tmp/release-agent"}, + }, + { + name: "context_and_footer_off_plain_answer", + showCtx: false, + showFooter: false, + want: []string{"answer"}, + forbid: []string{"[ctx:", "glm-5.1", "/tmp/release-agent"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + agent.model = "glm-5.1" + agent.workDir = "/tmp/release-agent" + engine.SetShowContextIndicator(tt.showCtx) + engine.SetReplyFooterEnabled(tt.showFooter) + agent.session.setResult(core.Event{Type: core.EventResult, Content: "answer", InputTokens: 28000, Done: true}) + + engine.ReceiveMessage(platform, turnMessage("metadata matrix")) + platform.waitTextContaining(t, "answer") + + texts, _, _, _ := platform.snapshot() + if len(texts) != 1 { + t.Fatalf("texts = %#v, want exactly one final reply", texts) + } + for _, want := range tt.want { + if !strings.Contains(texts[0], want) { + t.Fatalf("reply = %q, want contains %q", texts[0], want) + } + } + for _, forbidden := range tt.forbid { + if strings.Contains(texts[0], forbidden) { + t.Fatalf("reply = %q, should not contain %q", texts[0], forbidden) + } + } + }) + } +} + +func TestLongFinalResponseKeepsMetadataOnceAtTail(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + agent.model = "glm-5.1" + agent.workDir = "/tmp/release-agent" + engine.SetReplyFooterEnabled(true) + + body := strings.Repeat("long-response ", 420) + agent.session.setResult(core.Event{Type: core.EventResult, Content: body, InputTokens: 28000, Done: true}) + + engine.ReceiveMessage(platform, turnMessage("long final")) + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + texts, _, _, _ := platform.snapshot() + if len(texts) >= 2 { + joined := strings.Join(texts, "") + if strings.Count(joined, "[ctx:") != 1 || strings.Count(joined, "glm-5.1") != 1 { + t.Fatalf("chunks = %#v, want metadata exactly once", texts) + } + if !strings.Contains(texts[len(texts)-1], "[ctx: ~14%] · glm-5.1") { + t.Fatalf("last chunk = %q, want metadata at tail", texts[len(texts)-1]) + } + return + } + time.Sleep(10 * time.Millisecond) + } + texts, _, _, _ := platform.snapshot() + t.Fatalf("texts = %#v, want long response split into multiple chunks", texts) +} + +func TestDisplayVisibilityConfigurationMatrix(t *testing.T) { + tests := []struct { + name string + thinking bool + tools bool + wantThinking bool + wantTool bool + }{ + {name: "show_both", thinking: true, tools: true, wantThinking: true, wantTool: true}, + {name: "hide_thinking", thinking: false, tools: true, wantTool: true}, + {name: "hide_tools", thinking: true, tools: false, wantThinking: true}, + {name: "hide_both", thinking: false, tools: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + engine, agent, platform := newTurnEngine(t) + engine.SetDisplayConfig(core.DisplayCfg{ + Mode: "full", + CardMode: "legacy", + ThinkingMessages: tt.thinking, + ToolMessages: tt.tools, + ThinkingMaxLen: 300, + ToolMaxLen: 500, + }) + agent.session.blockFirstResult() + + msg := turnMessage("visibility matrix") + go engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + agent.session.emit(core.Event{Type: core.EventThinking, Content: "matrix thinking"}) + agent.session.emit(core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: "echo visible"}) + agent.session.emit(core.Event{Type: core.EventToolResult, ToolName: "Bash", ToolResult: "visible output", ToolStatus: "completed"}) + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: "matrix final", InputTokens: 52000, Done: true}) + platform.waitTextContaining(t, "matrix final") + + texts, _, _, _ := platform.snapshot() + joined := strings.Join(texts, "\n") + if got := strings.Contains(joined, "matrix thinking"); got != tt.wantThinking { + t.Fatalf("thinking visibility = %v, want %v; texts=%#v", got, tt.wantThinking, texts) + } + hasTool := strings.Contains(joined, "Bash") || strings.Contains(joined, "echo visible") || strings.Contains(joined, "visible output") + if hasTool != tt.wantTool { + t.Fatalf("tool visibility = %v, want %v; texts=%#v", hasTool, tt.wantTool, texts) + } + if countContaining(texts, "matrix final") != 1 { + t.Fatalf("texts=%#v, want exactly one final answer", texts) + } + }) + } +} + +func TestRichCardModeKeepsToolStepsAndFinalMetadataInOneCard(t *testing.T) { + agent := newTurnAgent() + agent.model = "glm-5.1" + agent.workDir = "/tmp/release-agent" + platform := &richPreviewPlatform{} + engine := core.NewEngine("release-rich-card", agent, []core.Platform{platform}, t.TempDir()+"/sessions.json", core.LangEnglish) + engine.SetReplyFooterEnabled(true) + engine.SetDisplayConfig(core.DisplayCfg{ + Mode: "full", + CardMode: "rich", + ThinkingMessages: true, + ToolMessages: true, + ThinkingMaxLen: 300, + ToolMaxLen: 500, + }) + t.Cleanup(func() { + engine.Stop() + _ = agent.Stop() + }) + agent.session.blockFirstResult() + + msg := turnMessage("rich card tool turn") + go engine.ReceiveMessage(platform, msg) + agent.session.waitRecords(t, 1) + + agent.session.emit(core.Event{Type: core.EventThinking, Content: "rich thinking"}) + agent.session.emit(core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: "echo rich"}) + agent.session.emit(core.Event{Type: core.EventToolResult, ToolName: "Bash", ToolResult: "rich output", ToolStatus: "completed"}) + agent.session.releaseFirstResult(core.Event{Type: core.EventResult, Content: "rich final", InputTokens: 28000, Done: true}) + platform.waitPreviewUpdates(t, 3) + + texts, starts, updates, deletes := platform.snapshotPreviewLifecycle() + if len(texts) != 0 || len(starts) != 1 || len(updates) == 0 || len(deletes) != 0 { + t.Fatalf("rich lifecycle = texts:%#v starts:%#v updates:%#v deletes:%#v, want one editable rich card", texts, starts, updates, deletes) + } + final := updates[len(updates)-1] + for _, want := range []string{"status=done", "step=Bash", "rich output", "markdown=rich final", "[ctx: ~14%] · glm-5.1"} { + if !strings.Contains(final, want) { + t.Fatalf("final rich card = %q, want contains %q", final, want) + } + } +} + +type previewLifecyclePlatform struct { + turnPlatform + + mu sync.Mutex + previewStarts []string + previewUpdates []string + previewDeletes []any +} + +func (p *previewLifecyclePlatform) Name() string { return "feishu" } + +func (p *previewLifecyclePlatform) KeepPreviewOnFinish() bool { return true } + +func (p *previewLifecyclePlatform) SendPreviewStart(_ context.Context, _ any, content string) (any, error) { + p.mu.Lock() + defer p.mu.Unlock() + p.previewStarts = append(p.previewStarts, content) + return "preview-1", nil +} + +func (p *previewLifecyclePlatform) UpdateMessage(_ context.Context, handle any, content string) error { + p.mu.Lock() + defer p.mu.Unlock() + p.previewUpdates = append(p.previewUpdates, content) + return nil +} + +func (p *previewLifecyclePlatform) DeletePreviewMessage(_ context.Context, handle any) error { + p.mu.Lock() + defer p.mu.Unlock() + p.previewDeletes = append(p.previewDeletes, handle) + return nil +} + +func (p *previewLifecyclePlatform) waitPreviewStarts(t *testing.T, n int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + p.mu.Lock() + got := len(p.previewStarts) + p.mu.Unlock() + if got >= n { + return + } + time.Sleep(10 * time.Millisecond) + } + _, starts, updates, deletes := p.snapshotPreviewLifecycle() + t.Fatalf("timeout waiting for %d preview starts; starts=%#v updates=%#v deletes=%#v", n, starts, updates, deletes) +} + +func (p *previewLifecyclePlatform) waitSentTexts(t *testing.T, n int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + texts, _, _, _ := p.snapshotPreviewLifecycle() + if len(texts) >= n { + return + } + time.Sleep(10 * time.Millisecond) + } + texts, starts, updates, deletes := p.snapshotPreviewLifecycle() + t.Fatalf("timeout waiting for %d final sends; texts=%#v starts=%#v updates=%#v deletes=%#v", n, texts, starts, updates, deletes) +} + +func (p *previewLifecyclePlatform) waitPreviewUpdates(t *testing.T, n int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + _, _, updates, _ := p.snapshotPreviewLifecycle() + if len(updates) >= n { + return + } + time.Sleep(10 * time.Millisecond) + } + texts, starts, updates, deletes := p.snapshotPreviewLifecycle() + t.Fatalf("timeout waiting for %d preview updates; texts=%#v starts=%#v updates=%#v deletes=%#v", n, texts, starts, updates, deletes) +} + +func (p *previewLifecyclePlatform) snapshotPreviewLifecycle() (texts []string, starts []string, updates []string, deletes []any) { + texts, _, _, _ = p.turnPlatform.snapshot() + p.mu.Lock() + defer p.mu.Unlock() + return texts, + append([]string(nil), p.previewStarts...), + append([]string(nil), p.previewUpdates...), + append([]any(nil), p.previewDeletes...) +} + +type richPreviewPlatform struct { + previewLifecyclePlatform +} + +func (p *richPreviewPlatform) BuildRichCard(status core.CardStatus, title string, steps []core.ToolStep, markdown string, streaming bool, elapsed time.Duration) string { + var b strings.Builder + b.WriteString("status=") + b.WriteString(string(status)) + if streaming { + b.WriteString(" streaming=true") + } + b.WriteString("\n") + for _, step := range steps { + b.WriteString("step=") + b.WriteString(step.Name) + b.WriteString(" summary=") + b.WriteString(step.Summary) + if step.Result != "" { + b.WriteString(" result=") + b.WriteString(step.Result) + } + b.WriteString("\n") + } + if markdown != "" { + b.WriteString("markdown=") + b.WriteString(markdown) + b.WriteString("\n") + } + return b.String() +} + +func assertStableSideChannelOnly(t *testing.T, platform *turnPlatform, sideText string) { + t.Helper() + deadline := time.Now().Add(300 * time.Millisecond) + var lastTexts []string + for time.Now().Before(deadline) { + texts, _, _, _ := platform.snapshot() + lastTexts = texts + count := 0 + for _, text := range texts { + if strings.Contains(text, sideText) { + count++ + } + if strings.Contains(text, "[ctx:") { + t.Fatalf("unexpected context-only duplicate reply: %#v", texts) + } + } + if count > 1 { + t.Fatalf("texts = %#v, want no duplicate side-channel text", texts) + } + time.Sleep(10 * time.Millisecond) + } + count := 0 + for _, text := range lastTexts { + if strings.Contains(text, sideText) { + count++ + } + } + if count != 1 { + t.Fatalf("texts = %#v, want exactly one side-channel text", lastTexts) + } +} + +func countContaining(texts []string, substr string) int { + count := 0 + for _, text := range texts { + if strings.Contains(text, substr) { + count++ + } + } + return count +} + +func containsText(texts []string, substr string) bool { + for _, text := range texts { + if strings.Contains(text, substr) { + return true + } + } + return false +} diff --git a/web/.pnpmrc.json b/web/.pnpmrc.json new file mode 100644 index 0000000..00369fe --- /dev/null +++ b/web/.pnpmrc.json @@ -0,0 +1 @@ +{"onlyBuiltDependencies":["esbuild"]} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..28322f7 --- /dev/null +++ b/web/embed.go @@ -0,0 +1,27 @@ +//go:build !no_web + +package web + +import ( + "embed" + "io/fs" + "log/slog" + + "github.com/chenhg5/cc-connect/core" +) + +//go:embed all:dist +var distFS embed.FS + +func init() { + sub, err := fs.Sub(distFS, "dist") + if err != nil { + slog.Warn("web: embedded dist directory unavailable; web admin disabled", "error", err) + return + } + if _, err := fs.Stat(sub, "index.html"); err != nil { + slog.Warn("web: embedded dist assets missing index.html; web admin disabled", "error", err) + return + } + core.RegisterWebAssets(sub) +} diff --git a/web/embed_stub.go b/web/embed_stub.go new file mode 100644 index 0000000..6c2cdb4 --- /dev/null +++ b/web/embed_stub.go @@ -0,0 +1,3 @@ +//go:build no_web + +package web diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..73e9783 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + CC-Connect Admin + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4330acd --- /dev/null +++ b/web/package.json @@ -0,0 +1,37 @@ +{ + "name": "cc-connect-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/typography": "^0.5.19", + "clsx": "^2.1.1", + "highlight.js": "^11.11.1", + "i18next": "^25.1.2", + "lucide-react": "^0.487.0", + "qrcode.react": "^4.2.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-i18next": "^15.5.1", + "react-markdown": "^10.1.0", + "react-router-dom": "^7.5.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", + "zustand": "^5.0.5" + }, + "devDependencies": { + "@types/react": "^19.1.2", + "@types/react-dom": "^19.1.2", + "@vitejs/plugin-react": "^4.4.1", + "autoprefixer": "^10.4.21", + "postcss": "^8.5.3", + "tailwindcss": "^3.4.17", + "typescript": "~5.8.3", + "vite": "^6.3.2" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..f4555e0 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,2773 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@3.4.19) + clsx: + specifier: ^2.1.1 + version: 2.1.1 + highlight.js: + specifier: ^11.11.1 + version: 11.11.1 + i18next: + specifier: ^25.1.2 + version: 25.10.9(typescript@5.8.3) + lucide-react: + specifier: ^0.487.0 + version: 0.487.0(react@19.2.4) + qrcode.react: + specifier: ^4.2.0 + version: 4.2.0(react@19.2.4) + react: + specifier: ^19.1.0 + version: 19.2.4 + react-dom: + specifier: ^19.1.0 + version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^15.5.1 + version: 15.7.4(i18next@25.10.9(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + react-router-dom: + specifier: ^7.5.0 + version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + rehype-highlight: + specifier: ^7.0.2 + version: 7.0.2 + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + zustand: + specifier: ^5.0.5 + version: 5.0.12(@types/react@19.2.14)(react@19.2.4) + devDependencies: + '@types/react': + specifier: ^19.1.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.1.2 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.7.0(vite@6.4.1(jiti@1.21.7)) + autoprefixer: + specifier: ^10.4.21 + version: 10.4.27(postcss@8.5.8) + postcss: + specifier: ^8.5.3 + version: 8.5.8 + tailwindcss: + specifier: ^3.4.17 + version: 3.4.19 + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^6.3.2 + version: 6.4.1(jiti@1.21.7) + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.2': + resolution: {integrity: sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.60.0': + resolution: {integrity: sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.0': + resolution: {integrity: sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.0': + resolution: {integrity: sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.0': + resolution: {integrity: sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.0': + resolution: {integrity: sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.0': + resolution: {integrity: sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.0': + resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.0': + resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.0': + resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.0': + resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.0': + resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.0': + resolution: {integrity: sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + resolution: {integrity: sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + resolution: {integrity: sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.0': + resolution: {integrity: sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.0': + resolution: {integrity: sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==} + cpu: [x64] + os: [win32] + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.10.10: + resolution: {integrity: sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001781: + resolution: {integrity: sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + electron-to-chromium@1.5.322: + resolution: {integrity: sha512-vFU34OcrvMcH66T+dYC3G4nURmgfDVewMIu6Q2urXpumAPSMmzvcn04KVVV8Opikq8Vs5nUbO/8laNhNRqSzYw==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + i18next@25.10.9: + resolution: {integrity: sha512-hQY9/bFoQKGlSKMlaCuLR8w1h5JjieqrsnZvEmj1Ja6Ec7fbyc4cTrCsY9mb9Sd8YQ/swsrKz1S9M8AcvVI70w==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lowlight@3.3.0: + resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@0.487.0: + resolution: {integrity: sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.36: + resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + qrcode.react@4.2.0: + resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-i18next@15.7.4: + resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} + peerDependencies: + i18next: '>= 23.4.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.13.2: + resolution: {integrity: sha512-aR7SUORwTqAW0JDeiWF07e9SBE9qGpByR9I8kJT5h/FrBKxPMS6TiC7rmVO+gC0q52Bx7JnjWe8Z1sR9faN4YA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.13.2: + resolution: {integrity: sha512-tX1Aee+ArlKQP+NIUd7SE6Li+CiGKwQtbS+FfRxPX6Pe4vHOo6nr9d++u5cwg+Z8K/x8tP+7qLmujDtfrAoUJA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + rehype-highlight@7.0.2: + resolution: {integrity: sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.60.0: + resolution: {integrity: sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.2': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.2 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.60.0': + optional: true + + '@rollup/rollup-android-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.0': + optional: true + + '@rollup/rollup-darwin-x64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.0': + optional: true + + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 3.4.19 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.2 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-react@4.7.0(vite@6.4.1(jiti@1.21.7))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.1(jiti@1.21.7) + transitivePeerDependencies: + - supports-color + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + autoprefixer@10.4.27(postcss@8.5.8): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001781 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + + bail@2.0.2: {} + + baseline-browser-mapping@2.10.10: {} + + binary-extensions@2.3.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.10 + caniuse-lite: 1.0.30001781 + electron-to-chromium: 1.5.322 + node-releases: 2.0.36 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001781: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + clsx@2.1.1: {} + + comma-separated-tokens@2.0.3: {} + + commander@4.1.1: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + electron-to-chromium@1.5.322: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + extend@3.0.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + highlight.js@11.11.1: {} + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + html-url-attributes@3.0.1: {} + + i18next@25.10.9(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.29.2 + optionalDependencies: + typescript: 5.8.3 + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-decimal@2.0.1: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-number@7.0.0: {} + + is-plain-obj@4.1.0: {} + + jiti@1.21.7: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + longest-streak@3.1.0: {} + + lowlight@3.3.0: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + highlight.js: 11.11.1 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@0.487.0(react@19.2.4): + dependencies: + react: 19.2.4 + + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.11: {} + + node-releases@2.0.36: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + pify@2.3.0: {} + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.8): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.8 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.8): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.8 + + postcss-nested@6.2.0(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.1.0: {} + + qrcode.react@4.2.0(react@19.2.4): + dependencies: + react: 19.2.4 + + queue-microtask@1.2.3: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-i18next@15.7.4(i18next@25.10.9(typescript@5.8.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.8.3): + dependencies: + '@babel/runtime': 7.29.2 + html-parse-stringify: 3.0.1 + i18next: 25.10.9(typescript@5.8.3) + react: 19.2.4 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.8.3 + + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-refresh@0.17.0: {} + + react-router-dom@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-router: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + + react-router@7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + cookie: 1.1.1 + react: 19.2.4 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + + react@19.2.4: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + + rehype-highlight@7.0.2: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-text: 4.0.2 + lowlight: 3.3.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rollup@4.60.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.0 + '@rollup/rollup-android-arm64': 4.60.0 + '@rollup/rollup-darwin-arm64': 4.60.0 + '@rollup/rollup-darwin-x64': 4.60.0 + '@rollup/rollup-freebsd-arm64': 4.60.0 + '@rollup/rollup-freebsd-x64': 4.60.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.0 + '@rollup/rollup-linux-arm-musleabihf': 4.60.0 + '@rollup/rollup-linux-arm64-gnu': 4.60.0 + '@rollup/rollup-linux-arm64-musl': 4.60.0 + '@rollup/rollup-linux-loong64-gnu': 4.60.0 + '@rollup/rollup-linux-loong64-musl': 4.60.0 + '@rollup/rollup-linux-ppc64-gnu': 4.60.0 + '@rollup/rollup-linux-ppc64-musl': 4.60.0 + '@rollup/rollup-linux-riscv64-gnu': 4.60.0 + '@rollup/rollup-linux-riscv64-musl': 4.60.0 + '@rollup/rollup-linux-s390x-gnu': 4.60.0 + '@rollup/rollup-linux-x64-gnu': 4.60.0 + '@rollup/rollup-linux-x64-musl': 4.60.0 + '@rollup/rollup-openbsd-x64': 4.60.0 + '@rollup/rollup-openharmony-arm64': 4.60.0 + '@rollup/rollup-win32-arm64-msvc': 4.60.0 + '@rollup/rollup-win32-ia32-msvc': 4.60.0 + '@rollup/rollup-win32-x64-gnu': 4.60.0 + '@rollup/rollup-win32-x64-msvc': 4.60.0 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-import: 15.1.0(postcss@8.5.8) + postcss-js: 4.1.0(postcss@8.5.8) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.8) + postcss-nested: 6.2.0(postcss@8.5.8) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-interface-checker@0.1.13: {} + + typescript@5.8.3: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@6.4.1(jiti@1.21.7): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + jiti: 1.21.7 + + void-elements@3.1.0: {} + + yallist@3.1.1: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + + zwitch@2.0.4: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..0ef6ea0 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1 @@ +onlyBuiltDependencies: esbuild diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/preview.html b/web/preview.html new file mode 100644 index 0000000..b9f2db7 --- /dev/null +++ b/web/preview.html @@ -0,0 +1,344 @@ + + + + + + +Vibe Usage + + + + + + + + + +
+ + +
+ +
Vibe Usage
+ +
+
1D
+
7D
+
30D
+
+ +
+ + + +
+ +
+
预估费用
+
$2372.04
+
+ +
+
总 Token
+
142.1M
+
+ +
+
输入 Token
+
127.1M
+
+ +
+
输出 Token
+
12.2M
+
+ +
+
缓存 Token
+
4415.5M
+
+ +
+ + + +
+ + + + + + +
+ + + + + + + + \ No newline at end of file diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..6a7e16a --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..b0e8374 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,40 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; +import { useAuthStore } from '@/store/auth'; +import Layout from '@/components/Layout/Layout'; +import Login from '@/pages/Login'; +import Dashboard from '@/pages/Dashboard'; +import ProjectList from '@/pages/Projects/ProjectList'; +import ProjectDetail from '@/pages/Projects/ProjectDetail'; +import ChatList from '@/pages/Chat/ChatList'; +import ChatView from '@/pages/Chat/ChatView'; +import CronList from '@/pages/Cron/CronList'; +import SystemConfig from '@/pages/System/Config'; +import ProviderList from '@/pages/Providers/ProviderList'; +import SkillList from '@/pages/Skills/SkillList'; + +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + if (!isAuthenticated) return ; + return <>{children}; +} + +export default function App() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + + return ( + + : } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/web/src/api/bridge.ts b/web/src/api/bridge.ts new file mode 100644 index 0000000..1285550 --- /dev/null +++ b/web/src/api/bridge.ts @@ -0,0 +1,10 @@ +import api from './client'; + +export interface BridgeAdapter { + platform: string; + project: string; + capabilities: string[]; + connected_at: string; +} + +export const listBridgeAdapters = () => api.get<{ adapters: BridgeAdapter[] }>('/bridge/adapters'); diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..7970da8 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,77 @@ +const API_BASE = '/api/v1'; + +type UnauthorizedHandler = () => void; + +class ApiClient { + private token: string = ''; + private onUnauthorized?: UnauthorizedHandler; + + setToken(token: string) { + this.token = token; + } + + getToken(): string { + return this.token; + } + + setOnUnauthorized(handler: UnauthorizedHandler) { + this.onUnauthorized = handler; + } + + private headers(): HeadersInit { + const h: HeadersInit = { 'Content-Type': 'application/json' }; + if (this.token) h['Authorization'] = `Bearer ${this.token}`; + return h; + } + + async request(method: string, path: string, body?: any, params?: Record): Promise { + let url = `${API_BASE}${path}`; + if (params) { + const qs = new URLSearchParams(params).toString(); + if (qs) url += `?${qs}`; + } + const res = await fetch(url, { + method, + headers: this.headers(), + body: body ? JSON.stringify(body) : undefined, + }); + if (res.status === 401 && this.onUnauthorized) { + this.onUnauthorized(); + throw new ApiError('Unauthorized', 401); + } + const json = await res.json(); + if (!json.ok) { + throw new ApiError(json.error || 'Unknown error', res.status); + } + return json.data as T; + } + + get(path: string, params?: Record) { return this.request('GET', path, undefined, params); } + post(path: string, body?: any) { return this.request('POST', path, body); } + put(path: string, body?: any) { return this.request('PUT', path, body); } + patch(path: string, body?: any) { return this.request('PATCH', path, body); } + delete(path: string) { return this.request('DELETE', path); } + + /** Fetch raw text (non-JSON) from an API endpoint. */ + async raw(path: string): Promise { + const h: HeadersInit = {}; + if (this.token) h['Authorization'] = `Bearer ${this.token}`; + const res = await fetch(`${API_BASE}${path}`, { headers: h }); + if (res.status === 401 && this.onUnauthorized) { + this.onUnauthorized(); + throw new ApiError('Unauthorized', 401); + } + if (!res.ok) throw new ApiError(res.statusText, res.status); + return res.text(); + } +} + +export class ApiError extends Error { + constructor(message: string, public status: number) { + super(message); + this.name = 'ApiError'; + } +} + +export const api = new ApiClient(); +export default api; diff --git a/web/src/api/cron.ts b/web/src/api/cron.ts new file mode 100644 index 0000000..9455a8a --- /dev/null +++ b/web/src/api/cron.ts @@ -0,0 +1,27 @@ +import api from './client'; + +export interface CronJob { + id: string; + project: string; + session_key: string; + cron_expr: string; + prompt: string; + exec: string; + work_dir: string; + description: string; + enabled: boolean; + silent: boolean; + mute: boolean; + session_mode: string; + mode: string; + timeout_mins: number | null; + created_at: string; + last_run: string; + last_error: string; +} + +export const listCronJobs = (project?: string) => + api.get<{ jobs: CronJob[] }>('/cron', project ? { project } : undefined); +export const createCronJob = (body: Partial) => api.post('/cron', body); +export const updateCronJob = (id: string, fields: Record) => api.patch(`/cron/${id}`, fields); +export const deleteCronJob = (id: string) => api.delete(`/cron/${id}`); diff --git a/web/src/api/heartbeat.ts b/web/src/api/heartbeat.ts new file mode 100644 index 0000000..5fc095f --- /dev/null +++ b/web/src/api/heartbeat.ts @@ -0,0 +1,22 @@ +import api from './client'; + +export interface HeartbeatStatus { + enabled: boolean; + paused: boolean; + interval_mins: number; + only_when_idle: boolean; + session_key: string; + silent: boolean; + run_count: number; + error_count: number; + skipped_busy: number; + last_run: string; + last_error: string; +} + +export const getHeartbeat = (project: string) => api.get(`/projects/${project}/heartbeat`); +export const pauseHeartbeat = (project: string) => api.post(`/projects/${project}/heartbeat/pause`); +export const resumeHeartbeat = (project: string) => api.post(`/projects/${project}/heartbeat/resume`); +export const triggerHeartbeat = (project: string) => api.post(`/projects/${project}/heartbeat/run`); +export const setHeartbeatInterval = (project: string, minutes: number) => + api.post(`/projects/${project}/heartbeat/interval`, { minutes }); diff --git a/web/src/api/index.ts b/web/src/api/index.ts new file mode 100644 index 0000000..5e3255e --- /dev/null +++ b/web/src/api/index.ts @@ -0,0 +1,8 @@ +export { api, ApiError } from './client'; +export * from './status'; +export * from './projects'; +export * from './sessions'; +export * from './providers'; +export * from './cron'; +export * from './heartbeat'; +export * from './settings'; diff --git a/web/src/api/projects.ts b/web/src/api/projects.ts new file mode 100644 index 0000000..aa50cb2 --- /dev/null +++ b/web/src/api/projects.ts @@ -0,0 +1,66 @@ +import api from './client'; + +export interface ProjectSummary { + name: string; + agent_type: string; + platforms: string[]; + sessions_count: number; + heartbeat_enabled: boolean; +} + +export interface PlatformConfigInfo { + type: string; + allow_from?: string; +} + +export interface ProjectDetail { + name: string; + agent_type: string; + work_dir?: string; + agent_mode?: string; + show_context_indicator?: boolean; + reply_footer?: boolean; + inject_sender?: boolean; + provider_refs?: string[]; + platform_configs?: PlatformConfigInfo[]; + platforms: { type: string; connected: boolean }[]; + sessions_count: number; + active_session_keys: string[]; + heartbeat: { + enabled: boolean; + paused: boolean; + interval_mins: number; + session_key: string; + }; + settings: { + admin_from: string; + language: string; + disabled_commands: string[]; + }; +} + +export interface ProjectSettingsUpdate { + language?: string; + admin_from?: string; + disabled_commands?: string[]; + work_dir?: string; + mode?: string; + agent_type?: string; + show_context_indicator?: boolean; + reply_footer?: boolean; + inject_sender?: boolean; + platform_allow_from?: Record; +} + +export const listAgentTypes = () => api.get<{ agents: string[]; platforms: string[] }>('/agents'); + +export const listProjects = () => api.get<{ projects: ProjectSummary[] }>('/projects'); +export const getProject = (name: string) => api.get(`/projects/${name}`); +export const updateProject = (name: string, body: ProjectSettingsUpdate) => api.patch(`/projects/${name}`, body); + +export const addPlatformToProject = (projectName: string, body: { + type: string; options: Record; work_dir?: string; agent_type?: string; +}) => api.post<{ message: string; restart_required: boolean }>(`/projects/${projectName}/add-platform`, body); + +export const deleteProject = (name: string) => + api.delete<{ message: string; restart_required: boolean }>(`/projects/${name}`); diff --git a/web/src/api/providers.ts b/web/src/api/providers.ts new file mode 100644 index 0000000..fb39595 --- /dev/null +++ b/web/src/api/providers.ts @@ -0,0 +1,101 @@ +import api from './client'; + +export interface ProviderModel { + model: string; + alias?: string; +} + +export interface Provider { + name: string; + active: boolean; + model: string; + base_url: string; +} + +export interface CodexConfig { + wire_api?: string; + http_headers?: Record; +} + +export interface GlobalProvider { + name: string; + api_key?: string; + base_url?: string; + model?: string; + thinking?: string; + env?: Record; + agent_types?: string[]; + models?: ProviderModel[]; + endpoints?: Record; + agent_models?: Record; + agent_model_lists?: Record; + codex?: CodexConfig; +} + +export interface PresetAgentConfig { + base_url: string; + model: string; + models?: string[]; + codex_config?: { wire_api?: string; http_headers?: Record }; +} + +export interface ProviderPreset { + name: string; + display_name: string; + agents: Record; + invite_url?: string; + description?: string; + description_zh?: string; + features?: string[]; + thinking?: string; + tier: number; + featured?: boolean; + website?: string; +} + +export interface PresetsResponse { + version: number; + updated_at?: string; + providers: ProviderPreset[]; +} + +// Project-level provider APIs (existing) +export const listProviders = (project: string) => + api.get<{ providers: Provider[]; active_provider: string }>(`/projects/${project}/providers`); +export const addProvider = (project: string, body: any) => api.post(`/projects/${project}/providers`, body); +export const removeProvider = (project: string, provider: string) => api.delete(`/projects/${project}/providers/${provider}`); +export const activateProvider = (project: string, provider: string) => api.post(`/projects/${project}/providers/${provider}/activate`); +export const listModels = (project: string) => api.get<{ models: string[]; current: string }>(`/projects/${project}/models`); +export const setModel = (project: string, model: string) => api.post(`/projects/${project}/model`, { model }); + +// Project provider_refs APIs +export const getProviderRefs = (project: string) => + api.get<{ provider_refs: string[] }>(`/projects/${project}/provider-refs`); +export const saveProviderRefs = (project: string, refs: string[]) => + api.put<{ message: string }>(`/projects/${project}/provider-refs`, { provider_refs: refs }); + +// Global provider APIs +export const listGlobalProviders = () => + api.get<{ providers: GlobalProvider[] }>('/providers'); +export const addGlobalProvider = (body: GlobalProvider) => + api.post<{ name: string; message: string }>('/providers', body); +export const updateGlobalProvider = (name: string, body: Partial) => + api.put<{ message: string }>(`/providers/${name}`, body); +export const removeGlobalProvider = (name: string) => + api.delete<{ message: string }>(`/providers/${name}`); +export const fetchProviderPresets = () => + api.get('/providers/presets'); + +// cc-switch migration +export interface CCSwitchProvider { + name: string; + app_type: string; + api_key?: string; + base_url?: string; + model?: string; + is_current: boolean; +} +export const listCCSwitchProviders = () => + api.get<{ providers: CCSwitchProvider[]; available: boolean; error?: string }>('/providers/cc-switch'); +export const importCCSwitchProviders = (names: string[]) => + api.post<{ imported: string[]; skipped: string[] }>('/providers/cc-switch', { names }); diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts new file mode 100644 index 0000000..de96156 --- /dev/null +++ b/web/src/api/sessions.ts @@ -0,0 +1,40 @@ +import api from './client'; + +export interface LastMessage { + role: string; + content: string; + timestamp: string; +} + +export interface Session { + id: string; + session_key: string; + name: string; + platform: string; + agent_type: string; + active: boolean; + live: boolean; + created_at: string; + updated_at: string; + history_count: number; + last_message: LastMessage | null; + user_name?: string; + chat_name?: string; +} + +export interface SessionDetail extends Session { + agent_session_id: string; + history: { role: string; content: string; timestamp: string }[]; +} + +export const listSessions = (project: string) => + api.get<{ sessions: Session[]; active_keys: Record }>(`/projects/${project}/sessions`); +export const getSession = (project: string, id: string, historyLimit?: number) => + api.get(`/projects/${project}/sessions/${id}`, historyLimit ? { history_limit: String(historyLimit) } : undefined); +export const createSession = (project: string, body: { session_key: string; name?: string }) => + api.post(`/projects/${project}/sessions`, body); +export const deleteSession = (project: string, id: string) => api.delete(`/projects/${project}/sessions/${id}`); +export const switchSession = (project: string, body: { session_key: string; session_id: string }) => + api.post(`/projects/${project}/sessions/switch`, body); +export const sendMessage = (project: string, body: { session_key: string; message: string }) => + api.post(`/projects/${project}/send`, body); diff --git a/web/src/api/settings.ts b/web/src/api/settings.ts new file mode 100644 index 0000000..0adf666 --- /dev/null +++ b/web/src/api/settings.ts @@ -0,0 +1,19 @@ +import api from './client'; + +export interface GlobalSettings { + language: string; + attachment_send: string; + log_level: string; + idle_timeout_mins: number; + thinking_messages: boolean; + thinking_max_len: number; + tool_messages: boolean; + tool_max_len: number; + stream_preview_enabled: boolean; + stream_preview_interval_ms: number; + rate_limit_max_messages: number; + rate_limit_window_secs: number; +} + +export const getGlobalSettings = () => api.get('/settings'); +export const updateGlobalSettings = (body: Partial) => api.patch('/settings', body); diff --git a/web/src/api/setup.ts b/web/src/api/setup.ts new file mode 100644 index 0000000..ff0a56e --- /dev/null +++ b/web/src/api/setup.ts @@ -0,0 +1,54 @@ +import api from './client'; + +export interface FeishuBeginResponse { + device_code: string; + qr_url: string; + interval: number; + expires_in: number; +} + +export interface FeishuPollResponse { + status: 'pending' | 'completed' | 'denied' | 'expired' | 'error'; + base_url?: string; + app_id?: string; + app_secret?: string; + platform?: string; + owner_open_id?: string; + slow_down?: boolean; + error?: string; +} + +export interface WeixinBeginResponse { + qr_key: string; + qr_url: string; +} + +export interface WeixinPollResponse { + status: 'wait' | 'scaned' | 'confirmed' | 'expired'; + bot_token?: string; + ilink_bot_id?: string; + base_url?: string; + ilink_user_id?: string; +} + +export const setupFeishuBegin = () => + api.post('/setup/feishu/begin', {}); + +export const setupFeishuPoll = (deviceCode: string, baseUrl?: string) => + api.post('/setup/feishu/poll', { device_code: deviceCode, base_url: baseUrl }); + +export const setupFeishuSave = (body: { + project: string; app_id: string; app_secret: string; platform_type: string; + owner_open_id?: string; work_dir?: string; agent_type?: string; +}) => api.post<{ message: string; restart_required: boolean }>('/setup/feishu/save', body); + +export const setupWeixinBegin = (apiUrl?: string) => + api.post('/setup/weixin/begin', { api_url: apiUrl }); + +export const setupWeixinPoll = (qrKey: string, apiUrl?: string) => + api.post('/setup/weixin/poll', { qr_key: qrKey, api_url: apiUrl }); + +export const setupWeixinSave = (body: { + project: string; token: string; base_url?: string; + ilink_bot_id?: string; ilink_user_id?: string; work_dir?: string; agent_type?: string; +}) => api.post<{ message: string; restart_required: boolean }>('/setup/weixin/save', body); diff --git a/web/src/api/skills.ts b/web/src/api/skills.ts new file mode 100644 index 0000000..32a881d --- /dev/null +++ b/web/src/api/skills.ts @@ -0,0 +1,54 @@ +import api from './client'; + +export interface SkillInfo { + name: string; + display_name?: string; + description?: string; + source: string; +} + +export interface ProjectSkills { + project: string; + agent_type: string; + dirs: string[]; + skills: SkillInfo[]; +} + +export interface SkillSource { + provider: string; + name?: string; + url?: string; +} + +export interface SkillPricing { + type: 'free' | 'paid' | 'freemium'; + price?: number; + currency?: string; +} + +export interface SkillPreset { + name: string; + display_name: string; + description?: string; + description_zh?: string; + version?: string; + author?: string; + url?: string; + agent_types?: string[]; + tags?: string[]; + featured?: boolean; + source?: SkillSource; + pricing?: SkillPricing; +} + +export interface SkillPresetsResponse { + version: number; + updated_at?: string; + skills: SkillPreset[]; +} + +export const listSkills = () => + api.get<{ projects: ProjectSkills[] }>('/skills'); + +export const fetchSkillPresets = () => + api.get('/skills/presets'); diff --git a/web/src/api/status.ts b/web/src/api/status.ts new file mode 100644 index 0000000..dd1ddd2 --- /dev/null +++ b/web/src/api/status.ts @@ -0,0 +1,13 @@ +import api from './client'; + +export interface SystemStatus { + version: string; + uptime_seconds: number; + connected_platforms: string[]; + projects_count: number; + bridge_adapters: { platform: string; project: string; capabilities: string[] }[]; +} + +export const getStatus = () => api.get('/status'); +export const restartSystem = (body?: { session_key?: string; platform?: string }) => api.post('/restart', body); +export const reloadConfig = () => api.post<{ message: string; projects_added: string[]; projects_removed: string[]; projects_updated: string[] }>('/reload'); diff --git a/web/src/components/Layout/Footer.tsx b/web/src/components/Layout/Footer.tsx new file mode 100644 index 0000000..17ddfa1 --- /dev/null +++ b/web/src/components/Layout/Footer.tsx @@ -0,0 +1,29 @@ +import { useEffect, useState } from 'react'; +import { getStatus } from '@/api/status'; + +export default function Footer() { + const [version, setVersion] = useState(''); + + useEffect(() => { + getStatus().then(s => setVersion(s.version || '')).catch(() => {}); + }, []); + + const year = new Date().getFullYear(); + + return ( +
+ © {year} CC-Connect + {version && ·} + {version && {version.startsWith('v') ? version : `v${version}`}} + · + + GitHub + +
+ ); +} diff --git a/web/src/components/Layout/Header.tsx b/web/src/components/Layout/Header.tsx new file mode 100644 index 0000000..1c923b9 --- /dev/null +++ b/web/src/components/Layout/Header.tsx @@ -0,0 +1,118 @@ +import { useTranslation } from 'react-i18next'; +import { useState, useRef, useEffect } from 'react'; +import { + RefreshCw, Sun, Moon, Monitor, LogOut, Languages, ChevronDown, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useThemeStore } from '@/store/theme'; +import { useAuthStore } from '@/store/auth'; + +const languages = [ + { code: 'en', label: 'EN' }, + { code: 'zh', label: '中文' }, + { code: 'zh-TW', label: '繁體' }, + { code: 'ja', label: '日本語' }, + { code: 'es', label: 'ES' }, +]; + +export default function Header() { + const { t, i18n } = useTranslation(); + const { theme, setTheme } = useThemeStore(); + const logout = useAuthStore((s) => s.logout); + const [spinning, setSpinning] = useState(false); + const [langOpen, setLangOpen] = useState(false); + const langRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (langRef.current && !langRef.current.contains(e.target as Node)) setLangOpen(false); + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const handleRefresh = () => { + setSpinning(true); + window.dispatchEvent(new CustomEvent('cc:refresh')); + setTimeout(() => setSpinning(false), 1000); + }; + + const themeIcons = { light: Sun, dark: Moon, system: Monitor }; + const nextTheme = { light: 'dark' as const, dark: 'system' as const, system: 'light' as const }; + const ThemeIcon = themeIcons[theme]; + + const changeLang = (code: string) => { + i18n.changeLanguage(code); + localStorage.setItem('cc_lang', code); + setLangOpen(false); + }; + + const btnCls = cn( + 'p-2 rounded-lg transition-all duration-200', + 'text-gray-500 dark:text-gray-400', + 'hover:bg-gray-100/90 dark:hover:bg-white/[0.08] hover:text-gray-800 dark:hover:text-white', + ); + + return ( +
+ + + {/* Language */} +
+ + {langOpen && ( +
+ {languages.map(l => ( + + ))} +
+ )} +
+ + {/* Theme */} + + + {/* Logout */} + +
+ ); +} diff --git a/web/src/components/Layout/Layout.tsx b/web/src/components/Layout/Layout.tsx new file mode 100644 index 0000000..fd37ef4 --- /dev/null +++ b/web/src/components/Layout/Layout.tsx @@ -0,0 +1,28 @@ +import { Outlet } from 'react-router-dom'; +import Sidebar from './Sidebar'; +import Header from './Header'; +import Footer from './Footer'; +import { cn } from '@/lib/utils'; + +export default function Layout() { + return ( +
+ +
+
+
+
+ +
+
+
+
+
+ ); +} diff --git a/web/src/components/Layout/Sidebar.tsx b/web/src/components/Layout/Sidebar.tsx new file mode 100644 index 0000000..9599366 --- /dev/null +++ b/web/src/components/Layout/Sidebar.tsx @@ -0,0 +1,96 @@ +import { NavLink } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + LayoutDashboard, + FolderKanban, + MessageSquare, + Clock, + Settings, + ChevronLeft, + ChevronRight, + Plug, + Puzzle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useState } from 'react'; + +const navItems = [ + { key: 'dashboard', path: '/', icon: LayoutDashboard }, + { key: 'projects', path: '/projects', icon: FolderKanban }, + { key: 'providers', path: '/providers', icon: Plug }, + { key: 'skills', path: '/skills', icon: Puzzle }, + { key: 'chat', path: '/chat', icon: MessageSquare }, + { key: 'cron', path: '/cron', icon: Clock }, + { key: 'system', path: '/system', icon: Settings }, +]; + +export default function Sidebar() { + const { t } = useTranslation(); + const [collapsed, setCollapsed] = useState(false); + + return ( + + ); +} diff --git a/web/src/components/ui/Badge.tsx b/web/src/components/ui/Badge.tsx new file mode 100644 index 0000000..724e9e3 --- /dev/null +++ b/web/src/components/ui/Badge.tsx @@ -0,0 +1,33 @@ +import { cn } from '@/lib/utils'; + +interface BadgeProps { + children: React.ReactNode; + variant?: 'default' | 'success' | 'warning' | 'danger' | 'info' | 'outline'; + className?: string; +} + +const variants = { + default: 'bg-gray-100/90 dark:bg-white/[0.08] text-gray-600 dark:text-gray-400 border border-gray-200/80 dark:border-white/[0.06]', + success: + 'bg-emerald-100/90 dark:bg-emerald-900/25 text-emerald-700 dark:text-emerald-400 border border-emerald-200/50 dark:border-emerald-500/20', + warning: + 'bg-amber-100/90 dark:bg-amber-900/25 text-amber-700 dark:text-amber-400 border border-amber-200/50 dark:border-amber-500/20', + danger: + 'bg-red-100/90 dark:bg-red-900/25 text-red-700 dark:text-red-400 border border-red-200/50 dark:border-red-500/20', + info: 'bg-accent/10 text-accent border border-accent/20', + outline: 'bg-transparent text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-white/[0.12]', +}; + +export function Badge({ children, variant = 'default', className }: BadgeProps) { + return ( + + {children} + + ); +} diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000..7140946 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,67 @@ +import { cn } from '@/lib/utils'; +import type { ButtonHTMLAttributes, ReactNode } from 'react'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: 'primary' | 'secondary' | 'danger' | 'ghost'; + size?: 'sm' | 'md' | 'lg'; + children: ReactNode; + loading?: boolean; +} + +const variants = { + primary: 'bg-accent text-white dark:text-black hover:bg-accent-dim font-medium dark:shadow-[0_0_20px_-6px_rgba(66,255,156,0.55)]', + secondary: + 'bg-gray-100/90 dark:bg-white/[0.08] text-gray-700 dark:text-gray-300 hover:bg-gray-200/90 dark:hover:bg-white/[0.12] border border-transparent dark:border-white/[0.06]', + danger: 'bg-red-500/10 text-red-600 dark:text-red-400 hover:bg-red-500/20', + ghost: + 'text-gray-500 dark:text-gray-400 hover:bg-gray-100/80 dark:hover:bg-white/[0.06] hover:text-gray-700 dark:hover:text-gray-200', +}; + +const sizes = { + sm: 'px-3 py-1.5 text-xs rounded-lg', + md: 'px-4 py-2 text-sm rounded-lg', + lg: 'px-6 py-2.5 text-sm rounded-xl', +}; + +export function Button({ + variant = 'primary', + size = 'md', + className, + children, + loading, + disabled, + ...props +}: ButtonProps) { + return ( + + ); +} diff --git a/web/src/components/ui/Card.tsx b/web/src/components/ui/Card.tsx new file mode 100644 index 0000000..d16061d --- /dev/null +++ b/web/src/components/ui/Card.tsx @@ -0,0 +1,49 @@ +import { cn } from '@/lib/utils'; +import type { ReactNode } from 'react'; + +interface CardProps { + children: ReactNode; + className?: string; + hover?: boolean; +} + +export function Card({ children, className, hover }: CardProps) { + return ( +
+ {children} +
+ ); +} + +interface StatCardProps { + label: string; + value: string | number; + accent?: boolean; +} + +export function StatCard({ label, value, accent }: StatCardProps) { + return ( + +

+ {label} +

+

+ {value} +

+
+ ); +} diff --git a/web/src/components/ui/EmptyState.tsx b/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000..ded31fa --- /dev/null +++ b/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,16 @@ +import { InboxIcon } from 'lucide-react'; +import type { ElementType } from 'react'; + +interface EmptyStateProps { + message: string; + icon?: ElementType<{ size?: number; strokeWidth?: number; className?: string }>; +} + +export function EmptyState({ message, icon: Icon = InboxIcon }: EmptyStateProps) { + return ( +
+ +

{message}

+
+ ); +} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx new file mode 100644 index 0000000..9b068a9 --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -0,0 +1,54 @@ +import { cn } from '@/lib/utils'; +import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; +} + +export function Input({ label, className, ...props }: InputProps) { + return ( +
+ {label && ( + + )} + +
+ ); +} + +interface TextareaProps extends TextareaHTMLAttributes { + label?: string; +} + +export function Textarea({ label, className, ...props }: TextareaProps) { + return ( +
+ {label && ( + + )} +