Compare commits
155 Commits
0a28aae041
...
v0.3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e45a1fba0 | ||
|
|
ad348e813f | ||
|
|
de5dcceeaf | ||
|
|
53fdcfec75 | ||
|
|
bad680cf24 | ||
|
|
a5e64ded83 | ||
|
|
77e368d354 | ||
|
|
db92a78d2b | ||
|
|
420deebdb4 | ||
|
|
0a6de3717a | ||
|
|
15645a2a3e | ||
|
|
eab65de723 | ||
|
|
81a5660f11 | ||
|
|
4bf01c6cca | ||
|
|
a799009720 | ||
|
|
549c23bd77 | ||
|
|
34be4d1e75 | ||
|
|
a390861520 | ||
|
|
ce9bdbbb9d | ||
|
|
5f4591f496 | ||
|
|
dc7968ffbc | ||
|
|
5fedd9130a | ||
|
|
c7e371c124 | ||
|
|
8748d7d49a | ||
|
|
825d36c204 | ||
|
|
65a8feff17 | ||
|
|
60dabae795 | ||
|
|
1bae7bd223 | ||
|
|
a0091e81f9 | ||
|
|
beb5ea9f53 | ||
|
|
89e96dc0a6 | ||
|
|
0c686ba170 | ||
|
|
74dc42c1fc | ||
|
|
a3d22fd874 | ||
|
|
8561910cd8 | ||
|
|
e569c1bcad | ||
|
|
4dcb24d5dd | ||
|
|
59f37e13b9 | ||
|
|
3a1d7012b4 | ||
|
|
41b24e4b7a | ||
|
|
06948dae74 | ||
|
|
bbd4aee828 | ||
|
|
d40f007818 | ||
|
|
3819a02159 | ||
|
|
9b65845c90 | ||
|
|
28176727d7 | ||
|
|
1d59cdcc25 | ||
|
|
edc6b9ea05 | ||
|
|
8e4a8ce57a | ||
|
|
c863ee4135 | ||
|
|
dd4a1140fe | ||
|
|
895317330b | ||
|
|
11e32f9802 | ||
|
|
8b7ff6383f | ||
|
|
964a8bfcff | ||
|
|
978b84893c | ||
|
|
7dd6821dc5 | ||
|
|
6abf5c87b2 | ||
|
|
b682c67f97 | ||
|
|
81309a5559 | ||
|
|
2006ad6d8c | ||
|
|
41bafb80e4 | ||
|
|
569380e133 | ||
|
|
10a5bea2b1 | ||
|
|
110815c1c5 | ||
|
|
29fc761980 | ||
|
|
d537aceb63 | ||
|
|
72b89c8ccc | ||
|
|
e19de02967 | ||
|
|
1c5f13e7eb | ||
|
|
816c771a2a | ||
|
|
642a8486cd | ||
|
|
605bcadea7 | ||
|
|
ccc1ead8c9 | ||
|
|
8bbbe8fbdd | ||
|
|
d9775834ed | ||
|
|
c32f0dce45 | ||
|
|
d864941665 | ||
|
|
9c2d831c65 | ||
|
|
2ab91f933f | ||
|
|
1fcb8cb332 | ||
|
|
3439c16e66 | ||
|
|
ce93987da8 | ||
|
|
bd7b7cc34a | ||
|
|
855452b4a2 | ||
|
|
1fcfa9123f | ||
|
|
e66b811436 | ||
|
|
8d5fa85a3a | ||
|
|
a4e7a23ca6 | ||
|
|
b67eea7b9a | ||
|
|
4a89b46857 | ||
|
|
047bf83b76 | ||
|
|
62aa142409 | ||
|
|
c93a2e80f9 | ||
|
|
9176fe3303 | ||
|
|
296a59def3 | ||
|
|
90bb2fb137 | ||
|
|
bc0bb91a83 | ||
|
|
0b39b2acfc | ||
|
|
75c27f5853 | ||
|
|
349866606c | ||
|
|
901f7a65d3 | ||
|
|
c52b41b99c | ||
|
|
ec76005c63 | ||
|
|
1736f8d924 | ||
|
|
f8b5e11c27 | ||
|
|
12c500ee90 | ||
|
|
81c9cf797f | ||
|
|
d18c1105c7 | ||
|
|
ca8e6dc51c | ||
|
|
30ad59c6eb | ||
|
|
123f140244 | ||
|
|
8db23f77cd | ||
|
|
6bfa10b0e5 | ||
|
|
65036b2ce7 | ||
|
|
76d73b2d0b | ||
|
|
78618a1b76 | ||
|
|
47e07b23d1 | ||
|
|
45ae7b8f01 | ||
|
|
e1c30b5953 | ||
|
|
b0d9fb4f39 | ||
|
|
dcc11c2b0f | ||
|
|
7f21454880 | ||
|
|
a893a1cef7 | ||
|
|
3fb48cdf51 | ||
|
|
f1bb1216bf | ||
|
|
b3faf7b810 | ||
|
|
89e4ee1c9c | ||
|
|
4df39eb1f2 | ||
|
|
a7d23143ef | ||
|
|
f72666b39e | ||
|
|
1f8ffee38e | ||
|
|
798f841b9a | ||
|
|
25c3dbb3d1 | ||
|
|
71cbc21b01 | ||
|
|
6deeba81a8 | ||
|
|
b862a7a6d0 | ||
|
|
fe1f76957d | ||
|
|
266e676dd4 | ||
|
|
402159c19a | ||
|
|
6d1b36e515 | ||
|
|
81d4889cee | ||
|
|
0eb2cd8ec3 | ||
|
|
b251ed7421 | ||
|
|
4a600e9954 | ||
|
|
cfb810b061 | ||
|
|
71bd999586 | ||
|
|
10d0cdeeae | ||
|
|
6e375aaab5 | ||
|
|
e7edf9a8d5 | ||
|
|
20431f625b | ||
|
|
d35f0f19fb | ||
|
|
4303b33b90 | ||
|
|
f9c0d24d7a | ||
|
|
ec3277234c |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
# Local environment (secrets)
|
# Local environment (secrets)
|
||||||
.env
|
.env
|
||||||
@@ -25,6 +26,7 @@ frontend/node_modules
|
|||||||
frontend/dist
|
frontend/dist
|
||||||
frontend/dist-ssr
|
frontend/dist-ssr
|
||||||
frontend/test-results
|
frontend/test-results
|
||||||
|
frontend/serve
|
||||||
frontend/*.local
|
frontend/*.local
|
||||||
server/target
|
server/target
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"story-kit": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "http://localhost:3001/mcp"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6
.story_kit/.gitignore
vendored
6
.story_kit/.gitignore
vendored
@@ -4,10 +4,16 @@ bot.toml
|
|||||||
# Matrix SDK state store
|
# Matrix SDK state store
|
||||||
matrix_store/
|
matrix_store/
|
||||||
matrix_device_id
|
matrix_device_id
|
||||||
|
matrix_history.json
|
||||||
|
|
||||||
# Agent worktrees and merge workspace (managed by the server, not tracked in git)
|
# Agent worktrees and merge workspace (managed by the server, not tracked in git)
|
||||||
worktrees/
|
worktrees/
|
||||||
merge_workspace/
|
merge_workspace/
|
||||||
|
|
||||||
|
# Intermediate pipeline stages (transient, not committed per spike 92)
|
||||||
|
work/2_current/
|
||||||
|
work/3_qa/
|
||||||
|
work/4_merge/
|
||||||
|
|
||||||
# Coverage reports (generated by cargo-llvm-cov, not tracked in git)
|
# Coverage reports (generated by cargo-llvm-cov, not tracked in git)
|
||||||
coverage/
|
coverage/
|
||||||
|
|||||||
7
.story_kit/problems.md
Normal file
7
.story_kit/problems.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Problems
|
||||||
|
|
||||||
|
Recurring issues observed during pipeline operation. Review periodically and create stories for systemic problems.
|
||||||
|
|
||||||
|
## 2026-03-18: Agent committed directly to master instead of worktree
|
||||||
|
|
||||||
|
Commit `5f4591f` ("fix: update should_commit_stage test to match 5_done") was made directly on master by an agent (likely mergemaster). Agents should only commit to their feature branch or merge-queue branch, never to master directly. The commit content was correct but the target branch was wrong. Suspect the agent ran `git commit` in the project root instead of the merge worktree directory.
|
||||||
@@ -220,7 +220,7 @@ role = "Merges completed coder work into master, runs quality gates, archives st
|
|||||||
model = "opus"
|
model = "opus"
|
||||||
max_turns = 30
|
max_turns = 30
|
||||||
max_budget_usd = 5.00
|
max_budget_usd = 5.00
|
||||||
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master using the merge_agent_work MCP tool.
|
prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master.
|
||||||
|
|
||||||
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
||||||
|
|
||||||
@@ -229,20 +229,43 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process.
|
|||||||
2. Review the result: check success, had_conflicts, conflicts_resolved, gates_passed, and gate_output
|
2. Review the result: check success, had_conflicts, conflicts_resolved, gates_passed, and gate_output
|
||||||
3. If merge succeeded and gates passed: report success to the human
|
3. If merge succeeded and gates passed: report success to the human
|
||||||
4. If conflicts were auto-resolved (conflicts_resolved=true) and gates passed: report success, noting which conflicts were resolved
|
4. If conflicts were auto-resolved (conflicts_resolved=true) and gates passed: report success, noting which conflicts were resolved
|
||||||
5. If conflicts could not be auto-resolved: call report_merge_failure(story_id='{{story_id}}', reason='<conflict details>') and report to the human. Master is untouched.
|
5. If conflicts could not be auto-resolved: **resolve them yourself** in the merge worktree (see below)
|
||||||
6. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and report to the human.
|
6. If merge failed for any other reason: call report_merge_failure(story_id='{{story_id}}', reason='<details>') and report to the human
|
||||||
7. If gates failed after merge: attempt to fix minor issues (see below), then re-trigger merge_agent_work. After 2 fix attempts, call report_merge_failure and stop.
|
7. If gates failed after merge: attempt to fix the issues yourself in the merge worktree, then re-trigger merge_agent_work. After 3 fix attempts, call report_merge_failure and stop.
|
||||||
|
|
||||||
## How Conflict Resolution Works
|
## Resolving Complex Conflicts Yourself
|
||||||
The merge pipeline uses a temporary merge-queue branch and worktree to isolate merges from master. Simple additive conflicts (both branches adding code at the same location) are resolved automatically by keeping both additions. Complex conflicts (modifying the same lines differently) are reported without touching master.
|
|
||||||
|
|
||||||
## Fixing Minor Gate Failures
|
When the auto-resolver fails, you have access to the merge worktree at `.story_kit/merge_workspace/`. Go in there and resolve the conflicts manually:
|
||||||
If quality gates fail (cargo clippy, cargo test, npm run build, npm test), attempt to fix minor issues yourself before reporting to the human.
|
|
||||||
|
|
||||||
**Fix yourself (up to 2 attempts total):**
|
1. Run `git diff --name-only --diff-filter=U` in the merge worktree to list conflicted files
|
||||||
|
2. **Build context before touching code.** Run `git log --oneline master...HEAD` on the feature branch to see its commits. Then run `git log --oneline --since="$(git log -1 --format=%ci <feature-branch-base-commit>)" master` to see what landed on master since the branch was created. Read the story files in `.story_kit/work/` for any recently merged stories that touch the same files — this tells you WHY master changed and what must be preserved.
|
||||||
|
3. Read each conflicted file and understand both sides of the conflict
|
||||||
|
4. **Understand intent, not just syntax.** The feature branch may be behind master — master's version of shared infrastructure is almost always correct. The feature branch's contribution is the NEW functionality it adds. Your job is to integrate the new into master's structure, not pick one side.
|
||||||
|
5. Resolve by integrating the feature's new functionality into master's code structure
|
||||||
|
5. Stage resolved files with `git add`
|
||||||
|
6. Run `cargo check` (and `npm run build` if frontend changed) to verify compilation
|
||||||
|
7. If it compiles, commit and re-trigger merge_agent_work
|
||||||
|
|
||||||
|
### Common conflict patterns in this project:
|
||||||
|
|
||||||
|
**Story file rename/rename conflicts:** Both branches moved the story .md file to different pipeline directories. Resolution: `git rm` both sides — story files in `work/2_current/`, `work/3_qa/`, `work/4_merge/` are gitignored and don't need to be committed.
|
||||||
|
|
||||||
|
**bot.rs tokio::select! conflicts:** Master has a `tokio::select!` loop in `handle_message()` that handles permission forwarding (story 275). Feature branches created before story 275 have a simpler direct `provider.chat_stream().await` call. Resolution: KEEP master's tokio::select! loop. Integrate only the feature's new logic (e.g. typing indicators, new callbacks) into the existing loop structure. Do NOT replace the loop with the old direct call.
|
||||||
|
|
||||||
|
**Duplicate functions/imports:** The auto-resolver keeps both sides, producing duplicates. Resolution: keep one copy (prefer master's version), delete the duplicate.
|
||||||
|
|
||||||
|
**Formatting-only conflicts:** Both sides reformatted the same code differently. Resolution: pick either side (prefer master).
|
||||||
|
|
||||||
|
## Fixing Gate Failures
|
||||||
|
|
||||||
|
If quality gates fail (cargo clippy, cargo test, npm run build, npm test), attempt to fix issues yourself in the merge worktree.
|
||||||
|
|
||||||
|
**Fix yourself (up to 3 attempts total):**
|
||||||
- Syntax errors (missing semicolons, brackets, commas)
|
- Syntax errors (missing semicolons, brackets, commas)
|
||||||
|
- Duplicate definitions from merge artifacts
|
||||||
- Simple type annotation errors
|
- Simple type annotation errors
|
||||||
- Unused import warnings flagged by clippy
|
- Unused import warnings flagged by clippy
|
||||||
|
- Mismatched braces from bad conflict resolution
|
||||||
- Trivial formatting issues that block compilation or linting
|
- Trivial formatting issues that block compilation or linting
|
||||||
|
|
||||||
**Report to human without attempting a fix:**
|
**Report to human without attempting a fix:**
|
||||||
@@ -250,17 +273,14 @@ If quality gates fail (cargo clippy, cargo test, npm run build, npm test), attem
|
|||||||
- Missing function implementations
|
- Missing function implementations
|
||||||
- Architectural changes required
|
- Architectural changes required
|
||||||
- Non-trivial refactoring needed
|
- Non-trivial refactoring needed
|
||||||
- Anything requiring understanding of broader system context
|
|
||||||
|
|
||||||
**Max retry limit:** If gates still fail after 2 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human. Do not retry further.
|
**Max retry limit:** If gates still fail after 3 fix attempts, call report_merge_failure to record the failure, then stop immediately and report the full gate output to the human.
|
||||||
|
|
||||||
## CRITICAL Rules
|
## CRITICAL Rules
|
||||||
- NEVER manually move story files between pipeline stages (e.g. from 4_merge/ to 5_done/)
|
- NEVER manually move story files between pipeline stages (e.g. from 4_merge/ to 5_done/)
|
||||||
- NEVER call accept_story — only merge_agent_work can move stories to done after a successful merge
|
- NEVER call accept_story — only merge_agent_work can move stories to done after a successful merge
|
||||||
- When merge fails, ALWAYS call report_merge_failure to record the failure — do NOT improvise with file moves
|
- When merge fails after exhausting your fix attempts, ALWAYS call report_merge_failure
|
||||||
- Only use MCP tools (merge_agent_work, report_merge_failure) to drive the merge process
|
|
||||||
- Only attempt fixes that are clearly minor and low-risk
|
|
||||||
- Report conflict resolution outcomes clearly
|
- Report conflict resolution outcomes clearly
|
||||||
- Report gate failures with full output so the human can act if needed
|
- Report gate failures with full output so the human can act if needed
|
||||||
- The server automatically runs acceptance gates when your process exits"""
|
- The server automatically runs acceptance gates when your process exits"""
|
||||||
system_prompt = "You are the mergemaster agent. Your primary responsibility is to trigger the merge_agent_work MCP tool and report the results. CRITICAL: Never manually move story files or call accept_story. When merge fails, call report_merge_failure to record the failure. For minor gate failures (syntax errors, unused imports, missing semicolons), attempt to fix them yourself — but stop after 2 attempts, call report_merge_failure, and report to the human. For complex failures or unresolvable conflicts, call report_merge_failure and report clearly so the human can act. The merge pipeline automatically resolves simple additive conflicts."
|
system_prompt = "You are the mergemaster agent. Your primary job is to merge feature branches to master. First try the merge_agent_work MCP tool. If the auto-resolver fails on complex conflicts, resolve them yourself in the merge worktree — you are an opus-class agent capable of understanding both sides of a conflict and producing correct merged code. Common patterns: keep master's tokio::select! permission loop in bot.rs, discard story file rename conflicts (gitignored), remove duplicate definitions. After resolving, verify compilation before re-triggering merge. CRITICAL: Never manually move story files or call accept_story. After 3 failed fix attempts, call report_merge_failure and stop."
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot ambient mode toggle via chat command"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 282: Matrix bot ambient mode toggle via chat command
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user chatting with Timmy in a Matrix room, I want to toggle between "addressed mode" (bot only responds when mentioned by name) and "ambient mode" (bot responds to all messages) via a chat command, so that I don't have to @-mention the bot on every message when I'm the only one around.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Matrix bot defaults to addressed mode — only forwards messages containing the bot's name to Claude
|
||||||
|
- [ ] Chat command "timmy ambient on" switches to ambient mode — bot forwards all room messages to Claude
|
||||||
|
- [ ] Chat command "timmy ambient off" switches back to addressed mode
|
||||||
|
- [ ] Mode persists until explicitly toggled (not across bot restarts)
|
||||||
|
- [ ] Bot confirms the mode switch with a short response in chat
|
||||||
|
- [ ] When other users join or are active, user can flip back to addressed mode to avoid noise
|
||||||
|
- [ ] Ambient mode applies per-room (not globally across all rooms)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Stop auto-committing intermediate pipeline moves"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Spike 92: Stop auto-committing intermediate pipeline moves
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Determine how to stop the filesystem watcher from auto-committing every pipeline stage move (upcoming -> current -> qa -> merge -> done -> archive) while still committing at terminal states (creation in upcoming, acceptance in done and archived). This keeps git history clean while preserving cross-machine portability for completed work.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The watcher in `server/src/io/watcher.rs` currently auto-commits every file change in `.story_kit/work/`. A single story run generates 5+ commits just from pipeline moves:
|
|
||||||
- `story-kit: create 42_story_foo`
|
|
||||||
- `story-kit: start 42_story_foo`
|
|
||||||
- `story-kit: queue 42_story_foo for QA`
|
|
||||||
- `story-kit: queue 42_story_foo for merge`
|
|
||||||
- `story-kit: accept 42_story_foo`
|
|
||||||
|
|
||||||
Since story runs complete relatively quickly, the intermediate state (current/qa/merge) is transient and doesn't need to be committed. Only creation and archival are meaningful checkpoints.
|
|
||||||
|
|
||||||
## Questions to Answer
|
|
||||||
|
|
||||||
1. Can we filter `stage_metadata()` to only commit for `1_upcoming` and `5_archived` stages while still broadcasting `WatcherEvent`s for all stages (so the frontend stays in sync)?
|
|
||||||
2. Should we keep `git add -A .story_kit/work/` for the committed stages, or narrow it to only the specific file?
|
|
||||||
3. What happens if the server crashes mid-pipeline? Uncommitted moves are lost — is this acceptable given the story can just be re-run?
|
|
||||||
4. Should intermediate moves be `.gitignore`d at the directory level, or is filtering in the watcher sufficient?
|
|
||||||
5. Do any other parts of the system (agent worktree setup, merge_agent_work, sparse checkout) depend on intermediate pipeline files being committed to master?
|
|
||||||
|
|
||||||
## Approach to Investigate
|
|
||||||
|
|
||||||
### Option A: Filter in `flush_pending()`
|
|
||||||
- In `flush_pending()`, still broadcast the `WatcherEvent` for all stages
|
|
||||||
- Only call `git_add_work_and_commit()` for stages `1_upcoming` and `5_archived`
|
|
||||||
- Simplest change — ~5 lines modified in `watcher.rs`
|
|
||||||
|
|
||||||
### Option B: Two-tier watcher
|
|
||||||
- Split into "commit-worthy" events (create, archive) and "notify-only" events (start, qa, merge)
|
|
||||||
- Commit-worthy events go through git
|
|
||||||
- Notify-only events just broadcast to WebSocket clients
|
|
||||||
- More explicit but same end result as Option A
|
|
||||||
|
|
||||||
### Option C: .gitignore intermediate directories
|
|
||||||
- Add `2_current/`, `3_qa/`, `4_merge/` to `.gitignore`
|
|
||||||
- Watcher still sees events (gitignore doesn't affect filesystem watching)
|
|
||||||
- Git naturally ignores them
|
|
||||||
- Risk: harder to debug, `git status` won't show pipeline state
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] Spike document updated with findings and recommendation
|
|
||||||
- [ ] If Option A is viable: prototype the change and verify git log is clean during a full story run
|
|
||||||
- [ ] Confirm frontend still receives real-time pipeline updates for all stages
|
|
||||||
- [ ] Confirm no other system depends on intermediate pipeline commits being on master
|
|
||||||
- [ ] Identify any edge cases (server crash, manual git operations, multi-machine sync)
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Matrix bot structured conversation history"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Story 266: Matrix bot structured conversation history
|
|
||||||
|
|
||||||
## User Story
|
|
||||||
|
|
||||||
As a user chatting with the Matrix bot, I want it to remember and own its prior responses naturally, so that conversations feel like talking to one continuous entity rather than a new instance each message.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] Conversation history is passed as structured API messages (user/assistant turns) rather than a flattened text prefix
|
|
||||||
- [ ] Claude recognises its prior responses as its own, maintaining consistent personality across a conversation
|
|
||||||
- [ ] Per-room history survives server restarts (persisted to disk or database)
|
|
||||||
- [ ] Rolling window trimming still applies to keep context bounded
|
|
||||||
- [ ] Multi-user rooms still attribute messages to the correct sender
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- TBD
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
name: "Human QA gate with rejection flow"
|
name: "Human QA gate with rejection flow"
|
||||||
agent: coder-opus
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Story 247: Human QA gate with rejection flow
|
# Story 247: Human QA gate with rejection flow
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot structured conversation history"
|
||||||
|
agent: coder-opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 266: Matrix bot structured conversation history
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user chatting with the Matrix bot, I want it to remember and own its prior responses naturally, so that conversations feel like talking to one continuous entity rather than a new instance each message.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Conversation history is passed as structured API messages (user/assistant turns) rather than a flattened text prefix
|
||||||
|
- [ ] Claude recognises its prior responses as its own, maintaining consistent personality across a conversation
|
||||||
|
- [ ] Per-room history survives server restarts (persisted to disk or database)
|
||||||
|
- [ ] Rolling window trimming still applies to keep context bounded
|
||||||
|
- [ ] Multi-user rooms still attribute messages to the correct sender
|
||||||
|
|
||||||
|
## Investigation Notes (2026-03-18)
|
||||||
|
|
||||||
|
The current implementation attempts session resumption via `--resume <session_id>` but it's not working:
|
||||||
|
|
||||||
|
### Code path: how session resumption is supposed to work
|
||||||
|
|
||||||
|
1. `server/src/matrix/bot.rs:671-676` — `handle_message()` reads `conv.session_id` from the per-room `RoomConversation` to get the resume ID.
|
||||||
|
2. `server/src/matrix/bot.rs:717` — passes `resume_session_id` to `provider.chat_stream()`.
|
||||||
|
3. `server/src/llm/providers/claude_code.rs:57` — `chat_stream()` stores it as `resume_id`.
|
||||||
|
4. `server/src/llm/providers/claude_code.rs:170-173` — if `resume_session_id` is `Some`, appends `--resume <id>` to the `claude -p` command.
|
||||||
|
5. `server/src/llm/providers/claude_code.rs:348` — `process_json_event()` looks for `json["session_id"]` in each streamed NDJSON event and sends it via a oneshot channel (`sid_tx`).
|
||||||
|
6. `server/src/llm/providers/claude_code.rs:122` — after the PTY exits, `sid_rx.await.ok()` captures the session ID (or `None` if never sent).
|
||||||
|
7. `server/src/matrix/bot.rs:785-787` — stores `new_session_id` back into `conv.session_id` and persists via `save_history()`.
|
||||||
|
|
||||||
|
### What's broken
|
||||||
|
|
||||||
|
- **No session_id captured:** `.story_kit/matrix_history.json` contains conversation entries but no `session_id`. `RoomConversation.session_id` is always `None`.
|
||||||
|
- **Root cause:** `claude -p --output-format stream-json` may not emit a `session_id` in its NDJSON events, or the parser at step 5 isn't matching the actual event shape. The oneshot channel never fires.
|
||||||
|
- **Effect:** Every message spawns a fresh Claude Code process with no `--resume` flag. Each turn is a blank slate.
|
||||||
|
- **History persistence works fine** — serialization round-trips correctly (test at `bot.rs:1335-1339`). The problem is purely that `--resume` is never invoked.
|
||||||
|
|
||||||
|
### Debugging steps
|
||||||
|
|
||||||
|
1. Run `claude -p "hello" --output-format stream-json --verbose 2>/dev/null` manually and inspect the NDJSON for a `session_id` field. Check what event type carries it and whether the key name matches what `process_json_event()` expects.
|
||||||
|
2. If `session_id` is present but nested differently (e.g. inside an `event` wrapper), fix the JSON path at `claude_code.rs:348`.
|
||||||
|
3. If `-p` mode doesn't emit `session_id` at all, consider an alternative: pass conversation history as a structured prompt prefix, or switch to the Claude API directly.
|
||||||
|
|
||||||
|
### Previous attempt failed (2026-03-18)
|
||||||
|
|
||||||
|
A sonnet coder attempted this story but did NOT fix the root cause. It rewrote the `chat_stream()` call in `bot.rs` to look identical to what was already there — it never investigated why `session_id` isn't being captured. The merge auto-resolver then jammed the duplicate call inside the `tokio::select!` permission loop, producing mismatched braces. The broken merge was reverted.
|
||||||
|
|
||||||
|
**What the coder must actually do:**
|
||||||
|
|
||||||
|
1. **Do NOT rewrite the `chat_stream()` call or the `tokio::select!` loop in `bot.rs`.** That code is correct and handles permission forwarding (story 275). Do not touch it.
|
||||||
|
2. **The bug is in `claude_code.rs`, not `bot.rs`.** The `process_json_event()` function at line ~348 looks for `json["session_id"]` but it's likely never finding it. Start by running step 1 above to see what the actual NDJSON output looks like.
|
||||||
|
3. **If `claude -p` doesn't emit `session_id` at all**, the `--resume` approach won't work. In that case, the fix is to pass conversation history as a prompt prefix (prepend prior turns to the user message) or use `--continue` instead of `--resume`, or call the Claude API directly instead of shelling out to the CLI.
|
||||||
|
4. **Rebase onto current master before starting.** Master has changed significantly (spike 92, story 275 permission handling, gitignore changes).
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: "Show assigned agent in expanded work item view"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 271: Show assigned agent in expanded work item view
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project owner viewing an expanded work item in the web UI, I want to see which agent (e.g. coder-opus) has been assigned via front matter, so that I know which coder is working on or will pick up the story.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Expanded work item view displays the agent front matter field if set
|
||||||
|
- [ ] Shows the specific agent name (e.g. 'coder-opus') not just 'assigned'
|
||||||
|
- [ ] If no agent is set in front matter, the field is omitted or shows unassigned
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot sends typing indicator while waiting for Claude response"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 273: Matrix bot sends typing indicator while waiting for Claude response
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user chatting with the Matrix bot, I want to see a typing indicator in Element while the bot is processing my message, so that I know it received my request and is working on a response.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Bot sets m.typing on the room as soon as it starts the Claude API call
|
||||||
|
- [ ] Typing indicator is cleared when the first response chunk is sent to the room
|
||||||
|
- [ ] Typing indicator is cleared on error so it doesn't get stuck
|
||||||
|
- [ ] No visible delay between sending a message and seeing the typing indicator
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "MCP pipeline status tool with agent assignments"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 274: MCP pipeline status tool with agent assignments
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user checking pipeline status, I want an MCP tool that returns a structured status report including which agent is assigned to each work item, so that I can quickly see what's active and spot stuck items.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] New MCP tool (e.g. `get_pipeline_status`) returns all work items across all active pipeline stages (current, qa, merge, done) with their stage, name, and assigned agent
|
||||||
|
- [ ] Upcoming backlog items are included with count or listing
|
||||||
|
- [ ] Agent assignment info comes from story front matter (`agent` field) and/or the running agent list
|
||||||
|
- [ ] Response is structured/deterministic (not free-form prose)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot uses its configured name instead of \"Claude\""
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 277: Matrix bot uses its configured name instead of "Claude"
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a Matrix user, I want the bot to identify itself by its configured name (e.g., "Timmy") rather than "Claude", so that the bot feels like a distinct personality in the chat.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] The Matrix bot refers to itself by its configured display name (e.g., 'Timmy') in conversations, not 'Claude'
|
||||||
|
- [ ] The bot's self-referencing name is derived from configuration, not hardcoded
|
||||||
|
- [ ] If no custom name is configured, the bot falls back to a sensible default
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: "Auto-assign agents to pipeline items on server startup"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 278: Auto-assign agents to pipeline items on server startup
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a ..., I want ..., so that ...
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] TODO
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Auto-assign should respect agent stage when front matter specifies agent"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 279: Auto-assign should respect agent stage when front matter specifies agent
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a project operator, I want auto-assign to respect the pipeline stage when a story's front matter specifies a preferred agent, so that a coder agent isn't assigned to do QA work just because the story originally requested that coder.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] When a story in `3_qa/` has `agent: coder-opus` in front matter, auto-assign skips the preferred agent (stage mismatch) and assigns a free QA-stage agent instead
|
||||||
|
- [ ] When a story in `2_current/` has `agent: coder-opus` in front matter, auto-assign still respects the preference (stage matches)
|
||||||
|
- [ ] When the preferred agent's stage mismatches, auto-assign logs a message indicating the stage mismatch and fallback
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Changing the front matter `agent` field automatically when a story advances stages
|
||||||
|
- Adding per-stage agent preferences to front matter
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot announces itself when it comes online"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 281: Matrix bot announces itself when it comes online
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user in the Matrix room, I want Timmy to post a message when he starts up, so that I know the bot is online and ready to accept commands.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Bot sends a brief greeting message to each configured room on startup (e.g. 'Timmy is online.')
|
||||||
|
- [ ] Message uses the configured display_name, not a hardcoded name
|
||||||
|
- [ ] Message is only sent once per startup, not on reconnects or sync resumptions
|
||||||
|
- [ ] Bot does not announce if it was already running (e.g. after a brief network blip)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: "Stop auto-committing intermediate pipeline moves"
|
||||||
|
agent: "coder-opus"
|
||||||
|
review_hold: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Spike 92: Stop auto-committing intermediate pipeline moves
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Determine how to stop the filesystem watcher from auto-committing every pipeline stage move (upcoming -> current -> qa -> merge -> done -> archive) while still committing at terminal states (creation in upcoming, acceptance in done and archived). This keeps git history clean while preserving cross-machine portability for completed work.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The watcher in `server/src/io/watcher.rs` currently auto-commits every file change in `.story_kit/work/`. A single story run generates 5+ commits just from pipeline moves:
|
||||||
|
- `story-kit: create 42_story_foo`
|
||||||
|
- `story-kit: start 42_story_foo`
|
||||||
|
- `story-kit: queue 42_story_foo for QA`
|
||||||
|
- `story-kit: queue 42_story_foo for merge`
|
||||||
|
- `story-kit: accept 42_story_foo`
|
||||||
|
|
||||||
|
Since story runs complete relatively quickly, the intermediate state (current/qa/merge) is transient and doesn't need to be committed. Only creation and archival are meaningful checkpoints.
|
||||||
|
|
||||||
|
## Questions to Answer
|
||||||
|
|
||||||
|
1. Can we filter `stage_metadata()` to only commit for `1_upcoming` and `6_archived` stages while still broadcasting `WatcherEvent`s for all stages (so the frontend stays in sync)?
|
||||||
|
2. Should we keep `git add -A .story_kit/work/` for the committed stages, or narrow it to only the specific file?
|
||||||
|
3. What happens if the server crashes mid-pipeline? Uncommitted moves are lost — is this acceptable given the story can just be re-run?
|
||||||
|
4. Should intermediate moves be `.gitignore`d at the directory level, or is filtering in the watcher sufficient?
|
||||||
|
5. Do any other parts of the system (agent worktree setup, merge_agent_work, sparse checkout) depend on intermediate pipeline files being committed to master?
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### Q1: Can we filter to only commit terminal stages?
|
||||||
|
|
||||||
|
**Yes.** The fix is in `flush_pending()`, not `stage_metadata()`. We add a `should_commit_stage()` predicate that returns `true` only for `1_upcoming` and `6_archived`. The event broadcast path is decoupled from the commit path — `flush_pending()` always broadcasts a `WatcherEvent` regardless of whether it commits.
|
||||||
|
|
||||||
|
Prototype implemented: added `COMMIT_WORTHY_STAGES` constant and `should_commit_stage()` function. The change is ~15 lines including the constant, predicate, and conditional in `flush_pending()`.
|
||||||
|
|
||||||
|
### Q2: Keep `git add -A .story_kit/work/` or narrow to specific file?
|
||||||
|
|
||||||
|
**Keep `git add -A .story_kit/work/`.** When committing a terminal stage (e.g. `6_archived`), the file has been moved from a previous stage (e.g. `5_done`). Using `-A` on the whole work directory captures both the addition in the new stage and the deletion from the old stage in a single commit. Narrowing to the specific file would miss the deletion side of the move.
|
||||||
|
|
||||||
|
### Q3: Server crash mid-pipeline — acceptable?
|
||||||
|
|
||||||
|
**Yes.** If the server crashes while a story is in `2_current`, `3_qa`, or `4_merge`, the file is lost from git but:
|
||||||
|
- The story file still exists on the filesystem (it's just not committed)
|
||||||
|
- The agent's work is in its own feature branch/worktree (independent of pipeline file state)
|
||||||
|
- The story can be re-queued from `1_upcoming` which IS committed
|
||||||
|
- Pipeline state is transient by nature — it reflects "what's happening right now", not permanent record
|
||||||
|
|
||||||
|
### Q4: `.gitignore` vs watcher filtering?
|
||||||
|
|
||||||
|
**Watcher filtering is sufficient.** `.gitignore` approach (Option C) has downsides:
|
||||||
|
- `git status` won't show pipeline state, making debugging harder
|
||||||
|
- If you ever need to commit an intermediate state (e.g. for a new feature), you'd have to fight `.gitignore`
|
||||||
|
- Watcher filtering is explicit and easy to understand — a constant lists the commit-worthy stages
|
||||||
|
- No risk of accidentally ignoring files that should be tracked
|
||||||
|
|
||||||
|
### Q5: Dependencies on intermediate pipeline commits?
|
||||||
|
|
||||||
|
**None found.** Thorough investigation confirmed:
|
||||||
|
|
||||||
|
1. **`merge_agent_work`** (`agents/merge.rs`): Creates a temporary `merge-queue/` branch and worktree. Reads the feature branch, not pipeline files. After merge, calls `move_story_to_archived()` which is a filesystem operation.
|
||||||
|
|
||||||
|
2. **Agent worktree setup** (`worktree.rs`): Creates worktrees from feature branches. Sparse checkout is a no-op (disabled). Does not read pipeline file state from git.
|
||||||
|
|
||||||
|
3. **MCP tool handlers** (`agents/lifecycle.rs`): `move_story_to_current()`, `move_story_to_merge()`, `move_story_to_qa()`, `move_story_to_archived()` — all pure filesystem `fs::rename()` operations. None perform git commits.
|
||||||
|
|
||||||
|
4. **Frontend** (`http/workflow.rs`): `load_pipeline_state()` reads directories from the filesystem directly via `fs::read_dir()`. Never calls git. WebSocket events keep the frontend in sync.
|
||||||
|
|
||||||
|
5. **No git inspection commands** reference pipeline stage directories anywhere in the codebase.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- **Multi-machine sync:** Only `1_upcoming` and `6_archived` are committed. If you push/pull, you'll see story creation and archival but not intermediate pipeline state. This is correct — intermediate state is machine-local runtime state.
|
||||||
|
- **Manual git operations:** `git status` will show uncommitted files in intermediate stages. This is actually helpful for debugging — you can see what's in the pipeline without grepping git log.
|
||||||
|
- **Sweep (5_done → 6_archived):** The sweep moves files to `6_archived`, which triggers a watcher event that WILL commit (since `6_archived` is a terminal stage). This naturally captures the final state.
|
||||||
|
|
||||||
|
## Approach to Investigate
|
||||||
|
|
||||||
|
### Option A: Filter in `flush_pending()` ← **RECOMMENDED**
|
||||||
|
- In `flush_pending()`, still broadcast the `WatcherEvent` for all stages
|
||||||
|
- Only call `git_add_work_and_commit()` for stages `1_upcoming` and `6_archived`
|
||||||
|
- Simplest change — ~15 lines modified in `watcher.rs`
|
||||||
|
|
||||||
|
### Option B: Two-tier watcher
|
||||||
|
- Split into "commit-worthy" events (create, archive) and "notify-only" events (start, qa, merge)
|
||||||
|
- Commit-worthy events go through git
|
||||||
|
- Notify-only events just broadcast to WebSocket clients
|
||||||
|
- More explicit but same end result as Option A
|
||||||
|
|
||||||
|
### Option C: .gitignore intermediate directories
|
||||||
|
- Add `2_current/`, `3_qa/`, `4_merge/` to `.gitignore`
|
||||||
|
- Watcher still sees events (gitignore doesn't affect filesystem watching)
|
||||||
|
- Git naturally ignores them
|
||||||
|
- Risk: harder to debug, `git status` won't show pipeline state
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
**Option A is viable and implemented.** The prototype is in `server/src/io/watcher.rs`:
|
||||||
|
- Added `COMMIT_WORTHY_STAGES` constant: `["1_upcoming", "6_archived"]`
|
||||||
|
- Added `should_commit_stage()` predicate
|
||||||
|
- Modified `flush_pending()` to conditionally commit based on stage, while always broadcasting events
|
||||||
|
- All 872 tests pass, clippy clean
|
||||||
|
|
||||||
|
A full story run will now produce only 2 pipeline commits instead of 5+:
|
||||||
|
- `story-kit: create 42_story_foo` (creation in `1_upcoming`)
|
||||||
|
- `story-kit: accept 42_story_foo` (archival in `6_archived`)
|
||||||
|
|
||||||
|
The intermediate moves (`start`, `queue for QA`, `queue for merge`, `done`) are still broadcast to WebSocket clients for real-time frontend updates, but no longer clutter git history.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Spike document updated with findings and recommendation
|
||||||
|
- [x] If Option A is viable: prototype the change and verify git log is clean during a full story run
|
||||||
|
- [x] Confirm frontend still receives real-time pipeline updates for all stages
|
||||||
|
- [x] Confirm no other system depends on intermediate pipeline commits being on master
|
||||||
|
- [x] Identify any edge cases (server crash, manual git operations, multi-machine sync)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: "QA test server overwrites root .mcp.json with wrong port"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 270: QA test server overwrites root .mcp.json with wrong port
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When the QA agent starts a test server in a worktree (e.g. on port 3012), that server auto-detects the shared project root and calls open_project, which writes .mcp.json with the test server's port. This clobbers the root .mcp.json that should always point to the main server (port 3001).
|
||||||
|
|
||||||
|
Root cause: open_project in server/src/io/fs.rs:527 unconditionally calls write_mcp_json(&p, port) with its own port. Because worktrees share .story_kit/ with the real project, the test server resolves to the real project root and overwrites the root .mcp.json instead of writing to its own worktree directory.
|
||||||
|
|
||||||
|
Fix: Remove the write_mcp_json call from open_project entirely. Worktree .mcp.json files are already written correctly during worktree creation (worktree.rs:81,97), and the root .mcp.json is committed in git. open_project should not touch it.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. QA agent starts on a story\n2. QA agent starts a test server in the worktree on a non-default port (e.g. 3012)\n3. Test server auto-opens the project root\n4. Root .mcp.json is overwritten with test port
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Root .mcp.json contains the QA test server's port (e.g. 3012) instead of the main server's port (3001). Interactive Claude sessions lose MCP connectivity.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Root .mcp.json always points to the primary server's port. Test servers started by QA agents should not overwrite it.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] QA test servers do not overwrite root .mcp.json
|
||||||
|
- [ ] Root .mcp.json always reflects the primary server's port
|
||||||
|
- [ ] Worktree .mcp.json files are only written during worktree creation
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: "Clear merge error front matter when story leaves merge stage"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 272: Clear merge error front matter when story leaves merge stage
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As an operator, I want merge error front matter to be automatically removed when a story is moved out of the merge stage via MCP, so that stale error metadata doesn't persist when the story is retried.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] When a story with merge_error front matter is moved out of 4_merge via MCP, the merge_error field is automatically stripped
|
||||||
|
- [ ] Works for all destinations: back to 2_current, back to 1_upcoming, or forward to 5_done
|
||||||
|
- [ ] Stories without merge_error front matter are unaffected
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot surfaces Claude Code permission prompts to chat"
|
||||||
|
agent: coder-opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 275: Matrix bot surfaces Claude Code permission prompts to chat
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user chatting with the Matrix bot, I want to see permission prompts from Claude Code in the chat and be able to approve or deny them, so that headless Claude Code sessions don't silently hang when they need authorization to proceed.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] When Claude Code hits a permission prompt during a bot-initiated session, the bot sends the prompt text to the Matrix room as a message
|
||||||
|
- [ ] The user can approve or deny the permission by replying in chat (e.g. yes/no or a reaction)
|
||||||
|
- [ ] The bot relays the user decision back to the Claude Code subprocess so execution continues
|
||||||
|
- [ ] If the user does not respond within a configurable timeout, the permission is denied (fail-closed)
|
||||||
|
- [ ] The bot does not hang or timeout silently when a permission prompt is pending - the user always sees what is happening
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
name: "Detect and log when root .mcp.json port is modified"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 276: Detect and log when root .mcp.json port is modified
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a ..., I want ..., so that ...
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] TODO
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
15
Cargo.lock
generated
15
Cargo.lock
generated
@@ -3997,7 +3997,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "story-kit"
|
name = "story-kit"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4025,7 +4025,7 @@ dependencies = [
|
|||||||
"strip-ansi-escapes",
|
"strip-ansi-escapes",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.28.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"toml 1.0.6+spec-1.1.0",
|
"toml 1.0.6+spec-1.1.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wait-timeout",
|
"wait-timeout",
|
||||||
@@ -4333,14 +4333,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.29.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tungstenite 0.28.0",
|
"tungstenite 0.29.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4562,9 +4562,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.28.0"
|
version = "0.29.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes 1.11.1",
|
"bytes 1.11.1",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -4574,7 +4574,6 @@ dependencies = [
|
|||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"utf-8",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ tempfile = "3"
|
|||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||||
toml = "1.0.6"
|
toml = "1.0.6"
|
||||||
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
||||||
tokio-tungstenite = "0.28.0"
|
tokio-tungstenite = "0.29.0"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
filetime = "0.2"
|
filetime = "0.2"
|
||||||
matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export interface WorkItemContent {
|
|||||||
content: string;
|
content: string;
|
||||||
stage: string;
|
stage: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
agent: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestCaseResult {
|
export interface TestCaseResult {
|
||||||
@@ -277,6 +278,9 @@ export const api = {
|
|||||||
getHomeDirectory(baseUrl?: string) {
|
getHomeDirectory(baseUrl?: string) {
|
||||||
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
return requestJson<string>("/io/fs/home", {}, baseUrl);
|
||||||
},
|
},
|
||||||
|
listProjectFiles(baseUrl?: string) {
|
||||||
|
return requestJson<string[]>("/io/fs/files", {}, baseUrl);
|
||||||
|
},
|
||||||
searchFiles(query: string, baseUrl?: string) {
|
searchFiles(query: string, baseUrl?: string) {
|
||||||
return requestJson<SearchResult[]>(
|
return requestJson<SearchResult[]>(
|
||||||
"/fs/search",
|
"/fs/search",
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ vi.mock("../api/client", () => {
|
|||||||
setModelPreference: vi.fn(),
|
setModelPreference: vi.fn(),
|
||||||
cancelChat: vi.fn(),
|
cancelChat: vi.fn(),
|
||||||
setAnthropicApiKey: vi.fn(),
|
setAnthropicApiKey: vi.fn(),
|
||||||
|
readFile: vi.fn(),
|
||||||
|
listProjectFiles: vi.fn(),
|
||||||
};
|
};
|
||||||
class ChatWebSocket {
|
class ChatWebSocket {
|
||||||
connect(handlers: WsHandlers) {
|
connect(handlers: WsHandlers) {
|
||||||
@@ -60,6 +62,8 @@ const mockedApi = {
|
|||||||
setModelPreference: vi.mocked(api.setModelPreference),
|
setModelPreference: vi.mocked(api.setModelPreference),
|
||||||
cancelChat: vi.mocked(api.cancelChat),
|
cancelChat: vi.mocked(api.cancelChat),
|
||||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||||
|
readFile: vi.mocked(api.readFile),
|
||||||
|
listProjectFiles: vi.mocked(api.listProjectFiles),
|
||||||
};
|
};
|
||||||
|
|
||||||
function setupMocks() {
|
function setupMocks() {
|
||||||
@@ -68,6 +72,8 @@ function setupMocks() {
|
|||||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||||
|
mockedApi.readFile.mockResolvedValue("");
|
||||||
|
mockedApi.listProjectFiles.mockResolvedValue([]);
|
||||||
mockedApi.cancelChat.mockResolvedValue(true);
|
mockedApi.cancelChat.mockResolvedValue(true);
|
||||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||||
}
|
}
|
||||||
@@ -625,7 +631,7 @@ describe("Chat localStorage persistence (Story 145)", () => {
|
|||||||
|
|
||||||
// Verify sendChat was called with ALL prior messages + the new one
|
// Verify sendChat was called with ALL prior messages + the new one
|
||||||
expect(lastSendChatArgs).not.toBeNull();
|
expect(lastSendChatArgs).not.toBeNull();
|
||||||
const args = lastSendChatArgs!;
|
const args = lastSendChatArgs as unknown as { messages: Message[]; config: unknown };
|
||||||
expect(args.messages).toHaveLength(3);
|
expect(args.messages).toHaveLength(3);
|
||||||
expect(args.messages[0]).toEqual({
|
expect(args.messages[0]).toEqual({
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -1344,7 +1350,7 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", ()
|
|||||||
|
|
||||||
expect(lastSendChatArgs).not.toBeNull();
|
expect(lastSendChatArgs).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
(lastSendChatArgs!.config as Record<string, unknown>).session_id,
|
((lastSendChatArgs as unknown as { messages: Message[]; config: unknown })?.config as Record<string, unknown>).session_id,
|
||||||
).toBe("persisted-session-xyz");
|
).toBe("persisted-session-xyz");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1387,3 +1393,57 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", ()
|
|||||||
expect(localStorage.getItem(otherKey)).toBe("other-session");
|
expect(localStorage.getItem(otherKey)).toBe("other-session");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("File reference expansion (Story 269 AC4)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
capturedWsHandlers = null;
|
||||||
|
lastSendChatArgs = null;
|
||||||
|
setupMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes file contents as context when message contains @file reference", async () => {
|
||||||
|
mockedApi.readFile.mockResolvedValue('fn main() { println!("hello"); }');
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Send a message...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { value: "explain @src/main.rs" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
|
||||||
|
const sentMessages = (
|
||||||
|
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
|
||||||
|
).messages;
|
||||||
|
const userMsg = sentMessages[sentMessages.length - 1];
|
||||||
|
expect(userMsg.content).toContain("explain @src/main.rs");
|
||||||
|
expect(userMsg.content).toContain("[File: src/main.rs]");
|
||||||
|
expect(userMsg.content).toContain("fn main()");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends message without modification when no @file references are present", async () => {
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Send a message...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { value: "hello world" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
|
||||||
|
const sentMessages = (
|
||||||
|
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
|
||||||
|
).messages;
|
||||||
|
const userMsg = sentMessages[sentMessages.length - 1];
|
||||||
|
expect(userMsg.content).toBe("hello world");
|
||||||
|
expect(mockedApi.readFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -554,7 +554,26 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMsg: Message = { role: "user", content: messageText };
|
// Expand @file references: append file contents as context
|
||||||
|
const fileRefs = [...messageText.matchAll(/(^|[\s\n])@([^\s@]+)/g)].map(
|
||||||
|
(m) => m[2],
|
||||||
|
);
|
||||||
|
let expandedText = messageText;
|
||||||
|
if (fileRefs.length > 0) {
|
||||||
|
const expansions = await Promise.allSettled(
|
||||||
|
fileRefs.map(async (ref) => {
|
||||||
|
const contents = await api.readFile(ref);
|
||||||
|
return { ref, contents };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const result of expansions) {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
expandedText += `\n\n[File: ${result.value.ref}]\n\`\`\`\n${result.value.contents}\n\`\`\``;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg: Message = { role: "user", content: expandedText };
|
||||||
const newHistory = [...messages, userMsg];
|
const newHistory = [...messages, userMsg];
|
||||||
|
|
||||||
setMessages(newHistory);
|
setMessages(newHistory);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
|
||||||
const { forwardRef, useEffect, useImperativeHandle, useRef, useState } = React;
|
const { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } = React;
|
||||||
|
|
||||||
export interface ChatInputHandle {
|
export interface ChatInputHandle {
|
||||||
appendToInput(text: string): void;
|
appendToInput(text: string): void;
|
||||||
@@ -14,6 +15,97 @@ interface ChatInputProps {
|
|||||||
onRemoveQueuedMessage: (id: string) => void;
|
onRemoveQueuedMessage: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Fuzzy-match: returns true if all chars of `query` appear in order in `str`. */
|
||||||
|
function fuzzyMatch(str: string, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const lower = str.toLowerCase();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
let qi = 0;
|
||||||
|
for (let i = 0; i < lower.length && qi < q.length; i++) {
|
||||||
|
if (lower[i] === q[qi]) qi++;
|
||||||
|
}
|
||||||
|
return qi === q.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score a fuzzy match: lower is better. Exact prefix match wins, then shorter paths. */
|
||||||
|
function fuzzyScore(str: string, query: string): number {
|
||||||
|
const lower = str.toLowerCase();
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
// Prefer matches where query appears as a contiguous substring
|
||||||
|
if (lower.includes(q)) return lower.indexOf(q);
|
||||||
|
return str.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePickerOverlayProps {
|
||||||
|
query: string;
|
||||||
|
files: string[];
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (file: string) => void;
|
||||||
|
onDismiss: () => void;
|
||||||
|
anchorRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilePickerOverlay({
|
||||||
|
query,
|
||||||
|
files,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
}: FilePickerOverlayProps) {
|
||||||
|
const filtered = files
|
||||||
|
.filter((f) => fuzzyMatch(f, query))
|
||||||
|
.sort((a, b) => fuzzyScore(a, query) - fuzzyScore(b, query))
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="file-picker-overlay"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: "#1e1e1e",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 100,
|
||||||
|
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||||
|
maxHeight: "240px",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtered.map((file, idx) => (
|
||||||
|
<button
|
||||||
|
key={file}
|
||||||
|
type="button"
|
||||||
|
data-testid={`file-picker-item-${idx}`}
|
||||||
|
onClick={() => onSelect(file)}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "8px 14px",
|
||||||
|
background: idx === selectedIndex ? "#2d4a6e" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
color: idx === selectedIndex ? "#ececec" : "#aaa",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
function ChatInput(
|
function ChatInput(
|
||||||
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
|
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
|
||||||
@@ -22,6 +114,12 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// File picker state
|
||||||
|
const [projectFiles, setProjectFiles] = useState<string[]>([]);
|
||||||
|
const [pickerQuery, setPickerQuery] = useState<string | null>(null);
|
||||||
|
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
|
||||||
|
const [pickerAtStart, setPickerAtStart] = useState(0);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
appendToInput(text: string) {
|
appendToInput(text: string) {
|
||||||
setInput((prev) => (prev ? `${prev}\n${text}` : text));
|
setInput((prev) => (prev ? `${prev}\n${text}` : text));
|
||||||
@@ -32,10 +130,104 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Compute filtered files for current picker query
|
||||||
|
const filteredFiles = pickerQuery !== null
|
||||||
|
? projectFiles
|
||||||
|
.filter((f) => fuzzyMatch(f, pickerQuery))
|
||||||
|
.sort((a, b) => fuzzyScore(a, pickerQuery) - fuzzyScore(b, pickerQuery))
|
||||||
|
.slice(0, 10)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const dismissPicker = useCallback(() => {
|
||||||
|
setPickerQuery(null);
|
||||||
|
setPickerSelectedIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectFile = useCallback(
|
||||||
|
(file: string) => {
|
||||||
|
// Replace the @query portion with @file
|
||||||
|
const before = input.slice(0, pickerAtStart);
|
||||||
|
const cursorPos = inputRef.current?.selectionStart ?? input.length;
|
||||||
|
const after = input.slice(cursorPos);
|
||||||
|
setInput(`${before}@${file}${after}`);
|
||||||
|
dismissPicker();
|
||||||
|
// Restore focus after state update
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
},
|
||||||
|
[input, pickerAtStart, dismissPicker],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setInput(val);
|
||||||
|
|
||||||
|
const cursor = e.target.selectionStart ?? val.length;
|
||||||
|
// Find the last @ before the cursor that starts a reference token
|
||||||
|
const textUpToCursor = val.slice(0, cursor);
|
||||||
|
// Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline)
|
||||||
|
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
|
||||||
|
|
||||||
|
if (atMatch) {
|
||||||
|
const query = atMatch[2];
|
||||||
|
const atPos = textUpToCursor.lastIndexOf("@");
|
||||||
|
setPickerAtStart(atPos);
|
||||||
|
setPickerQuery(query);
|
||||||
|
setPickerSelectedIndex(0);
|
||||||
|
|
||||||
|
// Lazily load files on first trigger
|
||||||
|
if (projectFiles.length === 0) {
|
||||||
|
api.listProjectFiles().then(setProjectFiles).catch(() => {});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pickerQuery !== null) dismissPicker();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectFiles.length, pickerQuery, dismissPicker],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (pickerQuery !== null && filteredFiles.length > 0) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setPickerSelectedIndex((i) => Math.min(i + 1, filteredFiles.length - 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setPickerSelectedIndex((i) => Math.max(i - 1, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectFile(filteredFiles[pickerSelectedIndex] ?? filteredFiles[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
dismissPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape" && pickerQuery !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
dismissPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSubmit();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[pickerQuery, filteredFiles, pickerSelectedIndex, selectFile, dismissPicker],
|
||||||
|
);
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!input.trim()) return;
|
if (!input.trim()) return;
|
||||||
onSubmit(input);
|
onSubmit(input);
|
||||||
setInput("");
|
setInput("");
|
||||||
|
dismissPicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -135,24 +327,30 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Input row */}
|
{/* Input row with file picker overlay */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{pickerQuery !== null && (
|
||||||
|
<FilePickerOverlay
|
||||||
|
query={pickerQuery}
|
||||||
|
files={projectFiles}
|
||||||
|
selectedIndex={pickerSelectedIndex}
|
||||||
|
onSelect={selectFile}
|
||||||
|
onDismiss={dismissPicker}
|
||||||
|
anchorRef={inputRef}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<textarea
|
<textarea
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={handleInputChange}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={handleKeyDown}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
rows={1}
|
rows={1}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
194
frontend/src/components/ChatInputFilePicker.test.tsx
Normal file
194
frontend/src/components/ChatInputFilePicker.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { ChatInput } from "./ChatInput";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
api: {
|
||||||
|
listProjectFiles: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedListProjectFiles = vi.mocked(api.listProjectFiles);
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
loading: false,
|
||||||
|
queuedMessages: [],
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
onRemoveQueuedMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedListProjectFiles.mockResolvedValue([
|
||||||
|
"src/main.rs",
|
||||||
|
"src/lib.rs",
|
||||||
|
"frontend/index.html",
|
||||||
|
"README.md",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File picker overlay (Story 269 AC1)", () => {
|
||||||
|
it("shows file picker overlay when @ is typed", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show file picker overlay for text without @", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "hello world" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File picker fuzzy matching (Story 269 AC2)", () => {
|
||||||
|
it("filters files by query typed after @", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// main.rs should be visible, README.md should not
|
||||||
|
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("README.md")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows all files when @ is typed with no query", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// All 4 files should be visible
|
||||||
|
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("src/lib.rs")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("README.md")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File picker selection (Story 269 AC3)", () => {
|
||||||
|
it("clicking a file inserts @path into the message", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-item-0")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("file-picker-item-0"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Picker should be dismissed and the file reference inserted
|
||||||
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toMatch(/^@\S+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter key selects highlighted file and inserts it into message", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toContain("@src/main.rs");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File picker dismiss (Story 269 AC5)", () => {
|
||||||
|
it("Escape key dismisses the file picker", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple @ references (Story 269 AC6)", () => {
|
||||||
|
it("typing @ after a completed reference triggers picker again", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
// First reference
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select file
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type a second @
|
||||||
|
await act(async () => {
|
||||||
|
const current = (textarea as HTMLTextAreaElement).value;
|
||||||
|
fireEvent.change(textarea, { target: { value: `${current} @` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ const DEFAULT_CONTENT = {
|
|||||||
content: "# Big Title\n\nSome content here.",
|
content: "# Big Title\n\nSome content here.",
|
||||||
stage: "current",
|
stage: "current",
|
||||||
name: "Big Title Story",
|
name: "Big Title Story",
|
||||||
|
agent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sampleTestResults: TestResultsResponse = {
|
const sampleTestResults: TestResultsResponse = {
|
||||||
@@ -436,6 +437,60 @@ describe("WorkItemDetailPanel - Agent Logs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WorkItemDetailPanel - Assigned Agent", () => {
|
||||||
|
it("shows assigned agent name when agent front matter field is set", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
...DEFAULT_CONTENT,
|
||||||
|
agent: "coder-opus",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="271_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
|
||||||
|
expect(agentEl).toHaveTextContent("coder-opus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits assigned agent field when no agent is set in front matter", async () => {
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="271_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByTestId("detail-panel-content");
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("detail-panel-assigned-agent"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the specific agent name not just 'assigned'", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
...DEFAULT_CONTENT,
|
||||||
|
agent: "coder-haiku",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="271_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
|
||||||
|
expect(agentEl).toHaveTextContent("coder-haiku");
|
||||||
|
expect(agentEl).not.toHaveTextContent("assigned");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("WorkItemDetailPanel - Test Results", () => {
|
describe("WorkItemDetailPanel - Test Results", () => {
|
||||||
it("shows empty test results message when no results exist", async () => {
|
it("shows empty test results message when no results exist", async () => {
|
||||||
mockedGetTestResults.mockResolvedValue(null);
|
mockedGetTestResults.mockResolvedValue(null);
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export function WorkItemDetailPanel({
|
|||||||
const [content, setContent] = useState<string | null>(null);
|
const [content, setContent] = useState<string | null>(null);
|
||||||
const [stage, setStage] = useState<string>("");
|
const [stage, setStage] = useState<string>("");
|
||||||
const [name, setName] = useState<string | null>(null);
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
||||||
@@ -133,6 +134,7 @@ export function WorkItemDetailPanel({
|
|||||||
setContent(data.content);
|
setContent(data.content);
|
||||||
setStage(data.stage);
|
setStage(data.stage);
|
||||||
setName(data.name);
|
setName(data.name);
|
||||||
|
setAssignedAgent(data.agent);
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load content");
|
setError(err instanceof Error ? err.message : "Failed to load content");
|
||||||
@@ -278,6 +280,14 @@ export function WorkItemDetailPanel({
|
|||||||
{stageLabel}
|
{stageLabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{assignedAgent ? (
|
||||||
|
<div
|
||||||
|
data-testid="detail-panel-assigned-agent"
|
||||||
|
style={{ fontSize: "0.75em", color: "#888" }}
|
||||||
|
>
|
||||||
|
Agent: {assignedAgent}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -16,10 +16,24 @@ export default defineConfig(() => {
|
|||||||
"/api": {
|
"/api": {
|
||||||
target: `http://127.0.0.1:${String(backendPort)}`,
|
target: `http://127.0.0.1:${String(backendPort)}`,
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
|
configure: (proxy) => {
|
||||||
|
proxy.on("error", (_err) => {
|
||||||
|
// Swallow proxy errors (e.g. ECONNREFUSED during backend restart)
|
||||||
|
// so the vite dev server doesn't crash.
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ["**/.story_kit/**", "**/target/**"],
|
ignored: [
|
||||||
|
"**/.story_kit/**",
|
||||||
|
"**/target/**",
|
||||||
|
"**/.git/**",
|
||||||
|
"**/server/**",
|
||||||
|
"**/Cargo.*",
|
||||||
|
"**/vendor/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ echo "=== Running Rust tests ==="
|
|||||||
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml"
|
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml"
|
||||||
|
|
||||||
echo "=== Running frontend unit tests ==="
|
echo "=== Running frontend unit tests ==="
|
||||||
cd "$PROJECT_ROOT/frontend"
|
if [ -d "$PROJECT_ROOT/frontend" ]; then
|
||||||
npm test
|
cd "$PROJECT_ROOT/frontend"
|
||||||
|
npm test
|
||||||
|
else
|
||||||
|
echo "Skipping frontend tests (no frontend directory)"
|
||||||
|
fi
|
||||||
|
|
||||||
# Disabled: e2e tests may be causing merge pipeline hangs (no running server
|
# Disabled: e2e tests may be causing merge pipeline hangs (no running server
|
||||||
# in merge workspace → Playwright blocks indefinitely). Re-enable once confirmed.
|
# in merge workspace → Playwright blocks indefinitely). Re-enable once confirmed.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "story-kit"
|
name = "story-kit"
|
||||||
version = "0.2.0"
|
version = "0.3.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -1293,11 +1293,6 @@ impl AgentPool {
|
|||||||
.and_then(|jobs| jobs.get(story_id).cloned())
|
.and_then(|jobs| jobs.get(story_id).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return the port this server is running on.
|
|
||||||
pub fn port(&self) -> u16 {
|
|
||||||
self.port
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get project root helper.
|
/// Get project root helper.
|
||||||
pub fn get_project_root(
|
pub fn get_project_root(
|
||||||
&self,
|
&self,
|
||||||
@@ -1473,10 +1468,12 @@ impl AgentPool {
|
|||||||
let preferred_agent =
|
let preferred_agent =
|
||||||
read_story_front_matter_agent(project_root, stage_dir, story_id);
|
read_story_front_matter_agent(project_root, stage_dir, story_id);
|
||||||
|
|
||||||
// Outcome: (already_assigned, chosen_agent, preferred_busy)
|
// Outcome: (already_assigned, chosen_agent, preferred_busy, stage_mismatch)
|
||||||
// preferred_busy=true means the story has a specific agent requested but it is
|
// preferred_busy=true means the story has a specific agent requested but it is
|
||||||
// currently occupied — the story should wait rather than fall back.
|
// currently occupied — the story should wait rather than fall back.
|
||||||
let (already_assigned, free_agent, preferred_busy) = {
|
// stage_mismatch=true means the preferred agent's stage doesn't match the
|
||||||
|
// pipeline stage, so we fell back to a generic stage agent.
|
||||||
|
let (already_assigned, free_agent, preferred_busy, stage_mismatch) = {
|
||||||
let agents = match self.agents.lock() {
|
let agents = match self.agents.lock() {
|
||||||
Ok(a) => a,
|
Ok(a) => a,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -1486,18 +1483,29 @@ impl AgentPool {
|
|||||||
};
|
};
|
||||||
let assigned = is_story_assigned_for_stage(&config, &agents, story_id, stage);
|
let assigned = is_story_assigned_for_stage(&config, &agents, story_id, stage);
|
||||||
if assigned {
|
if assigned {
|
||||||
(true, None, false)
|
(true, None, false, false)
|
||||||
} else if let Some(ref pref) = preferred_agent {
|
} else if let Some(ref pref) = preferred_agent {
|
||||||
// Story has a front-matter agent preference.
|
// Story has a front-matter agent preference.
|
||||||
if is_agent_free(&agents, pref) {
|
// Verify the preferred agent's stage matches the current
|
||||||
(false, Some(pref.clone()), false)
|
// pipeline stage — a coder shouldn't be assigned to QA.
|
||||||
|
let pref_stage_matches = config
|
||||||
|
.find_agent(pref)
|
||||||
|
.map(|cfg| agent_config_stage(cfg) == *stage)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !pref_stage_matches {
|
||||||
|
// Stage mismatch — fall back to any free agent for this stage.
|
||||||
|
let free = find_free_agent_for_stage(&config, &agents, stage)
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
(false, free, false, true)
|
||||||
|
} else if is_agent_free(&agents, pref) {
|
||||||
|
(false, Some(pref.clone()), false, false)
|
||||||
} else {
|
} else {
|
||||||
(false, None, true)
|
(false, None, true, false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let free = find_free_agent_for_stage(&config, &agents, stage)
|
let free = find_free_agent_for_stage(&config, &agents, stage)
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
(false, free, false)
|
(false, free, false, false)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1516,6 +1524,13 @@ impl AgentPool {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if stage_mismatch {
|
||||||
|
slog!(
|
||||||
|
"[auto-assign] Preferred agent '{}' stage mismatch for '{story_id}' in {stage_dir}/; falling back to stage-appropriate agent.",
|
||||||
|
preferred_agent.as_deref().unwrap_or("?")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
match free_agent {
|
match free_agent {
|
||||||
Some(agent_name) => {
|
Some(agent_name) => {
|
||||||
slog!(
|
slog!(
|
||||||
@@ -4753,4 +4768,130 @@ stage = "coder"
|
|||||||
"No agents should be assigned to a spike with review_hold"
|
"No agents should be assigned to a spike with review_hold"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Story 279: auto-assign respects agent stage from front matter ──────────
|
||||||
|
|
||||||
|
/// When a story in 3_qa/ has `agent: coder-1` in its front matter but
|
||||||
|
/// coder-1 is a coder-stage agent, auto-assign must NOT assign coder-1.
|
||||||
|
/// Instead it should fall back to a free QA-stage agent.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_assign_ignores_coder_preference_when_story_is_in_qa_stage() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
let qa_dir = sk.join("work/3_qa");
|
||||||
|
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
|
||||||
|
[[agent]]\nname = \"qa-1\"\nstage = \"qa\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Story in 3_qa/ with a preferred coder-stage agent.
|
||||||
|
std::fs::write(
|
||||||
|
qa_dir.join("story-qa1.md"),
|
||||||
|
"---\nname: QA Story\nagent: coder-1\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3001);
|
||||||
|
|
||||||
|
pool.auto_assign_available_work(tmp.path()).await;
|
||||||
|
|
||||||
|
let agents = pool.agents.lock().unwrap();
|
||||||
|
// coder-1 must NOT have been assigned (wrong stage for 3_qa/).
|
||||||
|
let coder_assigned = agents
|
||||||
|
.values()
|
||||||
|
.any(|a| a.agent_name == "coder-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
|
||||||
|
assert!(
|
||||||
|
!coder_assigned,
|
||||||
|
"coder-1 should not be assigned to a QA-stage story"
|
||||||
|
);
|
||||||
|
// qa-1 should have been assigned instead.
|
||||||
|
let qa_assigned = agents
|
||||||
|
.values()
|
||||||
|
.any(|a| a.agent_name == "qa-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
|
||||||
|
assert!(
|
||||||
|
qa_assigned,
|
||||||
|
"qa-1 should be assigned as fallback for the QA-stage story"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When a story in 2_current/ has `agent: coder-1` in its front matter and
|
||||||
|
/// coder-1 is a coder-stage agent, auto-assign must respect the preference
|
||||||
|
/// and assign coder-1 (not fall back to some other coder).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_assign_respects_coder_preference_when_story_is_in_current_stage() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
let current_dir = sk.join("work/2_current");
|
||||||
|
std::fs::create_dir_all(¤t_dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
|
||||||
|
[[agent]]\nname = \"coder-2\"\nstage = \"coder\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Story in 2_current/ with a preferred coder-1 agent.
|
||||||
|
std::fs::write(
|
||||||
|
current_dir.join("story-pref.md"),
|
||||||
|
"---\nname: Coder Story\nagent: coder-1\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3001);
|
||||||
|
|
||||||
|
pool.auto_assign_available_work(tmp.path()).await;
|
||||||
|
|
||||||
|
let agents = pool.agents.lock().unwrap();
|
||||||
|
// coder-1 should have been picked (it matches the stage and is preferred).
|
||||||
|
let coder1_assigned = agents
|
||||||
|
.values()
|
||||||
|
.any(|a| a.agent_name == "coder-1" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
|
||||||
|
assert!(
|
||||||
|
coder1_assigned,
|
||||||
|
"coder-1 should be assigned when it matches the stage and is preferred"
|
||||||
|
);
|
||||||
|
// coder-2 must NOT be assigned (not preferred).
|
||||||
|
let coder2_assigned = agents
|
||||||
|
.values()
|
||||||
|
.any(|a| a.agent_name == "coder-2" && matches!(a.status, AgentStatus::Pending | AgentStatus::Running));
|
||||||
|
assert!(
|
||||||
|
!coder2_assigned,
|
||||||
|
"coder-2 should not be assigned when coder-1 is explicitly preferred"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When the preferred agent's stage mismatches and no other agent of the
|
||||||
|
/// correct stage is available, auto-assign must not start any agent for that
|
||||||
|
/// story (no panic, no error).
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_assign_stage_mismatch_with_no_fallback_starts_no_agent() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
let qa_dir = sk.join("work/3_qa");
|
||||||
|
std::fs::create_dir_all(&qa_dir).unwrap();
|
||||||
|
// Only a coder agent is configured — no QA agent exists.
|
||||||
|
std::fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Story in 3_qa/ requests coder-1 (wrong stage) and no QA agent exists.
|
||||||
|
std::fs::write(
|
||||||
|
qa_dir.join("story-noqa.md"),
|
||||||
|
"---\nname: QA Story No Agent\nagent: coder-1\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3001);
|
||||||
|
|
||||||
|
// Must not panic.
|
||||||
|
pool.auto_assign_available_work(tmp.path()).await;
|
||||||
|
|
||||||
|
let agents = pool.agents.lock().unwrap();
|
||||||
|
assert!(
|
||||||
|
agents.is_empty(),
|
||||||
|
"No agent should be started when no stage-appropriate agent is available"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ struct WorkItemContentResponse {
|
|||||||
content: String,
|
content: String,
|
||||||
stage: String,
|
stage: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
agent: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single test case result for the OpenAPI response.
|
/// A single test case result for the OpenAPI response.
|
||||||
@@ -354,13 +355,14 @@ impl AgentsApi {
|
|||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
let content = std::fs::read_to_string(&file_path)
|
let content = std::fs::read_to_string(&file_path)
|
||||||
.map_err(|e| bad_request(format!("Failed to read work item: {e}")))?;
|
.map_err(|e| bad_request(format!("Failed to read work item: {e}")))?;
|
||||||
let name = crate::io::story_metadata::parse_front_matter(&content)
|
let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
|
||||||
.ok()
|
let name = metadata.as_ref().and_then(|m| m.name.clone());
|
||||||
.and_then(|m| m.name);
|
let agent = metadata.and_then(|m| m.agent);
|
||||||
return Ok(Json(WorkItemContentResponse {
|
return Ok(Json(WorkItemContentResponse {
|
||||||
content,
|
content,
|
||||||
stage: stage_name.to_string(),
|
stage: stage_name.to_string(),
|
||||||
name,
|
name,
|
||||||
|
agent,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ impl IoApi {
|
|||||||
Ok(Json(home))
|
Ok(Json(home))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all files in the project recursively, respecting .gitignore.
|
||||||
|
#[oai(path = "/io/fs/files", method = "get")]
|
||||||
|
async fn list_project_files(&self) -> OpenApiResult<Json<Vec<String>>> {
|
||||||
|
let files = io_fs::list_project_files(&self.ctx.state)
|
||||||
|
.await
|
||||||
|
.map_err(bad_request)?;
|
||||||
|
Ok(Json(files))
|
||||||
|
}
|
||||||
|
|
||||||
/// Search the currently open project for files containing the provided query string.
|
/// Search the currently open project for files containing the provided query string.
|
||||||
#[oai(path = "/io/search", method = "post")]
|
#[oai(path = "/io/search", method = "post")]
|
||||||
async fn search_files(
|
async fn search_files(
|
||||||
@@ -316,6 +325,53 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- list_project_files ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_project_files_returns_file_paths() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join("src")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
|
||||||
|
std::fs::write(dir.path().join("README.md"), "# readme").unwrap();
|
||||||
|
|
||||||
|
let api = make_api(&dir);
|
||||||
|
let result = api.list_project_files().await.unwrap();
|
||||||
|
let files = &result.0;
|
||||||
|
|
||||||
|
assert!(files.contains(&"README.md".to_string()));
|
||||||
|
assert!(files.contains(&"src/main.rs".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_project_files_excludes_directories() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("file.txt"), "").unwrap();
|
||||||
|
|
||||||
|
let api = make_api(&dir);
|
||||||
|
let result = api.list_project_files().await.unwrap();
|
||||||
|
let files = &result.0;
|
||||||
|
|
||||||
|
assert!(files.contains(&"file.txt".to_string()));
|
||||||
|
// Directories should not appear
|
||||||
|
assert!(!files.iter().any(|f| f == "subdir"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_project_files_returns_sorted_paths() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("z_last.txt"), "").unwrap();
|
||||||
|
std::fs::write(dir.path().join("a_first.txt"), "").unwrap();
|
||||||
|
|
||||||
|
let api = make_api(&dir);
|
||||||
|
let result = api.list_project_files().await.unwrap();
|
||||||
|
let files = &result.0;
|
||||||
|
|
||||||
|
let a_idx = files.iter().position(|f| f == "a_first.txt").unwrap();
|
||||||
|
let z_idx = files.iter().position(|f| f == "z_last.txt").unwrap();
|
||||||
|
assert!(a_idx < z_idx);
|
||||||
|
}
|
||||||
|
|
||||||
// --- list_directory (project-scoped) ---
|
// --- list_directory (project-scoped) ---
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -345,4 +401,5 @@ mod tests {
|
|||||||
let result = api.list_directory(payload).await;
|
let result = api.list_directory(payload).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::http::settings::get_editor_command_from_store;
|
|||||||
use crate::http::workflow::{
|
use crate::http::workflow::{
|
||||||
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
|
||||||
create_spike_file, create_story_file, list_bug_files, list_refactor_files,
|
create_spike_file, create_story_file, list_bug_files, list_refactor_files,
|
||||||
load_upcoming_stories, update_story_in_file, validate_story_dirs,
|
load_pipeline_state, load_upcoming_stories, update_story_in_file, validate_story_dirs,
|
||||||
};
|
};
|
||||||
use crate::worktree;
|
use crate::worktree;
|
||||||
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos, write_merge_failure};
|
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos, write_merge_failure};
|
||||||
@@ -19,6 +19,7 @@ use poem::web::Data;
|
|||||||
use poem::{Body, Request, Response};
|
use poem::{Body, Request, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -638,7 +639,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "update_story",
|
"name": "update_story",
|
||||||
"description": "Update the user story text and/or description of an existing story file. Replaces the content of the '## User Story' and/or '## Description' section in place. Auto-commits via the filesystem watcher.",
|
"description": "Update an existing story file. Can replace the '## User Story' and/or '## Description' section content, and/or set YAML front matter fields (e.g. agent, manual_qa). Auto-commits via the filesystem watcher.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -653,6 +654,17 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
"description": {
|
"description": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "New description text to replace the '## Description' section content"
|
"description": "New description text to replace the '## Description' section content"
|
||||||
|
},
|
||||||
|
"agent": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Set or change the 'agent' YAML front matter field"
|
||||||
|
},
|
||||||
|
"front_matter": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Arbitrary YAML front matter key-value pairs to set or update",
|
||||||
|
"additionalProperties": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["story_id"]
|
"required": ["story_id"]
|
||||||
@@ -850,6 +862,14 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
"required": ["story_id"]
|
"required": ["story_id"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "get_pipeline_status",
|
||||||
|
"description": "Return a structured snapshot of the full work item pipeline. Includes all active stages (current, qa, merge, done) with each item's stage, name, and assigned agent. Also includes upcoming backlog items.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "get_server_logs",
|
"name": "get_server_logs",
|
||||||
"description": "Return recent server log lines captured in the in-process ring buffer. Useful for diagnosing runtime behaviour such as WebSocket events, MCP call flow, and filesystem watcher activity.",
|
"description": "Return recent server log lines captured in the in-process ring buffer. Useful for diagnosing runtime behaviour such as WebSocket events, MCP call flow, and filesystem watcher activity.",
|
||||||
@@ -951,6 +971,8 @@ async fn handle_tools_call(
|
|||||||
"report_merge_failure" => tool_report_merge_failure(&args, ctx),
|
"report_merge_failure" => tool_report_merge_failure(&args, ctx),
|
||||||
// QA tools
|
// QA tools
|
||||||
"request_qa" => tool_request_qa(&args, ctx).await,
|
"request_qa" => tool_request_qa(&args, ctx).await,
|
||||||
|
// Pipeline status
|
||||||
|
"get_pipeline_status" => tool_get_pipeline_status(ctx),
|
||||||
// Diagnostics
|
// Diagnostics
|
||||||
"get_server_logs" => tool_get_server_logs(&args),
|
"get_server_logs" => tool_get_server_logs(&args),
|
||||||
// Permission bridge (Claude Code → frontend dialog)
|
// Permission bridge (Claude Code → frontend dialog)
|
||||||
@@ -1032,6 +1054,47 @@ fn tool_list_upcoming(ctx: &AppContext) -> Result<String, String> {
|
|||||||
.map_err(|e| format!("Serialization error: {e}"))
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, String> {
|
||||||
|
let state = load_pipeline_state(ctx)?;
|
||||||
|
|
||||||
|
fn map_items(items: &[crate::http::workflow::UpcomingStory], stage: &str) -> Vec<Value> {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.map(|s| {
|
||||||
|
json!({
|
||||||
|
"story_id": s.story_id,
|
||||||
|
"name": s.name,
|
||||||
|
"stage": stage,
|
||||||
|
"agent": s.agent.as_ref().map(|a| json!({
|
||||||
|
"agent_name": a.agent_name,
|
||||||
|
"model": a.model,
|
||||||
|
"status": a.status,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut active: Vec<Value> = Vec::new();
|
||||||
|
active.extend(map_items(&state.current, "current"));
|
||||||
|
active.extend(map_items(&state.qa, "qa"));
|
||||||
|
active.extend(map_items(&state.merge, "merge"));
|
||||||
|
active.extend(map_items(&state.done, "done"));
|
||||||
|
|
||||||
|
let upcoming: Vec<Value> = state
|
||||||
|
.upcoming
|
||||||
|
.iter()
|
||||||
|
.map(|s| json!({ "story_id": s.story_id, "name": s.name }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
serde_json::to_string_pretty(&json!({
|
||||||
|
"active": active,
|
||||||
|
"upcoming": upcoming,
|
||||||
|
"upcoming_count": upcoming.len(),
|
||||||
|
}))
|
||||||
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
let story_id = args
|
let story_id = args
|
||||||
.get("story_id")
|
.get("story_id")
|
||||||
@@ -1544,8 +1607,24 @@ fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|||||||
let user_story = args.get("user_story").and_then(|v| v.as_str());
|
let user_story = args.get("user_story").and_then(|v| v.as_str());
|
||||||
let description = args.get("description").and_then(|v| v.as_str());
|
let description = args.get("description").and_then(|v| v.as_str());
|
||||||
|
|
||||||
|
// Collect front matter fields: explicit `agent` param + arbitrary `front_matter` object.
|
||||||
|
let mut front_matter: HashMap<String, String> = HashMap::new();
|
||||||
|
if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
|
||||||
|
front_matter.insert("agent".to_string(), agent.to_string());
|
||||||
|
}
|
||||||
|
if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) {
|
||||||
|
for (k, v) in obj {
|
||||||
|
let val = match v {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
};
|
||||||
|
front_matter.insert(k.clone(), val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let front_matter_opt = if front_matter.is_empty() { None } else { Some(&front_matter) };
|
||||||
|
|
||||||
let root = ctx.state.get_project_root()?;
|
let root = ctx.state.get_project_root()?;
|
||||||
update_story_in_file(&root, story_id, user_story, description)?;
|
update_story_in_file(&root, story_id, user_story, description, front_matter_opt)?;
|
||||||
|
|
||||||
Ok(format!("Updated story '{story_id}'."))
|
Ok(format!("Updated story '{story_id}'."))
|
||||||
}
|
}
|
||||||
@@ -2202,7 +2281,8 @@ mod tests {
|
|||||||
assert!(names.contains(&"request_qa"));
|
assert!(names.contains(&"request_qa"));
|
||||||
assert!(names.contains(&"get_server_logs"));
|
assert!(names.contains(&"get_server_logs"));
|
||||||
assert!(names.contains(&"prompt_permission"));
|
assert!(names.contains(&"prompt_permission"));
|
||||||
assert_eq!(tools.len(), 34);
|
assert!(names.contains(&"get_pipeline_status"));
|
||||||
|
assert_eq!(tools.len(), 35);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2269,6 +2349,81 @@ mod tests {
|
|||||||
assert!(result.unwrap_err().contains("Missing required argument"));
|
assert!(result.unwrap_err().contains("Missing required argument"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_get_pipeline_status_returns_structured_response() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
|
||||||
|
for (stage, id, name) in &[
|
||||||
|
("1_upcoming", "10_story_upcoming", "Upcoming Story"),
|
||||||
|
("2_current", "20_story_current", "Current Story"),
|
||||||
|
("3_qa", "30_story_qa", "QA Story"),
|
||||||
|
("4_merge", "40_story_merge", "Merge Story"),
|
||||||
|
("5_done", "50_story_done", "Done Story"),
|
||||||
|
] {
|
||||||
|
let dir = root.join(".story_kit/work").join(stage);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
dir.join(format!("{id}.md")),
|
||||||
|
format!("---\nname: \"{name}\"\n---\n"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = test_ctx(root);
|
||||||
|
let result = tool_get_pipeline_status(&ctx).unwrap();
|
||||||
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||||
|
|
||||||
|
// Active stages include current, qa, merge, done
|
||||||
|
let active = parsed["active"].as_array().unwrap();
|
||||||
|
assert_eq!(active.len(), 4);
|
||||||
|
|
||||||
|
let stages: Vec<&str> = active.iter().map(|i| i["stage"].as_str().unwrap()).collect();
|
||||||
|
assert!(stages.contains(&"current"));
|
||||||
|
assert!(stages.contains(&"qa"));
|
||||||
|
assert!(stages.contains(&"merge"));
|
||||||
|
assert!(stages.contains(&"done"));
|
||||||
|
|
||||||
|
// Upcoming backlog
|
||||||
|
let upcoming = parsed["upcoming"].as_array().unwrap();
|
||||||
|
assert_eq!(upcoming.len(), 1);
|
||||||
|
assert_eq!(upcoming[0]["story_id"], "10_story_upcoming");
|
||||||
|
assert_eq!(parsed["upcoming_count"], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_get_pipeline_status_includes_agent_assignment() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
|
||||||
|
let current = root.join(".story_kit/work/2_current");
|
||||||
|
std::fs::create_dir_all(¤t).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
current.join("20_story_active.md"),
|
||||||
|
"---\nname: \"Active Story\"\n---\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let ctx = test_ctx(root);
|
||||||
|
ctx.agents.inject_test_agent(
|
||||||
|
"20_story_active",
|
||||||
|
"coder-1",
|
||||||
|
crate::agents::AgentStatus::Running,
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = tool_get_pipeline_status(&ctx).unwrap();
|
||||||
|
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||||
|
|
||||||
|
let active = parsed["active"].as_array().unwrap();
|
||||||
|
assert_eq!(active.len(), 1);
|
||||||
|
let item = &active[0];
|
||||||
|
assert_eq!(item["story_id"], "20_story_active");
|
||||||
|
assert_eq!(item["stage"], "current");
|
||||||
|
assert!(!item["agent"].is_null(), "agent should be present");
|
||||||
|
assert_eq!(item["agent"]["agent_name"], "coder-1");
|
||||||
|
assert_eq!(item["agent"]["status"], "running");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_get_story_todos_missing_file() {
|
fn tool_get_story_todos_missing_file() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ impl ProjectApi {
|
|||||||
payload.0.path,
|
payload.0.path,
|
||||||
&self.ctx.state,
|
&self.ctx.state,
|
||||||
self.ctx.store.as_ref(),
|
self.ctx.store.as_ref(),
|
||||||
self.ctx.agents.port(),
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::agents::AgentStatus;
|
use crate::agents::AgentStatus;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::io::story_metadata::{parse_front_matter, write_coverage_baseline};
|
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field, write_coverage_baseline};
|
||||||
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -706,10 +706,13 @@ pub fn update_story_in_file(
|
|||||||
story_id: &str,
|
story_id: &str,
|
||||||
user_story: Option<&str>,
|
user_story: Option<&str>,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
|
front_matter: Option<&HashMap<String, String>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if user_story.is_none() && description.is_none() {
|
let has_front_matter_updates = front_matter.map(|m| !m.is_empty()).unwrap_or(false);
|
||||||
|
if user_story.is_none() && description.is_none() && !has_front_matter_updates {
|
||||||
return Err(
|
return Err(
|
||||||
"At least one of 'user_story' or 'description' must be provided.".to_string(),
|
"At least one of 'user_story', 'description', or 'front_matter' must be provided."
|
||||||
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +720,13 @@ pub fn update_story_in_file(
|
|||||||
let mut contents = fs::read_to_string(&filepath)
|
let mut contents = fs::read_to_string(&filepath)
|
||||||
.map_err(|e| format!("Failed to read story file: {e}"))?;
|
.map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||||
|
|
||||||
|
if let Some(fields) = front_matter {
|
||||||
|
for (key, value) in fields {
|
||||||
|
let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', ""));
|
||||||
|
contents = set_front_matter_field(&contents, key, &yaml_value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(us) = user_story {
|
if let Some(us) = user_story {
|
||||||
contents = replace_section_content(&contents, "User Story", us)?;
|
contents = replace_section_content(&contents, "User Story", us)?;
|
||||||
}
|
}
|
||||||
@@ -1597,7 +1607,7 @@ mod tests {
|
|||||||
let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n";
|
let content = "---\nname: T\n---\n\n## User Story\n\nOld text\n\n## Acceptance Criteria\n\n- [ ] AC\n";
|
||||||
fs::write(&filepath, content).unwrap();
|
fs::write(&filepath, content).unwrap();
|
||||||
|
|
||||||
update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None).unwrap();
|
update_story_in_file(tmp.path(), "20_test", Some("New user story text"), None, None).unwrap();
|
||||||
|
|
||||||
let result = fs::read_to_string(&filepath).unwrap();
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(result.contains("New user story text"), "new text should be present");
|
assert!(result.contains("New user story text"), "new text should be present");
|
||||||
@@ -1614,7 +1624,7 @@ mod tests {
|
|||||||
let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n";
|
let content = "---\nname: T\n---\n\n## Description\n\nOld description\n\n## Acceptance Criteria\n\n- [ ] AC\n";
|
||||||
fs::write(&filepath, content).unwrap();
|
fs::write(&filepath, content).unwrap();
|
||||||
|
|
||||||
update_story_in_file(tmp.path(), "21_test", None, Some("New description")).unwrap();
|
update_story_in_file(tmp.path(), "21_test", None, Some("New description"), None).unwrap();
|
||||||
|
|
||||||
let result = fs::read_to_string(&filepath).unwrap();
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(result.contains("New description"), "new description present");
|
assert!(result.contains("New description"), "new description present");
|
||||||
@@ -1628,7 +1638,7 @@ mod tests {
|
|||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::write(current.join("22_test.md"), "---\nname: T\n---\n").unwrap();
|
fs::write(current.join("22_test.md"), "---\nname: T\n---\n").unwrap();
|
||||||
|
|
||||||
let result = update_story_in_file(tmp.path(), "22_test", None, None);
|
let result = update_story_in_file(tmp.path(), "22_test", None, None, None);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("At least one"));
|
assert!(result.unwrap_err().contains("At least one"));
|
||||||
}
|
}
|
||||||
@@ -1644,11 +1654,65 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None);
|
let result = update_story_in_file(tmp.path(), "23_test", Some("new text"), None, None);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("User Story"));
|
assert!(result.unwrap_err().contains("User Story"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_sets_agent_front_matter_field() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("24_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("agent".to_string(), "dev".to_string());
|
||||||
|
update_story_in_file(tmp.path(), "24_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(result.contains("agent: \"dev\""), "agent field should be set");
|
||||||
|
assert!(result.contains("name: T"), "name field preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_sets_arbitrary_front_matter_fields() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("25_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: T\n---\n\n## User Story\n\nSome story\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("manual_qa".to_string(), "true".to_string());
|
||||||
|
fields.insert("priority".to_string(), "high".to_string());
|
||||||
|
update_story_in_file(tmp.path(), "25_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(result.contains("manual_qa: \"true\""), "manual_qa field should be set");
|
||||||
|
assert!(result.contains("priority: \"high\""), "priority field should be set");
|
||||||
|
assert!(result.contains("name: T"), "name field preserved");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_front_matter_only_no_section_required() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".story_kit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
// File without a User Story section — front matter update should succeed
|
||||||
|
let filepath = current.join("26_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: T\n---\n\nNo sections here.\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("agent".to_string(), "dev".to_string());
|
||||||
|
let result = update_story_in_file(tmp.path(), "26_test", None, None, Some(&fields));
|
||||||
|
assert!(result.is_ok(), "front-matter-only update should not require body sections");
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(contents.contains("agent: \"dev\""));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Bug file helper tests ──────────────────────────────────────────────────
|
// ── Bug file helper tests ──────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
use crate::state::SessionState;
|
use crate::state::SessionState;
|
||||||
use crate::store::StoreOps;
|
use crate::store::StoreOps;
|
||||||
use crate::worktree::write_mcp_json as worktree_write_mcp_json;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
@@ -515,17 +514,12 @@ pub async fn open_project(
|
|||||||
path: String,
|
path: String,
|
||||||
state: &SessionState,
|
state: &SessionState,
|
||||||
store: &dyn StoreOps,
|
store: &dyn StoreOps,
|
||||||
port: u16,
|
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let p = PathBuf::from(&path);
|
let p = PathBuf::from(&path);
|
||||||
|
|
||||||
ensure_project_root_with_story_kit(p.clone()).await?;
|
ensure_project_root_with_story_kit(p.clone()).await?;
|
||||||
validate_project_path(p.clone()).await?;
|
validate_project_path(p.clone()).await?;
|
||||||
|
|
||||||
// Write .mcp.json so that claude-code can connect to the MCP server.
|
|
||||||
// Best-effort: failure should not prevent the project from opening.
|
|
||||||
let _ = worktree_write_mcp_json(&p, port);
|
|
||||||
|
|
||||||
{
|
{
|
||||||
// TRACE:MERGE-DEBUG — remove once root cause is found
|
// TRACE:MERGE-DEBUG — remove once root cause is found
|
||||||
crate::slog!("[MERGE-DEBUG] open_project: setting project_root to {:?}", p);
|
crate::slog!("[MERGE-DEBUG] open_project: setting project_root to {:?}", p);
|
||||||
@@ -727,6 +721,42 @@ pub async fn create_directory_absolute(path: String) -> Result<bool, String> {
|
|||||||
.map_err(|e| format!("Task failed: {}", e))?
|
.map_err(|e| format!("Task failed: {}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all files in the project recursively, respecting .gitignore.
|
||||||
|
/// Returns relative paths from the project root (files only, not directories).
|
||||||
|
pub async fn list_project_files(state: &SessionState) -> Result<Vec<String>, String> {
|
||||||
|
let root = state.get_project_root()?;
|
||||||
|
list_project_files_impl(root).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_project_files_impl(root: PathBuf) -> Result<Vec<String>, String> {
|
||||||
|
use ignore::WalkBuilder;
|
||||||
|
|
||||||
|
let root_clone = root.clone();
|
||||||
|
let files = tokio::task::spawn_blocking(move || {
|
||||||
|
let mut result = Vec::new();
|
||||||
|
let walker = WalkBuilder::new(&root_clone).git_ignore(true).build();
|
||||||
|
|
||||||
|
for entry in walker.flatten() {
|
||||||
|
if entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
|
||||||
|
let relative = entry
|
||||||
|
.path()
|
||||||
|
.strip_prefix(&root_clone)
|
||||||
|
.unwrap_or(entry.path())
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
result.push(relative);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.sort();
|
||||||
|
result
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Task failed: {e}"))?;
|
||||||
|
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -777,7 +807,6 @@ mod tests {
|
|||||||
project_dir.to_string_lossy().to_string(),
|
project_dir.to_string_lossy().to_string(),
|
||||||
&state,
|
&state,
|
||||||
&store,
|
&store,
|
||||||
3001,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -787,7 +816,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_project_writes_mcp_json_to_project_root() {
|
async fn open_project_does_not_write_mcp_json() {
|
||||||
|
// open_project must NOT overwrite .mcp.json — test servers started by QA
|
||||||
|
// agents share the real project root, so writing here would clobber the
|
||||||
|
// root .mcp.json with the wrong port. .mcp.json is written once during
|
||||||
|
// worktree creation (worktree.rs) and should not be touched again.
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
let project_dir = dir.path().join("myproject");
|
let project_dir = dir.path().join("myproject");
|
||||||
fs::create_dir_all(&project_dir).unwrap();
|
fs::create_dir_all(&project_dir).unwrap();
|
||||||
@@ -798,17 +831,14 @@ mod tests {
|
|||||||
project_dir.to_string_lossy().to_string(),
|
project_dir.to_string_lossy().to_string(),
|
||||||
&state,
|
&state,
|
||||||
&store,
|
&store,
|
||||||
4242,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let mcp_path = project_dir.join(".mcp.json");
|
let mcp_path = project_dir.join(".mcp.json");
|
||||||
assert!(mcp_path.exists(), ".mcp.json should be written to project root");
|
|
||||||
let content = fs::read_to_string(&mcp_path).unwrap();
|
|
||||||
assert!(
|
assert!(
|
||||||
content.contains("http://localhost:4242/mcp"),
|
!mcp_path.exists(),
|
||||||
".mcp.json should contain the correct port"
|
"open_project must not write .mcp.json — that would overwrite the root with the wrong port"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -868,7 +898,6 @@ mod tests {
|
|||||||
project_dir.to_string_lossy().to_string(),
|
project_dir.to_string_lossy().to_string(),
|
||||||
&state,
|
&state,
|
||||||
&store,
|
&store,
|
||||||
3001,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1329,7 +1358,6 @@ mod tests {
|
|||||||
project_dir.to_string_lossy().to_string(),
|
project_dir.to_string_lossy().to_string(),
|
||||||
&state,
|
&state,
|
||||||
&store,
|
&store,
|
||||||
0,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1353,7 +1381,6 @@ mod tests {
|
|||||||
project_dir.to_string_lossy().to_string(),
|
project_dir.to_string_lossy().to_string(),
|
||||||
&state,
|
&state,
|
||||||
&store,
|
&store,
|
||||||
0,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1588,4 +1615,68 @@ mod tests {
|
|||||||
"scaffold should not overwrite existing project.toml"
|
"scaffold should not overwrite existing project.toml"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- list_project_files_impl ---
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_project_files_returns_all_files() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::create_dir(dir.path().join("src")).unwrap();
|
||||||
|
fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
|
||||||
|
fs::write(dir.path().join("README.md"), "# readme").unwrap();
|
||||||
|
|
||||||
|
let files = list_project_files_impl(dir.path().to_path_buf())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(files.contains(&"README.md".to_string()));
|
||||||
|
assert!(files.contains(&"src/main.rs".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_project_files_excludes_dirs_from_output() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::create_dir(dir.path().join("subdir")).unwrap();
|
||||||
|
fs::write(dir.path().join("file.txt"), "").unwrap();
|
||||||
|
|
||||||
|
let files = list_project_files_impl(dir.path().to_path_buf())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(files.contains(&"file.txt".to_string()));
|
||||||
|
assert!(!files.iter().any(|f| f == "subdir"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_project_files_returns_sorted() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("z.txt"), "").unwrap();
|
||||||
|
fs::write(dir.path().join("a.txt"), "").unwrap();
|
||||||
|
|
||||||
|
let files = list_project_files_impl(dir.path().to_path_buf())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let a_idx = files.iter().position(|f| f == "a.txt").unwrap();
|
||||||
|
let z_idx = files.iter().position(|f| f == "z.txt").unwrap();
|
||||||
|
assert!(a_idx < z_idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_project_files_with_state() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("hello.rs"), "").unwrap();
|
||||||
|
let state = make_state_with_root(dir.path().to_path_buf());
|
||||||
|
|
||||||
|
let files = list_project_files(&state).await.unwrap();
|
||||||
|
|
||||||
|
assert!(files.contains(&"hello.rs".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_project_files_errors_without_project() {
|
||||||
|
let state = SessionState::default();
|
||||||
|
let result = list_project_files(&state).await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ fn remove_front_matter_field(contents: &str, key: &str) -> String {
|
|||||||
/// Insert or update a key: value pair in the YAML front matter of a markdown string.
|
/// Insert or update a key: value pair in the YAML front matter of a markdown string.
|
||||||
///
|
///
|
||||||
/// If no front matter (opening `---`) is found, returns the content unchanged.
|
/// If no front matter (opening `---`) is found, returns the content unchanged.
|
||||||
fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
|
pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String {
|
||||||
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
|
||||||
if lines.is_empty() || lines[0].trim() != "---" {
|
if lines.is_empty() || lines[0].trim() != "---" {
|
||||||
return contents.to_string();
|
return contents.to_string();
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
//! the event so connected clients stay in sync.
|
//! the event so connected clients stay in sync.
|
||||||
|
|
||||||
use crate::config::{ProjectConfig, WatcherConfig};
|
use crate::config::{ProjectConfig, WatcherConfig};
|
||||||
|
use crate::io::story_metadata::clear_front_matter_field;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher};
|
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher, recommended_watcher};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -154,11 +155,25 @@ fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, Strin
|
|||||||
Err(format!("git commit failed: {stderr}"))
|
Err(format!("git commit failed: {stderr}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stages that represent meaningful git checkpoints (creation and archival).
|
||||||
|
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
|
||||||
|
/// that don't need to be committed — they're only relevant while the server is
|
||||||
|
/// running and are broadcast to WebSocket clients for real-time UI updates.
|
||||||
|
const COMMIT_WORTHY_STAGES: &[&str] = &["1_upcoming", "5_done", "6_archived"];
|
||||||
|
|
||||||
|
/// Return `true` if changes in `stage` should be committed to git.
|
||||||
|
fn should_commit_stage(stage: &str) -> bool {
|
||||||
|
COMMIT_WORTHY_STAGES.contains(&stage)
|
||||||
|
}
|
||||||
|
|
||||||
/// Process a batch of pending (path → stage) entries: commit and broadcast.
|
/// Process a batch of pending (path → stage) entries: commit and broadcast.
|
||||||
///
|
///
|
||||||
/// Only files that still exist on disk are used to derive the commit message
|
/// Only files that still exist on disk are used to derive the commit message
|
||||||
/// (they represent the destination of a move or a new file). Deletions are
|
/// (they represent the destination of a move or a new file). Deletions are
|
||||||
/// captured by `git add -A .story_kit/work/` automatically.
|
/// captured by `git add -A .story_kit/work/` automatically.
|
||||||
|
///
|
||||||
|
/// Only terminal stages (`1_upcoming` and `6_archived`) trigger git commits.
|
||||||
|
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
|
||||||
fn flush_pending(
|
fn flush_pending(
|
||||||
pending: &HashMap<PathBuf, String>,
|
pending: &HashMap<PathBuf, String>,
|
||||||
git_root: &Path,
|
git_root: &Path,
|
||||||
@@ -190,27 +205,46 @@ fn flush_pending(
|
|||||||
("remove", item.to_string(), format!("story-kit: remove {item}"))
|
("remove", item.to_string(), format!("story-kit: remove {item}"))
|
||||||
};
|
};
|
||||||
|
|
||||||
slog!("[watcher] flush: {commit_msg}");
|
// Strip stale merge_failure front matter from any story that has left 4_merge/.
|
||||||
match git_add_work_and_commit(git_root, &commit_msg) {
|
for (path, stage) in &additions {
|
||||||
Ok(committed) => {
|
if *stage != "4_merge"
|
||||||
if committed {
|
&& let Err(e) = clear_front_matter_field(path, "merge_failure")
|
||||||
slog!("[watcher] committed: {commit_msg}");
|
{
|
||||||
} else {
|
slog!("[watcher] Warning: could not clear merge_failure from {}: {e}", path.display());
|
||||||
slog!("[watcher] skipped (already committed): {commit_msg}");
|
|
||||||
}
|
|
||||||
let stage = additions.first().map_or("unknown", |(_, s)| s);
|
|
||||||
let evt = WatcherEvent::WorkItem {
|
|
||||||
stage: stage.to_string(),
|
|
||||||
item_id,
|
|
||||||
action: action.to_string(),
|
|
||||||
commit_msg,
|
|
||||||
};
|
|
||||||
let _ = event_tx.send(evt);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
slog!("[watcher] git error: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only commit for terminal stages; intermediate moves are broadcast-only.
|
||||||
|
let dest_stage = additions.first().map_or("unknown", |(_, s)| *s);
|
||||||
|
let should_commit = should_commit_stage(dest_stage);
|
||||||
|
|
||||||
|
if should_commit {
|
||||||
|
slog!("[watcher] flush: {commit_msg}");
|
||||||
|
match git_add_work_and_commit(git_root, &commit_msg) {
|
||||||
|
Ok(committed) => {
|
||||||
|
if committed {
|
||||||
|
slog!("[watcher] committed: {commit_msg}");
|
||||||
|
} else {
|
||||||
|
slog!("[watcher] skipped (already committed): {commit_msg}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[watcher] git error: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog!("[watcher] flush (broadcast-only): {commit_msg}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always broadcast the event so connected WebSocket clients stay in sync.
|
||||||
|
let evt = WatcherEvent::WorkItem {
|
||||||
|
stage: dest_stage.to_string(),
|
||||||
|
item_id,
|
||||||
|
action: action.to_string(),
|
||||||
|
commit_msg,
|
||||||
|
};
|
||||||
|
let _ = event_tx.send(evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan `work/5_done/` and move any `.md` files whose mtime is older than
|
/// Scan `work/5_done/` and move any `.md` files whose mtime is older than
|
||||||
@@ -537,7 +571,50 @@ mod tests {
|
|||||||
// ── flush_pending ─────────────────────────────────────────────────────────
|
// ── flush_pending ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn flush_pending_commits_and_broadcasts_work_item_for_addition() {
|
fn flush_pending_commits_and_broadcasts_for_terminal_stage() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(tmp.path());
|
||||||
|
let stage_dir = make_stage_dir(tmp.path(), "1_upcoming");
|
||||||
|
let story_path = stage_dir.join("42_story_foo.md");
|
||||||
|
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
|
||||||
|
|
||||||
|
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
|
||||||
|
let mut pending = HashMap::new();
|
||||||
|
pending.insert(story_path, "1_upcoming".to_string());
|
||||||
|
|
||||||
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
|
let evt = rx.try_recv().expect("expected a broadcast event");
|
||||||
|
match evt {
|
||||||
|
WatcherEvent::WorkItem {
|
||||||
|
stage,
|
||||||
|
item_id,
|
||||||
|
action,
|
||||||
|
commit_msg,
|
||||||
|
} => {
|
||||||
|
assert_eq!(stage, "1_upcoming");
|
||||||
|
assert_eq!(item_id, "42_story_foo");
|
||||||
|
assert_eq!(action, "create");
|
||||||
|
assert_eq!(commit_msg, "story-kit: create 42_story_foo");
|
||||||
|
}
|
||||||
|
other => panic!("unexpected event: {other:?}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the file was actually committed.
|
||||||
|
let log = std::process::Command::new("git")
|
||||||
|
.args(["log", "--oneline", "-1"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.expect("git log");
|
||||||
|
let log_msg = String::from_utf8_lossy(&log.stdout);
|
||||||
|
assert!(
|
||||||
|
log_msg.contains("story-kit: create 42_story_foo"),
|
||||||
|
"terminal stage should produce a git commit"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_pending_broadcasts_without_commit_for_intermediate_stage() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
init_git_repo(tmp.path());
|
init_git_repo(tmp.path());
|
||||||
let stage_dir = make_stage_dir(tmp.path(), "2_current");
|
let stage_dir = make_stage_dir(tmp.path(), "2_current");
|
||||||
@@ -550,6 +627,7 @@ mod tests {
|
|||||||
|
|
||||||
flush_pending(&pending, tmp.path(), &tx);
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
|
// Event should still be broadcast for frontend sync.
|
||||||
let evt = rx.try_recv().expect("expected a broadcast event");
|
let evt = rx.try_recv().expect("expected a broadcast event");
|
||||||
match evt {
|
match evt {
|
||||||
WatcherEvent::WorkItem {
|
WatcherEvent::WorkItem {
|
||||||
@@ -565,6 +643,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
other => panic!("unexpected event: {other:?}"),
|
other => panic!("unexpected event: {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify NO git commit was made (only the initial empty commit should exist).
|
||||||
|
let log = std::process::Command::new("git")
|
||||||
|
.args(["log", "--oneline"])
|
||||||
|
.current_dir(tmp.path())
|
||||||
|
.output()
|
||||||
|
.expect("git log");
|
||||||
|
let log_msg = String::from_utf8_lossy(&log.stdout);
|
||||||
|
assert!(
|
||||||
|
!log_msg.contains("story-kit:"),
|
||||||
|
"intermediate stage should NOT produce a git commit"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -590,6 +680,7 @@ mod tests {
|
|||||||
|
|
||||||
flush_pending(&pending, tmp.path(), &tx);
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
|
// All stages should broadcast events regardless of commit behavior.
|
||||||
let evt = rx.try_recv().expect("expected broadcast for stage {stage}");
|
let evt = rx.try_recv().expect("expected broadcast for stage {stage}");
|
||||||
match evt {
|
match evt {
|
||||||
WatcherEvent::WorkItem {
|
WatcherEvent::WorkItem {
|
||||||
@@ -672,6 +763,128 @@ mod tests {
|
|||||||
assert!(rx.try_recv().is_err(), "no event for empty pending map");
|
assert!(rx.try_recv().is_err(), "no event for empty pending map");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── flush_pending clears merge_failure ─────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_pending_clears_merge_failure_when_leaving_merge_stage() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(tmp.path());
|
||||||
|
let stage_dir = make_stage_dir(tmp.path(), "2_current");
|
||||||
|
let story_path = stage_dir.join("50_story_retry.md");
|
||||||
|
fs::write(
|
||||||
|
&story_path,
|
||||||
|
"---\nname: Retry Story\nmerge_failure: \"conflicts detected\"\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (tx, _rx) = tokio::sync::broadcast::channel(16);
|
||||||
|
let mut pending = HashMap::new();
|
||||||
|
pending.insert(story_path.clone(), "2_current".to_string());
|
||||||
|
|
||||||
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&story_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
!contents.contains("merge_failure"),
|
||||||
|
"merge_failure should be stripped when story lands in 2_current"
|
||||||
|
);
|
||||||
|
assert!(contents.contains("name: Retry Story"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_pending_clears_merge_failure_when_moving_to_upcoming() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(tmp.path());
|
||||||
|
let stage_dir = make_stage_dir(tmp.path(), "1_upcoming");
|
||||||
|
let story_path = stage_dir.join("51_story_reset.md");
|
||||||
|
fs::write(
|
||||||
|
&story_path,
|
||||||
|
"---\nname: Reset Story\nmerge_failure: \"gate failed\"\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (tx, _rx) = tokio::sync::broadcast::channel(16);
|
||||||
|
let mut pending = HashMap::new();
|
||||||
|
pending.insert(story_path.clone(), "1_upcoming".to_string());
|
||||||
|
|
||||||
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&story_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
!contents.contains("merge_failure"),
|
||||||
|
"merge_failure should be stripped when story lands in 1_upcoming"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_pending_clears_merge_failure_when_moving_to_done() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(tmp.path());
|
||||||
|
let stage_dir = make_stage_dir(tmp.path(), "5_done");
|
||||||
|
let story_path = stage_dir.join("52_story_done.md");
|
||||||
|
fs::write(
|
||||||
|
&story_path,
|
||||||
|
"---\nname: Done Story\nmerge_failure: \"stale error\"\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (tx, _rx) = tokio::sync::broadcast::channel(16);
|
||||||
|
let mut pending = HashMap::new();
|
||||||
|
pending.insert(story_path.clone(), "5_done".to_string());
|
||||||
|
|
||||||
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&story_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
!contents.contains("merge_failure"),
|
||||||
|
"merge_failure should be stripped when story lands in 5_done"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_pending_preserves_merge_failure_when_in_merge_stage() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(tmp.path());
|
||||||
|
let stage_dir = make_stage_dir(tmp.path(), "4_merge");
|
||||||
|
let story_path = stage_dir.join("53_story_merging.md");
|
||||||
|
fs::write(
|
||||||
|
&story_path,
|
||||||
|
"---\nname: Merging Story\nmerge_failure: \"conflicts\"\n---\n# Story\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (tx, _rx) = tokio::sync::broadcast::channel(16);
|
||||||
|
let mut pending = HashMap::new();
|
||||||
|
pending.insert(story_path.clone(), "4_merge".to_string());
|
||||||
|
|
||||||
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&story_path).unwrap();
|
||||||
|
assert!(
|
||||||
|
contents.contains("merge_failure"),
|
||||||
|
"merge_failure should be preserved when story is in 4_merge"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flush_pending_no_op_when_no_merge_failure() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
init_git_repo(tmp.path());
|
||||||
|
let stage_dir = make_stage_dir(tmp.path(), "2_current");
|
||||||
|
let story_path = stage_dir.join("54_story_clean.md");
|
||||||
|
let original = "---\nname: Clean Story\n---\n# Story\n";
|
||||||
|
fs::write(&story_path, original).unwrap();
|
||||||
|
|
||||||
|
let (tx, _rx) = tokio::sync::broadcast::channel(16);
|
||||||
|
let mut pending = HashMap::new();
|
||||||
|
pending.insert(story_path.clone(), "2_current".to_string());
|
||||||
|
|
||||||
|
flush_pending(&pending, tmp.path(), &tx);
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&story_path).unwrap();
|
||||||
|
assert_eq!(contents, original, "file without merge_failure should be unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
// ── stage_for_path (additional edge cases) ────────────────────────────────
|
// ── stage_for_path (additional edge cases) ────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -721,6 +934,20 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_commit_stage_only_for_terminal_stages() {
|
||||||
|
// Terminal stages — should commit.
|
||||||
|
assert!(should_commit_stage("1_upcoming"));
|
||||||
|
assert!(should_commit_stage("5_done"));
|
||||||
|
assert!(should_commit_stage("6_archived"));
|
||||||
|
// Intermediate stages — broadcast-only, no commit.
|
||||||
|
assert!(!should_commit_stage("2_current"));
|
||||||
|
assert!(!should_commit_stage("3_qa"));
|
||||||
|
assert!(!should_commit_stage("4_merge"));
|
||||||
|
// Unknown — no commit.
|
||||||
|
assert!(!should_commit_stage("unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stage_metadata_returns_correct_actions() {
|
fn stage_metadata_returns_correct_actions() {
|
||||||
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
|
let (action, msg) = stage_metadata("2_current", "42_story_foo").unwrap();
|
||||||
|
|||||||
@@ -294,6 +294,7 @@ where
|
|||||||
&user_message,
|
&user_message,
|
||||||
&project_root.to_string_lossy(),
|
&project_root.to_string_lossy(),
|
||||||
config.session_id.as_deref(),
|
config.session_id.as_deref(),
|
||||||
|
None,
|
||||||
&mut cancel_rx,
|
&mut cancel_rx,
|
||||||
|token| on_token(token),
|
|token| on_token(token),
|
||||||
|thinking| on_thinking(thinking),
|
|thinking| on_thinking(thinking),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ impl ClaudeCodeProvider {
|
|||||||
user_message: &str,
|
user_message: &str,
|
||||||
project_root: &str,
|
project_root: &str,
|
||||||
session_id: Option<&str>,
|
session_id: Option<&str>,
|
||||||
|
system_prompt: Option<&str>,
|
||||||
cancel_rx: &mut watch::Receiver<bool>,
|
cancel_rx: &mut watch::Receiver<bool>,
|
||||||
mut on_token: F,
|
mut on_token: F,
|
||||||
mut on_thinking: T,
|
mut on_thinking: T,
|
||||||
@@ -55,6 +56,7 @@ impl ClaudeCodeProvider {
|
|||||||
let message = user_message.to_string();
|
let message = user_message.to_string();
|
||||||
let cwd = project_root.to_string();
|
let cwd = project_root.to_string();
|
||||||
let resume_id = session_id.map(|s| s.to_string());
|
let resume_id = session_id.map(|s| s.to_string());
|
||||||
|
let sys_prompt = system_prompt.map(|s| s.to_string());
|
||||||
let cancelled = Arc::new(AtomicBool::new(false));
|
let cancelled = Arc::new(AtomicBool::new(false));
|
||||||
let cancelled_clone = cancelled.clone();
|
let cancelled_clone = cancelled.clone();
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ impl ClaudeCodeProvider {
|
|||||||
&message,
|
&message,
|
||||||
&cwd,
|
&cwd,
|
||||||
resume_id.as_deref(),
|
resume_id.as_deref(),
|
||||||
|
sys_prompt.as_deref(),
|
||||||
cancelled,
|
cancelled,
|
||||||
token_tx,
|
token_tx,
|
||||||
thinking_tx,
|
thinking_tx,
|
||||||
@@ -120,6 +123,7 @@ impl ClaudeCodeProvider {
|
|||||||
.map_err(|e| format!("PTY task panicked: {e}"))??;
|
.map_err(|e| format!("PTY task panicked: {e}"))??;
|
||||||
|
|
||||||
let captured_session_id = sid_rx.await.ok();
|
let captured_session_id = sid_rx.await.ok();
|
||||||
|
slog!("[pty-debug] RECEIVED session_id: {:?}", captured_session_id);
|
||||||
let structured_messages: Vec<Message> = msg_rx.try_iter().collect();
|
let structured_messages: Vec<Message> = msg_rx.try_iter().collect();
|
||||||
|
|
||||||
Ok(ClaudeCodeResult {
|
Ok(ClaudeCodeResult {
|
||||||
@@ -146,6 +150,7 @@ fn run_pty_session(
|
|||||||
user_message: &str,
|
user_message: &str,
|
||||||
cwd: &str,
|
cwd: &str,
|
||||||
resume_session_id: Option<&str>,
|
resume_session_id: Option<&str>,
|
||||||
|
_system_prompt: Option<&str>,
|
||||||
cancelled: Arc<AtomicBool>,
|
cancelled: Arc<AtomicBool>,
|
||||||
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
||||||
thinking_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
thinking_tx: tokio::sync::mpsc::UnboundedSender<String>,
|
||||||
@@ -184,6 +189,8 @@ fn run_pty_session(
|
|||||||
// a tool requires user approval, instead of using PTY stdin/stdout.
|
// a tool requires user approval, instead of using PTY stdin/stdout.
|
||||||
cmd.arg("--permission-prompt-tool");
|
cmd.arg("--permission-prompt-tool");
|
||||||
cmd.arg("mcp__story-kit__prompt_permission");
|
cmd.arg("mcp__story-kit__prompt_permission");
|
||||||
|
// Note: --system is not a valid Claude Code CLI flag. System-level
|
||||||
|
// instructions (like bot name) are prepended to the user prompt instead.
|
||||||
cmd.cwd(cwd);
|
cmd.cwd(cwd);
|
||||||
// Keep TERM reasonable but disable color
|
// Keep TERM reasonable but disable color
|
||||||
cmd.env("NO_COLOR", "1");
|
cmd.env("NO_COLOR", "1");
|
||||||
@@ -346,6 +353,7 @@ fn process_json_event(
|
|||||||
// Capture session_id from the first event that carries it
|
// Capture session_id from the first event that carries it
|
||||||
if let Some(tx) = sid_tx.take() {
|
if let Some(tx) = sid_tx.take() {
|
||||||
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
|
if let Some(sid) = json.get("session_id").and_then(|s| s.as_str()) {
|
||||||
|
slog!("[pty-debug] CAPTURED session_id: {}", sid);
|
||||||
let _ = tx.send(sid.to_string());
|
let _ = tx.send(sid.to_string());
|
||||||
} else {
|
} else {
|
||||||
*sid_tx = Some(tx);
|
*sid_tx = Some(tx);
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
explicit_root.to_string_lossy().to_string(),
|
explicit_root.to_string_lossy().to_string(),
|
||||||
&app_state,
|
&app_state,
|
||||||
store.as_ref(),
|
store.as_ref(),
|
||||||
port,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -81,7 +80,6 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
project_root.to_string_lossy().to_string(),
|
project_root.to_string_lossy().to_string(),
|
||||||
&app_state,
|
&app_state,
|
||||||
store.as_ref(),
|
store.as_ref(),
|
||||||
port,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
@@ -169,6 +167,10 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
|
|
||||||
// Clone watcher_tx for the Matrix bot before it is moved into AppContext.
|
// Clone watcher_tx for the Matrix bot before it is moved into AppContext.
|
||||||
let watcher_tx_for_bot = watcher_tx.clone();
|
let watcher_tx_for_bot = watcher_tx.clone();
|
||||||
|
// Wrap perm_rx in Arc<Mutex> so it can be shared with both the WebSocket
|
||||||
|
// handler (via AppContext) and the Matrix bot.
|
||||||
|
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
|
||||||
|
let perm_rx_for_bot = Arc::clone(&perm_rx);
|
||||||
|
|
||||||
// Capture project root, agents Arc, and reconciliation sender before ctx
|
// Capture project root, agents Arc, and reconciliation sender before ctx
|
||||||
// is consumed by build_routes.
|
// is consumed by build_routes.
|
||||||
@@ -185,7 +187,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
watcher_tx,
|
watcher_tx,
|
||||||
reconciliation_tx,
|
reconciliation_tx,
|
||||||
perm_tx,
|
perm_tx,
|
||||||
perm_rx: Arc::new(tokio::sync::Mutex::new(perm_rx)),
|
perm_rx,
|
||||||
};
|
};
|
||||||
|
|
||||||
let app = build_routes(ctx);
|
let app = build_routes(ctx);
|
||||||
@@ -194,7 +196,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
// Optional Matrix bot: connect to the homeserver and start listening for
|
// Optional Matrix bot: connect to the homeserver and start listening for
|
||||||
// messages if `.story_kit/bot.toml` is present and enabled.
|
// messages if `.story_kit/bot.toml` is present and enabled.
|
||||||
if let Some(ref root) = startup_root {
|
if let Some(ref root) = startup_root {
|
||||||
matrix::spawn_bot(root, watcher_tx_for_bot);
|
matrix::spawn_bot(root, watcher_tx_for_bot, perm_rx_for_bot);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On startup:
|
// On startup:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
@@ -14,12 +15,14 @@ use matrix_sdk::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
use pulldown_cmark::{Options, Parser, html};
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Duration;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::{mpsc, oneshot, watch};
|
||||||
|
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use matrix_sdk::encryption::verification::{
|
use matrix_sdk::encryption::verification::{
|
||||||
@@ -34,7 +37,8 @@ use super::config::BotConfig;
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Role of a participant in the conversation history.
|
/// Role of a participant in the conversation history.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum ConversationRole {
|
pub enum ConversationRole {
|
||||||
/// A message sent by a Matrix room participant.
|
/// A message sent by a Matrix room participant.
|
||||||
User,
|
User,
|
||||||
@@ -43,7 +47,7 @@ pub enum ConversationRole {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A single turn in the per-room conversation history.
|
/// A single turn in the per-room conversation history.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct ConversationEntry {
|
pub struct ConversationEntry {
|
||||||
pub role: ConversationRole,
|
pub role: ConversationRole,
|
||||||
/// Matrix user ID (e.g. `@alice:example.com`). Empty for assistant turns.
|
/// Matrix user ID (e.g. `@alice:example.com`). Empty for assistant turns.
|
||||||
@@ -51,11 +55,81 @@ pub struct ConversationEntry {
|
|||||||
pub content: String,
|
pub content: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-room conversation history, keyed by room ID.
|
/// Per-room state: conversation entries plus the Claude Code session ID for
|
||||||
|
/// structured conversation resumption.
|
||||||
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct RoomConversation {
|
||||||
|
/// Claude Code session ID used to resume multi-turn conversations so the
|
||||||
|
/// LLM receives prior turns as structured API messages rather than a
|
||||||
|
/// flattened text prefix.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
/// Rolling conversation entries (used for turn counting and persistence).
|
||||||
|
pub entries: Vec<ConversationEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-room conversation state, keyed by room ID (serialised as string).
|
||||||
///
|
///
|
||||||
/// Wrapped in `Arc<TokioMutex<…>>` so it can be shared across concurrent
|
/// Wrapped in `Arc<TokioMutex<…>>` so it can be shared across concurrent
|
||||||
/// event-handler tasks without blocking the sync loop.
|
/// event-handler tasks without blocking the sync loop.
|
||||||
pub type ConversationHistory = Arc<TokioMutex<HashMap<OwnedRoomId, Vec<ConversationEntry>>>>;
|
pub type ConversationHistory = Arc<TokioMutex<HashMap<OwnedRoomId, RoomConversation>>>;
|
||||||
|
|
||||||
|
/// On-disk format for persisted conversation history. Room IDs are stored as
|
||||||
|
/// strings because `OwnedRoomId` does not implement `Serialize` as a map key.
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct PersistedHistory {
|
||||||
|
rooms: HashMap<String, RoomConversation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path to the persisted conversation history file relative to project root.
|
||||||
|
const HISTORY_FILE: &str = ".story_kit/matrix_history.json";
|
||||||
|
|
||||||
|
/// Load conversation history from disk, returning an empty map on any error.
|
||||||
|
pub fn load_history(project_root: &std::path::Path) -> HashMap<OwnedRoomId, RoomConversation> {
|
||||||
|
let path = project_root.join(HISTORY_FILE);
|
||||||
|
let data = match std::fs::read_to_string(&path) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return HashMap::new(),
|
||||||
|
};
|
||||||
|
let persisted: PersistedHistory = match serde_json::from_str(&data) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[matrix-bot] Failed to parse history file: {e}");
|
||||||
|
return HashMap::new();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
persisted
|
||||||
|
.rooms
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(k, v)| {
|
||||||
|
k.parse::<OwnedRoomId>()
|
||||||
|
.ok()
|
||||||
|
.map(|room_id| (room_id, v))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save conversation history to disk. Errors are logged but not propagated.
|
||||||
|
pub fn save_history(
|
||||||
|
project_root: &std::path::Path,
|
||||||
|
history: &HashMap<OwnedRoomId, RoomConversation>,
|
||||||
|
) {
|
||||||
|
let persisted = PersistedHistory {
|
||||||
|
rooms: history
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||||
|
.collect(),
|
||||||
|
};
|
||||||
|
let path = project_root.join(HISTORY_FILE);
|
||||||
|
match serde_json::to_string_pretty(&persisted) {
|
||||||
|
Ok(json) => {
|
||||||
|
if let Err(e) = std::fs::write(&path, json) {
|
||||||
|
slog!("[matrix-bot] Failed to write history file: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => slog!("[matrix-bot] Failed to serialise history: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bot context
|
// Bot context
|
||||||
@@ -77,6 +151,32 @@ pub struct BotContext {
|
|||||||
/// bot so it can continue a conversation thread without requiring an
|
/// bot so it can continue a conversation thread without requiring an
|
||||||
/// explicit `@mention` on every follow-up.
|
/// explicit `@mention` on every follow-up.
|
||||||
pub bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
|
pub bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
|
||||||
|
/// Receiver for permission requests from the MCP `prompt_permission` tool.
|
||||||
|
/// During an active chat the bot locks this to poll for incoming requests.
|
||||||
|
pub perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
|
/// Per-room pending permission reply senders. When a permission prompt is
|
||||||
|
/// posted to a room the oneshot sender is stored here; when the user
|
||||||
|
/// replies (yes/no) the event handler resolves it.
|
||||||
|
pub pending_perm_replies:
|
||||||
|
Arc<TokioMutex<HashMap<OwnedRoomId, oneshot::Sender<PermissionDecision>>>>,
|
||||||
|
/// How long to wait for a user to respond to a permission prompt before
|
||||||
|
/// denying (fail-closed).
|
||||||
|
pub permission_timeout_secs: u64,
|
||||||
|
/// The name the bot uses to refer to itself. Derived from `display_name`
|
||||||
|
/// in bot.toml; defaults to "Assistant" when unset.
|
||||||
|
pub bot_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Startup announcement
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Format the startup greeting the bot sends to each room when it comes online.
|
||||||
|
///
|
||||||
|
/// Uses the bot's configured display name so the message reads naturally
|
||||||
|
/// (e.g. "Timmy is online.").
|
||||||
|
pub fn format_startup_announcement(bot_name: &str) -> String {
|
||||||
|
format!("{bot_name} is online.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -90,6 +190,7 @@ pub async fn run_bot(
|
|||||||
config: BotConfig,
|
config: BotConfig,
|
||||||
project_root: PathBuf,
|
project_root: PathBuf,
|
||||||
watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
|
watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
|
||||||
|
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let store_path = project_root.join(".story_kit").join("matrix_store");
|
let store_path = project_root.join(".story_kit").join("matrix_store");
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
@@ -216,19 +317,36 @@ pub async fn run_bot(
|
|||||||
target_room_ids
|
target_room_ids
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clone values needed by the notification listener before they are moved
|
// Clone values needed by the notification listener and startup announcement
|
||||||
// into BotContext.
|
// before they are moved into BotContext.
|
||||||
let notif_room_ids = target_room_ids.clone();
|
let notif_room_ids = target_room_ids.clone();
|
||||||
let notif_project_root = project_root.clone();
|
let notif_project_root = project_root.clone();
|
||||||
|
let announce_room_ids = target_room_ids.clone();
|
||||||
|
|
||||||
|
let persisted = load_history(&project_root);
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Loaded persisted conversation history for {} room(s)",
|
||||||
|
persisted.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let bot_name = config
|
||||||
|
.display_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "Assistant".to_string());
|
||||||
|
let announce_bot_name = bot_name.clone();
|
||||||
|
|
||||||
let ctx = BotContext {
|
let ctx = BotContext {
|
||||||
bot_user_id,
|
bot_user_id,
|
||||||
target_room_ids,
|
target_room_ids,
|
||||||
project_root,
|
project_root,
|
||||||
allowed_users: config.allowed_users,
|
allowed_users: config.allowed_users,
|
||||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
history: Arc::new(TokioMutex::new(persisted)),
|
||||||
history_size: config.history_size,
|
history_size: config.history_size,
|
||||||
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||||
|
perm_rx,
|
||||||
|
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
|
permission_timeout_secs: config.permission_timeout_secs,
|
||||||
|
bot_name,
|
||||||
};
|
};
|
||||||
|
|
||||||
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected");
|
||||||
@@ -247,6 +365,23 @@ pub async fn run_bot(
|
|||||||
notif_project_root,
|
notif_project_root,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Send a startup announcement to each configured room so users know the
|
||||||
|
// bot is online. This runs once per process start — the sync loop handles
|
||||||
|
// reconnects internally so this code is never reached again on a network
|
||||||
|
// blip or sync resumption.
|
||||||
|
let announce_msg = format_startup_announcement(&announce_bot_name);
|
||||||
|
slog!("[matrix-bot] Sending startup announcement: {announce_msg}");
|
||||||
|
for room_id in &announce_room_ids {
|
||||||
|
if let Some(room) = client.get_room(room_id) {
|
||||||
|
let content = RoomMessageEventContent::text_plain(announce_msg.clone());
|
||||||
|
if let Err(e) = room.send(content).await {
|
||||||
|
slog!("[matrix-bot] Failed to send startup announcement to {room_id}: {e}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog!("[matrix-bot] Room {room_id} not found in client state, skipping announcement");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
slog!("[matrix-bot] Starting Matrix sync loop");
|
slog!("[matrix-bot] Starting Matrix sync loop");
|
||||||
|
|
||||||
// This blocks until the connection is terminated or an error occurs.
|
// This blocks until the connection is terminated or an error occurs.
|
||||||
@@ -262,6 +397,24 @@ pub async fn run_bot(
|
|||||||
// Address-filtering helpers
|
// Address-filtering helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns `true` if the message body is an affirmative permission response.
|
||||||
|
///
|
||||||
|
/// Recognised affirmative tokens (case-insensitive): `yes`, `y`, `approve`,
|
||||||
|
/// `allow`, `ok`. Anything else — including ambiguous text — is treated as
|
||||||
|
/// denial (fail-closed).
|
||||||
|
fn is_permission_approval(body: &str) -> bool {
|
||||||
|
// Strip a leading @mention (e.g. "@timmy yes") so the bot name doesn't
|
||||||
|
// interfere with the check.
|
||||||
|
let trimmed = body
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('@')
|
||||||
|
.split_whitespace()
|
||||||
|
.last()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
matches!(trimmed.as_str(), "yes" | "y" | "approve" | "allow" | "ok")
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` if the message mentions the bot.
|
/// Returns `true` if the message mentions the bot.
|
||||||
///
|
///
|
||||||
/// Checks both the plain-text `body` and an optional `formatted_body` (HTML).
|
/// Checks both the plain-text `body` and an optional `formatted_body` (HTML).
|
||||||
@@ -559,6 +712,33 @@ async fn on_room_message(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there is a pending permission prompt for this room, interpret the
|
||||||
|
// message as a yes/no response instead of starting a new chat.
|
||||||
|
{
|
||||||
|
let mut pending = ctx.pending_perm_replies.lock().await;
|
||||||
|
if let Some(tx) = pending.remove(&incoming_room_id) {
|
||||||
|
let decision = if is_permission_approval(&body) {
|
||||||
|
PermissionDecision::Approve
|
||||||
|
} else {
|
||||||
|
PermissionDecision::Deny
|
||||||
|
};
|
||||||
|
let _ = tx.send(decision);
|
||||||
|
let confirmation = if decision == PermissionDecision::Approve {
|
||||||
|
"Permission approved."
|
||||||
|
} else {
|
||||||
|
"Permission denied."
|
||||||
|
};
|
||||||
|
let html = markdown_to_html(confirmation);
|
||||||
|
if let Ok(resp) = room
|
||||||
|
.send(RoomMessageEventContent::text_html(confirmation, html))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(resp.event_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let sender = ev.sender.to_string();
|
let sender = ev.sender.to_string();
|
||||||
let user_message = body;
|
let user_message = body;
|
||||||
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
slog!("[matrix-bot] Message from {sender}: {user_message}");
|
||||||
@@ -574,33 +754,10 @@ async fn on_room_message(
|
|||||||
// Message handler
|
// Message handler
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Build a context string from the room's conversation history to prepend to
|
/// Build the user-facing prompt for a single turn. In multi-user rooms the
|
||||||
/// the user's current message. Returns an empty string when history is empty.
|
/// sender is included so the LLM can distinguish participants.
|
||||||
fn build_context_prefix(
|
fn format_user_prompt(sender: &str, message: &str) -> String {
|
||||||
history: &[ConversationEntry],
|
format!("{sender}: {message}")
|
||||||
current_sender: &str,
|
|
||||||
current_message: &str,
|
|
||||||
) -> String {
|
|
||||||
if history.is_empty() {
|
|
||||||
return format!("{current_sender}: {current_message}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = String::from("[Conversation history for this room]\n");
|
|
||||||
for entry in history {
|
|
||||||
match entry.role {
|
|
||||||
ConversationRole::User => {
|
|
||||||
out.push_str(&format!("User ({}): {}\n", entry.sender, entry.content));
|
|
||||||
}
|
|
||||||
ConversationRole::Assistant => {
|
|
||||||
out.push_str(&format!("Assistant: {}\n", entry.content));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push('\n');
|
|
||||||
out.push_str(&format!(
|
|
||||||
"Current message from {current_sender}: {current_message}"
|
|
||||||
));
|
|
||||||
out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_message(
|
async fn handle_message(
|
||||||
@@ -610,14 +767,23 @@ async fn handle_message(
|
|||||||
sender: String,
|
sender: String,
|
||||||
user_message: String,
|
user_message: String,
|
||||||
) {
|
) {
|
||||||
// Read current history for this room before calling the LLM.
|
// Look up the room's existing Claude Code session ID (if any) so we can
|
||||||
let history_snapshot: Vec<ConversationEntry> = {
|
// resume the conversation with structured API messages instead of
|
||||||
|
// flattening history into a text prefix.
|
||||||
|
let resume_session_id: Option<String> = {
|
||||||
let guard = ctx.history.lock().await;
|
let guard = ctx.history.lock().await;
|
||||||
guard.get(&room_id).cloned().unwrap_or_default()
|
guard
|
||||||
|
.get(&room_id)
|
||||||
|
.and_then(|conv| conv.session_id.clone())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build the prompt with conversation context.
|
// The prompt is just the current message with sender attribution.
|
||||||
let prompt_with_context = build_context_prefix(&history_snapshot, &sender, &user_message);
|
// Prior conversation context is carried by the Claude Code session.
|
||||||
|
let bot_name = &ctx.bot_name;
|
||||||
|
let prompt = format!(
|
||||||
|
"[Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.]\n\n{}",
|
||||||
|
format_user_prompt(&sender, &user_message)
|
||||||
|
);
|
||||||
|
|
||||||
let provider = ClaudeCodeProvider::new();
|
let provider = ClaudeCodeProvider::new();
|
||||||
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
let (cancel_tx, mut cancel_rx) = watch::channel(false);
|
||||||
@@ -632,6 +798,7 @@ async fn handle_message(
|
|||||||
// block the LLM stream while waiting for Matrix send round-trips.
|
// block the LLM stream while waiting for Matrix send round-trips.
|
||||||
let post_room = room.clone();
|
let post_room = room.clone();
|
||||||
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
let sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
||||||
|
let sent_ids_for_post = Arc::clone(&sent_ids);
|
||||||
let post_task = tokio::spawn(async move {
|
let post_task = tokio::spawn(async move {
|
||||||
while let Some(chunk) = msg_rx.recv().await {
|
while let Some(chunk) = msg_rx.recv().await {
|
||||||
let html = markdown_to_html(&chunk);
|
let html = markdown_to_html(&chunk);
|
||||||
@@ -639,7 +806,7 @@ async fn handle_message(
|
|||||||
.send(RoomMessageEventContent::text_html(chunk, html))
|
.send(RoomMessageEventContent::text_html(chunk, html))
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
sent_ids.lock().await.insert(response.event_id);
|
sent_ids_for_post.lock().await.insert(response.event_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -650,34 +817,96 @@ async fn handle_message(
|
|||||||
let sent_any_chunk = Arc::new(AtomicBool::new(false));
|
let sent_any_chunk = Arc::new(AtomicBool::new(false));
|
||||||
let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk);
|
let sent_any_chunk_for_callback = Arc::clone(&sent_any_chunk);
|
||||||
|
|
||||||
let result = provider
|
let project_root_str = ctx.project_root.to_string_lossy().to_string();
|
||||||
.chat_stream(
|
let chat_fut = provider.chat_stream(
|
||||||
&prompt_with_context,
|
&prompt,
|
||||||
&ctx.project_root.to_string_lossy(),
|
&project_root_str,
|
||||||
None, // Each Matrix conversation turn is independent at the Claude Code session level.
|
resume_session_id.as_deref(),
|
||||||
&mut cancel_rx,
|
None,
|
||||||
move |token| {
|
&mut cancel_rx,
|
||||||
let mut buf = buffer_for_callback.lock().unwrap();
|
move |token| {
|
||||||
buf.push_str(token);
|
let mut buf = buffer_for_callback.lock().unwrap();
|
||||||
// Flush complete paragraphs as they arrive.
|
buf.push_str(token);
|
||||||
let paragraphs = drain_complete_paragraphs(&mut buf);
|
// Flush complete paragraphs as they arrive.
|
||||||
for chunk in paragraphs {
|
let paragraphs = drain_complete_paragraphs(&mut buf);
|
||||||
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
|
for chunk in paragraphs {
|
||||||
let _ = msg_tx_for_callback.send(chunk);
|
sent_any_chunk_for_callback.store(true, Ordering::Relaxed);
|
||||||
|
let _ = msg_tx_for_callback.send(chunk);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|_thinking| {}, // Discard thinking tokens
|
||||||
|
|_activity| {}, // Discard activity signals
|
||||||
|
);
|
||||||
|
tokio::pin!(chat_fut);
|
||||||
|
|
||||||
|
// Lock the permission receiver for the duration of this chat session.
|
||||||
|
// Permission requests from the MCP `prompt_permission` tool arrive here.
|
||||||
|
let mut perm_rx_guard = ctx.perm_rx.lock().await;
|
||||||
|
|
||||||
|
let result = loop {
|
||||||
|
tokio::select! {
|
||||||
|
r = &mut chat_fut => break r,
|
||||||
|
|
||||||
|
Some(perm_fwd) = perm_rx_guard.recv() => {
|
||||||
|
// Post the permission prompt to the Matrix room.
|
||||||
|
let prompt_msg = format!(
|
||||||
|
"**Permission Request**\n\n\
|
||||||
|
Tool: `{}`\n```json\n{}\n```\n\n\
|
||||||
|
Reply **yes** to approve or **no** to deny.",
|
||||||
|
perm_fwd.tool_name,
|
||||||
|
serde_json::to_string_pretty(&perm_fwd.tool_input)
|
||||||
|
.unwrap_or_else(|_| perm_fwd.tool_input.to_string()),
|
||||||
|
);
|
||||||
|
let html = markdown_to_html(&prompt_msg);
|
||||||
|
if let Ok(resp) = room
|
||||||
|
.send(RoomMessageEventContent::text_html(&prompt_msg, html))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
sent_ids.lock().await.insert(resp.event_id);
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|_thinking| {}, // Discard thinking tokens
|
// Store the MCP oneshot sender so the event handler can
|
||||||
|_activity| {}, // Discard activity signals
|
// resolve it when the user replies yes/no.
|
||||||
)
|
ctx.pending_perm_replies
|
||||||
.await;
|
.lock()
|
||||||
|
.await
|
||||||
|
.insert(room_id.clone(), perm_fwd.response_tx);
|
||||||
|
|
||||||
|
// Spawn a timeout task: auto-deny if the user does not respond.
|
||||||
|
let pending = Arc::clone(&ctx.pending_perm_replies);
|
||||||
|
let timeout_room_id = room_id.clone();
|
||||||
|
let timeout_room = room.clone();
|
||||||
|
let timeout_sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
|
||||||
|
let timeout_secs = ctx.permission_timeout_secs;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
|
||||||
|
if let Some(tx) = pending.lock().await.remove(&timeout_room_id) {
|
||||||
|
let _ = tx.send(PermissionDecision::Deny);
|
||||||
|
let msg = "Permission request timed out — denied (fail-closed).";
|
||||||
|
let html = markdown_to_html(msg);
|
||||||
|
if let Ok(resp) = timeout_room
|
||||||
|
.send(RoomMessageEventContent::text_html(msg, html))
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
timeout_sent_ids.lock().await.insert(resp.event_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
drop(perm_rx_guard);
|
||||||
|
|
||||||
// Flush any remaining text that didn't end with a paragraph boundary.
|
// Flush any remaining text that didn't end with a paragraph boundary.
|
||||||
let remaining = buffer.lock().unwrap().trim().to_string();
|
let remaining = buffer.lock().unwrap().trim().to_string();
|
||||||
let did_send_any = sent_any_chunk.load(Ordering::Relaxed);
|
let did_send_any = sent_any_chunk.load(Ordering::Relaxed);
|
||||||
|
|
||||||
let assistant_reply = match result {
|
let (assistant_reply, new_session_id) = match result {
|
||||||
Ok(ClaudeCodeResult { messages, .. }) => {
|
Ok(ClaudeCodeResult {
|
||||||
if !remaining.is_empty() {
|
messages,
|
||||||
|
session_id,
|
||||||
|
}) => {
|
||||||
|
let reply = if !remaining.is_empty() {
|
||||||
let _ = msg_tx.send(remaining.clone());
|
let _ = msg_tx.send(remaining.clone());
|
||||||
remaining
|
remaining
|
||||||
} else if !did_send_any {
|
} else if !did_send_any {
|
||||||
@@ -696,13 +925,15 @@ async fn handle_message(
|
|||||||
last_text
|
last_text
|
||||||
} else {
|
} else {
|
||||||
remaining
|
remaining
|
||||||
}
|
};
|
||||||
|
slog!("[matrix-bot] session_id from chat_stream: {:?}", session_id);
|
||||||
|
(reply, session_id)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[matrix-bot] LLM error: {e}");
|
slog!("[matrix-bot] LLM error: {e}");
|
||||||
let err_msg = format!("Error processing your request: {e}");
|
let err_msg = format!("Error processing your request: {e}");
|
||||||
let _ = msg_tx.send(err_msg.clone());
|
let _ = msg_tx.send(err_msg.clone());
|
||||||
err_msg
|
(err_msg, None)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -711,25 +942,40 @@ async fn handle_message(
|
|||||||
drop(msg_tx);
|
drop(msg_tx);
|
||||||
let _ = post_task.await;
|
let _ = post_task.await;
|
||||||
|
|
||||||
// Record this exchange in the per-room conversation history.
|
// Record this exchange in the per-room conversation history and persist
|
||||||
|
// the session ID so the next turn resumes with structured API messages.
|
||||||
if !assistant_reply.starts_with("Error processing") {
|
if !assistant_reply.starts_with("Error processing") {
|
||||||
let mut guard = ctx.history.lock().await;
|
let mut guard = ctx.history.lock().await;
|
||||||
let entries = guard.entry(room_id).or_default();
|
let conv = guard.entry(room_id).or_default();
|
||||||
entries.push(ConversationEntry {
|
|
||||||
|
// Store the session ID so the next turn uses --resume.
|
||||||
|
slog!("[matrix-bot] storing session_id: {:?} (was: {:?})", new_session_id, conv.session_id);
|
||||||
|
if new_session_id.is_some() {
|
||||||
|
conv.session_id = new_session_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
role: ConversationRole::User,
|
role: ConversationRole::User,
|
||||||
sender: sender.clone(),
|
sender: sender.clone(),
|
||||||
content: user_message,
|
content: user_message,
|
||||||
});
|
});
|
||||||
entries.push(ConversationEntry {
|
conv.entries.push(ConversationEntry {
|
||||||
role: ConversationRole::Assistant,
|
role: ConversationRole::Assistant,
|
||||||
sender: String::new(),
|
sender: String::new(),
|
||||||
content: assistant_reply,
|
content: assistant_reply,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trim to the configured maximum, dropping the oldest entries first.
|
// Trim to the configured maximum, dropping the oldest entries first.
|
||||||
if entries.len() > ctx.history_size {
|
// The session_id is preserved: Claude Code's --resume loads the full
|
||||||
let excess = entries.len() - ctx.history_size;
|
// conversation from its own session transcript on disk, so trimming
|
||||||
entries.drain(..excess);
|
// our local tracking doesn't affect the LLM's context.
|
||||||
|
if conv.entries.len() > ctx.history_size {
|
||||||
|
let excess = conv.entries.len() - ctx.history_size;
|
||||||
|
conv.entries.drain(..excess);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persist to disk so history survives server restarts.
|
||||||
|
save_history(&ctx.project_root, &guard);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,6 +1238,7 @@ mod tests {
|
|||||||
fn bot_context_has_no_require_verified_devices_field() {
|
fn bot_context_has_no_require_verified_devices_field() {
|
||||||
// Verification is always on — BotContext no longer has a toggle field.
|
// Verification is always on — BotContext no longer has a toggle field.
|
||||||
// This test verifies the struct can be constructed and cloned without it.
|
// This test verifies the struct can be constructed and cloned without it.
|
||||||
|
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
|
||||||
let ctx = BotContext {
|
let ctx = BotContext {
|
||||||
bot_user_id: make_user_id("@bot:example.com"),
|
bot_user_id: make_user_id("@bot:example.com"),
|
||||||
target_room_ids: vec![],
|
target_room_ids: vec![],
|
||||||
@@ -1000,6 +1247,10 @@ mod tests {
|
|||||||
history: Arc::new(TokioMutex::new(HashMap::new())),
|
history: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
history_size: 20,
|
history_size: 20,
|
||||||
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
|
||||||
|
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
|
||||||
|
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
|
permission_timeout_secs: 120,
|
||||||
|
bot_name: "Assistant".to_string(),
|
||||||
};
|
};
|
||||||
// Clone must work (required by Matrix SDK event handler injection).
|
// Clone must work (required by Matrix SDK event handler injection).
|
||||||
let _cloned = ctx.clone();
|
let _cloned = ctx.clone();
|
||||||
@@ -1128,62 +1379,18 @@ mod tests {
|
|||||||
assert_eq!(buf, "Third.");
|
assert_eq!(buf, "Third.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- build_context_prefix -----------------------------------------------
|
// -- format_user_prompt -------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_context_prefix_empty_history() {
|
fn format_user_prompt_includes_sender_and_message() {
|
||||||
let prefix = build_context_prefix(&[], "@alice:example.com", "Hello!");
|
let prompt = format_user_prompt("@alice:example.com", "Hello!");
|
||||||
assert_eq!(prefix, "@alice:example.com: Hello!");
|
assert_eq!(prompt, "@alice:example.com: Hello!");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_context_prefix_includes_history_entries() {
|
fn format_user_prompt_different_users() {
|
||||||
let history = vec![
|
let prompt = format_user_prompt("@bob:example.com", "What's up?");
|
||||||
ConversationEntry {
|
assert_eq!(prompt, "@bob:example.com: What's up?");
|
||||||
role: ConversationRole::User,
|
|
||||||
sender: "@alice:example.com".to_string(),
|
|
||||||
content: "What is story 42?".to_string(),
|
|
||||||
},
|
|
||||||
ConversationEntry {
|
|
||||||
role: ConversationRole::Assistant,
|
|
||||||
sender: String::new(),
|
|
||||||
content: "Story 42 is about…".to_string(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let prefix = build_context_prefix(&history, "@bob:example.com", "Tell me more.");
|
|
||||||
assert!(prefix.contains("[Conversation history for this room]"));
|
|
||||||
assert!(prefix.contains("User (@alice:example.com): What is story 42?"));
|
|
||||||
assert!(prefix.contains("Assistant: Story 42 is about…"));
|
|
||||||
assert!(prefix.contains("Current message from @bob:example.com: Tell me more."));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn build_context_prefix_attributes_multiple_users() {
|
|
||||||
let history = vec![
|
|
||||||
ConversationEntry {
|
|
||||||
role: ConversationRole::User,
|
|
||||||
sender: "@alice:example.com".to_string(),
|
|
||||||
content: "First question".to_string(),
|
|
||||||
},
|
|
||||||
ConversationEntry {
|
|
||||||
role: ConversationRole::Assistant,
|
|
||||||
sender: String::new(),
|
|
||||||
content: "First answer".to_string(),
|
|
||||||
},
|
|
||||||
ConversationEntry {
|
|
||||||
role: ConversationRole::User,
|
|
||||||
sender: "@bob:example.com".to_string(),
|
|
||||||
content: "Follow-up".to_string(),
|
|
||||||
},
|
|
||||||
ConversationEntry {
|
|
||||||
role: ConversationRole::Assistant,
|
|
||||||
sender: String::new(),
|
|
||||||
content: "Second answer".to_string(),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
let prefix = build_context_prefix(&history, "@alice:example.com", "Another question");
|
|
||||||
assert!(prefix.contains("User (@alice:example.com): First question"));
|
|
||||||
assert!(prefix.contains("User (@bob:example.com): Follow-up"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- conversation history trimming --------------------------------------
|
// -- conversation history trimming --------------------------------------
|
||||||
@@ -1197,37 +1404,44 @@ mod tests {
|
|||||||
// Add 6 entries (3 user + 3 assistant turns).
|
// Add 6 entries (3 user + 3 assistant turns).
|
||||||
{
|
{
|
||||||
let mut guard = history.lock().await;
|
let mut guard = history.lock().await;
|
||||||
let entries = guard.entry(room_id.clone()).or_default();
|
let conv = guard.entry(room_id.clone()).or_default();
|
||||||
|
conv.session_id = Some("test-session".to_string());
|
||||||
for i in 0..3usize {
|
for i in 0..3usize {
|
||||||
entries.push(ConversationEntry {
|
conv.entries.push(ConversationEntry {
|
||||||
role: ConversationRole::User,
|
role: ConversationRole::User,
|
||||||
sender: "@user:example.com".to_string(),
|
sender: "@user:example.com".to_string(),
|
||||||
content: format!("msg {i}"),
|
content: format!("msg {i}"),
|
||||||
});
|
});
|
||||||
entries.push(ConversationEntry {
|
conv.entries.push(ConversationEntry {
|
||||||
role: ConversationRole::Assistant,
|
role: ConversationRole::Assistant,
|
||||||
sender: String::new(),
|
sender: String::new(),
|
||||||
content: format!("reply {i}"),
|
content: format!("reply {i}"),
|
||||||
});
|
});
|
||||||
if entries.len() > history_size {
|
if conv.entries.len() > history_size {
|
||||||
let excess = entries.len() - history_size;
|
let excess = conv.entries.len() - history_size;
|
||||||
entries.drain(..excess);
|
conv.entries.drain(..excess);
|
||||||
|
conv.session_id = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let guard = history.lock().await;
|
let guard = history.lock().await;
|
||||||
let entries = guard.get(&room_id).unwrap();
|
let conv = guard.get(&room_id).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
entries.len(),
|
conv.entries.len(),
|
||||||
history_size,
|
history_size,
|
||||||
"history must be trimmed to history_size"
|
"history must be trimmed to history_size"
|
||||||
);
|
);
|
||||||
// The oldest entries (msg 0 / reply 0) should have been dropped.
|
// The oldest entries (msg 0 / reply 0) should have been dropped.
|
||||||
assert!(
|
assert!(
|
||||||
entries.iter().all(|e| !e.content.contains("msg 0")),
|
conv.entries.iter().all(|e| !e.content.contains("msg 0")),
|
||||||
"oldest entries must be dropped"
|
"oldest entries must be dropped"
|
||||||
);
|
);
|
||||||
|
// Session ID must be cleared when trimming occurs.
|
||||||
|
assert!(
|
||||||
|
conv.session_id.is_none(),
|
||||||
|
"session_id must be cleared on trim to start a fresh session"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1241,6 +1455,7 @@ mod tests {
|
|||||||
guard
|
guard
|
||||||
.entry(room_a.clone())
|
.entry(room_a.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
|
.entries
|
||||||
.push(ConversationEntry {
|
.push(ConversationEntry {
|
||||||
role: ConversationRole::User,
|
role: ConversationRole::User,
|
||||||
sender: "@alice:example.com".to_string(),
|
sender: "@alice:example.com".to_string(),
|
||||||
@@ -1249,6 +1464,7 @@ mod tests {
|
|||||||
guard
|
guard
|
||||||
.entry(room_b.clone())
|
.entry(room_b.clone())
|
||||||
.or_default()
|
.or_default()
|
||||||
|
.entries
|
||||||
.push(ConversationEntry {
|
.push(ConversationEntry {
|
||||||
role: ConversationRole::User,
|
role: ConversationRole::User,
|
||||||
sender: "@bob:example.com".to_string(),
|
sender: "@bob:example.com".to_string(),
|
||||||
@@ -1257,12 +1473,131 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let guard = history.lock().await;
|
let guard = history.lock().await;
|
||||||
let entries_a = guard.get(&room_a).unwrap();
|
let conv_a = guard.get(&room_a).unwrap();
|
||||||
let entries_b = guard.get(&room_b).unwrap();
|
let conv_b = guard.get(&room_b).unwrap();
|
||||||
assert_eq!(entries_a.len(), 1);
|
assert_eq!(conv_a.entries.len(), 1);
|
||||||
assert_eq!(entries_b.len(), 1);
|
assert_eq!(conv_b.entries.len(), 1);
|
||||||
assert_eq!(entries_a[0].content, "Room A message");
|
assert_eq!(conv_a.entries[0].content, "Room A message");
|
||||||
assert_eq!(entries_b[0].content, "Room B message");
|
assert_eq!(conv_b.entries[0].content, "Room B message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- persistence --------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn save_and_load_history_round_trip() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let story_kit_dir = dir.path().join(".story_kit");
|
||||||
|
std::fs::create_dir_all(&story_kit_dir).unwrap();
|
||||||
|
|
||||||
|
let room_id: OwnedRoomId = "!persist:example.com".parse().unwrap();
|
||||||
|
let mut map: HashMap<OwnedRoomId, RoomConversation> = HashMap::new();
|
||||||
|
let conv = map.entry(room_id.clone()).or_default();
|
||||||
|
conv.session_id = Some("session-abc".to_string());
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::User,
|
||||||
|
sender: "@alice:example.com".to_string(),
|
||||||
|
content: "hello".to_string(),
|
||||||
|
});
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::Assistant,
|
||||||
|
sender: String::new(),
|
||||||
|
content: "hi there!".to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
save_history(dir.path(), &map);
|
||||||
|
|
||||||
|
let loaded = load_history(dir.path());
|
||||||
|
let loaded_conv = loaded.get(&room_id).expect("room must exist after load");
|
||||||
|
assert_eq!(loaded_conv.session_id.as_deref(), Some("session-abc"));
|
||||||
|
assert_eq!(loaded_conv.entries.len(), 2);
|
||||||
|
assert_eq!(loaded_conv.entries[0].role, ConversationRole::User);
|
||||||
|
assert_eq!(loaded_conv.entries[0].sender, "@alice:example.com");
|
||||||
|
assert_eq!(loaded_conv.entries[0].content, "hello");
|
||||||
|
assert_eq!(loaded_conv.entries[1].role, ConversationRole::Assistant);
|
||||||
|
assert_eq!(loaded_conv.entries[1].content, "hi there!");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_history_returns_empty_on_missing_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let loaded = load_history(dir.path());
|
||||||
|
assert!(loaded.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_history_returns_empty_on_corrupt_file() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let story_kit_dir = dir.path().join(".story_kit");
|
||||||
|
std::fs::create_dir_all(&story_kit_dir).unwrap();
|
||||||
|
std::fs::write(dir.path().join(HISTORY_FILE), "not valid json").unwrap();
|
||||||
|
let loaded = load_history(dir.path());
|
||||||
|
assert!(loaded.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- session_id tracking ------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn session_id_preserved_within_history_size() {
|
||||||
|
let history: ConversationHistory = Arc::new(TokioMutex::new(HashMap::new()));
|
||||||
|
let room_id: OwnedRoomId = "!session:example.com".parse().unwrap();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = history.lock().await;
|
||||||
|
let conv = guard.entry(room_id.clone()).or_default();
|
||||||
|
conv.session_id = Some("sess-1".to_string());
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::User,
|
||||||
|
sender: "@alice:example.com".to_string(),
|
||||||
|
content: "hello".to_string(),
|
||||||
|
});
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::Assistant,
|
||||||
|
sender: String::new(),
|
||||||
|
content: "hi".to_string(),
|
||||||
|
});
|
||||||
|
// No trimming needed (2 entries, well under any reasonable limit).
|
||||||
|
}
|
||||||
|
|
||||||
|
let guard = history.lock().await;
|
||||||
|
let conv = guard.get(&room_id).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
conv.session_id.as_deref(),
|
||||||
|
Some("sess-1"),
|
||||||
|
"session_id must be preserved when no trimming occurs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- multi-user room attribution ----------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multi_user_entries_preserve_sender() {
|
||||||
|
let history: ConversationHistory = Arc::new(TokioMutex::new(HashMap::new()));
|
||||||
|
let room_id: OwnedRoomId = "!multi:example.com".parse().unwrap();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = history.lock().await;
|
||||||
|
let conv = guard.entry(room_id.clone()).or_default();
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::User,
|
||||||
|
sender: "@alice:example.com".to_string(),
|
||||||
|
content: "from alice".to_string(),
|
||||||
|
});
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::Assistant,
|
||||||
|
sender: String::new(),
|
||||||
|
content: "reply to alice".to_string(),
|
||||||
|
});
|
||||||
|
conv.entries.push(ConversationEntry {
|
||||||
|
role: ConversationRole::User,
|
||||||
|
sender: "@bob:example.com".to_string(),
|
||||||
|
content: "from bob".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let guard = history.lock().await;
|
||||||
|
let conv = guard.get(&room_id).unwrap();
|
||||||
|
assert_eq!(conv.entries[0].sender, "@alice:example.com");
|
||||||
|
assert_eq!(conv.entries[2].sender, "@bob:example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- self-sign device key decision logic -----------------------------------
|
// -- self-sign device key decision logic -----------------------------------
|
||||||
@@ -1319,4 +1654,80 @@ mod tests {
|
|||||||
"user with no cross-signing setup should be rejected"
|
"user with no cross-signing setup should be rejected"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- is_permission_approval -----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_permission_approval_accepts_yes_variants() {
|
||||||
|
assert!(is_permission_approval("yes"));
|
||||||
|
assert!(is_permission_approval("Yes"));
|
||||||
|
assert!(is_permission_approval("YES"));
|
||||||
|
assert!(is_permission_approval("y"));
|
||||||
|
assert!(is_permission_approval("Y"));
|
||||||
|
assert!(is_permission_approval("approve"));
|
||||||
|
assert!(is_permission_approval("allow"));
|
||||||
|
assert!(is_permission_approval("ok"));
|
||||||
|
assert!(is_permission_approval("OK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_permission_approval_denies_no_and_other() {
|
||||||
|
assert!(!is_permission_approval("no"));
|
||||||
|
assert!(!is_permission_approval("No"));
|
||||||
|
assert!(!is_permission_approval("n"));
|
||||||
|
assert!(!is_permission_approval("deny"));
|
||||||
|
assert!(!is_permission_approval("reject"));
|
||||||
|
assert!(!is_permission_approval("maybe"));
|
||||||
|
assert!(!is_permission_approval(""));
|
||||||
|
assert!(!is_permission_approval("yes please do it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_permission_approval_strips_at_mention_prefix() {
|
||||||
|
assert!(is_permission_approval("@timmy yes"));
|
||||||
|
assert!(!is_permission_approval("@timmy no"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_permission_approval_handles_whitespace() {
|
||||||
|
assert!(is_permission_approval(" yes "));
|
||||||
|
assert!(is_permission_approval("\tyes\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- format_startup_announcement ----------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_announcement_uses_bot_name() {
|
||||||
|
assert_eq!(format_startup_announcement("Timmy"), "Timmy is online.");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn startup_announcement_uses_configured_display_name_not_hardcoded() {
|
||||||
|
assert_eq!(format_startup_announcement("HAL"), "HAL is online.");
|
||||||
|
assert_eq!(format_startup_announcement("Assistant"), "Assistant is online.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- bot_name / system prompt -------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bot_name_system_prompt_format() {
|
||||||
|
let bot_name = "Timmy";
|
||||||
|
let system_prompt =
|
||||||
|
format!("Your name is {bot_name}. Refer to yourself as {bot_name}, not Claude.");
|
||||||
|
assert_eq!(
|
||||||
|
system_prompt,
|
||||||
|
"Your name is Timmy. Refer to yourself as Timmy, not Claude."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bot_name_defaults_to_assistant_when_display_name_absent() {
|
||||||
|
// When display_name is not set in bot.toml, bot_name should be "Assistant".
|
||||||
|
// This mirrors the logic in run_bot: config.display_name.clone().unwrap_or_else(...)
|
||||||
|
fn resolve_bot_name(display_name: Option<String>) -> String {
|
||||||
|
display_name.unwrap_or_else(|| "Assistant".to_string())
|
||||||
|
}
|
||||||
|
assert_eq!(resolve_bot_name(None), "Assistant");
|
||||||
|
assert_eq!(resolve_bot_name(Some("Timmy".to_string())), "Timmy");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ fn default_history_size() -> usize {
|
|||||||
20
|
20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_permission_timeout_secs() -> u64 {
|
||||||
|
120
|
||||||
|
}
|
||||||
|
|
||||||
/// Configuration for the Matrix bot, read from `.story_kit/bot.toml`.
|
/// Configuration for the Matrix bot, read from `.story_kit/bot.toml`.
|
||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
pub struct BotConfig {
|
pub struct BotConfig {
|
||||||
@@ -35,11 +39,20 @@ pub struct BotConfig {
|
|||||||
/// dropped. Defaults to 20.
|
/// dropped. Defaults to 20.
|
||||||
#[serde(default = "default_history_size")]
|
#[serde(default = "default_history_size")]
|
||||||
pub history_size: usize,
|
pub history_size: usize,
|
||||||
|
/// Timeout in seconds for permission prompts surfaced to the Matrix room.
|
||||||
|
/// If the user does not respond within this window the permission is denied
|
||||||
|
/// (fail-closed). Defaults to 120 seconds.
|
||||||
|
#[serde(default = "default_permission_timeout_secs")]
|
||||||
|
pub permission_timeout_secs: u64,
|
||||||
/// Previously used to select an Anthropic model. Now ignored — the bot
|
/// Previously used to select an Anthropic model. Now ignored — the bot
|
||||||
/// uses Claude Code which manages its own model selection. Kept for
|
/// uses Claude Code which manages its own model selection. Kept for
|
||||||
/// backwards compatibility so existing bot.toml files still parse.
|
/// backwards compatibility so existing bot.toml files still parse.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
|
/// Display name the bot uses to identify itself in conversations.
|
||||||
|
/// If unset, the bot falls back to "Assistant".
|
||||||
|
#[serde(default)]
|
||||||
|
pub display_name: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BotConfig {
|
impl BotConfig {
|
||||||
@@ -256,6 +269,88 @@ history_size = 50
|
|||||||
assert_eq!(config.history_size, 50);
|
assert_eq!(config.history_size, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_reads_display_name() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_ids = ["!abc:example.com"]
|
||||||
|
enabled = true
|
||||||
|
display_name = "Timmy"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.display_name.as_deref(), Some("Timmy"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_display_name_defaults_to_none_when_absent() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_ids = ["!abc:example.com"]
|
||||||
|
enabled = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert!(config.display_name.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_uses_default_permission_timeout() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_ids = ["!abc:example.com"]
|
||||||
|
enabled = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.permission_timeout_secs, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_respects_custom_permission_timeout() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".story_kit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
room_ids = ["!abc:example.com"]
|
||||||
|
enabled = true
|
||||||
|
permission_timeout_secs = 60
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.permission_timeout_secs, 60);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_ignores_legacy_require_verified_devices_key() {
|
fn load_ignores_legacy_require_verified_devices_key() {
|
||||||
// Old bot.toml files that still have `require_verified_devices = true`
|
// Old bot.toml files that still have `require_verified_devices = true`
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ pub mod notifications;
|
|||||||
|
|
||||||
pub use config::BotConfig;
|
pub use config::BotConfig;
|
||||||
|
|
||||||
|
use crate::http::context::PermissionForward;
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::sync::broadcast;
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::{Mutex as TokioMutex, broadcast, mpsc};
|
||||||
|
|
||||||
/// Attempt to start the Matrix bot.
|
/// Attempt to start the Matrix bot.
|
||||||
///
|
///
|
||||||
@@ -35,8 +37,16 @@ use tokio::sync::broadcast;
|
|||||||
/// posts stage-transition messages to all configured rooms whenever a work
|
/// posts stage-transition messages to all configured rooms whenever a work
|
||||||
/// item moves between pipeline stages.
|
/// item moves between pipeline stages.
|
||||||
///
|
///
|
||||||
|
/// `perm_rx` is the permission-request receiver shared with the MCP
|
||||||
|
/// `prompt_permission` tool. The bot locks it during active chat sessions
|
||||||
|
/// to surface permission prompts to the Matrix room and relay user decisions.
|
||||||
|
///
|
||||||
/// Must be called from within a Tokio runtime context (e.g., from `main`).
|
/// Must be called from within a Tokio runtime context (e.g., from `main`).
|
||||||
pub fn spawn_bot(project_root: &Path, watcher_tx: broadcast::Sender<WatcherEvent>) {
|
pub fn spawn_bot(
|
||||||
|
project_root: &Path,
|
||||||
|
watcher_tx: broadcast::Sender<WatcherEvent>,
|
||||||
|
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
|
||||||
|
) {
|
||||||
let config = match BotConfig::load(project_root) {
|
let config = match BotConfig::load(project_root) {
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
None => {
|
None => {
|
||||||
@@ -54,7 +64,7 @@ pub fn spawn_bot(project_root: &Path, watcher_tx: broadcast::Sender<WatcherEvent
|
|||||||
let root = project_root.to_path_buf();
|
let root = project_root.to_path_buf();
|
||||||
let watcher_rx = watcher_tx.subscribe();
|
let watcher_rx = watcher_tx.subscribe();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = bot::run_bot(config, root, watcher_rx).await {
|
if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx).await {
|
||||||
crate::slog!("[matrix-bot] Fatal error: {e}");
|
crate::slog!("[matrix-bot] Fatal error: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user