初始化仓库
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
# Delete Batch Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add explicit batch deletion support for `/delete 1,2,3`, `/delete 3-7`, and `/delete 1,3-5,8` without introducing ambiguous whitespace-based parsing.
|
||||
|
||||
**Architecture:** Keep the existing single-delete flow intact for one plain argument, and add a narrow parser that only activates when `/delete` receives one argument containing comma/range syntax. Resolve list positions from a single session snapshot, deduplicate targets, then execute deletions with a combined reply that reports successes and blocked items.
|
||||
|
||||
**Tech Stack:** Go 1.24, existing `core.Engine` command handlers, `testing` package.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add failing command tests
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add command-level tests for:
|
||||
- `/delete 1,2,3`
|
||||
- `/delete 3-7`
|
||||
- `/delete 1,3-5,8`
|
||||
- invalid explicit syntax like `/delete 1,3-a,8`
|
||||
- non-supported whitespace-separated args staying non-batch
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./core -run TestCmdDelete`
|
||||
Expected: FAIL because batch parsing does not exist yet.
|
||||
|
||||
### Task 2: Implement explicit batch delete parsing
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go`
|
||||
- Modify: `core/i18n.go`
|
||||
|
||||
**Step 1: Write minimal implementation**
|
||||
|
||||
Add a helper that:
|
||||
- only recognizes one-argument explicit batch syntax
|
||||
- parses comma-separated integers and inclusive ranges
|
||||
- rejects malformed items
|
||||
- deduplicates indices while preserving order
|
||||
|
||||
Update `cmdDelete` to:
|
||||
- route explicit batch syntax through the new helper
|
||||
- keep existing single-delete behavior for plain one-argument input
|
||||
- reject ambiguous multi-argument inputs with usage text
|
||||
- aggregate batch results into one reply
|
||||
|
||||
**Step 2: Run targeted tests**
|
||||
|
||||
Run: `go test ./core -run TestCmdDelete`
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Verify no regression in core tests
|
||||
|
||||
**Files:**
|
||||
- Test: `core/engine_test.go`
|
||||
- Test: `core/i18n_test.go`
|
||||
|
||||
**Step 1: Run broader verification**
|
||||
|
||||
Run: `go test ./core/...`
|
||||
Expected: PASS
|
||||
@@ -0,0 +1,66 @@
|
||||
# Feishu Delete Card Design
|
||||
|
||||
**Date:** 2026-03-11
|
||||
|
||||
**Goal:** Let Feishu users enter `/delete` with no arguments to open a card-based multi-select delete flow, while keeping explicit delete arguments such as `/delete 1,2,3` working as direct command execution.
|
||||
|
||||
## Scope
|
||||
|
||||
- Only `/delete` with no arguments activates card selection mode.
|
||||
- The normal `/list` card remains unchanged.
|
||||
- Explicit delete arguments continue to bypass the card flow.
|
||||
- Card flow is only for card-capable platforms; non-card platforms keep usage text.
|
||||
|
||||
## Interaction Flow
|
||||
|
||||
1. User sends `/delete`.
|
||||
2. If the platform supports cards, cc-connect renders a delete-mode session list card.
|
||||
3. Each session row exposes a single right-side button that toggles selected/unselected state.
|
||||
4. The card footer provides:
|
||||
- `删除已选`
|
||||
- `取消`
|
||||
- pagination controls
|
||||
5. `删除已选` opens a confirmation card listing the selected sessions.
|
||||
6. The confirmation card provides:
|
||||
- `确认删除`
|
||||
- `返回继续选择`
|
||||
7. On confirmation, cc-connect deletes the selected sessions, skips the active session, clears the temporary selection state, and renders a result card.
|
||||
|
||||
## State Model
|
||||
|
||||
Delete-mode state should be tracked per `sessionKey`, separate from the agent interactive process state. The minimum state needed is:
|
||||
|
||||
- whether delete mode is active
|
||||
- current page in delete mode
|
||||
- selected session IDs
|
||||
- whether the user is currently on the confirmation card
|
||||
|
||||
Session IDs, not row numbers, must be the source of truth after selection, so cross-page selection remains stable even if list ordering changes between renders.
|
||||
|
||||
## Card Actions
|
||||
|
||||
- `act:/delete-mode open`
|
||||
- `act:/delete-mode toggle <session-id>`
|
||||
- `act:/delete-mode page <n>`
|
||||
- `act:/delete-mode confirm`
|
||||
- `act:/delete-mode back`
|
||||
- `act:/delete-mode submit`
|
||||
- `act:/delete-mode cancel`
|
||||
|
||||
All delete-mode actions should update the card in place on Feishu. They should not dispatch as plain user commands.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Empty selection cannot proceed to confirmation; the card should stay in delete mode with a hint.
|
||||
- Deleting the active session must be reported as blocked, not silently ignored.
|
||||
- If a selected session no longer exists, report it in the result card rather than failing the whole batch.
|
||||
- Cancel must always clear delete-mode state.
|
||||
|
||||
## Testing
|
||||
|
||||
- Rendering delete-mode cards with selected/unselected rows
|
||||
- In-place toggle behavior and page persistence
|
||||
- Confirmation card content
|
||||
- Submit path deleting only selected session IDs
|
||||
- Active-session protection in card-driven batch delete
|
||||
- Explicit `/delete 1,3-5,8` continuing to work outside card mode
|
||||
@@ -0,0 +1,175 @@
|
||||
# Feishu Delete Card Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a Feishu card-based multi-select delete flow triggered by `/delete` without changing existing explicit batch delete command semantics.
|
||||
|
||||
**Architecture:** Introduce a per-session delete-mode state keyed by `sessionKey`, render a dedicated delete-selection card and confirmation card, and route Feishu card `act:` callbacks through new delete-mode actions. Keep `/list` unchanged and keep explicit `/delete <args>` command execution on the existing code path.
|
||||
|
||||
**Tech Stack:** Go 1.24, `core.Engine`, shared card builder utilities, Feishu interactive cards, Go `testing`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add failing tests for delete-mode card flow
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add tests for:
|
||||
- `/delete` with no args on a card-capable platform renders delete-mode card instead of usage text
|
||||
- delete-mode list toggles selected session IDs through card actions
|
||||
- confirmation card lists selected sessions
|
||||
- submit deletes only selected sessions and clears delete-mode state
|
||||
- cancel returns to normal list/current view and clears state
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./core -run 'TestCmdDelete|TestDeleteMode'`
|
||||
Expected: FAIL because delete-mode state and card actions do not exist.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add the smallest delete-mode state and rendering hooks required for the first test to pass before expanding behavior.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./core -run 'TestCmdDelete|TestDeleteMode'`
|
||||
Expected: PASS for the implemented slice.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/engine_test.go core/engine.go core/i18n.go
|
||||
git commit -m "feat: add delete mode card flow scaffolding"
|
||||
```
|
||||
|
||||
### Task 2: Implement delete-mode state and card rendering
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go`
|
||||
- Modify: `core/i18n.go`
|
||||
- Test: `core/engine_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add tests covering:
|
||||
- selected rows remain selected across pagination
|
||||
- delete-mode footer buttons enable confirmation only when selection exists
|
||||
- confirmation card back button preserves selection
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./core -run 'TestDeleteMode'`
|
||||
Expected: FAIL on missing state transitions or incorrect card rendering.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Implement:
|
||||
- delete-mode state structure
|
||||
- render function for delete-mode list
|
||||
- render function for confirmation/result cards
|
||||
- helper to resolve display names from selected session IDs
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./core -run 'TestDeleteMode'`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/engine.go core/engine_test.go core/i18n.go
|
||||
git commit -m "feat: render delete selection cards"
|
||||
```
|
||||
|
||||
### Task 3: Wire Feishu card actions to delete-mode behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go`
|
||||
- Test: `core/engine_test.go`
|
||||
- Inspect: `platform/feishu/feishu.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add tests for:
|
||||
- `act:/delete-mode toggle <id>`
|
||||
- `act:/delete-mode confirm`
|
||||
- `act:/delete-mode back`
|
||||
- `act:/delete-mode submit`
|
||||
- `act:/delete-mode cancel`
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./core -run 'TestDeleteMode'`
|
||||
Expected: FAIL because action dispatch does not recognize delete-mode actions.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Update `handleCardNav` and `executeCardAction` to:
|
||||
- mutate delete-mode state in place
|
||||
- re-render the correct card after each action
|
||||
- clear state on submit/cancel
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./core -run 'TestDeleteMode'`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/engine.go core/engine_test.go
|
||||
git commit -m "feat: wire delete mode card actions"
|
||||
```
|
||||
|
||||
### Task 4: Reuse deletion logic and protect edge cases
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go`
|
||||
- Test: `core/engine_test.go`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add tests for:
|
||||
- active session in selected set is blocked and reported
|
||||
- missing session ID in selected set is reported without aborting the whole batch
|
||||
- explicit `/delete 1,2,3` and `/delete 1,3-5,8` still use command parsing path
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./core -run 'TestCmdDelete|TestDeleteMode'`
|
||||
Expected: FAIL on batch execution/reporting edge cases.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Refactor deletion helpers so card-mode submit can delete by selected session ID while sharing reply/result formatting with the command path.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./core -run 'TestCmdDelete|TestDeleteMode'`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/engine.go core/engine_test.go
|
||||
git commit -m "feat: execute delete mode batch removal"
|
||||
```
|
||||
|
||||
### Task 5: Run broader verification
|
||||
|
||||
**Files:**
|
||||
- Test: `core/engine_test.go`
|
||||
- Test: `platform/feishu/platform_test.go`
|
||||
|
||||
**Step 1: Run core verification**
|
||||
|
||||
Run: `go test ./core/...`
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Run repository verification**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS
|
||||
@@ -0,0 +1,105 @@
|
||||
# Multi-Workspace Feature Design
|
||||
|
||||
## Overview
|
||||
|
||||
Enable a single cc-connect bot (one Slack token) to serve multiple workspaces, with the channel determining which Claude Code working directory and session to use.
|
||||
|
||||
## Config
|
||||
|
||||
```toml
|
||||
[[projects]]
|
||||
name = "claude"
|
||||
mode = "multi-workspace"
|
||||
base_dir = "~/workspace"
|
||||
|
||||
[projects.agent]
|
||||
type = "claudecode"
|
||||
permission_mode = "yolo"
|
||||
|
||||
[[projects.platforms]]
|
||||
type = "slack"
|
||||
bot_token = "xoxb-..."
|
||||
app_token = "xapp-..."
|
||||
```
|
||||
|
||||
- `mode = "multi-workspace"` enables the feature. Omitting or `"single"` preserves current behavior.
|
||||
- `base_dir` is the parent directory where workspaces live. Replaces `work_dir` on the agent.
|
||||
- Agent config has no `work_dir` — resolved per-channel at runtime.
|
||||
|
||||
## Workspace Resolution Flow
|
||||
|
||||
When a message arrives in a channel:
|
||||
|
||||
1. **Check bindings** — look up `workspace_bindings.json` for an existing channel-to-workspace mapping.
|
||||
2. **Convention match** — if no binding, check if `<base_dir>/<channel-name>/` exists. If yes, auto-bind and confirm:
|
||||
> "Found `~/workspace/model-profiler` matching this channel. Binding workspace and starting session... Ready."
|
||||
3. **Ask for repo** — if no match, reply:
|
||||
> "No workspace found for this channel. What repo should I clone?"
|
||||
User provides URL, bot confirms:
|
||||
> "I'll clone `org/repo` to `~/workspace/repo-name` and bind to this channel. OK?"
|
||||
4. **Clone and bind** — on confirmation, clone the repo, save the binding, spawn agent subprocess. Explicit feedback throughout:
|
||||
> "Cloning `github.com/org/repo` to `~/workspace/repo-name`..."
|
||||
> "Clone complete. Binding workspace to this channel... Ready."
|
||||
|
||||
### Binding Storage
|
||||
|
||||
Persisted in `~/.cc-connect/workspace_bindings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"project:claude": {
|
||||
"C0AKYKUF75K": {
|
||||
"channel_name": "model-profiler",
|
||||
"workspace": "/home/leigh/workspace/model-profiler",
|
||||
"bound_at": "2026-03-12T10:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Subprocess Management
|
||||
|
||||
Engine maintains `workspaceAgents map[string]*workspaceState` keyed by workspace path. Each `workspaceState` holds the agent subprocess, its SessionManager, and a `lastActivity` timestamp.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
1. **Spawn on first message** — start a Claude Code subprocess with `work_dir` set to the resolved workspace.
|
||||
2. **Resume on subsequent messages** — reuse the running subprocess with saved session ID.
|
||||
3. **Idle reap** — background goroutine checks `lastActivity` every minute. Subprocesses idle >15 minutes are stopped. Session ID is preserved so the next message transparently restarts.
|
||||
4. **Graceful shutdown** — on bot shutdown, stop all subprocesses cleanly.
|
||||
|
||||
### Session Management
|
||||
|
||||
Each workspace gets its own SessionManager instance with a separate JSON file (same naming scheme as today: `project_hash.json`). Named sessions within a workspace work exactly as they do now.
|
||||
|
||||
## Message Routing Changes
|
||||
|
||||
In `Engine.handleMessage`, the multi-workspace path inserts before the existing flow:
|
||||
|
||||
1. **Extract channel ID** from the message's session key (`slack:channelID:userID`).
|
||||
2. **Resolve workspace** — look up binding, convention match, or trigger init flow.
|
||||
3. **If no workspace resolved** (init flow in progress) — handle the init conversation directly, don't forward to any agent.
|
||||
4. **If workspace resolved** — get or spawn the agent subprocess for that workspace, then continue with existing message processing.
|
||||
|
||||
`interactiveStates` gets keyed by workspace+sessionKey (rather than just sessionKey) so the same user in different channels hits different agent processes.
|
||||
|
||||
### New Commands
|
||||
|
||||
- `/workspace` — show current channel's bound workspace
|
||||
- `/workspace init <url>` — clone and bind
|
||||
- `/workspace unbind` — remove binding
|
||||
- `/workspace list` — show all bindings
|
||||
|
||||
Existing commands (`/sessions`, `/model`, etc.) work per-workspace.
|
||||
|
||||
## Error Handling & Edge Cases
|
||||
|
||||
- **Unbound channel, bot mentioned** — bot asks for repo URL. No agent forwarding until binding is established.
|
||||
- **Clone fails** (bad URL, auth, disk) — bot reports error and asks user to try again. No partial binding saved.
|
||||
- **Workspace directory deleted externally** — on next message, bot detects missing directory, removes the binding, re-enters init flow: "Workspace `~/workspace/foo` no longer exists. What repo should I clone?"
|
||||
- **Agent subprocess crashes** — restart on next message using saved session ID (same as current behavior).
|
||||
- **Bot in unwanted channel** — without binding or matching directory, it just asks for a repo. User can ignore or remove the bot.
|
||||
|
||||
## Architecture: Approach 1 (Engine-level multiplexing)
|
||||
|
||||
The Engine itself handles multi-workspace routing. No new meta-engine or wrapper layers. The multi-workspace logic is gated behind the `mode` config field, so single-workspace projects are completely unaffected.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-12-multi-workspace-plan.md",
|
||||
"tasks": [
|
||||
{"id": 1, "subject": "Task 1: Add config fields", "status": "pending"},
|
||||
{"id": 2, "subject": "Task 2: Workspace binding persistence", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 3, "subject": "Task 3: Workspace state and idle reaper", "status": "pending", "blockedBy": [1]},
|
||||
{"id": 4, "subject": "Task 4: Channel name resolution in Slack platform", "status": "pending"},
|
||||
{"id": 5, "subject": "Task 5: Engine multi-workspace fields and constructor", "status": "pending", "blockedBy": [2, 3]},
|
||||
{"id": 6, "subject": "Task 6: Workspace resolution logic", "status": "pending", "blockedBy": [4, 5]},
|
||||
{"id": 7, "subject": "Task 7: Init flow conversation handler", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 8, "subject": "Task 8: Wire multi-workspace routing into handleMessage", "status": "pending", "blockedBy": [6, 7]},
|
||||
{"id": 9, "subject": "Task 9: Workspace commands", "status": "pending", "blockedBy": [6]},
|
||||
{"id": 10, "subject": "Task 10: Wire multi-workspace in main.go", "status": "pending", "blockedBy": [5]},
|
||||
{"id": 11, "subject": "Task 11: Integration testing", "status": "pending", "blockedBy": [8, 9, 10]},
|
||||
{"id": 12, "subject": "Task 12: Update config.example.toml and verify build", "status": "pending", "blockedBy": [11]}
|
||||
],
|
||||
"lastUpdated": "2026-03-12T01:30:00Z"
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
# Usage Command Design
|
||||
|
||||
**Date:** 2026-03-12
|
||||
|
||||
**Goal:** Add a built-in `/usage` command that reports model/account quota usage, starting with Codex running under ChatGPT OAuth, while keeping the retrieval path generic so other agents can plug in later.
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a new built-in slash command: `/usage`.
|
||||
- The command is independent from `/status` and `/doctor`.
|
||||
- Usage retrieval is exposed as an optional agent capability, not hardcoded into the engine for a single vendor.
|
||||
- First implementation targets the Codex agent when local ChatGPT OAuth credentials are available in `~/.codex/auth.json`.
|
||||
- Unsupported agents should return a clear “not supported” style message rather than failing the whole command system.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Command Layer
|
||||
|
||||
`core/engine.go` will register and dispatch `/usage` as a normal built-in command. The engine should not know how ChatGPT, Gemini, or any future provider exposes quota data. It only detects whether the current agent implements a usage-reporting interface and formats the response.
|
||||
|
||||
### Agent Capability Layer
|
||||
|
||||
Add a new optional interface in `core/interfaces.go`, for example:
|
||||
|
||||
- `UsageReporter`
|
||||
- a method such as `GetUsage(context.Context) (*UsageReport, error)`
|
||||
|
||||
The report type should be generic enough to cover multiple providers:
|
||||
|
||||
- provider/agent name
|
||||
- subject or account label
|
||||
- plan type / tier if available
|
||||
- one or more rate-limit buckets/windows
|
||||
- optional credits/balance fields
|
||||
- raw provider-specific metadata only if needed for debugging
|
||||
|
||||
This keeps future integrations local to each agent package.
|
||||
|
||||
### Codex Implementation
|
||||
|
||||
`agent/codex` will implement the new interface by:
|
||||
|
||||
1. Reading `~/.codex/auth.json`
|
||||
2. Extracting:
|
||||
- `tokens.access_token`
|
||||
- `tokens.account_id`
|
||||
3. Calling:
|
||||
- `GET https://chatgpt.com/backend-api/wham/usage`
|
||||
4. Passing headers:
|
||||
- `Authorization: Bearer <access_token>`
|
||||
- `ChatGPT-Account-Id: <account_id>`
|
||||
- `User-Agent: codex-cli`
|
||||
5. Mapping the JSON response into the generic usage report
|
||||
|
||||
If `auth.json` is missing, fields are absent, or the HTTP call fails, the agent should return a normal error so `/usage` can present a concise failure message.
|
||||
|
||||
## Data Model
|
||||
|
||||
The generic report should support at least:
|
||||
|
||||
- identity:
|
||||
- provider
|
||||
- account_id
|
||||
- user/email if available
|
||||
- plan:
|
||||
- plan_type
|
||||
- standard rate limits:
|
||||
- allowed
|
||||
- limit_reached
|
||||
- primary window
|
||||
- secondary window
|
||||
- review/code-review limits:
|
||||
- same window shape when available
|
||||
- credits:
|
||||
- has_credits
|
||||
- unlimited
|
||||
- balance
|
||||
|
||||
Each window should preserve:
|
||||
|
||||
- used percent
|
||||
- total window seconds
|
||||
- reset-after seconds
|
||||
- reset timestamp if supplied
|
||||
|
||||
This is enough for the current ChatGPT OAuth response and still general enough for other providers with multiple quota windows.
|
||||
|
||||
## Output Format
|
||||
|
||||
The first output version should be plain text and compact. Suggested shape:
|
||||
|
||||
- title line with agent/provider
|
||||
- plan line
|
||||
- standard usage section
|
||||
- code review usage section if present
|
||||
- credits section if present
|
||||
|
||||
For each window:
|
||||
|
||||
- whether requests are currently allowed
|
||||
- whether the limit is reached
|
||||
- used percent
|
||||
- reset timing
|
||||
|
||||
Prefer rendering both relative time and absolute time if easy to do consistently. If absolute rendering is added, it should use local time formatting already used by the project, or a simple RFC3339 fallback.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Agent does not implement usage reporting:
|
||||
- reply with a user-facing “current agent does not support `/usage`”
|
||||
- Codex auth file missing:
|
||||
- explain that ChatGPT OAuth login data was not found
|
||||
- Token/account id missing:
|
||||
- explain credentials are incomplete
|
||||
- HTTP non-200:
|
||||
- include status code in logs; show concise failure text to user
|
||||
- JSON decode failure:
|
||||
- report provider response parse failure
|
||||
|
||||
Do not expose bearer tokens or raw auth file contents in user-visible output or logs.
|
||||
|
||||
## Testing
|
||||
|
||||
Minimum tests should cover:
|
||||
|
||||
- command dispatch recognizes `/usage`
|
||||
- engine returns unsupported message when agent lacks the interface
|
||||
- engine formats a successful generic usage report
|
||||
- Codex usage fetch maps a representative `wham/usage` payload correctly
|
||||
- Codex usage fetch errors on missing auth file / missing token fields
|
||||
|
||||
Network-dependent tests should use injected transport or `httptest`, not live requests.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No merging into `/status` or `/doctor`
|
||||
- No card UI in the first version
|
||||
- No polling or background caching in the first version
|
||||
- No support for non-Codex agents in this change beyond the shared interface
|
||||
@@ -0,0 +1,155 @@
|
||||
# Usage Command Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add a built-in `/usage` command with a generic agent usage-reporting interface, and implement the first provider-backed version for Codex using ChatGPT OAuth quota data.
|
||||
|
||||
**Architecture:** The engine gains a new built-in command that depends only on an optional `UsageReporter` interface. Agents that implement the interface return a generic `UsageReport`; the engine formats that into text. The Codex agent reads ChatGPT OAuth credentials from `~/.codex/auth.json`, calls the `wham/usage` endpoint, and maps the response into the generic structure.
|
||||
|
||||
**Tech Stack:** Go, built-in command dispatch in `core/engine.go`, optional agent interfaces in `core/interfaces.go`, HTTP/JSON via Go standard library, table-driven tests.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add generic usage-reporting types
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/interfaces.go`
|
||||
|
||||
**Step 1: Add the failing interface usage surface**
|
||||
|
||||
Define:
|
||||
|
||||
- `UsageReporter`
|
||||
- `UsageReport`
|
||||
- `UsageBucket`
|
||||
- `UsageWindow`
|
||||
- `UsageCredits`
|
||||
|
||||
The structure should cover provider identity, plan/tier, generic allowed/limit-reached flags, one or more windows, and credits metadata.
|
||||
|
||||
**Step 2: Run targeted compile check**
|
||||
|
||||
Run: `go test ./core/...`
|
||||
Expected: compile failures in engine/tests until the command layer is wired.
|
||||
|
||||
**Step 3: Keep the types minimal**
|
||||
|
||||
Do not add provider-specific fields unless they are broadly useful.
|
||||
|
||||
**Step 4: Re-run compile check**
|
||||
|
||||
Run: `go test ./core/...`
|
||||
Expected: still failing only because `/usage` command is not yet fully integrated.
|
||||
|
||||
### Task 2: Add `/usage` built-in command and output formatter
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go`
|
||||
- Modify: `core/i18n.go`
|
||||
- Test: `core/engine_test.go`
|
||||
|
||||
**Step 1: Write/extend failing tests**
|
||||
|
||||
Add tests for:
|
||||
|
||||
- `/usage` dispatch to a supporting agent
|
||||
- unsupported-agent message
|
||||
- successful output formatting with representative report data
|
||||
|
||||
**Step 2: Run the targeted tests**
|
||||
|
||||
Run: `go test ./core/... -run 'Test.*Usage|TestHandleCommand'`
|
||||
Expected: FAIL because the command is not registered yet.
|
||||
|
||||
**Step 3: Implement command plumbing**
|
||||
|
||||
Update:
|
||||
|
||||
- built-in command registration
|
||||
- command dispatch switch
|
||||
- bot command listing
|
||||
- help/i18n text
|
||||
|
||||
Add a `cmdUsage` implementation that:
|
||||
|
||||
- type-asserts `UsageReporter`
|
||||
- fetches usage with timeout
|
||||
- formats a concise text response
|
||||
|
||||
**Step 4: Run the targeted tests again**
|
||||
|
||||
Run: `go test ./core/... -run 'Test.*Usage|TestHandleCommand'`
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: Implement Codex usage retrieval against ChatGPT OAuth
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/codex/codex.go`
|
||||
- Add or Modify: `agent/codex/usage.go`
|
||||
- Test: `agent/codex/usage_test.go`
|
||||
|
||||
**Step 1: Write failing tests for the mapper/fetcher**
|
||||
|
||||
Cover:
|
||||
|
||||
- successful parsing of a representative `wham/usage` payload
|
||||
- missing `auth.json`
|
||||
- missing token/account fields
|
||||
- HTTP error response
|
||||
|
||||
**Step 2: Run the targeted tests**
|
||||
|
||||
Run: `go test ./agent/codex -run 'Test.*Usage'`
|
||||
Expected: FAIL because the implementation does not exist yet.
|
||||
|
||||
**Step 3: Implement minimal production code**
|
||||
|
||||
Add:
|
||||
|
||||
- auth file path resolver
|
||||
- auth JSON loader
|
||||
- HTTP request builder
|
||||
- response DTOs
|
||||
- mapping into `core.UsageReport`
|
||||
|
||||
Prefer dependency injection for:
|
||||
|
||||
- auth file path / reader
|
||||
- HTTP client / transport
|
||||
|
||||
So tests avoid live network and real user files.
|
||||
|
||||
**Step 4: Run the targeted tests again**
|
||||
|
||||
Run: `go test ./agent/codex -run 'Test.*Usage'`
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: End-to-end verification and cleanup
|
||||
|
||||
**Files:**
|
||||
- Modify: `README.md`
|
||||
- Modify: `README.zh-CN.md`
|
||||
- Modify: `CHANGELOG.md`
|
||||
|
||||
**Step 1: Document the new command**
|
||||
|
||||
Add `/usage` to command lists and a short explanation that support depends on the current agent.
|
||||
|
||||
**Step 2: Run focused test suites**
|
||||
|
||||
Run: `go test ./core/... ./agent/codex`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Run broader verification**
|
||||
|
||||
Run: `go test ./...`
|
||||
Expected: PASS, or identify unrelated pre-existing failures clearly.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git add core/interfaces.go core/engine.go core/i18n.go core/engine_test.go agent/codex/codex.go agent/codex/usage.go agent/codex/usage_test.go README.md README.zh-CN.md CHANGELOG.md docs/plans/2026-03-12-usage-design.md docs/plans/2026-03-12-usage.md
|
||||
git commit -m "feat: add usage command for codex oauth"
|
||||
```
|
||||
@@ -0,0 +1,118 @@
|
||||
# Session Resilience Design
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Branch:** feat/multi-workspace
|
||||
|
||||
## Problem
|
||||
|
||||
Multi-workspace mode introduces long-lived, concurrent Claude Code sessions that are reaped on idle and resumed on demand. Several failure modes cause silent context loss ("context rot"):
|
||||
|
||||
1. **CWD mismatch** — workspace paths that differ by trailing slash, symlink, or relative segment map to different Claude Code session directories, causing resume to silently start a fresh session
|
||||
2. **Resume failure** — when a session's context is too large, `--resume` fails with "Prompt is too long" and the session becomes permanently broken until manual `!new`
|
||||
3. **Invisible context degradation** — users have no signal that context is filling up until Claude starts forgetting things
|
||||
4. **Silent failures** — session lifecycle events (spawn, resume, reap, failure) lack diagnostic logging
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Path Normalization
|
||||
|
||||
**Helper:** `normalizeWorkspacePath(path string) string` in `workspace_state.go`
|
||||
|
||||
```
|
||||
filepath.Clean(path) → filepath.EvalSymlinks(cleanedPath)
|
||||
```
|
||||
|
||||
If `EvalSymlinks` fails (path doesn't exist yet), fall back to `filepath.Clean` only.
|
||||
|
||||
**Applied at two sites:**
|
||||
- `workspacePool.GetOrCreate(workspace)` — normalize the key before map lookup/insert
|
||||
- Workspace binding resolution — normalize before the workspace string enters the system
|
||||
|
||||
**Logging:** `slog.Debug("workspace path normalized", "original", path, "normalized", result)` when normalization changes the input.
|
||||
|
||||
### 2. Resume Failure → Fresh Session Fallback
|
||||
|
||||
**Location:** `getOrCreateInteractiveStateWith()` in engine.go
|
||||
|
||||
**Current behavior:** `StartSession` failure → state with nil `agentSession` → broken until `!new`.
|
||||
|
||||
**New behavior:**
|
||||
|
||||
1. If `StartSession` fails AND `session.AgentSessionID != ""` (resume attempt):
|
||||
- Log failure with diagnostics: session ID, error message, cwd
|
||||
- Clear `session.AgentSessionID` and save
|
||||
- Retry `agent.StartSession(ctx, "")` for a fresh session
|
||||
- Post platform notification: *"Session context was too large to resume — starting fresh. Project context is preserved in CLAUDE.md."*
|
||||
2. If fresh retry also fails → fall through to existing nil-state behavior
|
||||
3. If original call was already fresh (`AgentSessionID == ""`) → no retry, fall through as today
|
||||
|
||||
**Notification:** Send via `p.Send(ctx, replyCtx, msg)` — both are available on the `interactiveState` being constructed.
|
||||
|
||||
### 3. Context Consumption Indicator
|
||||
|
||||
**Dual-track approach with logging to compare accuracy over time.**
|
||||
|
||||
#### Track A: SDK token counts (accurate, cc-connect-owned)
|
||||
|
||||
- In `processInteractiveEvents`, parse `result` events for `input_tokens` usage
|
||||
- Store cumulative `input_tokens` on the `interactiveState` (updated each turn)
|
||||
- Compute percentage: `input_tokens / 200_000 * 100` (model context window)
|
||||
- Append `[ctx: XX%]` to every message relayed to the platform
|
||||
|
||||
#### Track B: Claude self-report (approximate, for comparison)
|
||||
|
||||
- Add to system prompt via `--append-system-prompt`: instruction to append `[ctx: ~XX%]` to every response
|
||||
- Parse the self-reported value from Claude's output before relaying
|
||||
|
||||
#### Logging
|
||||
|
||||
On every turn that has both values:
|
||||
```
|
||||
slog.Info("context_usage",
|
||||
"sdk_pct", sdkPct,
|
||||
"self_reported_pct", selfReportedPct,
|
||||
"session_key", sessionKey,
|
||||
"input_tokens", inputTokens)
|
||||
```
|
||||
|
||||
Over time, compare drift to decide whether the system prompt instruction adds value.
|
||||
|
||||
#### Display
|
||||
|
||||
Appended to every visible message relayed to the platform:
|
||||
```
|
||||
Here's the refactored auth module...
|
||||
[ctx: 62%]
|
||||
```
|
||||
|
||||
If no token data available yet (first message), skip the indicator.
|
||||
|
||||
### 4. Diagnostic Logging
|
||||
|
||||
Structured `slog` logging at key lifecycle points:
|
||||
|
||||
| Event | Level | Fields |
|
||||
|-------|-------|--------|
|
||||
| Session spawn | Info | normalized cwd, session ID (or "new"), model |
|
||||
| Session resume | Info | session ID, JSONL file path, file size |
|
||||
| Resume failure | Error | session ID, error, stderr, cwd, JSONL file size |
|
||||
| Fresh fallback | Warn | original session ID, new session ID, cwd |
|
||||
| Idle reap | Info | session key, workspace path, idle duration, token count at reap |
|
||||
| Context per-turn | Info | session key, input_tokens, sdk_pct, self_reported_pct |
|
||||
| Path normalization | Debug | original path, normalized path (only when changed) |
|
||||
|
||||
**JSONL file size:** Resolve via `findProjectDir` + stat at resume time. Log even if file not found (indicates cwd mismatch).
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **Proactive compaction** — likely to cause more trouble than it's worth; the context indicator gives users agency to compact manually
|
||||
- **Session summary → new session pattern** — more robust but significantly more implementation work; revisit if resume-with-fallback proves insufficient
|
||||
- **Disk/memory monitoring** — out of scope; can be added as operational tooling later
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Path normalization (prerequisite for everything else being reliable)
|
||||
2. Diagnostic logging (needed to verify the other changes work)
|
||||
3. Resume failure fallback (highest-value fix)
|
||||
4. Context consumption indicator (most complex, benefits from logging already being in place)
|
||||
@@ -0,0 +1,793 @@
|
||||
# Session Resilience Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Eliminate silent session context loss in multi-workspace mode by normalizing paths, handling resume failures gracefully, surfacing context consumption to users, and adding diagnostic logging.
|
||||
|
||||
**Architecture:** Four independent changes layered bottom-up: path normalization (prevents mismatches), diagnostic logging (makes failures visible), resume fallback (auto-recovers from broken resumes), context indicator (gives users agency over compaction).
|
||||
|
||||
**Tech Stack:** Go, Claude Code CLI (stream-json protocol), slog structured logging
|
||||
|
||||
**Design doc:** `docs/plans/2026-03-13-session-resilience-design.md`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `normalizeWorkspacePath` helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/workspace_state.go`
|
||||
- Create: `core/workspace_state_test.go` (add test cases)
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add to `core/workspace_state_test.go`:
|
||||
|
||||
```go
|
||||
func TestNormalizeWorkspacePath(t *testing.T) {
|
||||
// Create a real temp directory for symlink tests
|
||||
tmp := t.TempDir()
|
||||
realDir := filepath.Join(tmp, "real-project")
|
||||
if err := os.Mkdir(realDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
symlink := filepath.Join(tmp, "link-project")
|
||||
if err := os.Symlink(realDir, symlink); err != nil {
|
||||
t.Skip("symlinks not supported")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"trailing slash", realDir + "/", realDir},
|
||||
{"double slash", filepath.Join(tmp, "real-project") + "//", realDir},
|
||||
{"dot segment", filepath.Join(tmp, ".", "real-project"), realDir},
|
||||
{"dotdot segment", filepath.Join(tmp, "real-project", "subdir", ".."), realDir},
|
||||
{"symlink resolved", symlink, realDir},
|
||||
{"nonexistent uses Clean only", "/nonexistent/path/./foo/../bar", "/nonexistent/path/bar"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := normalizeWorkspacePath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./core/ -run TestNormalizeWorkspacePath -v`
|
||||
Expected: FAIL — `normalizeWorkspacePath` undefined
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add to `core/workspace_state.go`:
|
||||
|
||||
```go
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// normalizeWorkspacePath cleans and resolves a workspace path to prevent
|
||||
// mismatches caused by trailing slashes, symlinks, or relative segments.
|
||||
// If the path cannot be resolved (e.g. doesn't exist yet), falls back to
|
||||
// filepath.Clean only.
|
||||
func normalizeWorkspacePath(path string) string {
|
||||
cleaned := filepath.Clean(path)
|
||||
resolved, err := filepath.EvalSymlinks(cleaned)
|
||||
if err != nil {
|
||||
// Path doesn't exist yet — best effort
|
||||
return cleaned
|
||||
}
|
||||
if resolved != path {
|
||||
slog.Debug("workspace path normalized", "original", path, "normalized", resolved)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./core/ -run TestNormalizeWorkspacePath -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/workspace_state.go core/workspace_state_test.go
|
||||
git commit -m "feat: add normalizeWorkspacePath helper for consistent pool keys"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Apply path normalization at entry points
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/workspace_state.go` — `GetOrCreate`
|
||||
- Modify: `core/engine.go:5656,5681` — `resolveWorkspace` return values
|
||||
- Modify: `core/engine.go:993` — `getOrCreateWorkspaceAgent`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
Add to `core/workspace_state_test.go`:
|
||||
|
||||
```go
|
||||
func TestWorkspacePoolNormalizesKeys(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
realDir := filepath.Join(tmp, "project")
|
||||
if err := os.Mkdir(realDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pool := newWorkspacePool(15 * time.Minute)
|
||||
|
||||
// Access with trailing slash
|
||||
ws1 := pool.GetOrCreate(realDir + "/")
|
||||
// Access without trailing slash
|
||||
ws2 := pool.GetOrCreate(realDir)
|
||||
|
||||
if ws1 != ws2 {
|
||||
t.Error("trailing slash created a different workspace state")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./core/ -run TestWorkspacePoolNormalizesKeys -v`
|
||||
Expected: FAIL — two different states returned
|
||||
|
||||
**Step 3: Normalize in `GetOrCreate` and `Get`**
|
||||
|
||||
In `core/workspace_state.go`, modify `GetOrCreate`:
|
||||
|
||||
```go
|
||||
func (p *workspacePool) GetOrCreate(workspace string) *workspaceState {
|
||||
workspace = normalizeWorkspacePath(workspace)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if s, ok := p.states[workspace]; ok {
|
||||
return s
|
||||
}
|
||||
s := newWorkspaceState(workspace)
|
||||
p.states[workspace] = s
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
Modify `Get` similarly:
|
||||
|
||||
```go
|
||||
func (p *workspacePool) Get(workspace string) *workspaceState {
|
||||
workspace = normalizeWorkspacePath(workspace)
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.states[workspace]
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Normalize in `resolveWorkspace` returns**
|
||||
|
||||
In `core/engine.go`, at lines 5656 and 5681 where workspace paths are returned, wrap with normalization:
|
||||
|
||||
At line 5656:
|
||||
```go
|
||||
return normalizeWorkspacePath(b.Workspace), b.ChannelName, nil
|
||||
```
|
||||
|
||||
At line 5681:
|
||||
```go
|
||||
normalized := normalizeWorkspacePath(candidate)
|
||||
e.workspaceBindings.Bind(projectKey, channelID, channelName, normalized)
|
||||
slog.Info("workspace auto-bound by convention",
|
||||
"channel", channelName, "workspace", normalized)
|
||||
return normalized, channelName, nil
|
||||
```
|
||||
|
||||
**Step 5: Run tests**
|
||||
|
||||
Run: `go test ./core/ -run TestWorkspacePool -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add core/workspace_state.go core/workspace_state_test.go core/engine.go
|
||||
git commit -m "feat: normalize workspace paths at pool and resolution entry points"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Add token usage fields to Event and parse in session
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/message.go:87-98` — add `InputTokens`, `OutputTokens` to `Event`
|
||||
- Modify: `agent/claudecode/session.go:268-282` — parse usage from result JSON
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Add to a new file or existing test for claudecode session parsing. Since `handleResult` is on the unexported `claudeSession`, test via the event channel:
|
||||
|
||||
Create `agent/claudecode/session_test.go` test (or add to existing):
|
||||
|
||||
```go
|
||||
func TestHandleResultParsesUsage(t *testing.T) {
|
||||
// Simulate a result event JSON with usage data
|
||||
raw := map[string]any{
|
||||
"type": "result",
|
||||
"result": "test response",
|
||||
"session_id": "sess-123",
|
||||
"usage": map[string]any{
|
||||
"input_tokens": float64(50000),
|
||||
"output_tokens": float64(1500),
|
||||
},
|
||||
}
|
||||
|
||||
cs := &claudeSession{
|
||||
events: make(chan core.Event, 1),
|
||||
ctx: context.Background(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
cs.alive.Store(true)
|
||||
|
||||
cs.handleResult(raw)
|
||||
|
||||
evt := <-cs.events
|
||||
if evt.InputTokens != 50000 {
|
||||
t.Errorf("InputTokens = %d, want 50000", evt.InputTokens)
|
||||
}
|
||||
if evt.OutputTokens != 1500 {
|
||||
t.Errorf("OutputTokens = %d, want 1500", evt.OutputTokens)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./agent/claudecode/ -run TestHandleResultParsesUsage -v`
|
||||
Expected: FAIL — `InputTokens` field doesn't exist on Event
|
||||
|
||||
**Step 3: Add fields to Event**
|
||||
|
||||
In `core/message.go`, add to the `Event` struct:
|
||||
|
||||
```go
|
||||
InputTokens int // populated for EventResult — total input tokens this turn
|
||||
OutputTokens int // populated for EventResult — output tokens this turn
|
||||
```
|
||||
|
||||
**Step 4: Parse usage in handleResult**
|
||||
|
||||
In `agent/claudecode/session.go`, modify `handleResult`:
|
||||
|
||||
```go
|
||||
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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./agent/claudecode/ -run TestHandleResultParsesUsage -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add core/message.go agent/claudecode/session.go agent/claudecode/session_test.go
|
||||
git commit -m "feat: parse token usage from Claude Code result events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Track context percentage on interactiveState and append to messages
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go:192-202` — add `inputTokens` field to `interactiveState`
|
||||
- Modify: `core/engine.go:1326-1352` — track tokens on EventResult
|
||||
- Modify: `core/engine.go:1354+` — append `[ctx: XX%]` to relayed messages
|
||||
|
||||
**Step 1: Add field to interactiveState**
|
||||
|
||||
In `core/engine.go`, add to the `interactiveState` struct:
|
||||
|
||||
```go
|
||||
type interactiveState struct {
|
||||
// ... existing fields ...
|
||||
inputTokens int // last known input_tokens from result event (context size proxy)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Update EventResult handler to track tokens and append indicator**
|
||||
|
||||
In `processInteractiveEvents`, in the `case EventResult:` block (around line 1326), after the existing session ID handling:
|
||||
|
||||
```go
|
||||
case EventResult:
|
||||
if event.SessionID != "" {
|
||||
session.mu.Lock()
|
||||
session.AgentSessionID = event.SessionID
|
||||
session.mu.Unlock()
|
||||
}
|
||||
|
||||
// Track context consumption
|
||||
if event.InputTokens > 0 {
|
||||
state.mu.Lock()
|
||||
state.inputTokens = event.InputTokens
|
||||
state.mu.Unlock()
|
||||
}
|
||||
|
||||
fullResponse := event.Content
|
||||
if fullResponse == "" && len(textParts) > 0 {
|
||||
fullResponse = strings.Join(textParts, "")
|
||||
}
|
||||
if fullResponse == "" {
|
||||
fullResponse = e.i18n.T(MsgEmptyResponse)
|
||||
}
|
||||
|
||||
// Append context indicator
|
||||
if event.InputTokens > 0 {
|
||||
pct := event.InputTokens * 100 / 200_000
|
||||
fullResponse += fmt.Sprintf("\n[ctx: %d%%]", pct)
|
||||
}
|
||||
|
||||
// ... rest of existing EventResult handling
|
||||
```
|
||||
|
||||
**Step 3: Also append indicator to intermediate messages (tool use, thinking)**
|
||||
|
||||
For every visible message sent during a turn, we need the indicator. The simplest approach: store the last known percentage on the state, and have a helper:
|
||||
|
||||
```go
|
||||
func contextIndicator(inputTokens int) string {
|
||||
if inputTokens <= 0 {
|
||||
return ""
|
||||
}
|
||||
pct := inputTokens * 100 / 200_000
|
||||
return fmt.Sprintf("\n[ctx: %d%%]", pct)
|
||||
}
|
||||
```
|
||||
|
||||
Append `contextIndicator(state.inputTokens)` to every `e.send()` call in the event loop for EventThinking, EventToolUse, and EventResult. Read `state.inputTokens` under the state mutex that's already being acquired.
|
||||
|
||||
**Step 4: Run existing tests**
|
||||
|
||||
Run: `go test ./core/ -v -count=1`
|
||||
Expected: PASS (no test changes needed — this is additive to message content)
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add core/engine.go
|
||||
git commit -m "feat: append context consumption indicator [ctx: XX%] to relayed messages"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Add system prompt instruction for Claude self-reporting
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/interfaces.go:36-81` — append context self-report instruction to `AgentSystemPrompt()`
|
||||
|
||||
**Step 1: Add instruction to system prompt**
|
||||
|
||||
At the end of the `AgentSystemPrompt()` return string, before the closing backtick, add:
|
||||
|
||||
```go
|
||||
## Context awareness
|
||||
At the end of every message you send, append your estimate of your context window consumption as: [ctx: ~XX%]
|
||||
This helps the user decide when to run /compact. Be honest — if you're unsure, estimate conservatively.
|
||||
```
|
||||
|
||||
**Step 2: Run existing tests**
|
||||
|
||||
Run: `go test ./core/ -v -count=1`
|
||||
Expected: PASS
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add core/interfaces.go
|
||||
git commit -m "feat: instruct agent to self-report context usage for comparison logging"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Add dual-track context logging
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go` — EventResult handler, add structured log with both values
|
||||
|
||||
**Step 1: Parse self-reported percentage from response**
|
||||
|
||||
Add helper in `core/engine.go`:
|
||||
|
||||
```go
|
||||
import "regexp"
|
||||
|
||||
var ctxSelfReportRe = regexp.MustCompile(`\[ctx:\s*~?(\d+)%\]`)
|
||||
|
||||
// parseSelfReportedCtx extracts the self-reported context percentage from a response.
|
||||
// Returns -1 if not found.
|
||||
func parseSelfReportedCtx(response string) int {
|
||||
m := ctxSelfReportRe.FindStringSubmatch(response)
|
||||
if m == nil {
|
||||
return -1
|
||||
}
|
||||
v, _ := strconv.Atoi(m[1])
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Write test for parser**
|
||||
|
||||
```go
|
||||
func TestParseSelfReportedCtx(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"some response\n[ctx: ~45%]", 45},
|
||||
{"response [ctx: 80%]", 80},
|
||||
{"no indicator", -1},
|
||||
{"[ctx: ~100%] mid-text", 100},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := parseSelfReportedCtx(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("parseSelfReportedCtx(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Run test to verify it fails, implement, verify pass**
|
||||
|
||||
Run: `go test ./core/ -run TestParseSelfReportedCtx -v`
|
||||
|
||||
**Step 4: Add structured logging in EventResult handler**
|
||||
|
||||
After computing the SDK percentage but before appending the indicator, add:
|
||||
|
||||
```go
|
||||
if event.InputTokens > 0 {
|
||||
sdkPct := event.InputTokens * 100 / 200_000
|
||||
selfPct := parseSelfReportedCtx(fullResponse)
|
||||
slog.Info("context_usage",
|
||||
"session_key", sessionKey,
|
||||
"sdk_pct", sdkPct,
|
||||
"self_reported_pct", selfPct,
|
||||
"input_tokens", event.InputTokens,
|
||||
"output_tokens", event.OutputTokens,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Strip Claude's self-reported indicator before appending the real one**
|
||||
|
||||
So we don't show duplicate indicators, strip the self-reported one from the response before appending the SDK-based one:
|
||||
|
||||
```go
|
||||
// Strip self-reported indicator (we replace it with the accurate SDK one)
|
||||
fullResponse = ctxSelfReportRe.ReplaceAllString(fullResponse, "")
|
||||
fullResponse = strings.TrimRight(fullResponse, "\n ")
|
||||
fullResponse += fmt.Sprintf("\n[ctx: %d%%]", sdkPct)
|
||||
```
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add core/engine.go core/engine_test.go
|
||||
git commit -m "feat: dual-track context usage logging (SDK vs self-reported)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Add diagnostic logging to session lifecycle
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go:1097-1119` — spawn/resume logging
|
||||
- Modify: `core/engine.go:259-283` — reap logging
|
||||
- Modify: `agent/claudecode/session.go:42-117` — spawn logging with JSONL path/size
|
||||
- Modify: `agent/claudecode/claudecode.go:195-224` — log cwd at StartSession
|
||||
|
||||
**Step 1: Enhanced spawn logging in `getOrCreateInteractiveStateWith`**
|
||||
|
||||
Replace the existing log at line 1118 with:
|
||||
|
||||
```go
|
||||
slog.Info("session spawned",
|
||||
"session_key", sessionKey,
|
||||
"agent_session", session.AgentSessionID,
|
||||
"is_resume", session.AgentSessionID != "",
|
||||
"elapsed", startElapsed,
|
||||
)
|
||||
```
|
||||
|
||||
**Step 2: Add JSONL file size logging in `claudecode.StartSession`**
|
||||
|
||||
In `agent/claudecode/claudecode.go`, in `StartSession`, before calling `newClaudeSession`, add:
|
||||
|
||||
```go
|
||||
if sessionID != "" {
|
||||
// Log session file details for diagnostics
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
absWorkDir, _ := filepath.Abs(a.workDir)
|
||||
if homeDir != "" {
|
||||
projectDir := findProjectDir(homeDir, absWorkDir)
|
||||
sessionFile := filepath.Join(projectDir, sessionID+".jsonl")
|
||||
if info, err := os.Stat(sessionFile); err == nil {
|
||||
slog.Info("session resume attempt",
|
||||
"session_id", sessionID,
|
||||
"jsonl_path", sessionFile,
|
||||
"jsonl_size_bytes", info.Size(),
|
||||
"work_dir", absWorkDir,
|
||||
)
|
||||
} else {
|
||||
slog.Warn("session file not found for resume",
|
||||
"session_id", sessionID,
|
||||
"expected_path", sessionFile,
|
||||
"work_dir", absWorkDir,
|
||||
"error", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Enhanced reap logging in `runIdleReaper`**
|
||||
|
||||
In `core/engine.go`, in the reap loop (around line 271), add idle duration:
|
||||
|
||||
```go
|
||||
reaped := e.workspacePool.ReapIdle()
|
||||
for _, ws := range reaped {
|
||||
e.interactiveMu.Lock()
|
||||
for key, state := range e.interactiveStates {
|
||||
if state.workspaceDir == ws {
|
||||
state.mu.Lock()
|
||||
tokenCount := state.inputTokens
|
||||
state.mu.Unlock()
|
||||
slog.Info("session idle-reaped",
|
||||
"session_key", key,
|
||||
"workspace", ws,
|
||||
"last_ctx_pct", tokenCount*100/200_000,
|
||||
"input_tokens", tokenCount,
|
||||
)
|
||||
if state.agentSession != nil {
|
||||
state.agentSession.Close()
|
||||
}
|
||||
delete(e.interactiveStates, key)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Log stderr on session process failure**
|
||||
|
||||
In `agent/claudecode/session.go`, the `readLoop` already logs stderr on failure (line 125). Enhance it:
|
||||
|
||||
```go
|
||||
slog.Error("claudeSession: process failed",
|
||||
"error", err,
|
||||
"stderr", stderrMsg,
|
||||
"work_dir", cs.workDir,
|
||||
"session_id", cs.CurrentSessionID(),
|
||||
)
|
||||
```
|
||||
|
||||
**Step 5: Run all tests**
|
||||
|
||||
Run: `go test ./core/ ./agent/claudecode/ -v -count=1`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add core/engine.go agent/claudecode/session.go agent/claudecode/claudecode.go
|
||||
git commit -m "feat: add diagnostic logging for session spawn, resume, reap, and failure"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Resume failure fallback with user notification
|
||||
|
||||
**Files:**
|
||||
- Modify: `core/engine.go:1097-1105` — retry logic in `getOrCreateInteractiveStateWith`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
Add to `core/engine_test.go`:
|
||||
|
||||
```go
|
||||
func TestResumeFailureFallsBackToFreshSession(t *testing.T) {
|
||||
callCount := 0
|
||||
agent := &stubAgent{
|
||||
startSessionFunc: func(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
||||
callCount++
|
||||
if sessionID != "" {
|
||||
// Simulate resume failure
|
||||
return nil, fmt.Errorf("Prompt is too long")
|
||||
}
|
||||
// Fresh session succeeds
|
||||
return &stubAgentSession{alive: true}, nil
|
||||
},
|
||||
}
|
||||
|
||||
e := newTestEngine(t)
|
||||
e.agent = agent
|
||||
|
||||
session := e.sessions.GetOrCreateActive("test:chan:user")
|
||||
session.AgentSessionID = "old-session-id"
|
||||
|
||||
p := &stubPlatform{}
|
||||
state := e.getOrCreateInteractiveState("test:chan:user", p, nil, session)
|
||||
|
||||
if state.agentSession == nil {
|
||||
t.Fatal("expected agentSession to be non-nil after fallback")
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Errorf("expected 2 StartSession calls (resume + fresh), got %d", callCount)
|
||||
}
|
||||
if session.AgentSessionID != "" {
|
||||
t.Errorf("expected AgentSessionID cleared, got %q", session.AgentSessionID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note: This test may need adjustment to match the actual test helpers in the codebase. Check `core/engine_test.go` for the existing `stubAgent` and `newTestEngine` patterns and adapt accordingly.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `go test ./core/ -run TestResumeFailureFallsBackToFreshSession -v`
|
||||
Expected: FAIL — current code doesn't retry
|
||||
|
||||
**Step 3: Implement retry logic**
|
||||
|
||||
Replace the error handling block in `getOrCreateInteractiveStateWith` (lines 1100-1104):
|
||||
|
||||
```go
|
||||
startAt := time.Now()
|
||||
agentSession, err := agent.StartSession(e.ctx, session.AgentSessionID)
|
||||
startElapsed := time.Since(startAt)
|
||||
if err != nil {
|
||||
if session.AgentSessionID != "" {
|
||||
// Resume failed — log diagnostics and retry with fresh session
|
||||
slog.Error("session resume failed, falling back to fresh session",
|
||||
"session_key", sessionKey,
|
||||
"failed_session_id", session.AgentSessionID,
|
||||
"error", err,
|
||||
"elapsed", startElapsed,
|
||||
)
|
||||
|
||||
// Clear the stale session ID
|
||||
session.mu.Lock()
|
||||
session.AgentSessionID = ""
|
||||
session.mu.Unlock()
|
||||
|
||||
// Notify user
|
||||
if p != nil {
|
||||
go func() {
|
||||
_ = p.Send(context.Background(), replyCtx,
|
||||
"⚠️ Session context was too large to resume — starting fresh. Project context is preserved in CLAUDE.md.")
|
||||
}()
|
||||
}
|
||||
|
||||
// Retry with fresh session
|
||||
freshStart := time.Now()
|
||||
agentSession, err = agent.StartSession(e.ctx, "")
|
||||
freshElapsed := time.Since(freshStart)
|
||||
if err != nil {
|
||||
slog.Error("fresh session also failed",
|
||||
"session_key", sessionKey,
|
||||
"error", err,
|
||||
"elapsed", freshElapsed,
|
||||
)
|
||||
state = &interactiveState{platform: p, replyCtx: replyCtx, quiet: quietMode}
|
||||
e.interactiveStates[sessionKey] = state
|
||||
return state
|
||||
}
|
||||
slog.Info("fresh session started after resume failure",
|
||||
"session_key", sessionKey,
|
||||
"elapsed", freshElapsed,
|
||||
)
|
||||
} else {
|
||||
slog.Error("failed to start interactive session",
|
||||
"session_key", sessionKey,
|
||||
"error", err,
|
||||
"elapsed", startElapsed,
|
||||
)
|
||||
state = &interactiveState{platform: p, replyCtx: replyCtx, quiet: quietMode}
|
||||
e.interactiveStates[sessionKey] = state
|
||||
return state
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `go test ./core/ -run TestResumeFailureFallsBackToFreshSession -v`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Run full test suite**
|
||||
|
||||
Run: `go test ./core/ ./agent/claudecode/ -v -count=1`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add core/engine.go core/engine_test.go
|
||||
git commit -m "feat: auto-recover from resume failure with fresh session and user notification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Final integration verification
|
||||
|
||||
**Files:** None — verification only
|
||||
|
||||
**Step 1: Run full test suite**
|
||||
|
||||
Run: `go test ./... -count=1`
|
||||
Expected: PASS
|
||||
|
||||
**Step 2: Verify build**
|
||||
|
||||
Run: `go build ./...`
|
||||
Expected: no errors
|
||||
|
||||
**Step 3: Review all changes**
|
||||
|
||||
Run: `git log --oneline main..HEAD`
|
||||
|
||||
Verify the commit sequence matches the plan:
|
||||
1. `normalizeWorkspacePath` helper
|
||||
2. Apply normalization at entry points
|
||||
3. Token usage fields + parsing
|
||||
4. Context indicator on messages
|
||||
5. System prompt self-report instruction
|
||||
6. Dual-track logging
|
||||
7. Diagnostic lifecycle logging
|
||||
8. Resume failure fallback
|
||||
|
||||
**Step 4: Final commit (if any fixups needed)**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "fix: address issues found during integration verification"
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"planPath": "docs/plans/2026-03-13-session-resilience-plan.md",
|
||||
"tasks": [
|
||||
{"id": 7, "subject": "Task 1: Add normalizeWorkspacePath helper", "status": "pending"},
|
||||
{"id": 8, "subject": "Task 2: Apply path normalization at entry points", "status": "pending", "blockedBy": [7]},
|
||||
{"id": 9, "subject": "Task 3: Add token usage fields to Event and parse in session", "status": "pending"},
|
||||
{"id": 10, "subject": "Task 4: Track context % on interactiveState and append to messages", "status": "pending", "blockedBy": [9]},
|
||||
{"id": 11, "subject": "Task 5: Add system prompt instruction for self-reporting", "status": "pending"},
|
||||
{"id": 12, "subject": "Task 6: Add dual-track context logging", "status": "pending", "blockedBy": [10, 11]},
|
||||
{"id": 13, "subject": "Task 7: Add diagnostic logging to session lifecycle", "status": "pending"},
|
||||
{"id": 14, "subject": "Task 8: Resume failure fallback with user notification", "status": "pending"},
|
||||
{"id": 15, "subject": "Task 9: Final integration verification", "status": "pending", "blockedBy": [8, 12, 13, 14]}
|
||||
],
|
||||
"lastUpdated": "2026-03-13T00:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# ACP 适配层设计(草案)
|
||||
|
||||
本文描述在 cc-connect 中增加 **Agent Client Protocol(ACP)** 适配的可行方案,目标是让 **已实现 ACP Agent 端** 的上游进程(见 [官方 Agents 列表](https://agentclientprotocol.com/get-started/agents))能通过 **统一协议**接入现有 `core.Engine`,减少为每个 CLI 单独维护解析逻辑的成本。
|
||||
|
||||
## 1. 背景与术语
|
||||
|
||||
- **ACP**:基于 JSON-RPC 的标准,用于 **Client(如编辑器)↔ Agent(编码助手进程)** 通信;与 IM 无关。
|
||||
- **对 cc-connect 的价值**:在 `agent/` 侧实现 **ACP Client**(连接子进程或 socket 上的 ACP Agent),将 ACP 消息映射为现有的 `core.Agent` / `core.AgentSession` / `core.Event`,从而使 **飞书 / Telegram 等平台** 与「任意兼容 ACP 的 Agent 后端」对接。
|
||||
- **不在本文范围(可选二期)**:让 cc-connect **作为 ACP Agent 对外暴露**,供 Zed 等编辑器直连;需完整实现协议 Agent 侧,工作量更大。
|
||||
|
||||
## 2. 架构约束(与仓库规则一致)
|
||||
|
||||
- `core/` **不** import `agent/*`;新逻辑全部放在 `agent/acp/`(或 `agent/acpclient/`)。
|
||||
- 通过 `core.RegisterAgent("acp", factory)` 在 `init()` 注册;`cmd/cc-connect/plugin_agent_acp.go` + `Makefile` + `config.example.toml` 与现有 agent 插件一致。
|
||||
- 权限、会话、卡片等多为 Engine 已有能力;适配层专注 **协议 ↔ Event**。
|
||||
|
||||
## 3. 目标与非目标
|
||||
|
||||
### 3.1 一期目标(MVP)
|
||||
|
||||
- 配置驱动启动子进程:`command` + `args` + `work_dir` + `env`(与现有 agent options 风格一致)。
|
||||
- Transport:**stdio JSON-RPC**(ACP 文档中最常见);后续再评估 HTTP/WebSocket。
|
||||
- 映射能力(按优先级):
|
||||
1. 会话生命周期:与 `StartSession` / `Close` / `CurrentSessionID` 对齐。
|
||||
2. **Prompt turn**:用户文本(及后续可选图片/文件)→ ACP 对应方法;响应流 → `Event`(`EventResult`、`EventThinking`、增量文本等,与现有 Engine 消费方式一致)。
|
||||
3. **工具调用与用户批准**:映射到 `EventPermission` + `RespondPermission`(若 ACP 方法名与字段与 core 不完全一致,在适配层做字段转换)。
|
||||
- 单项目、单用户会话语义与现有一致:`session_key` 仍由 Platform 提供,ACP 侧使用独立 `sessionID` 字符串与 cc-connect 会话绑定策略需在实现阶段定稿(建议:cc-connect `sessionID` 传入 adapter,ACP session id 由子进程返回或持久化路径配置)。
|
||||
|
||||
### 3.2 明确延后(二期+)
|
||||
|
||||
- ACP **File System / Terminal** 全量映射(若与 IM 展示模型差距大,可先降级为文本摘要或仅日志)。
|
||||
- **Slash commands / Agent plan** 与 IM 命令体系的统一(可先忽略或透传为纯文本)。
|
||||
- cc-connect **作为 ACP Server** 供编辑器连接。
|
||||
|
||||
## 4. 组件划分
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `agent/acp/agent.go` | 实现 `core.Agent`:`Name`、`StartSession`、`ListSessions`、`Stop` |
|
||||
| `agent/acp/session.go` | 实现 `core.AgentSession`:`Send`、`Events`、`RespondPermission`、`Close` 等 |
|
||||
| `agent/acp/rpc.go`(或 `transport_stdio.go`) | stdio 上的 JSON-RPC 读写、request id、并发与取消 |
|
||||
| `agent/acp/mapping.go` | ACP 通知/结果 → `core.Event`;`PermissionResult` ↔ ACP 工具批准结构 |
|
||||
| 测试 | 子进程 mock:固定 JSON-RPC 回放 fixture,避免 CI 依赖真实 Cursor/Codex 二进制 |
|
||||
|
||||
## 5. 配置草案(`config.example.toml`)
|
||||
|
||||
```toml
|
||||
# [[projects]]
|
||||
# [projects.agent]
|
||||
# type = "acp"
|
||||
# [projects.agent.options]
|
||||
# command = "path/to/agent" # 或 npx / uvx 等
|
||||
# args = [] # 可选
|
||||
# # cwd 默认 work_dir;env 可扩展
|
||||
# # acp_transport = "stdio" # 默认;预留 "http" 等
|
||||
```
|
||||
|
||||
具体字段名以实现时与 `config` 解析为准,需 **向后兼容**:未安装插件时 `no_acp` build tag 行为与现有 agent 一致。
|
||||
|
||||
## 6. 风险与依赖
|
||||
|
||||
- **协议版本**:需锁定所实现的 ACP schema 版本;上游变更时通过集成测试与 changelog 跟进。
|
||||
- **Agent 差异**:列表中各产品对 ACP 子集支持不同;MVP 文档中写明「已验证」矩阵(至少 1~2 个开源/可脚本化 Agent)。
|
||||
- **router / 代理场景**:若子进程同时向 stdout 打非 JSON 日志,会破坏流式解析;与 claudecode `router_url` 下禁用 `--verbose` 同类问题需在 ACP 层统一约束(仅 JSON-RPC 行写入协议通道)。
|
||||
|
||||
## 7. 实施顺序建议
|
||||
|
||||
1. 阅读官方 **Protocol / Session / Prompt / Content / Tool** 章节与 **Schema**,列出与 `core.Event` 的字段对照表。
|
||||
2. 实现 stdio transport + 最小会话握手(无 UI)。
|
||||
3. 打通一轮 prompt → 文本结果 → `EventResult`。
|
||||
4. 接入权限与工具事件;补 `engine` 层无需改动的验证测试。
|
||||
5. 文档:`docs/` 简短用户说明 + `config.example.toml` 示例。
|
||||
6. (可选)在 CI 中使用 mock server 跑 `go test ./agent/acp/...`。
|
||||
|
||||
## 8. 参考链接
|
||||
|
||||
- [ACP Introduction](https://agentclientprotocol.com/get-started/introduction)
|
||||
- [ACP Agents 列表](https://agentclientprotocol.com/get-started/agents)
|
||||
- [Protocol Overview](https://agentclientprotocol.com/protocol/overview)(以官网当前版本为准)
|
||||
|
||||
---
|
||||
|
||||
*Status: design draft — 实现跟踪可在本文件追加「Implementation log」小节或单独 tasks JSON。*
|
||||
@@ -0,0 +1,111 @@
|
||||
# Integration Test Plan
|
||||
|
||||
Integration tests verify real agent-platform interactions using actual agent binaries
|
||||
with a mock platform. Tests are gated by `//go:build integration` and excluded from
|
||||
normal CI. Run with:
|
||||
|
||||
```bash
|
||||
go test -tags=integration ./tests/integration/...
|
||||
```
|
||||
|
||||
## Philosophy
|
||||
|
||||
- **Real agents, mocked platform**: Agents run as real subprocesses; platform is mocked
|
||||
to record and verify all messages without network dependencies.
|
||||
- **Agent pooling**: Agent instances are reused across tests to avoid per-test startup
|
||||
overhead (Claude Code cold start ~3-6s).
|
||||
- **Resilient assertions**: Use case-insensitive substring matching, generous timeouts,
|
||||
and skip agents that fail due to auth/infra issues (e.g., OpenCode needs GitLab token).
|
||||
|
||||
---
|
||||
|
||||
## Implemented Cases
|
||||
|
||||
### Session Management
|
||||
- [x] `TestNewSession_ClaudeCode` — New session spawns, agent responds
|
||||
- [x] `TestNewSession_Codex` — Same for Codex
|
||||
- [x] `TestListSessions_ShowsActiveSessions` — `/list` shows active sessions
|
||||
- [x] `TestSwitchSession` — `/switch` changes active session
|
||||
- [x] `TestStopCommand` — `/stop` interrupts active session
|
||||
- [x] `TestNewSessionClearsContext` — After `/new`, prior context is cleared
|
||||
- [x] `TestHistoryCommand` — `/history` returns conversation history
|
||||
- [x] `TestConcurrentSessionIsolation` — Two sessions don't cross-talk
|
||||
|
||||
### Agent Interaction
|
||||
- [x] `TestEventParsing_ThinkToolUse` — Tool calls (echo) are parsed and produce output
|
||||
- [x] `TestMarkdownLongTextChunking` — Long responses chunked correctly
|
||||
- [x] `TestPermissionModeSwitch` — `/mode yolo` and `/mode default` work
|
||||
- [x] `TestAgentCodex` — Codex agent responds
|
||||
- [x] `TestAgentCursor` — Cursor agent responds (⚠️ may respond in locale)
|
||||
- [x] `TestAgentGemini` — ⚠️ Fails due to quota exhaustion in CI env
|
||||
- [x] `TestAgentOpencode` — ⚠️ Requires GitLab auth, skipped
|
||||
- [x] `TestSharedCasesAcrossAgents` — Same prompts validated across agents
|
||||
- [x] `TestLongTextChunking` — Very long user input (5k+ chars) processed
|
||||
|
||||
### Commands & Built-ins
|
||||
- [x] `TestShellCommand` — `/shell` executes (skips if `admin_from` not set)
|
||||
- [x] `TestProviderSwitch` — `/provider list` works
|
||||
|
||||
### i18n
|
||||
- [x] `TestLanguageSwitch` — `/lang zh` changes language (⚠️ may skip due to locale)
|
||||
- [x] `TestEmptyMessage` — Whitespace-only messages handled gracefully
|
||||
|
||||
### Message Handling
|
||||
- [x] `TestImageAttachmentRouting` — Image-bearing messages routed (⚠️ needs real image)
|
||||
|
||||
---
|
||||
|
||||
## Planned Cases (not yet implemented)
|
||||
|
||||
### Multi-Agent / Provider
|
||||
- [ ] `TestSessionResume` — Send `/new`, reconnect to same session key, verify context preserved
|
||||
- [ ] `TestProviderSwitchActual` — `/provider switch <name>` actually changes provider mid-session
|
||||
- [ ] `TestModelSwitch` — `/model <name>` changes model; responses reflect new model
|
||||
- [ ] `TestConcurrentMultiAgent` — Two different agent types active simultaneously
|
||||
|
||||
### Commands & Built-ins
|
||||
- [ ] `TestCustomCommand` — Register and invoke a custom command
|
||||
- [ ] `TestAliasCommand` — Create/use alias; verify substitution
|
||||
- [ ] `TestDirCommand` — `/dir` navigates workspace; agent respects new directory
|
||||
- [ ] `TestSearchCommand` — `/search <query>` invokes search
|
||||
|
||||
### Permission & Safety
|
||||
- [ ] `TestPermissionPromptBypass` — `yolo` mode bypasses permission prompts; `default` shows them
|
||||
- [ ] `TestSensitiveMessageRedaction` — Tokens/secrets in user input are redacted
|
||||
- [ ] `TestBannedWordBlocking` — Banned-word messages rejected with feedback
|
||||
|
||||
### Message Handling
|
||||
- [ ] `TestFileAttachmentRouting` — File attachments reach agent
|
||||
- [ ] `TestVoiceMessageHandling` — Voice messages transcribed/processed or gracefully rejected
|
||||
- [ ] `TestMarkdownParsing` — Markdown in agent responses rendered correctly
|
||||
|
||||
### Rate Limiting & Performance
|
||||
- [ ] `TestIncomingRateLimit` — Rapid messages rate-limited; excess queued/rejected
|
||||
- [ ] `TestOutgoingRateLimit` — Rapid agent output respects platform limits
|
||||
- [ ] `TestSlowAgentTimeout` — Slow agent (>idle timeout) flagged or session reaped
|
||||
|
||||
### i18n
|
||||
- [ ] `TestMultiLanguageResponses` — Same prompt in different language configs produces localized responses
|
||||
|
||||
### Error & Edge Cases
|
||||
- [ ] `TestBadAgentOutput` — Malformed agent output handled gracefully (no panic)
|
||||
- [ ] `TestAgentCrashRecovery` — Agent dies mid-session; engine detects, notifies, allows respawn
|
||||
- [ ] `TestVeryLongAgentResponse` — Extremely long response (>50k chars); chunking works, no OOM
|
||||
- [ ] `TestConcurrentSessionCreation` — Rapid session creation; keys unique, no state leakage
|
||||
|
||||
### ACP / Relay
|
||||
- [ ] `TestACPMessageRelay` — ACP message relayed to agent; response returns via correct channel
|
||||
- [ ] `TestRelaySessionKeyPreservation` — `CC_SESSION_KEY` propagated through relay; session continuity maintained
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **Timeout guidelines**: Simple prompts ("say hi") — 30s; tool use — 60s; slow agents
|
||||
(gemini, opencode) — 90s; long output — 120s.
|
||||
- **Skip vs Fail**: Auth/infra failures (`Skip`) are expected in some environments;
|
||||
code bugs should `Fatal`. Use `t.Skipf("reason")` for expected env issues.
|
||||
- **Agent pool reuse**: The pool key includes `workDir`, so tests using the same workDir
|
||||
share the same agent instance. Use `t.TempDir()` for isolation.
|
||||
- **Parallel tests**: Use `t.Parallel()` for independent tests. Avoid parallel subtests
|
||||
that share session keys to prevent race conditions.
|
||||
Reference in New Issue
Block a user