Compare commits
68 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
618a2779ff | ||
|
|
721d12bcfe | ||
|
|
df6d2db327 | ||
|
|
49285c1865 | ||
|
|
0c15be43b8 | ||
|
|
9408bd2cdf | ||
|
|
a24e4c5c85 | ||
|
|
c0133fe733 | ||
|
|
752c3904bf | ||
|
|
bac53ac09a | ||
|
|
b2ef2eca5f | ||
|
|
fb05f71e76 | ||
|
|
438be196c9 | ||
|
|
f1b4894d6e | ||
|
|
bd281fd749 | ||
|
|
79edc28334 | ||
|
|
92c53704f0 | ||
|
|
7223fa2f10 | ||
|
|
dedf951b17 | ||
|
|
aad583defd | ||
|
|
88b02cf746 | ||
|
|
1a9833d820 | ||
|
|
a904cda629 | ||
|
|
c755c03f0e | ||
|
|
a8630f3e1b | ||
|
|
9fb1bd5711 | ||
|
|
0b3ce0f33e | ||
|
|
f4b7573f0a | ||
|
|
bb801ba826 | ||
|
|
53634d638d | ||
|
|
b50e7cff00 | ||
|
|
68973b0bb8 | ||
|
|
34bbf5a122 | ||
|
|
ed3c5f9c95 | ||
|
|
59d1a2c069 | ||
|
|
52e73bfbea | ||
|
|
4e590401a5 | ||
|
|
6b6815325d | ||
|
|
f874783b09 | ||
|
|
292f9cdfe2 | ||
|
|
1cce46d3fa | ||
|
|
e85c06df19 | ||
|
|
8b85ca743e | ||
|
|
1a7b6c7342 | ||
|
|
4a94158ef2 | ||
|
|
f10ea1ecf2 | ||
|
|
1a3b69301a | ||
|
|
6d3eab92fd | ||
|
|
f6920a87ad | ||
|
|
5f9d903987 | ||
|
|
ea916d27f4 | ||
|
|
970b9bcd9d | ||
|
|
a5ee6890f5 | ||
|
|
41dc3292bb | ||
|
|
3766f8b464 | ||
|
|
0c85ecc85c | ||
|
|
2c29a4d2b8 | ||
|
|
454d694d24 | ||
|
|
96bedd70dc | ||
|
|
fffdd5c5ea | ||
|
|
4805598932 | ||
|
|
3d55e2fcc6 | ||
|
|
96b31d1a48 | ||
|
|
11168fa426 | ||
|
|
c2c2d65889 | ||
|
|
5c8c4b7ff3 | ||
|
|
fbab93f493 | ||
|
|
78ff6d104e |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@
|
|||||||
# App specific (root-level; storkit subdirectory patterns live in .storkit/.gitignore)
|
# App specific (root-level; storkit subdirectory patterns live in .storkit/.gitignore)
|
||||||
store.json
|
store.json
|
||||||
.storkit_port
|
.storkit_port
|
||||||
|
.storkit/bot.toml.bak
|
||||||
|
|
||||||
# Rust stuff
|
# Rust stuff
|
||||||
target
|
target
|
||||||
|
|||||||
3
.storkit/.gitignore
vendored
3
.storkit/.gitignore
vendored
@@ -20,3 +20,6 @@ coverage/
|
|||||||
|
|
||||||
# Token usage log (generated at runtime, contains cost data)
|
# Token usage log (generated at runtime, contains cost data)
|
||||||
token_usage.jsonl
|
token_usage.jsonl
|
||||||
|
|
||||||
|
# Chat service logs
|
||||||
|
whatsapp_history.json
|
||||||
|
|||||||
@@ -228,7 +228,29 @@ If a user hands you this document and says "Apply this process to my project":
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. Code Quality
|
## 6. Chat Bot Configuration
|
||||||
|
|
||||||
|
Story Kit includes a chat bot that can be connected to one messaging platform at a time. The bot handles commands, LLM conversations, and pipeline notifications.
|
||||||
|
|
||||||
|
**Only one transport can be active at a time.** To configure the bot, copy the appropriate example file to `.storkit/bot.toml`:
|
||||||
|
|
||||||
|
| Transport | Example file | Webhook endpoint |
|
||||||
|
|-----------|-------------|-----------------|
|
||||||
|
| Matrix | `bot.toml.matrix.example` | *(uses Matrix sync, no webhook)* |
|
||||||
|
| WhatsApp (Meta Cloud API) | `bot.toml.whatsapp-meta.example` | `/webhook/whatsapp` |
|
||||||
|
| WhatsApp (Twilio) | `bot.toml.whatsapp-twilio.example` | `/webhook/whatsapp` |
|
||||||
|
| Slack | `bot.toml.slack.example` | `/webhook/slack` |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .storkit/bot.toml.matrix.example .storkit/bot.toml
|
||||||
|
# Edit bot.toml with your credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
The `bot.toml` file is gitignored (it contains secrets). The example files are checked in for reference.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Code Quality
|
||||||
|
|
||||||
**MANDATORY:** Before completing Step 3 (Verification) of any story, you MUST run all applicable linters, formatters, and test suites and fix ALL errors and warnings. Zero tolerance for warnings or errors.
|
**MANDATORY:** Before completing Step 3 (Verification) of any story, you MUST run all applicable linters, formatters, and test suites and fix ALL errors and warnings. Zero tolerance for warnings or errors.
|
||||||
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
homeserver = "https://matrix.example.com"
|
|
||||||
username = "@botname:example.com"
|
|
||||||
password = "your-bot-password"
|
|
||||||
|
|
||||||
# List one or more rooms to listen in. Use a single-element list for one room.
|
|
||||||
room_ids = ["!roomid:example.com"]
|
|
||||||
|
|
||||||
# Optional: the deprecated single-room key is still accepted for backwards compat.
|
|
||||||
# room_id = "!roomid:example.com"
|
|
||||||
|
|
||||||
allowed_users = ["@youruser:example.com"]
|
|
||||||
enabled = false
|
|
||||||
|
|
||||||
# Maximum conversation turns to remember per room (default: 20).
|
|
||||||
# history_size = 20
|
|
||||||
|
|
||||||
# Rooms where the bot responds to all messages (not just addressed ones).
|
|
||||||
# This list is updated automatically when users toggle ambient mode at runtime.
|
|
||||||
# ambient_rooms = ["!roomid:example.com"]
|
|
||||||
|
|
||||||
# ── WhatsApp Business API ──────────────────────────────────────────────
|
|
||||||
# Set transport = "whatsapp" to use WhatsApp instead of Matrix.
|
|
||||||
# The webhook endpoint will be available at /webhook/whatsapp.
|
|
||||||
# You must configure this URL in the Meta Developer Dashboard.
|
|
||||||
#
|
|
||||||
# transport = "whatsapp"
|
|
||||||
# whatsapp_phone_number_id = "123456789012345"
|
|
||||||
# whatsapp_access_token = "EAAx..."
|
|
||||||
# whatsapp_verify_token = "my-secret-verify-token"
|
|
||||||
#
|
|
||||||
# ── 24-hour messaging window & notification templates ─────────────────
|
|
||||||
# WhatsApp only allows free-form text messages within 24 hours of the last
|
|
||||||
# inbound message from a user. For proactive pipeline notifications sent
|
|
||||||
# after the window expires, an approved Meta message template is used.
|
|
||||||
#
|
|
||||||
# Register the template in the Meta Business Manager:
|
|
||||||
# 1. Go to Business Settings → WhatsApp → Message Templates → Create.
|
|
||||||
# 2. Category: UTILITY
|
|
||||||
# 3. Template name: pipeline_notification (or your chosen name below)
|
|
||||||
# 4. Language: English (en_US)
|
|
||||||
# 5. Body text (example):
|
|
||||||
# Story *{{1}}* has moved to *{{2}}*.
|
|
||||||
# Where {{1}} = story name, {{2}} = pipeline stage.
|
|
||||||
# 6. Submit for review. Meta typically approves utility templates within
|
|
||||||
# minutes; transactional categories may take longer.
|
|
||||||
#
|
|
||||||
# Once approved, set the name below (default: "pipeline_notification"):
|
|
||||||
# whatsapp_notification_template = "pipeline_notification"
|
|
||||||
|
|
||||||
# ── Slack Bot API ─────────────────────────────────────────────────────
|
|
||||||
# Set transport = "slack" to use Slack instead of Matrix.
|
|
||||||
# The webhook endpoint will be available at /webhook/slack.
|
|
||||||
# Configure this URL in the Slack App → Event Subscriptions → Request URL.
|
|
||||||
#
|
|
||||||
# Required Slack App scopes: chat:write, chat:update
|
|
||||||
# Subscribe to bot events: message.channels, message.groups, message.im
|
|
||||||
#
|
|
||||||
# transport = "slack"
|
|
||||||
# slack_bot_token = "xoxb-..."
|
|
||||||
# slack_signing_secret = "your-signing-secret"
|
|
||||||
# slack_channel_ids = ["C01ABCDEF"]
|
|
||||||
26
.storkit/bot.toml.matrix.example
Normal file
26
.storkit/bot.toml.matrix.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Matrix Transport
|
||||||
|
# Copy this file to bot.toml and fill in your values.
|
||||||
|
# Only one transport can be active at a time.
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
transport = "matrix"
|
||||||
|
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@botname:example.com"
|
||||||
|
password = "your-bot-password"
|
||||||
|
|
||||||
|
# List one or more rooms to listen in.
|
||||||
|
room_ids = ["!roomid:example.com"]
|
||||||
|
|
||||||
|
# Users allowed to interact with the bot (fail-closed: empty = nobody).
|
||||||
|
allowed_users = ["@youruser:example.com"]
|
||||||
|
|
||||||
|
# Bot display name in chat.
|
||||||
|
# display_name = "Assistant"
|
||||||
|
|
||||||
|
# Maximum conversation turns to remember per room (default: 20).
|
||||||
|
# history_size = 20
|
||||||
|
|
||||||
|
# Rooms where the bot responds to all messages (not just addressed ones).
|
||||||
|
# This list is updated automatically when users toggle ambient mode at runtime.
|
||||||
|
# ambient_rooms = ["!roomid:example.com"]
|
||||||
23
.storkit/bot.toml.slack.example
Normal file
23
.storkit/bot.toml.slack.example
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Slack Transport
|
||||||
|
# Copy this file to bot.toml and fill in your values.
|
||||||
|
# Only one transport can be active at a time.
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. Create a Slack App at api.slack.com/apps
|
||||||
|
# 2. Add OAuth scopes: chat:write, chat:update
|
||||||
|
# 3. Subscribe to bot events: message.channels, message.groups, message.im
|
||||||
|
# 4. Install the app to your workspace
|
||||||
|
# 5. Set your webhook URL in Event Subscriptions: https://your-server/webhook/slack
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
transport = "slack"
|
||||||
|
|
||||||
|
slack_bot_token = "xoxb-..."
|
||||||
|
slack_signing_secret = "your-signing-secret"
|
||||||
|
slack_channel_ids = ["C01ABCDEF"]
|
||||||
|
|
||||||
|
# Bot display name (used in formatted messages).
|
||||||
|
# display_name = "Assistant"
|
||||||
|
|
||||||
|
# Maximum conversation turns to remember per channel (default: 20).
|
||||||
|
# history_size = 20
|
||||||
28
.storkit/bot.toml.whatsapp-meta.example
Normal file
28
.storkit/bot.toml.whatsapp-meta.example
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# WhatsApp Transport (Meta Cloud API)
|
||||||
|
# Copy this file to bot.toml and fill in your values.
|
||||||
|
# Only one transport can be active at a time.
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. Create a Meta Business App at developers.facebook.com
|
||||||
|
# 2. Add the WhatsApp product
|
||||||
|
# 3. Copy your Phone Number ID and generate a permanent access token
|
||||||
|
# 4. Register your webhook URL: https://your-server/webhook/whatsapp
|
||||||
|
# 5. Set the verify token below to match what you configure in Meta's dashboard
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_provider = "meta"
|
||||||
|
|
||||||
|
whatsapp_phone_number_id = "123456789012345"
|
||||||
|
whatsapp_access_token = "EAAx..."
|
||||||
|
whatsapp_verify_token = "my-secret-verify-token"
|
||||||
|
|
||||||
|
# Optional: name of the approved Meta message template used for notifications
|
||||||
|
# sent outside the 24-hour messaging window (default: "pipeline_notification").
|
||||||
|
# whatsapp_notification_template = "pipeline_notification"
|
||||||
|
|
||||||
|
# Bot display name (used in formatted messages).
|
||||||
|
# display_name = "Assistant"
|
||||||
|
|
||||||
|
# Maximum conversation turns to remember per user (default: 20).
|
||||||
|
# history_size = 20
|
||||||
24
.storkit/bot.toml.whatsapp-twilio.example
Normal file
24
.storkit/bot.toml.whatsapp-twilio.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# WhatsApp Transport (Twilio)
|
||||||
|
# Copy this file to bot.toml and fill in your values.
|
||||||
|
# Only one transport can be active at a time.
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. Sign up at twilio.com
|
||||||
|
# 2. Activate the WhatsApp sandbox (Messaging > Try it out > Send a WhatsApp message)
|
||||||
|
# 3. Send the sandbox join code from your WhatsApp to the sandbox number
|
||||||
|
# 4. Copy your Account SID, Auth Token, and sandbox number below
|
||||||
|
# 5. Set your webhook URL in the Twilio console: https://your-server/webhook/whatsapp
|
||||||
|
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_provider = "twilio"
|
||||||
|
|
||||||
|
twilio_account_sid = "ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||||
|
twilio_auth_token = "your_auth_token"
|
||||||
|
twilio_whatsapp_number = "+14155238886"
|
||||||
|
|
||||||
|
# Bot display name (used in formatted messages).
|
||||||
|
# display_name = "Assistant"
|
||||||
|
|
||||||
|
# Maximum conversation turns to remember per user (default: 20).
|
||||||
|
# history_size = 20
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "WhatsApp transport supports Twilio API as alternative to Meta Cloud API"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 382: WhatsApp transport supports Twilio API as alternative to Meta Cloud API
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user, I want to use Twilio's WhatsApp API instead of Meta's Cloud API directly, so that I can avoid Meta's painful developer onboarding and use Twilio's simpler signup process.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] bot.toml supports a `whatsapp_provider` field with values `meta` (default, current behavior) or `twilio`
|
||||||
|
- [ ] When provider is `twilio`, messages are sent via Twilio's REST API (`api.twilio.com`) using Account SID + Auth Token
|
||||||
|
- [ ] When provider is `twilio`, inbound webhooks parse Twilio's form-encoded format instead of Meta's JSON
|
||||||
|
- [ ] Twilio config requires `twilio_account_sid`, `twilio_auth_token`, and `twilio_whatsapp_number` in bot.toml
|
||||||
|
- [ ] All existing bot commands and LLM passthrough work identically regardless of provider
|
||||||
|
- [ ] 24-hour messaging window logic still applies (Twilio enforces this server-side too)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
name: "Reorganize chat system into chat module with transport submodules"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor 383: Reorganize chat system into chat module with transport submodules
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
Currently chat-related code is scattered at the top level of `src/`: `transport.rs`, `whatsapp.rs`, `slack.rs`, plus `matrix/` as a directory module. This should be reorganized into a clean module hierarchy:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
chat/
|
||||||
|
mod.rs # Generic chat traits, types, ChatTransport etc.
|
||||||
|
transport/
|
||||||
|
mod.rs
|
||||||
|
matrix/ # Existing matrix module moved here
|
||||||
|
whatsapp.rs # Existing whatsapp.rs moved here
|
||||||
|
slack.rs # Existing slack.rs moved here
|
||||||
|
twilio.rs # Future Twilio transport
|
||||||
|
```
|
||||||
|
|
||||||
|
The `ChatTransport` trait and shared chat types should live in `chat/mod.rs`. Each transport implementation becomes a submodule of `chat::transport`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] ChatTransport trait and shared chat types live in `chat/mod.rs`
|
||||||
|
- [ ] Matrix transport lives in `chat/transport/matrix/`
|
||||||
|
- [ ] WhatsApp transport lives in `chat/transport/whatsapp.rs`
|
||||||
|
- [ ] Slack transport lives in `chat/transport/slack.rs`
|
||||||
|
- [ ] Top-level `transport.rs`, `whatsapp.rs`, `slack.rs`, and `matrix/` are removed
|
||||||
|
- [ ] All existing tests pass without modification (or with only import path changes)
|
||||||
|
- [ ] No functional changes — pure file reorganization and re-exports
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: "Web UI OAuth flow for Claude authentication"
|
name: "Web UI OAuth flow for Claude authentication"
|
||||||
|
agent: "coder-opus"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Story 368: Web UI OAuth flow for Claude authentication
|
# Story 368: Web UI OAuth flow for Claude authentication
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: "No-arg storkit in empty directory skips scaffold"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 371: No-arg storkit in empty directory skips scaffold
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When running `storkit` with no path argument from an empty directory (no `.storkit/`), the server starts but never calls `open_project` or the scaffold. The `find_story_kit_root` check fails to find `.storkit/`, so the fallback at main.rs:179-186 just sets `project_root = cwd` without scaffolding. This means no `.storkit/`, no `project.toml`, no `.mcp.json`, no `CLAUDE.md` — the project is non-functional.
|
||||||
|
|
||||||
|
The explicit path branch (`storkit .`) works correctly because it calls `open_project` → `ensure_project_root_with_story_kit` → `scaffold_story_kit`. The no-arg branch should do the same.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Create a new empty directory
|
||||||
|
2. cd into it
|
||||||
|
3. Run `storkit` (no path argument)
|
||||||
|
4. Observe that no scaffold is created — `.storkit/`, `CLAUDE.md`, `.mcp.json`, etc. are all missing
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Server starts with project_root set to cwd but no scaffold runs. The project is non-functional — no agent config, no MCP endpoint, no work pipeline directories.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
Running `storkit` with no arguments from a directory without `.storkit/` should scaffold the project the same as `storkit .` does — calling `open_project` and triggering `ensure_project_root_with_story_kit`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Running `storkit` with no args from a dir without `.storkit/` calls `open_project` and triggers the full scaffold
|
||||||
|
- [ ] The no-arg fallback path in main.rs calls `open_project(cwd)` instead of just setting project_root directly
|
||||||
|
- [ ] After `storkit` completes startup, `.storkit/project.toml`, `.mcp.json`, `CLAUDE.md`, and `script/test` all exist
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: "Scaffold auto-detects tech stack and configures script/test"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 372: Scaffold auto-detects tech stack and configures script/test
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user setting up a new project with storkit, I want the scaffold to detect my project's tech stack and generate a working `script/test` automatically, so that agents can run tests immediately without manual configuration.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Scaffold detects Go projects (go.mod) and adds `go test ./...` to script/test
|
||||||
|
- [ ] Scaffold detects Node.js projects (package.json) and adds `npm test` to script/test
|
||||||
|
- [ ] Scaffold detects Rust projects (Cargo.toml) and adds `cargo test` to script/test
|
||||||
|
- [ ] Scaffold detects Python projects (pyproject.toml or requirements.txt) and adds `pytest` to script/test
|
||||||
|
- [ ] Scaffold handles multi-stack projects (e.g. Go + Next.js) by combining the relevant test commands
|
||||||
|
- [ ] project.toml component entries are generated to match detected tech stack
|
||||||
|
- [ ] Falls back to the generic 'No tests configured' stub if no known stack is detected
|
||||||
|
- [ ] Coder agent prompt includes instruction to configure `script/test` for the project's test framework if it still contains the generic stub
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "Scaffold gitignore missing transient pipeline stage directories"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 373: Scaffold gitignore missing transient pipeline stage directories
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `write_story_kit_gitignore` function in `server/src/io/fs.rs` does not include the transient pipeline stages (`work/2_current/`, `work/3_qa/`, `work/4_merge/`) in the `.storkit/.gitignore` entries list. These stages are not committed to git (only `1_backlog`, `5_done`, and `6_archived` are commit-worthy per spike 92), so they should be ignored for new projects.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Scaffold a new project with storkit
|
||||||
|
2. Check `.storkit/.gitignore`
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
`.storkit/.gitignore` only contains `bot.toml`, `matrix_store/`, `matrix_device_id`, `worktrees/`, `merge_workspace/`, `coverage/`. The transient pipeline directories are missing.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
`.storkit/.gitignore` also includes `work/2_current/`, `work/3_qa/`, `work/4_merge/`.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Scaffold writes work/2_current/, work/3_qa/, work/4_merge/ to .storkit/.gitignore
|
||||||
|
- [ ] Idempotent — running scaffold again does not duplicate entries
|
||||||
|
- [ ] Existing .storkit/.gitignore files get the new entries appended on next scaffold run
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: "Web UI implements all bot commands as slash commands"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 374: Web UI implements all bot commands as slash commands
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user working in the storkit web UI, I want to type slash commands (e.g. `/status`, `/start 42`, `/cost`) in the chat input to trigger the same deterministic bot commands available in Matrix, so that I can manage my project entirely from the browser without needing a chat bot.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] /status — shows pipeline status and agent availability; /status <number> shows story triage dump
|
||||||
|
- [ ] /assign <number> <model> — pre-assign a model to a story
|
||||||
|
- [ ] /start <number> — start a coder on a story; /start <number> opus for specific model
|
||||||
|
- [ ] /show <number> — display full text of a work item
|
||||||
|
- [ ] /move <number> <stage> — move a work item to a pipeline stage
|
||||||
|
- [ ] /delete <number> — remove a work item from the pipeline
|
||||||
|
- [ ] /cost — show token spend (24h total, top stories, by agent type, all-time)
|
||||||
|
- [ ] /git — show git status (branch, uncommitted changes, ahead/behind)
|
||||||
|
- [ ] /overview <number> — show implementation summary for a merged story
|
||||||
|
- [ ] /rebuild — rebuild the server binary and restart
|
||||||
|
- [ ] /reset — clear the current Claude Code session
|
||||||
|
- [ ] /help — list all available slash commands
|
||||||
|
- [ ] Slash commands are handled at the frontend/backend level without LLM invocation
|
||||||
|
- [ ] Unrecognised slash commands show a helpful error message
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: "Default project.toml contains Rust-specific setup commands for non-Rust projects"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 375: Default project.toml contains Rust-specific setup commands for non-Rust projects
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When scaffolding a new project where no tech stack is detected, the generated `project.toml` contains Rust-specific setup commands (`cargo check`) as example fallback components. This causes coder agents to try to satisfy Rust gates on non-Rust projects.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
1. In `detect_components_toml()` fallback (when no stack markers found): replace the Rust/pnpm example components with a single generic `app` component with empty `setup = []`
|
||||||
|
2. In the onboarding prompt Step 4: simplify to configure `[[component]]` entries based on what the user told the LLM in Step 2 (tech stack), rather than re-scanning the filesystem independently
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Default project.toml does not contain language-specific setup commands when that language is not detected in the project
|
||||||
|
- [ ] If go.mod is present, setup commands use Go tooling
|
||||||
|
- [ ] If package.json is present, setup commands use npm/node tooling
|
||||||
|
- [ ] If no known stack is detected, setup commands are empty or just echo a placeholder
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Create a new Go + Next.js project directory with `go.mod` and `package.json`
|
||||||
|
2. Run `storkit .` to scaffold
|
||||||
|
3. Check `.storkit/project.toml` — the component setup commands reference cargo/Rust
|
||||||
|
4. Start a coder agent — it creates a `Cargo.toml` trying to satisfy the Rust setup commands
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
The scaffolded `project.toml` has Rust-specific setup commands (`cargo check`) even for non-Rust projects. Agents try to satisfy these and create spurious files.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
The scaffolded `project.toml` should have generic or stack-appropriate setup commands. If no known stack is detected, setup commands should be empty or minimal (not Rust-specific).
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Default project.toml does not contain language-specific setup commands when that language is not detected in the project
|
||||||
|
- [ ] If go.mod is present, setup commands use Go tooling
|
||||||
|
- [ ] If package.json is present, setup commands use npm/node tooling
|
||||||
|
- [ ] If no known stack is detected, setup commands are empty or just echo a placeholder
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Rename MCP whatsup tool to status for consistency"
|
||||||
|
agent: coder-opus
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 376: Rename MCP whatsup tool to status for consistency
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a developer using storkit's MCP tools, I want the MCP tool to be called `status` instead of `whatsup`, so that the naming is consistent between the bot command (`status`), the web UI slash command (`/status`), and the MCP tool.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] MCP tool is renamed from 'whatsup' to 'status'
|
||||||
|
- [ ] MCP tool is discoverable as 'status' via tools/list
|
||||||
|
- [ ] The tool still accepts a story_id parameter and returns the same triage data
|
||||||
|
- [ ] Old 'whatsup' tool name is removed from the MCP registry
|
||||||
|
- [ ] Any internal references to the whatsup tool name are updated
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
name: "update_story MCP tool writes front matter values as YAML strings instead of native types"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 377: update_story MCP tool writes front matter values as YAML strings instead of native types
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `update_story` MCP tool accepts `front_matter` as a `Map<String, String>`, so all values are written as quoted YAML strings. Fields like `retry_count` (expected `u32`) and `blocked` (expected `bool`) end up as `"0"` and `"false"` in the YAML. This causes `parse_front_matter()` to fail because serde_yaml cannot deserialize a quoted string into `u32` or `bool`. When parsing fails, the story `name` comes back as `None`, so the status command shows no title for the story.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Call `update_story` with `front_matter: {"blocked": "false", "retry_count": "0"}`
|
||||||
|
2. Read the story file — front matter contains `blocked: "false"` and `retry_count: "0"` (quoted strings)
|
||||||
|
3. Call `get_pipeline_status` or the bot `status` command
|
||||||
|
4. The story shows with no title/name
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Front matter values are written as quoted YAML strings. `parse_front_matter()` fails to deserialize `"false"` as `bool` and `"0"` as `u32`, returning an error. The story name is lost and the status command shows no title.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
The `update_story` tool should write `blocked` and `retry_count` as native YAML types (unquoted `false` and `0`), or `parse_front_matter()` should accept both string and native representations. The story name should always be displayed correctly in the status command.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] update_story with front_matter {"blocked": "false"} writes `blocked: false` (unquoted) in the YAML
|
||||||
|
- [ ] update_story with front_matter {"retry_count": "0"} writes `retry_count: 0` (unquoted) in the YAML
|
||||||
|
- [ ] Story name is displayed correctly in the status command after update_story modifies front matter fields
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Status command shows work item type (story, bug, spike, refactor) next to each item"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 378: Status command shows work item type (story, bug, spike, refactor) next to each item
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user viewing the pipeline status, I want to see the type of each work item (story, bug, spike, refactor) so that I can quickly understand what kind of work is in progress without having to open individual files.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] The status command displays the work item type (story, bug, spike, refactor) as a label next to each item — e.g. "375 [bug] — Default project.toml contains Rust-specific setup commands"
|
||||||
|
- [ ] The type is extracted from the story_id filename convention ({id}_{type}_{slug})
|
||||||
|
- [ ] All known types are supported: story, bug, spike, refactor
|
||||||
|
- [ ] Unknown or missing types are omitted gracefully (no crash, no placeholder)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
name: "start_agent ignores story front matter agent assignment"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 379: start_agent ignores story front matter agent assignment
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When a model is pre-assigned to a story via the `assign` command (which writes `agent: coder-opus` to the story's YAML front matter), the MCP `start_agent` tool ignores this field. It only looks at the `agent_name` argument passed directly in the tool call. If none is passed, it auto-selects the first idle coder (usually sonnet), bypassing the user's assignment.
|
||||||
|
|
||||||
|
The auto-assign pipeline (`auto_assign.rs`) correctly reads and respects the front matter `agent` field, but the direct `tool_start_agent` path in `agent_tools.rs` does not.
|
||||||
|
|
||||||
|
Additionally, the `show` (whatsup/triage) command should display the assigned agent from the story's front matter so users can verify their assignment took effect.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
1. Run `assign 368 opus` — this writes `agent: coder-opus` to story 368's front matter
|
||||||
|
2. Run `start 368` (without specifying a model)
|
||||||
|
3. Observe that a sonnet coder is assigned, not coder-opus
|
||||||
|
4. Run `show 368` — the assigned agent is not displayed
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
The `start_agent` MCP tool ignores the `agent` field in the story's front matter and picks the first idle coder. The `show` command does not display the pre-assigned agent.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
When no explicit `agent_name` is passed to `start_agent`, it should read the story's front matter `agent` field and use that agent if it's available. The `show` command should display the assigned agent from front matter.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] start_agent without an explicit agent_name reads the story's front matter `agent` field and uses it if the agent is idle
|
||||||
|
- [ ] If the preferred agent from front matter is busy, start_agent either waits or falls back to auto-selection (matching auto_assign behavior)
|
||||||
|
- [ ] The show/triage command displays the assigned agent from story front matter when present
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Assign command restarts coder when story is already in progress"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 380: Assign command restarts coder when story is already in progress
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user, I want `assign X opus` on a running story to stop the current coder, update the front matter, and start the newly assigned agent, so that I can switch models mid-flight without manually stopping and restarting.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] When assign is called on a story with a running coder, the current coder agent is stopped
|
||||||
|
- [ ] The story's front matter `agent` field is updated to the new agent name
|
||||||
|
- [ ] The newly assigned agent is started on the story automatically
|
||||||
|
- [ ] When assign is called on a story with no running coder, it behaves as before (just updates front matter)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: "Bot command to delete a worktree"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 381: Bot command to delete a worktree
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user, I want a bot command to delete a worktree so that I can clean up orphaned or unwanted worktrees without SSHing into the server.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] A new bot command (e.g. `rmtree <story_number>`) deletes the worktree for the given story
|
||||||
|
- [ ] The command stops any running agent on that story before removing the worktree
|
||||||
|
- [ ] The command returns a confirmation message on success
|
||||||
|
- [ ] The command returns a helpful error if no worktree exists for the given story
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
41
Cargo.lock
generated
41
Cargo.lock
generated
@@ -1954,9 +1954,9 @@ checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
version = "0.1.14"
|
version = "0.1.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.11.0",
|
"bitflags 2.11.0",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -3274,6 +3274,7 @@ dependencies = [
|
|||||||
"rustls-platform-verifier",
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -3823,9 +3824,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "1.0.4"
|
version = "1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
@@ -4016,7 +4017,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "storkit"
|
name = "storkit"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -4046,7 +4047,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite 0.29.0",
|
"tokio-tungstenite 0.29.0",
|
||||||
"toml 1.0.7+spec-1.1.0",
|
"toml 1.1.0+spec-1.1.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wait-timeout",
|
"wait-timeout",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -4393,14 +4394,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "1.0.7+spec-1.1.0"
|
version = "1.1.0+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96"
|
checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime 1.0.1+spec-1.1.0",
|
"toml_datetime 1.1.0+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"toml_writer",
|
"toml_writer",
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.0",
|
||||||
@@ -4417,39 +4418,39 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.0.1+spec-1.1.0"
|
version = "1.1.0+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9"
|
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_edit"
|
name = "toml_edit"
|
||||||
version = "0.25.5+spec-1.1.0"
|
version = "0.25.8+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1"
|
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"toml_datetime 1.0.1+spec-1.1.0",
|
"toml_datetime 1.1.0+spec-1.1.0",
|
||||||
"toml_parser",
|
"toml_parser",
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_parser"
|
name = "toml_parser"
|
||||||
version = "1.0.10+spec-1.1.0"
|
version = "1.1.0+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420"
|
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"winnow 1.0.0",
|
"winnow 1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_writer"
|
name = "toml_writer"
|
||||||
version = "1.0.7+spec-1.1.0"
|
version = "1.1.0+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d"
|
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
@@ -4660,9 +4661,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-segmentation"
|
name = "unicode-segmentation"
|
||||||
version = "1.12.0"
|
version = "1.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
|
checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-xid"
|
name = "unicode-xid"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ serde_yaml = "0.9"
|
|||||||
strip-ansi-escapes = "0.2"
|
strip-ansi-escapes = "0.2"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
|
||||||
toml = "1.0.7"
|
toml = "1.1.0"
|
||||||
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
uuid = { version = "1.22.0", features = ["v4", "serde"] }
|
||||||
tokio-tungstenite = "0.29.0"
|
tokio-tungstenite = "0.29.0"
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
@@ -35,6 +35,6 @@ matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
|||||||
"sqlite",
|
"sqlite",
|
||||||
"e2e-encryption",
|
"e2e-encryption",
|
||||||
] }
|
] }
|
||||||
pulldown-cmark = { version = "0.13.1", default-features = false, features = [
|
pulldown-cmark = { version = "0.13.3", default-features = false, features = [
|
||||||
"html",
|
"html",
|
||||||
] }
|
] }
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ services:
|
|||||||
- GIT_USER_EMAIL=${GIT_USER_EMAIL:?Set GIT_USER_EMAIL}
|
- GIT_USER_EMAIL=${GIT_USER_EMAIL:?Set GIT_USER_EMAIL}
|
||||||
# Optional: override the server port (default 3001)
|
# Optional: override the server port (default 3001)
|
||||||
- STORKIT_PORT=3001
|
- STORKIT_PORT=3001
|
||||||
|
# Bind to all interfaces so Docker port forwarding works.
|
||||||
|
- STORKIT_HOST=0.0.0.0
|
||||||
# Optional: Matrix bot credentials (if using Matrix integration)
|
# Optional: Matrix bot credentials (if using Matrix integration)
|
||||||
- MATRIX_HOMESERVER=${MATRIX_HOMESERVER:-}
|
- MATRIX_HOMESERVER=${MATRIX_HOMESERVER:-}
|
||||||
- MATRIX_USER=${MATRIX_USER:-}
|
- MATRIX_USER=${MATRIX_USER:-}
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.0",
|
"version": "0.6.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { defineConfig } from "@playwright/test";
|
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
const configDir = dirname(fileURLToPath(new URL(import.meta.url)));
|
const configDir = dirname(fileURLToPath(new URL(import.meta.url)));
|
||||||
const frontendRoot = resolve(configDir, ".");
|
const frontendRoot = resolve(configDir, ".");
|
||||||
|
|||||||
@@ -382,6 +382,14 @@ export const api = {
|
|||||||
deleteStory(storyId: string) {
|
deleteStory(storyId: string) {
|
||||||
return callMcpTool("delete_story", { story_id: storyId });
|
return callMcpTool("delete_story", { story_id: storyId });
|
||||||
},
|
},
|
||||||
|
/** Execute a bot slash command without LLM invocation. Returns markdown response text. */
|
||||||
|
botCommand(command: string, args: string, baseUrl?: string) {
|
||||||
|
return requestJson<{ response: string }>(
|
||||||
|
"/bot/command",
|
||||||
|
{ method: "POST", body: JSON.stringify({ command, args }) },
|
||||||
|
baseUrl,
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function callMcpTool(
|
async function callMcpTool(
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ vi.mock("../api/client", () => {
|
|||||||
setAnthropicApiKey: vi.fn(),
|
setAnthropicApiKey: vi.fn(),
|
||||||
readFile: vi.fn(),
|
readFile: vi.fn(),
|
||||||
listProjectFiles: vi.fn(),
|
listProjectFiles: vi.fn(),
|
||||||
|
botCommand: vi.fn(),
|
||||||
};
|
};
|
||||||
class ChatWebSocket {
|
class ChatWebSocket {
|
||||||
connect(handlers: WsHandlers) {
|
connect(handlers: WsHandlers) {
|
||||||
@@ -64,6 +65,7 @@ const mockedApi = {
|
|||||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||||
readFile: vi.mocked(api.readFile),
|
readFile: vi.mocked(api.readFile),
|
||||||
listProjectFiles: vi.mocked(api.listProjectFiles),
|
listProjectFiles: vi.mocked(api.listProjectFiles),
|
||||||
|
botCommand: vi.mocked(api.botCommand),
|
||||||
};
|
};
|
||||||
|
|
||||||
function setupMocks() {
|
function setupMocks() {
|
||||||
@@ -76,6 +78,7 @@ function setupMocks() {
|
|||||||
mockedApi.listProjectFiles.mockResolvedValue([]);
|
mockedApi.listProjectFiles.mockResolvedValue([]);
|
||||||
mockedApi.cancelChat.mockResolvedValue(true);
|
mockedApi.cancelChat.mockResolvedValue(true);
|
||||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||||
|
mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Default provider selection (Story 206)", () => {
|
describe("Default provider selection (Story 206)", () => {
|
||||||
@@ -1457,3 +1460,204 @@ describe("File reference expansion (Story 269 AC4)", () => {
|
|||||||
expect(mockedApi.readFile).not.toHaveBeenCalled();
|
expect(mockedApi.readFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Slash command handling (Story 374)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
capturedWsHandlers = null;
|
||||||
|
lastSendChatArgs = null;
|
||||||
|
setupMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: /status calls botCommand and displays response", async () => {
|
||||||
|
mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" });
|
||||||
|
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: "/status" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||||
|
"status",
|
||||||
|
"",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
|
||||||
|
// Should NOT go to LLM
|
||||||
|
expect(lastSendChatArgs).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: /status <number> passes args to botCommand", async () => {
|
||||||
|
mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
|
||||||
|
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: "/status 42" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||||
|
"status",
|
||||||
|
"42",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: /start <number> calls botCommand", async () => {
|
||||||
|
mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
|
||||||
|
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: "/start 42 opus" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.botCommand).toHaveBeenCalledWith(
|
||||||
|
"start",
|
||||||
|
"42 opus",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("Started agent")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: /git calls botCommand", async () => {
|
||||||
|
mockedApi.botCommand.mockResolvedValue({ response: "On branch main" });
|
||||||
|
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: "/git" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: /cost calls botCommand", async () => {
|
||||||
|
mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" });
|
||||||
|
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: "/cost" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: /reset clears messages and session without LLM", async () => {
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||||
|
|
||||||
|
// First add a message so there is history to clear
|
||||||
|
act(() => {
|
||||||
|
capturedWsHandlers?.onUpdate([
|
||||||
|
{ role: "user", content: "hello" },
|
||||||
|
{ role: "assistant", content: "world" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("world")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Send a message...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { value: "/reset" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// LLM must NOT be invoked
|
||||||
|
expect(lastSendChatArgs).toBeNull();
|
||||||
|
// botCommand must NOT be invoked (reset is frontend-only)
|
||||||
|
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
||||||
|
// Confirmation message should appear
|
||||||
|
expect(await screen.findByText(/Session reset/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: unrecognised slash command shows error message", 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: "/foobar" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Unknown command/)).toBeInTheDocument();
|
||||||
|
// Should NOT go to LLM
|
||||||
|
expect(lastSendChatArgs).toBeNull();
|
||||||
|
// Should NOT call botCommand
|
||||||
|
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: /help shows help overlay", 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: "/help" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("help-overlay")).toBeInTheDocument();
|
||||||
|
expect(lastSendChatArgs).toBeNull();
|
||||||
|
expect(mockedApi.botCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("AC: botCommand API error shows error message in chat", async () => {
|
||||||
|
mockedApi.botCommand.mockRejectedValue(new Error("Server error"));
|
||||||
|
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: "/git" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText(/Error running command/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -612,6 +612,80 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /reset — clear session and message history without LLM
|
||||||
|
if (/^\/reset\s*$/i.test(messageText)) {
|
||||||
|
setMessages([]);
|
||||||
|
setClaudeSessionId(null);
|
||||||
|
setStreamingContent("");
|
||||||
|
setStreamingThinking("");
|
||||||
|
setActivityStatus(null);
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "Session reset. Starting a fresh conversation.",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slash commands forwarded to the backend bot command endpoint
|
||||||
|
const slashMatch = messageText.match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
|
||||||
|
if (slashMatch) {
|
||||||
|
const cmd = slashMatch[1].toLowerCase();
|
||||||
|
const args = (slashMatch[2] ?? "").trim();
|
||||||
|
|
||||||
|
// Ignore commands handled elsewhere
|
||||||
|
if (cmd !== "btw") {
|
||||||
|
const knownCommands = new Set([
|
||||||
|
"status",
|
||||||
|
"assign",
|
||||||
|
"start",
|
||||||
|
"show",
|
||||||
|
"move",
|
||||||
|
"delete",
|
||||||
|
"cost",
|
||||||
|
"git",
|
||||||
|
"overview",
|
||||||
|
"rebuild",
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (knownCommands.has(cmd)) {
|
||||||
|
// Show the slash command in chat as a user message (display only)
|
||||||
|
setMessages((prev: Message[]) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", content: messageText },
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
const result = await api.botCommand(cmd, args, undefined);
|
||||||
|
setMessages((prev: Message[]) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "assistant", content: result.response },
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
setMessages((prev: Message[]) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: `**Error running command:** ${e}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown slash command
|
||||||
|
setMessages((prev: Message[]) => [
|
||||||
|
...prev,
|
||||||
|
{ role: "user", content: messageText },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: `Unknown command: \`/${cmd}\`. Type \`/help\` to see available commands.`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// /btw <question> — answered from context without disrupting main chat
|
// /btw <question> — answered from context without disrupting main chat
|
||||||
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
|
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
|
||||||
if (btwMatch) {
|
if (btwMatch) {
|
||||||
|
|||||||
@@ -12,6 +12,57 @@ const SLASH_COMMANDS: SlashCommand[] = [
|
|||||||
name: "/help",
|
name: "/help",
|
||||||
description: "Show this list of available slash commands.",
|
description: "Show this list of available slash commands.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "/status",
|
||||||
|
description:
|
||||||
|
"Show pipeline status and agent availability. `/status <number>` shows a story triage dump.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/assign <number> <model>",
|
||||||
|
description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/start <number>",
|
||||||
|
description:
|
||||||
|
"Start a coder on a story. Optionally specify a model: `/start <number> opus`.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/show <number>",
|
||||||
|
description: "Display the full text of a work item.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/move <number> <stage>",
|
||||||
|
description:
|
||||||
|
"Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/delete <number>",
|
||||||
|
description:
|
||||||
|
"Remove a work item from the pipeline and stop any running agent.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/cost",
|
||||||
|
description:
|
||||||
|
"Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/git",
|
||||||
|
description:
|
||||||
|
"Show git status: branch, uncommitted changes, and ahead/behind remote.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/overview <number>",
|
||||||
|
description: "Show the implementation summary for a merged story.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/rebuild",
|
||||||
|
description: "Rebuild the server binary and restart.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "/reset",
|
||||||
|
description:
|
||||||
|
"Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "/btw <question>",
|
name: "/btw <question>",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "storkit"
|
name = "storkit"
|
||||||
version = "0.5.0"
|
version = "0.6.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ notify = { workspace = true }
|
|||||||
poem = { workspace = true, features = ["websocket"] }
|
poem = { workspace = true, features = ["websocket"] }
|
||||||
poem-openapi = { workspace = true, features = ["swagger-ui"] }
|
poem-openapi = { workspace = true, features = ["swagger-ui"] }
|
||||||
portable-pty = { workspace = true }
|
portable-pty = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["json", "stream"] }
|
reqwest = { workspace = true, features = ["json", "stream", "form"] }
|
||||||
rust-embed = { workspace = true }
|
rust-embed = { workspace = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -102,13 +102,29 @@ fn run_command_with_timeout(
|
|||||||
args: &[&str],
|
args: &[&str],
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
) -> Result<(bool, String), String> {
|
) -> Result<(bool, String), String> {
|
||||||
let mut child = Command::new(program)
|
// On Linux, execve can return ETXTBSY (26) briefly after a file is written
|
||||||
.args(args)
|
// before the kernel releases its "write open" state. Retry once after a
|
||||||
|
// short pause to handle this race condition.
|
||||||
|
let mut last_err = None;
|
||||||
|
let mut cmd = Command::new(&program);
|
||||||
|
cmd.args(args)
|
||||||
.current_dir(dir)
|
.current_dir(dir)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped());
|
||||||
.spawn()
|
let mut child = loop {
|
||||||
.map_err(|e| format!("Failed to spawn command: {e}"))?;
|
match cmd.spawn() {
|
||||||
|
Ok(c) => break c,
|
||||||
|
Err(e) if e.raw_os_error() == Some(26) => {
|
||||||
|
// ETXTBSY — wait briefly and retry once
|
||||||
|
if last_err.is_some() {
|
||||||
|
return Err(format!("Failed to spawn command: {e}"));
|
||||||
|
}
|
||||||
|
last_err = Some(e);
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||||
|
}
|
||||||
|
Err(e) => return Err(format!("Failed to spawn command: {e}")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Drain stdout/stderr in background threads so the pipe buffers never fill.
|
// Drain stdout/stderr in background threads so the pipe buffers never fill.
|
||||||
let stdout_handle = child.stdout.take().map(|r| {
|
let stdout_handle = child.stdout.take().map(|r| {
|
||||||
|
|||||||
@@ -253,6 +253,24 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read the preferred agent from the story's front matter before acquiring
|
||||||
|
// the lock. When no explicit agent_name is given, this lets start_agent
|
||||||
|
// honour `agent: coder-opus` written by the `assign` command — mirroring
|
||||||
|
// the auto_assign path (bug 379).
|
||||||
|
let front_matter_agent: Option<String> = if agent_name.is_none() {
|
||||||
|
find_active_story_stage(project_root, story_id).and_then(|stage_dir| {
|
||||||
|
let path = project_root
|
||||||
|
.join(".storkit")
|
||||||
|
.join("work")
|
||||||
|
.join(stage_dir)
|
||||||
|
.join(format!("{story_id}.md"));
|
||||||
|
let contents = std::fs::read_to_string(path).ok()?;
|
||||||
|
crate::io::story_metadata::parse_front_matter(&contents).ok()?.agent
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Atomically resolve agent name, check availability, and register as
|
// Atomically resolve agent name, check availability, and register as
|
||||||
// Pending. When `agent_name` is `None` the first idle coder is
|
// Pending. When `agent_name` is `None` the first idle coder is
|
||||||
// selected inside the lock so no TOCTOU race can occur between the
|
// selected inside the lock so no TOCTOU race can occur between the
|
||||||
@@ -268,7 +286,32 @@ impl AgentPool {
|
|||||||
|
|
||||||
resolved_name = match agent_name {
|
resolved_name = match agent_name {
|
||||||
Some(name) => name.to_string(),
|
Some(name) => name.to_string(),
|
||||||
None => auto_assign::find_free_agent_for_stage(&config, &agents, &PipelineStage::Coder)
|
None => {
|
||||||
|
// Honour the `agent:` field in the story's front matter so that
|
||||||
|
// `start 368` after `assign 368 opus` picks the right agent
|
||||||
|
// (bug 379). Mirrors the auto_assign selection logic.
|
||||||
|
if let Some(ref pref) = front_matter_agent {
|
||||||
|
let stage_matches = config
|
||||||
|
.find_agent(pref)
|
||||||
|
.map(|cfg| agent_config_stage(cfg) == PipelineStage::Coder)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if stage_matches {
|
||||||
|
if auto_assign::is_agent_free(&agents, pref) {
|
||||||
|
pref.clone()
|
||||||
|
} else {
|
||||||
|
return Err(format!(
|
||||||
|
"Preferred agent '{pref}' from story front matter is busy; \
|
||||||
|
story '{story_id}' has been queued in work/2_current/ and will \
|
||||||
|
be auto-assigned when it becomes available"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Stage mismatch — fall back to any free coder.
|
||||||
|
auto_assign::find_free_agent_for_stage(
|
||||||
|
&config,
|
||||||
|
&agents,
|
||||||
|
&PipelineStage::Coder,
|
||||||
|
)
|
||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
if config
|
if config
|
||||||
@@ -285,7 +328,33 @@ impl AgentPool {
|
|||||||
"No coder agent configured. Specify an agent_name explicitly."
|
"No coder agent configured. Specify an agent_name explicitly."
|
||||||
.to_string()
|
.to_string()
|
||||||
}
|
}
|
||||||
})?,
|
})?
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
auto_assign::find_free_agent_for_stage(
|
||||||
|
&config,
|
||||||
|
&agents,
|
||||||
|
&PipelineStage::Coder,
|
||||||
|
)
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.ok_or_else(|| {
|
||||||
|
if config
|
||||||
|
.agent
|
||||||
|
.iter()
|
||||||
|
.any(|a| agent_config_stage(a) == PipelineStage::Coder)
|
||||||
|
{
|
||||||
|
format!(
|
||||||
|
"All coder agents are busy; story '{story_id}' has been \
|
||||||
|
queued in work/2_current/ and will be auto-assigned when \
|
||||||
|
one becomes available"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"No coder agent configured. Specify an agent_name explicitly."
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
key = composite_key(story_id, &resolved_name);
|
key = composite_key(story_id, &resolved_name);
|
||||||
@@ -2196,6 +2265,108 @@ stage = "coder"
|
|||||||
assert_eq!(agents.len(), 1, "existing agents should not be affected");
|
assert_eq!(agents.len(), 1, "existing agents should not be affected");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── front matter agent preference (bug 379) ──────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_agent_honours_front_matter_agent_when_idle() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".storkit");
|
||||||
|
let backlog = sk.join("work/1_backlog");
|
||||||
|
std::fs::create_dir_all(&backlog).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-sonnet"
|
||||||
|
stage = "coder"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-opus"
|
||||||
|
stage = "coder"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Story file with agent preference in front matter.
|
||||||
|
std::fs::write(
|
||||||
|
backlog.join("368_story_test.md"),
|
||||||
|
"---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3010);
|
||||||
|
// coder-sonnet is busy so without front matter the auto-selection
|
||||||
|
// would skip coder-opus and try something else.
|
||||||
|
pool.inject_test_agent("other-story", "coder-sonnet", AgentStatus::Running);
|
||||||
|
|
||||||
|
let result = pool
|
||||||
|
.start_agent(tmp.path(), "368_story_test", None, None)
|
||||||
|
.await;
|
||||||
|
match result {
|
||||||
|
Ok(info) => {
|
||||||
|
assert_eq!(
|
||||||
|
info.agent_name, "coder-opus",
|
||||||
|
"should pick the front-matter preferred agent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// Allowed to fail for infrastructure reasons (no git repo),
|
||||||
|
// but NOT due to agent selection ignoring the preference.
|
||||||
|
assert!(
|
||||||
|
!err.contains("All coder agents are busy"),
|
||||||
|
"should not report busy when coder-opus is idle: {err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!err.contains("coder-sonnet"),
|
||||||
|
"should not have picked coder-sonnet: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_agent_returns_error_when_front_matter_agent_busy() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".storkit");
|
||||||
|
let backlog = sk.join("work/1_backlog");
|
||||||
|
std::fs::create_dir_all(&backlog).unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
sk.join("project.toml"),
|
||||||
|
r#"
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-sonnet"
|
||||||
|
stage = "coder"
|
||||||
|
|
||||||
|
[[agent]]
|
||||||
|
name = "coder-opus"
|
||||||
|
stage = "coder"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::write(
|
||||||
|
backlog.join("368_story_test.md"),
|
||||||
|
"---\nname: Test Story\nagent: coder-opus\n---\n# Story 368\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3011);
|
||||||
|
// Preferred agent is busy — should NOT fall back to coder-sonnet.
|
||||||
|
pool.inject_test_agent("other-story", "coder-opus", AgentStatus::Running);
|
||||||
|
|
||||||
|
let result = pool
|
||||||
|
.start_agent(tmp.path(), "368_story_test", None, None)
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err(), "expected error when preferred agent is busy");
|
||||||
|
let err = result.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
err.contains("coder-opus"),
|
||||||
|
"error should mention the preferred agent: {err}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
err.contains("busy") || err.contains("queued"),
|
||||||
|
"error should say agent is busy or story is queued: {err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── archive + cleanup integration test ───────────────────────────────────
|
// ── archive + cleanup integration test ───────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
//! sending and editing messages, allowing the bot logic (commands, htop,
|
//! sending and editing messages, allowing the bot logic (commands, htop,
|
||||||
//! notifications) to work against any chat platform — Matrix, WhatsApp, etc.
|
//! notifications) to work against any chat platform — Matrix, WhatsApp, etc.
|
||||||
|
|
||||||
|
pub mod transport;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
/// A platform-agnostic identifier for a sent message.
|
/// A platform-agnostic identifier for a sent message.
|
||||||
@@ -13,9 +15,6 @@ use async_trait::async_trait;
|
|||||||
/// producing and consuming these identifiers.
|
/// producing and consuming these identifiers.
|
||||||
pub type MessageId = String;
|
pub type MessageId = String;
|
||||||
|
|
||||||
/// A platform-agnostic identifier for a chat room / channel / conversation.
|
|
||||||
pub type RoomId = String;
|
|
||||||
|
|
||||||
/// Abstraction over a chat platform's message-sending capabilities.
|
/// Abstraction over a chat platform's message-sending capabilities.
|
||||||
///
|
///
|
||||||
/// Implementations must be `Send + Sync` so they can be shared across
|
/// Implementations must be `Send + Sync` so they can be shared across
|
||||||
@@ -65,11 +64,11 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn whatsapp_transport_satisfies_trait() {
|
fn whatsapp_transport_satisfies_trait() {
|
||||||
fn assert_transport<T: ChatTransport>() {}
|
fn assert_transport<T: ChatTransport>() {}
|
||||||
assert_transport::<crate::whatsapp::WhatsAppTransport>();
|
assert_transport::<crate::chat::transport::whatsapp::WhatsAppTransport>();
|
||||||
|
|
||||||
// Verify it can be wrapped in Arc<dyn ChatTransport>.
|
// Verify it can be wrapped in Arc<dyn ChatTransport>.
|
||||||
let _: Arc<dyn ChatTransport> =
|
let _: Arc<dyn ChatTransport> =
|
||||||
Arc::new(crate::whatsapp::WhatsAppTransport::new(
|
Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
|
||||||
"test-phone".to_string(),
|
"test-phone".to_string(),
|
||||||
"test-token".to_string(),
|
"test-token".to_string(),
|
||||||
"pipeline_notification".to_string(),
|
"pipeline_notification".to_string(),
|
||||||
@@ -81,7 +80,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn matrix_transport_is_send_sync() {
|
fn matrix_transport_is_send_sync() {
|
||||||
fn assert_send_sync<T: Send + Sync>() {}
|
fn assert_send_sync<T: Send + Sync>() {}
|
||||||
assert_send_sync::<crate::matrix::transport_impl::MatrixTransport>();
|
assert_send_sync::<crate::chat::transport::matrix::transport_impl::MatrixTransport>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verify that SlackTransport satisfies the ChatTransport trait and
|
/// Verify that SlackTransport satisfies the ChatTransport trait and
|
||||||
@@ -89,9 +88,24 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn slack_transport_satisfies_trait() {
|
fn slack_transport_satisfies_trait() {
|
||||||
fn assert_transport<T: ChatTransport>() {}
|
fn assert_transport<T: ChatTransport>() {}
|
||||||
assert_transport::<crate::slack::SlackTransport>();
|
assert_transport::<crate::chat::transport::slack::SlackTransport>();
|
||||||
|
|
||||||
let _: Arc<dyn ChatTransport> =
|
let _: Arc<dyn ChatTransport> =
|
||||||
Arc::new(crate::slack::SlackTransport::new("xoxb-test".to_string()));
|
Arc::new(crate::chat::transport::slack::SlackTransport::new("xoxb-test".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that TwilioWhatsAppTransport satisfies the ChatTransport trait
|
||||||
|
/// and can be used as `Arc<dyn ChatTransport>` (compile-time check).
|
||||||
|
#[test]
|
||||||
|
fn twilio_transport_satisfies_trait() {
|
||||||
|
fn assert_transport<T: ChatTransport>() {}
|
||||||
|
assert_transport::<crate::chat::transport::whatsapp::TwilioWhatsAppTransport>();
|
||||||
|
|
||||||
|
let _: Arc<dyn ChatTransport> =
|
||||||
|
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||||
|
"ACtest".to_string(),
|
||||||
|
"authtoken".to_string(),
|
||||||
|
"+14155551234".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
537
server/src/chat/transport/matrix/assign.rs
Normal file
537
server/src/chat/transport/matrix/assign.rs
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
//! Assign command: pre-assign or re-assign a coder model to a story.
|
||||||
|
//!
|
||||||
|
//! `{bot_name} assign {number} {model}` finds the story by number, updates the
|
||||||
|
//! `agent` field in its front matter, and — when a coder is already running on
|
||||||
|
//! the story — stops the current coder and starts the newly-assigned one.
|
||||||
|
//!
|
||||||
|
//! When no coder is running (the story has not been started yet), the command
|
||||||
|
//! behaves as before: it simply persists the assignment in the front matter so
|
||||||
|
//! that the next `start` invocation picks it up automatically.
|
||||||
|
|
||||||
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
|
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// All pipeline stage directories to search when finding a work item by number.
|
||||||
|
const STAGES: &[&str] = &[
|
||||||
|
"1_backlog",
|
||||||
|
"2_current",
|
||||||
|
"3_qa",
|
||||||
|
"4_merge",
|
||||||
|
"5_done",
|
||||||
|
"6_archived",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// A parsed assign command from a Matrix message body.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum AssignCommand {
|
||||||
|
/// Assign the story with this number to the given model.
|
||||||
|
Assign {
|
||||||
|
story_number: String,
|
||||||
|
model: String,
|
||||||
|
},
|
||||||
|
/// The user typed `assign` but without valid arguments.
|
||||||
|
BadArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an assign command from a raw Matrix message body.
|
||||||
|
///
|
||||||
|
/// Strips the bot mention prefix and checks whether the first word is `assign`.
|
||||||
|
/// Returns `None` when the message is not an assign command at all.
|
||||||
|
pub fn extract_assign_command(
|
||||||
|
message: &str,
|
||||||
|
bot_name: &str,
|
||||||
|
bot_user_id: &str,
|
||||||
|
) -> Option<AssignCommand> {
|
||||||
|
let stripped = strip_mention(message, bot_name, bot_user_id);
|
||||||
|
let trimmed = stripped
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
|
|
||||||
|
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
||||||
|
Some((c, a)) => (c, a.trim()),
|
||||||
|
None => (trimmed, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !cmd.eq_ignore_ascii_case("assign") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split args into story number and model.
|
||||||
|
let (number_str, model_str) = match args.split_once(char::is_whitespace) {
|
||||||
|
Some((n, m)) => (n.trim(), m.trim()),
|
||||||
|
None => (args, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if number_str.is_empty()
|
||||||
|
|| !number_str.chars().all(|c| c.is_ascii_digit())
|
||||||
|
|| model_str.is_empty()
|
||||||
|
{
|
||||||
|
return Some(AssignCommand::BadArgs);
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(AssignCommand::Assign {
|
||||||
|
story_number: number_str.to_string(),
|
||||||
|
model: model_str.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a model name hint (e.g. `"opus"`) to a full agent name
|
||||||
|
/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`,
|
||||||
|
/// it is returned unchanged to prevent double-prefixing.
|
||||||
|
pub fn resolve_agent_name(model: &str) -> String {
|
||||||
|
if model.starts_with("coder-") {
|
||||||
|
model.to_string()
|
||||||
|
} else {
|
||||||
|
format!("coder-{model}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an assign command asynchronously.
|
||||||
|
///
|
||||||
|
/// Finds the work item by `story_number` across all pipeline stages, updates
|
||||||
|
/// the `agent` field in its front matter, and — if a coder is currently
|
||||||
|
/// running on the story — stops it and starts the newly-assigned agent.
|
||||||
|
/// Returns a markdown-formatted response string.
|
||||||
|
pub async fn handle_assign(
|
||||||
|
bot_name: &str,
|
||||||
|
story_number: &str,
|
||||||
|
model_str: &str,
|
||||||
|
project_root: &Path,
|
||||||
|
agents: &AgentPool,
|
||||||
|
) -> String {
|
||||||
|
// Find the story file across all pipeline stages.
|
||||||
|
let mut found: Option<(std::path::PathBuf, String)> = None;
|
||||||
|
'outer: for stage in STAGES {
|
||||||
|
let dir = project_root.join(".storkit").join("work").join(stage);
|
||||||
|
if !dir.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(stem) = path
|
||||||
|
.file_stem()
|
||||||
|
.and_then(|s| s.to_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
{
|
||||||
|
let file_num = stem
|
||||||
|
.split('_')
|
||||||
|
.next()
|
||||||
|
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_string();
|
||||||
|
if file_num == story_number {
|
||||||
|
found = Some((path, stem));
|
||||||
|
break 'outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (path, story_id) = match found {
|
||||||
|
Some(f) => f,
|
||||||
|
None => {
|
||||||
|
return format!(
|
||||||
|
"No story, bug, or spike with number **{story_number}** found."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read the human-readable name from front matter for the response.
|
||||||
|
let story_name = std::fs::read_to_string(&path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|contents| {
|
||||||
|
parse_front_matter(&contents)
|
||||||
|
.ok()
|
||||||
|
.and_then(|m| m.name)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| story_id.clone());
|
||||||
|
|
||||||
|
let agent_name = resolve_agent_name(model_str);
|
||||||
|
|
||||||
|
// Write `agent: <agent_name>` into the story's front matter.
|
||||||
|
let write_result = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("Failed to read story file: {e}"))
|
||||||
|
.and_then(|contents| {
|
||||||
|
let updated = set_front_matter_field(&contents, "agent", &agent_name);
|
||||||
|
std::fs::write(&path, &updated)
|
||||||
|
.map_err(|e| format!("Failed to write story file: {e}"))
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = write_result {
|
||||||
|
return format!("Failed to assign model to **{story_name}**: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check whether a coder is already running on this story.
|
||||||
|
let running_coders: Vec<_> = agents
|
||||||
|
.list_agents()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| {
|
||||||
|
a.story_id == story_id
|
||||||
|
&& a.agent_name.starts_with("coder")
|
||||||
|
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if running_coders.is_empty() {
|
||||||
|
// No coder running — just persist the assignment.
|
||||||
|
return format!(
|
||||||
|
"Assigned **{agent_name}** to **{story_name}** (story {story_number}). \
|
||||||
|
The model will be used when the story starts."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop each running coder, then start the newly assigned one.
|
||||||
|
let stopped: Vec<String> = running_coders
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.agent_name.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for coder in &running_coders {
|
||||||
|
if let Err(e) = agents
|
||||||
|
.stop_agent(project_root, &story_id, &coder.agent_name)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
crate::slog!(
|
||||||
|
"[matrix-bot] assign: failed to stop agent {} for {}: {e}",
|
||||||
|
coder.agent_name,
|
||||||
|
story_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::slog!(
|
||||||
|
"[matrix-bot] assign (bot={bot_name}): stopped {:?} for {}; starting {agent_name}",
|
||||||
|
stopped,
|
||||||
|
story_id
|
||||||
|
);
|
||||||
|
|
||||||
|
match agents
|
||||||
|
.start_agent(project_root, &story_id, Some(&agent_name), None)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) => {
|
||||||
|
format!(
|
||||||
|
"Reassigned **{story_name}** (story {story_number}): \
|
||||||
|
stopped **{}** and started **{}**.",
|
||||||
|
stopped.join(", "),
|
||||||
|
info.agent_name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
format!(
|
||||||
|
"Assigned **{agent_name}** to **{story_name}** (story {story_number}): \
|
||||||
|
stopped **{}** but failed to start the new agent: {e}",
|
||||||
|
stopped.join(", ")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the bot mention prefix from a raw Matrix message body.
|
||||||
|
///
|
||||||
|
/// Mirrors the logic in `commands::strip_bot_mention` and `start::strip_mention`.
|
||||||
|
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||||
|
let trimmed = message.trim();
|
||||||
|
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
if let Some(localpart) = bot_user_id.split(':').next()
|
||||||
|
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||||
|
{
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||||
|
if text.len() < prefix.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = &text[prefix.len()..];
|
||||||
|
match rest.chars().next() {
|
||||||
|
None => Some(rest),
|
||||||
|
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
|
||||||
|
_ => Some(rest),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// -- extract_assign_command -----------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_full_user_id() {
|
||||||
|
let cmd = extract_assign_command(
|
||||||
|
"@timmy:home.local assign 42 opus",
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:home.local",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(AssignCommand::Assign {
|
||||||
|
story_number: "42".to_string(),
|
||||||
|
model: "opus".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_display_name() {
|
||||||
|
let cmd = extract_assign_command("Timmy assign 42 sonnet", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(AssignCommand::Assign {
|
||||||
|
story_number: "42".to_string(),
|
||||||
|
model: "sonnet".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_localpart() {
|
||||||
|
let cmd = extract_assign_command("@timmy assign 7 opus", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(AssignCommand::Assign {
|
||||||
|
story_number: "7".to_string(),
|
||||||
|
model: "opus".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_case_insensitive_command() {
|
||||||
|
let cmd = extract_assign_command("Timmy ASSIGN 99 opus", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(AssignCommand::Assign {
|
||||||
|
story_number: "99".to_string(),
|
||||||
|
model: "opus".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_no_args_is_bad_args() {
|
||||||
|
let cmd = extract_assign_command("Timmy assign", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, Some(AssignCommand::BadArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_missing_model_is_bad_args() {
|
||||||
|
let cmd = extract_assign_command("Timmy assign 42", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, Some(AssignCommand::BadArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_non_numeric_number_is_bad_args() {
|
||||||
|
let cmd = extract_assign_command("Timmy assign abc opus", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, Some(AssignCommand::BadArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_non_assign_command_returns_none() {
|
||||||
|
let cmd = extract_assign_command("Timmy help", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- resolve_agent_name --------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_agent_name_prefixes_bare_model() {
|
||||||
|
assert_eq!(resolve_agent_name("opus"), "coder-opus");
|
||||||
|
assert_eq!(resolve_agent_name("sonnet"), "coder-sonnet");
|
||||||
|
assert_eq!(resolve_agent_name("haiku"), "coder-haiku");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_agent_name_does_not_double_prefix() {
|
||||||
|
assert_eq!(resolve_agent_name("coder-opus"), "coder-opus");
|
||||||
|
assert_eq!(resolve_agent_name("coder-sonnet"), "coder-sonnet");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- handle_assign (no running coder) ------------------------------------
|
||||||
|
|
||||||
|
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
|
||||||
|
let dir = root.join(".storkit/work").join(stage);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
std::fs::write(dir.join(filename), content).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_assign_returns_not_found_for_unknown_number() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
for stage in STAGES {
|
||||||
|
std::fs::create_dir_all(tmp.path().join(".storkit/work").join(stage)).unwrap();
|
||||||
|
}
|
||||||
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
||||||
|
let response = handle_assign("Timmy", "999", "opus", tmp.path(), &agents).await;
|
||||||
|
assert!(
|
||||||
|
response.contains("No story") && response.contains("999"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_assign_writes_front_matter_when_no_coder_running() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_story_file(
|
||||||
|
tmp.path(),
|
||||||
|
"1_backlog",
|
||||||
|
"42_story_test.md",
|
||||||
|
"---\nname: Test Feature\n---\n\n# Story 42\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
||||||
|
let response = handle_assign("Timmy", "42", "opus", tmp.path(), &agents).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.contains("coder-opus"),
|
||||||
|
"response should mention agent: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
response.contains("Test Feature"),
|
||||||
|
"response should mention story name: {response}"
|
||||||
|
);
|
||||||
|
// Should say "will be used when the story starts" (no restart)
|
||||||
|
assert!(
|
||||||
|
response.contains("start"),
|
||||||
|
"response should indicate assignment for future start: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let contents = std::fs::read_to_string(
|
||||||
|
tmp.path().join(".storkit/work/1_backlog/42_story_test.md"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
contents.contains("agent: coder-opus"),
|
||||||
|
"front matter should contain agent field: {contents}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_assign_with_already_prefixed_name_does_not_double_prefix() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_story_file(
|
||||||
|
tmp.path(),
|
||||||
|
"1_backlog",
|
||||||
|
"7_story_small.md",
|
||||||
|
"---\nname: Small Story\n---\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
||||||
|
let response = handle_assign("Timmy", "7", "coder-opus", tmp.path(), &agents).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.contains("coder-opus"),
|
||||||
|
"should not double-prefix: {response}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!response.contains("coder-coder-opus"),
|
||||||
|
"must not double-prefix: {response}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let contents = std::fs::read_to_string(
|
||||||
|
tmp.path().join(".storkit/work/1_backlog/7_story_small.md"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
contents.contains("agent: coder-opus"),
|
||||||
|
"must write coder-opus, not coder-coder-opus: {contents}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_assign_overwrites_existing_agent_field() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_story_file(
|
||||||
|
tmp.path(),
|
||||||
|
"1_backlog",
|
||||||
|
"5_story_existing.md",
|
||||||
|
"---\nname: Existing\nagent: coder-sonnet\n---\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
||||||
|
handle_assign("Timmy", "5", "opus", tmp.path(), &agents).await;
|
||||||
|
|
||||||
|
let contents = std::fs::read_to_string(
|
||||||
|
tmp.path().join(".storkit/work/1_backlog/5_story_existing.md"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
contents.contains("agent: coder-opus"),
|
||||||
|
"should overwrite old agent: {contents}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!contents.contains("coder-sonnet"),
|
||||||
|
"old agent should no longer appear: {contents}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_assign_finds_story_in_any_stage() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_story_file(
|
||||||
|
tmp.path(),
|
||||||
|
"3_qa",
|
||||||
|
"99_story_in_qa.md",
|
||||||
|
"---\nname: In QA\n---\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
||||||
|
let response = handle_assign("Timmy", "99", "opus", tmp.path(), &agents).await;
|
||||||
|
assert!(
|
||||||
|
response.contains("coder-opus"),
|
||||||
|
"should find story in qa stage: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- handle_assign (with running coder) ----------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_assign_stops_running_coder_and_reports_reassignment() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
write_story_file(
|
||||||
|
tmp.path(),
|
||||||
|
"2_current",
|
||||||
|
"10_story_current.md",
|
||||||
|
"---\nname: Current Story\nagent: coder-sonnet\n---\n",
|
||||||
|
);
|
||||||
|
|
||||||
|
let agents = std::sync::Arc::new(AgentPool::new_test(3000));
|
||||||
|
// Inject a running coder for this story.
|
||||||
|
agents.inject_test_agent("10_story_current", "coder-sonnet", AgentStatus::Running);
|
||||||
|
|
||||||
|
let response = handle_assign("Timmy", "10", "opus", tmp.path(), &agents).await;
|
||||||
|
|
||||||
|
// The response should mention both stopped and started agents.
|
||||||
|
assert!(
|
||||||
|
response.contains("coder-sonnet"),
|
||||||
|
"response should mention the stopped agent: {response}"
|
||||||
|
);
|
||||||
|
// Should indicate a restart occurred (not just "will be used when starts")
|
||||||
|
assert!(
|
||||||
|
response.to_lowercase().contains("stop") || response.to_lowercase().contains("reassign"),
|
||||||
|
"response should indicate stop/reassign: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ use crate::agents::AgentPool;
|
|||||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
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 crate::transport::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
Client,
|
Client,
|
||||||
config::SyncSettings,
|
config::SyncSettings,
|
||||||
@@ -217,7 +217,7 @@ pub async fn run_bot(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let store_path = project_root.join(".storkit").join("matrix_store");
|
let store_path = project_root.join(".storkit").join("matrix_store");
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.homeserver_url(&config.homeserver)
|
.homeserver_url(config.homeserver.as_deref().unwrap_or_default())
|
||||||
.sqlite_store(&store_path, None)
|
.sqlite_store(&store_path, None)
|
||||||
.build()
|
.build()
|
||||||
.await
|
.await
|
||||||
@@ -232,7 +232,10 @@ pub async fn run_bot(
|
|||||||
|
|
||||||
let mut login_builder = client
|
let mut login_builder = client
|
||||||
.matrix_auth()
|
.matrix_auth()
|
||||||
.login_username(&config.username, &config.password)
|
.login_username(
|
||||||
|
config.username.as_deref().unwrap_or_default(),
|
||||||
|
config.password.as_deref().unwrap_or_default(),
|
||||||
|
)
|
||||||
.initial_device_display_name("Storkit Bot");
|
.initial_device_display_name("Storkit Bot");
|
||||||
|
|
||||||
if let Some(ref device_id) = saved_device_id {
|
if let Some(ref device_id) = saved_device_id {
|
||||||
@@ -265,8 +268,10 @@ pub async fn run_bot(
|
|||||||
{
|
{
|
||||||
use matrix_sdk::ruma::api::client::uiaa;
|
use matrix_sdk::ruma::api::client::uiaa;
|
||||||
let password_auth = uiaa::AuthData::Password(uiaa::Password::new(
|
let password_auth = uiaa::AuthData::Password(uiaa::Password::new(
|
||||||
uiaa::UserIdentifier::UserIdOrLocalpart(config.username.clone()),
|
uiaa::UserIdentifier::UserIdOrLocalpart(
|
||||||
config.password.clone(),
|
config.username.clone().unwrap_or_default(),
|
||||||
|
),
|
||||||
|
config.password.clone().unwrap_or_default(),
|
||||||
));
|
));
|
||||||
if let Err(e) = client
|
if let Err(e) = client
|
||||||
.encryption()
|
.encryption()
|
||||||
@@ -369,8 +374,16 @@ pub async fn run_bot(
|
|||||||
// Create the transport abstraction based on the configured transport type.
|
// Create the transport abstraction based on the configured transport type.
|
||||||
let transport: Arc<dyn ChatTransport> = match config.transport.as_str() {
|
let transport: Arc<dyn ChatTransport> = match config.transport.as_str() {
|
||||||
"whatsapp" => {
|
"whatsapp" => {
|
||||||
slog!("[matrix-bot] Using WhatsApp transport");
|
if config.whatsapp_provider == "twilio" {
|
||||||
Arc::new(crate::whatsapp::WhatsAppTransport::new(
|
slog!("[matrix-bot] Using WhatsApp/Twilio transport");
|
||||||
|
Arc::new(crate::chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||||
|
config.twilio_account_sid.clone().unwrap_or_default(),
|
||||||
|
config.twilio_auth_token.clone().unwrap_or_default(),
|
||||||
|
config.twilio_whatsapp_number.clone().unwrap_or_default(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
slog!("[matrix-bot] Using WhatsApp/Meta transport");
|
||||||
|
Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
|
||||||
config.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
config.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
||||||
config.whatsapp_access_token.clone().unwrap_or_default(),
|
config.whatsapp_access_token.clone().unwrap_or_default(),
|
||||||
config
|
config
|
||||||
@@ -379,6 +392,7 @@ pub async fn run_bot(
|
|||||||
.unwrap_or_else(|| "pipeline_notification".to_string()),
|
.unwrap_or_else(|| "pipeline_notification".to_string()),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
slog!("[matrix-bot] Using Matrix transport");
|
slog!("[matrix-bot] Using Matrix transport");
|
||||||
Arc::new(super::transport_impl::MatrixTransport::new(client.clone()))
|
Arc::new(super::transport_impl::MatrixTransport::new(client.clone()))
|
||||||
@@ -861,6 +875,46 @@ async fn on_room_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for the assign command, which requires async agent ops (stop +
|
||||||
|
// start) and cannot be handled by the sync command registry.
|
||||||
|
if let Some(assign_cmd) = super::assign::extract_assign_command(
|
||||||
|
&user_message,
|
||||||
|
&ctx.bot_name,
|
||||||
|
ctx.bot_user_id.as_str(),
|
||||||
|
) {
|
||||||
|
let response = match assign_cmd {
|
||||||
|
super::assign::AssignCommand::Assign {
|
||||||
|
story_number,
|
||||||
|
model,
|
||||||
|
} => {
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}"
|
||||||
|
);
|
||||||
|
super::assign::handle_assign(
|
||||||
|
&ctx.bot_name,
|
||||||
|
&story_number,
|
||||||
|
&model,
|
||||||
|
&ctx.project_root,
|
||||||
|
&ctx.agents,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
super::assign::AssignCommand::BadArgs => {
|
||||||
|
format!(
|
||||||
|
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
|
||||||
|
ctx.bot_name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let html = markdown_to_html(&response);
|
||||||
|
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||||
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for the htop command, which requires async Matrix access (Room)
|
// Check for the htop command, which requires async Matrix access (Room)
|
||||||
// and cannot be handled by the sync command registry.
|
// and cannot be handled by the sync command registry.
|
||||||
if let Some(htop_cmd) =
|
if let Some(htop_cmd) =
|
||||||
@@ -919,6 +973,39 @@ async fn on_room_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for the rmtree command, which requires async agent/worktree ops
|
||||||
|
// and cannot be handled by the sync command registry.
|
||||||
|
if let Some(rmtree_cmd) = super::rmtree::extract_rmtree_command(
|
||||||
|
&user_message,
|
||||||
|
&ctx.bot_name,
|
||||||
|
ctx.bot_user_id.as_str(),
|
||||||
|
) {
|
||||||
|
let response = match rmtree_cmd {
|
||||||
|
super::rmtree::RmtreeCommand::Rmtree { story_number } => {
|
||||||
|
slog!(
|
||||||
|
"[matrix-bot] Handling rmtree command from {sender}: story {story_number}"
|
||||||
|
);
|
||||||
|
super::rmtree::handle_rmtree(
|
||||||
|
&ctx.bot_name,
|
||||||
|
&story_number,
|
||||||
|
&ctx.project_root,
|
||||||
|
&ctx.agents,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
super::rmtree::RmtreeCommand::BadArgs => {
|
||||||
|
format!("Usage: `{} rmtree <number>`", ctx.bot_name)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let html = markdown_to_html(&response);
|
||||||
|
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &response, &html).await
|
||||||
|
&& let Ok(event_id) = msg_id.parse()
|
||||||
|
{
|
||||||
|
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for the start command, which requires async agent ops and cannot
|
// Check for the start command, which requires async agent ops and cannot
|
||||||
// be handled by the sync command registry.
|
// be handled by the sync command registry.
|
||||||
if let Some(start_cmd) = super::start::extract_start_command(
|
if let Some(start_cmd) = super::start::extract_start_command(
|
||||||
@@ -1530,7 +1617,7 @@ mod tests {
|
|||||||
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
|
||||||
agents: Arc::new(AgentPool::new_test(3000)),
|
agents: Arc::new(AgentPool::new_test(3000)),
|
||||||
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
|
||||||
transport: Arc::new(crate::whatsapp::WhatsAppTransport::new("test-phone".to_string(), "test-token".to_string(), "pipeline_notification".to_string())),
|
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new("test-phone".to_string(), "test-token".to_string(), "pipeline_notification".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();
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
//! Handler for the `ambient` command.
|
//! Handler for the `ambient` command.
|
||||||
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
use crate::matrix::config::save_ambient_rooms;
|
use crate::chat::transport::matrix::config::save_ambient_rooms;
|
||||||
|
|
||||||
/// Toggle ambient mode for this room.
|
/// Toggle ambient mode for this room.
|
||||||
///
|
///
|
||||||
57
server/src/chat/transport/matrix/commands/assign.rs
Normal file
57
server/src/chat/transport/matrix/commands/assign.rs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
//! Handler stub for the `assign` command.
|
||||||
|
//!
|
||||||
|
//! The real implementation lives in `crate::chat::transport::matrix::assign` (async). This
|
||||||
|
//! stub exists only so that `assign` appears in the help registry — the
|
||||||
|
//! handler always returns `None` so the bot's message loop falls through to
|
||||||
|
//! the async handler in `bot.rs`.
|
||||||
|
|
||||||
|
use super::CommandContext;
|
||||||
|
|
||||||
|
pub(super) fn handle_assign(_ctx: &CommandContext) -> Option<String> {
|
||||||
|
// Handled asynchronously in bot.rs / crate::chat::transport::matrix::assign.
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
// -- registration / help ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assign_command_is_registered() {
|
||||||
|
use super::super::commands;
|
||||||
|
let found = commands().iter().any(|c| c.name == "assign");
|
||||||
|
assert!(found, "assign command must be in the registry");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assign_command_appears_in_help() {
|
||||||
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy help",
|
||||||
|
);
|
||||||
|
let output = result.unwrap();
|
||||||
|
assert!(
|
||||||
|
output.contains("assign"),
|
||||||
|
"help should list assign command: {output}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn assign_command_falls_through_to_none_in_registry() {
|
||||||
|
// The assign handler in the registry returns None (handled async in bot.rs).
|
||||||
|
let result = super::super::tests::try_cmd_addressed(
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:homeserver.local",
|
||||||
|
"@timmy assign 42 opus",
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"assign should not produce a sync response (handled async): {result:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ mod move_story;
|
|||||||
mod overview;
|
mod overview;
|
||||||
mod show;
|
mod show;
|
||||||
mod status;
|
mod status;
|
||||||
mod whatsup;
|
mod triage;
|
||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
@@ -40,7 +40,7 @@ pub struct BotCommand {
|
|||||||
/// message body.
|
/// message body.
|
||||||
///
|
///
|
||||||
/// All identifiers are platform-agnostic strings so this struct works with
|
/// All identifiers are platform-agnostic strings so this struct works with
|
||||||
/// any [`ChatTransport`](crate::transport::ChatTransport) implementation.
|
/// any [`ChatTransport`](crate::chat::ChatTransport) implementation.
|
||||||
pub struct CommandDispatch<'a> {
|
pub struct CommandDispatch<'a> {
|
||||||
/// The bot's display name (e.g., "Timmy").
|
/// The bot's display name (e.g., "Timmy").
|
||||||
pub bot_name: &'a str,
|
pub bot_name: &'a str,
|
||||||
@@ -137,6 +137,11 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
description: "Remove a work item from the pipeline: `delete <number>`",
|
description: "Remove a work item from the pipeline: `delete <number>`",
|
||||||
handler: handle_delete_fallback,
|
handler: handle_delete_fallback,
|
||||||
},
|
},
|
||||||
|
BotCommand {
|
||||||
|
name: "rmtree",
|
||||||
|
description: "Delete the worktree for a story without removing it from the pipeline: `rmtree <number>`",
|
||||||
|
handler: handle_rmtree_fallback,
|
||||||
|
},
|
||||||
BotCommand {
|
BotCommand {
|
||||||
name: "reset",
|
name: "reset",
|
||||||
description: "Clear the current Claude Code session and start fresh",
|
description: "Clear the current Claude Code session and start fresh",
|
||||||
@@ -252,6 +257,16 @@ fn handle_start_fallback(_ctx: &CommandContext) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback handler for the `rmtree` command when it is not intercepted by
|
||||||
|
/// the async handler in `on_room_message`. In practice this is never called —
|
||||||
|
/// rmtree is detected and handled before `try_handle_command` is invoked.
|
||||||
|
/// The entry exists in the registry only so `help` lists it.
|
||||||
|
///
|
||||||
|
/// Returns `None` to prevent the LLM from receiving "rmtree" as a prompt.
|
||||||
|
fn handle_rmtree_fallback(_ctx: &CommandContext) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Fallback handler for the `delete` command when it is not intercepted by
|
/// Fallback handler for the `delete` command when it is not intercepted by
|
||||||
/// the async handler in `on_room_message`. In practice this is never called —
|
/// the async handler in `on_room_message`. In practice this is never called —
|
||||||
/// delete is detected and handled before `try_handle_command` is invoked.
|
/// delete is detected and handled before `try_handle_command` is invoked.
|
||||||
@@ -10,29 +10,42 @@ pub(super) fn handle_status(ctx: &CommandContext) -> Option<String> {
|
|||||||
if ctx.args.trim().is_empty() {
|
if ctx.args.trim().is_empty() {
|
||||||
Some(build_pipeline_status(ctx.project_root, ctx.agents))
|
Some(build_pipeline_status(ctx.project_root, ctx.agents))
|
||||||
} else {
|
} else {
|
||||||
super::whatsup::handle_whatsup(ctx)
|
super::triage::handle_triage(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format a short display label for a work item.
|
/// Format a short display label for a work item.
|
||||||
///
|
///
|
||||||
/// Extracts the leading numeric ID from the file stem (e.g. `"293"` from
|
/// Extracts the leading numeric ID and optional type tag from the file stem
|
||||||
/// `"293_story_register_all_bot_commands"`) and combines it with the human-
|
/// (e.g. `"293"` and `"story"` from `"293_story_register_all_bot_commands"`)
|
||||||
/// readable name from the front matter when available.
|
/// and combines them with the human-readable name from the front matter when
|
||||||
|
/// available. Known types (`story`, `bug`, `spike`, `refactor`) are shown as
|
||||||
|
/// bracketed labels; unknown or missing types are omitted silently.
|
||||||
///
|
///
|
||||||
/// Examples:
|
/// Examples:
|
||||||
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 — Register all bot commands"`
|
/// - `("293_story_foo", Some("Register all bot commands"))` → `"293 [story] — Register all bot commands"`
|
||||||
/// - `("293_story_foo", None)` → `"293"`
|
/// - `("375_bug_foo", None)` → `"375 [bug]"`
|
||||||
|
/// - `("293_story_foo", None)` → `"293 [story]"`
|
||||||
/// - `("no_number_here", None)` → `"no_number_here"`
|
/// - `("no_number_here", None)` → `"no_number_here"`
|
||||||
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
|
pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String {
|
||||||
let number = stem
|
let mut parts = stem.splitn(3, '_');
|
||||||
.split('_')
|
let first = parts.next().unwrap_or(stem);
|
||||||
.next()
|
let (number, type_label) = if !first.is_empty() && first.chars().all(|c| c.is_ascii_digit()) {
|
||||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
let t = parts.next().and_then(|t| match t {
|
||||||
.unwrap_or(stem);
|
"story" | "bug" | "spike" | "refactor" => Some(t),
|
||||||
match name {
|
_ => None,
|
||||||
Some(n) => format!("{number} — {n}"),
|
});
|
||||||
|
(first, t)
|
||||||
|
} else {
|
||||||
|
(stem, None)
|
||||||
|
};
|
||||||
|
let prefix = match type_label {
|
||||||
|
Some(t) => format!("{number} [{t}]"),
|
||||||
None => number.to_string(),
|
None => number.to_string(),
|
||||||
|
};
|
||||||
|
match name {
|
||||||
|
Some(n) => format!("{prefix} — {n}"),
|
||||||
|
None => prefix,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,13 +213,13 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn short_label_extracts_number_and_name() {
|
fn short_label_extracts_number_and_name() {
|
||||||
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
|
let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands"));
|
||||||
assert_eq!(label, "293 — Register all bot commands");
|
assert_eq!(label, "293 [story] — Register all bot commands");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn short_label_number_only_when_no_name() {
|
fn short_label_number_only_when_no_name() {
|
||||||
let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
|
let label = story_short_label("297_story_improve_bot_status_command_formatting", None);
|
||||||
assert_eq!(label, "297");
|
assert_eq!(label, "297 [story]");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -224,6 +237,37 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_label_shows_bug_type() {
|
||||||
|
let label = story_short_label("375_bug_default_project_toml", Some("Default project.toml issue"));
|
||||||
|
assert_eq!(label, "375 [bug] — Default project.toml issue");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_label_shows_spike_type() {
|
||||||
|
let label = story_short_label("61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture"));
|
||||||
|
assert_eq!(label, "61 [spike] — Filesystem watcher architecture");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_label_shows_refactor_type() {
|
||||||
|
let label = story_short_label("260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys"));
|
||||||
|
assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_label_omits_unknown_type() {
|
||||||
|
let label = story_short_label("42_task_do_something", Some("Do something"));
|
||||||
|
assert_eq!(label, "42 — Do something");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_label_no_type_when_only_id() {
|
||||||
|
// Stem with only a numeric ID and no type segment
|
||||||
|
let label = story_short_label("42", Some("Some item"));
|
||||||
|
assert_eq!(label, "42 — Some item");
|
||||||
|
}
|
||||||
|
|
||||||
// -- build_pipeline_status formatting -----------------------------------
|
// -- build_pipeline_status formatting -----------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -248,8 +292,8 @@ mod tests {
|
|||||||
"output must not show full filename stem: {output}"
|
"output must not show full filename stem: {output}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("293 — Register all bot commands"),
|
output.contains("293 [story] — Register all bot commands"),
|
||||||
"output must show number and title: {output}"
|
"output must show number, type, and title: {output}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +332,7 @@ mod tests {
|
|||||||
let output = build_pipeline_status(tmp.path(), &agents);
|
let output = build_pipeline_status(tmp.path(), &agents);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("293 — Register all bot commands — $0.29"),
|
output.contains("293 [story] — Register all bot commands — $0.29"),
|
||||||
"output must show cost next to story: {output}"
|
"output must show cost next to story: {output}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -351,7 +395,7 @@ mod tests {
|
|||||||
let output = build_pipeline_status(tmp.path(), &agents);
|
let output = build_pipeline_status(tmp.path(), &agents);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("293 — Register all bot commands — $0.29"),
|
output.contains("293 [story] — Register all bot commands — $0.29"),
|
||||||
"output must show aggregated cost: {output}"
|
"output must show aggregated cost: {output}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
//! Handler for the `whatsup` command.
|
//! Handler for the story triage dump subcommand of `status`.
|
||||||
//!
|
//!
|
||||||
//! Produces a triage dump for a story that is currently in-progress
|
//! Produces a triage dump for a story that is currently in-progress
|
||||||
//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state,
|
//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state,
|
||||||
@@ -10,8 +10,8 @@ use super::CommandContext;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Handle `{bot_name} whatsup {number}`.
|
/// Handle `{bot_name} status {number}`.
|
||||||
pub(super) fn handle_whatsup(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_triage(ctx: &CommandContext) -> Option<String> {
|
||||||
let num_str = ctx.args.trim();
|
let num_str = ctx.args.trim();
|
||||||
if num_str.is_empty() {
|
if num_str.is_empty() {
|
||||||
return Some(format!(
|
return Some(format!(
|
||||||
@@ -281,7 +281,7 @@ mod tests {
|
|||||||
|
|
||||||
use super::super::{CommandDispatch, try_handle_command};
|
use super::super::{CommandDispatch, try_handle_command};
|
||||||
|
|
||||||
fn whatsup_cmd(root: &Path, args: &str) -> Option<String> {
|
fn status_triage_cmd(root: &Path, args: &str) -> Option<String> {
|
||||||
let agents = Arc::new(AgentPool::new_test(3000));
|
let agents = Arc::new(AgentPool::new_test(3000));
|
||||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
let room_id = "!test:example.com".to_string();
|
let room_id = "!test:example.com".to_string();
|
||||||
@@ -329,7 +329,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn whatsup_no_args_returns_usage() {
|
fn whatsup_no_args_returns_usage() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = whatsup_cmd(tmp.path(), "").unwrap();
|
let output = status_triage_cmd(tmp.path(), "").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Pipeline Status"),
|
output.contains("Pipeline Status"),
|
||||||
"no args should show pipeline status: {output}"
|
"no args should show pipeline status: {output}"
|
||||||
@@ -339,7 +339,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn whatsup_non_numeric_returns_error() {
|
fn whatsup_non_numeric_returns_error() {
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let output = whatsup_cmd(tmp.path(), "abc").unwrap();
|
let output = status_triage_cmd(tmp.path(), "abc").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("Invalid"),
|
output.contains("Invalid"),
|
||||||
"non-numeric arg should return error: {output}"
|
"non-numeric arg should return error: {output}"
|
||||||
@@ -358,7 +358,7 @@ mod tests {
|
|||||||
"42_story_not_in_current.md",
|
"42_story_not_in_current.md",
|
||||||
"---\nname: Not in current\n---\n",
|
"---\nname: Not in current\n---\n",
|
||||||
);
|
);
|
||||||
let output = whatsup_cmd(tmp.path(), "42").unwrap();
|
let output = status_triage_cmd(tmp.path(), "42").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("42"),
|
output.contains("42"),
|
||||||
"message should include story number: {output}"
|
"message should include story number: {output}"
|
||||||
@@ -380,7 +380,7 @@ mod tests {
|
|||||||
"99_story_my_feature.md",
|
"99_story_my_feature.md",
|
||||||
"---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
|
"---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n",
|
||||||
);
|
);
|
||||||
let output = whatsup_cmd(tmp.path(), "99").unwrap();
|
let output = status_triage_cmd(tmp.path(), "99").unwrap();
|
||||||
assert!(output.contains("99"), "should show story number: {output}");
|
assert!(output.contains("99"), "should show story number: {output}");
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("My Feature"),
|
output.contains("My Feature"),
|
||||||
@@ -401,7 +401,7 @@ mod tests {
|
|||||||
"99_story_criteria_test.md",
|
"99_story_criteria_test.md",
|
||||||
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
|
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
|
||||||
);
|
);
|
||||||
let output = whatsup_cmd(tmp.path(), "99").unwrap();
|
let output = status_triage_cmd(tmp.path(), "99").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("First thing"),
|
output.contains("First thing"),
|
||||||
"should show unchecked criterion: {output}"
|
"should show unchecked criterion: {output}"
|
||||||
@@ -426,7 +426,7 @@ mod tests {
|
|||||||
"55_story_blocked_story.md",
|
"55_story_blocked_story.md",
|
||||||
"---\nname: Blocked Story\nblocked: true\n---\n",
|
"---\nname: Blocked Story\nblocked: true\n---\n",
|
||||||
);
|
);
|
||||||
let output = whatsup_cmd(tmp.path(), "55").unwrap();
|
let output = status_triage_cmd(tmp.path(), "55").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("blocked"),
|
output.contains("blocked"),
|
||||||
"should show blocked field: {output}"
|
"should show blocked field: {output}"
|
||||||
@@ -442,7 +442,7 @@ mod tests {
|
|||||||
"55_story_agent_story.md",
|
"55_story_agent_story.md",
|
||||||
"---\nname: Agent Story\nagent: coder-1\n---\n",
|
"---\nname: Agent Story\nagent: coder-1\n---\n",
|
||||||
);
|
);
|
||||||
let output = whatsup_cmd(tmp.path(), "55").unwrap();
|
let output = status_triage_cmd(tmp.path(), "55").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("coder-1"),
|
output.contains("coder-1"),
|
||||||
"should show agent field: {output}"
|
"should show agent field: {output}"
|
||||||
@@ -458,7 +458,7 @@ mod tests {
|
|||||||
"77_story_no_worktree.md",
|
"77_story_no_worktree.md",
|
||||||
"---\nname: No Worktree\n---\n",
|
"---\nname: No Worktree\n---\n",
|
||||||
);
|
);
|
||||||
let output = whatsup_cmd(tmp.path(), "77").unwrap();
|
let output = status_triage_cmd(tmp.path(), "77").unwrap();
|
||||||
// Branch name should still appear
|
// Branch name should still appear
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("feature/story-77"),
|
output.contains("feature/story-77"),
|
||||||
@@ -475,7 +475,7 @@ mod tests {
|
|||||||
"77_story_no_log.md",
|
"77_story_no_log.md",
|
||||||
"---\nname: No Log\n---\n",
|
"---\nname: No Log\n---\n",
|
||||||
);
|
);
|
||||||
let output = whatsup_cmd(tmp.path(), "77").unwrap();
|
let output = status_triage_cmd(tmp.path(), "77").unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"),
|
output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"),
|
||||||
"should indicate no log exists: {output}"
|
"should indicate no log exists: {output}"
|
||||||
@@ -13,11 +13,17 @@ fn default_permission_timeout_secs() -> u64 {
|
|||||||
#[derive(Deserialize, Clone, Debug)]
|
#[derive(Deserialize, Clone, Debug)]
|
||||||
pub struct BotConfig {
|
pub struct BotConfig {
|
||||||
/// Matrix homeserver URL, e.g. `https://matrix.example.com`
|
/// Matrix homeserver URL, e.g. `https://matrix.example.com`
|
||||||
pub homeserver: String,
|
/// Only required when `transport = "matrix"` (the default).
|
||||||
|
#[serde(default)]
|
||||||
|
pub homeserver: Option<String>,
|
||||||
/// Bot user ID, e.g. `@storykit:example.com`
|
/// Bot user ID, e.g. `@storykit:example.com`
|
||||||
pub username: String,
|
/// Only required when `transport = "matrix"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub username: Option<String>,
|
||||||
/// Bot password
|
/// Bot password
|
||||||
pub password: String,
|
/// Only required when `transport = "matrix"`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub password: Option<String>,
|
||||||
/// Matrix room IDs to join, e.g. `["!roomid:example.com"]`.
|
/// Matrix room IDs to join, e.g. `["!roomid:example.com"]`.
|
||||||
/// Use an array for multiple rooms; a single string is accepted via the
|
/// Use an array for multiple rooms; a single string is accepted via the
|
||||||
/// deprecated `room_id` key for backwards compatibility.
|
/// deprecated `room_id` key for backwards compatibility.
|
||||||
@@ -87,6 +93,26 @@ pub struct BotConfig {
|
|||||||
/// use. Defaults to `"pipeline_notification"`.
|
/// use. Defaults to `"pipeline_notification"`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub whatsapp_notification_template: Option<String>,
|
pub whatsapp_notification_template: Option<String>,
|
||||||
|
/// Which WhatsApp provider to use: `"meta"` (default, direct Graph API)
|
||||||
|
/// or `"twilio"` (Twilio REST API as alternative to Meta).
|
||||||
|
///
|
||||||
|
/// When `"twilio"`, the Twilio-specific fields below are required instead
|
||||||
|
/// of the Meta `whatsapp_phone_number_id` / `whatsapp_access_token` pair.
|
||||||
|
#[serde(default = "default_whatsapp_provider")]
|
||||||
|
pub whatsapp_provider: String,
|
||||||
|
|
||||||
|
// ── Twilio WhatsApp fields ─────────────────────────────────────────
|
||||||
|
// Only required when `transport = "whatsapp"` and `whatsapp_provider = "twilio"`.
|
||||||
|
|
||||||
|
/// Twilio Account SID (starts with `AC`).
|
||||||
|
#[serde(default)]
|
||||||
|
pub twilio_account_sid: Option<String>,
|
||||||
|
/// Twilio Auth Token.
|
||||||
|
#[serde(default)]
|
||||||
|
pub twilio_auth_token: Option<String>,
|
||||||
|
/// Twilio WhatsApp sender number in E.164 format, e.g. `+14155551234`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub twilio_whatsapp_number: Option<String>,
|
||||||
|
|
||||||
// ── Slack Bot API fields ─────────────────────────────────────────
|
// ── Slack Bot API fields ─────────────────────────────────────────
|
||||||
// These are only required when `transport = "slack"`.
|
// These are only required when `transport = "slack"`.
|
||||||
@@ -106,6 +132,10 @@ fn default_transport() -> String {
|
|||||||
"matrix".to_string()
|
"matrix".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_whatsapp_provider() -> String {
|
||||||
|
"meta".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
impl BotConfig {
|
impl BotConfig {
|
||||||
/// Load bot configuration from `.storkit/bot.toml`.
|
/// Load bot configuration from `.storkit/bot.toml`.
|
||||||
///
|
///
|
||||||
@@ -133,7 +163,31 @@ impl BotConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if config.transport == "whatsapp" {
|
if config.transport == "whatsapp" {
|
||||||
// Validate WhatsApp-specific fields.
|
if config.whatsapp_provider == "twilio" {
|
||||||
|
// Validate Twilio-specific fields.
|
||||||
|
if config.twilio_account_sid.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||||
|
twilio_account_sid"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if config.twilio_auth_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||||
|
twilio_auth_token"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if config.twilio_whatsapp_number.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: whatsapp_provider=\"twilio\" requires \
|
||||||
|
twilio_whatsapp_number"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Validate Meta (default) WhatsApp fields.
|
||||||
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
|
if config.whatsapp_phone_number_id.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
"[bot] bot.toml: transport=\"whatsapp\" requires \
|
||||||
@@ -155,6 +209,7 @@ impl BotConfig {
|
|||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if config.transport == "slack" {
|
} else if config.transport == "slack" {
|
||||||
// Validate Slack-specific fields.
|
// Validate Slack-specific fields.
|
||||||
if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
|
if config.slack_bot_token.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
@@ -178,13 +233,34 @@ impl BotConfig {
|
|||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
} else if config.room_ids.is_empty() {
|
} else {
|
||||||
|
// Default transport is Matrix — validate Matrix-specific fields.
|
||||||
|
if config.homeserver.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: transport=\"matrix\" requires homeserver"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if config.username.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: transport=\"matrix\" requires username"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if config.password.as_ref().is_none_or(|s| s.is_empty()) {
|
||||||
|
eprintln!(
|
||||||
|
"[bot] bot.toml: transport=\"matrix\" requires password"
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if config.room_ids.is_empty() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"[matrix-bot] bot.toml has no room_ids configured — \
|
"[matrix-bot] bot.toml has no room_ids configured — \
|
||||||
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
|
add `room_ids = [\"!roomid:example.com\"]` to bot.toml"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Some(config)
|
Some(config)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +362,8 @@ enabled = true
|
|||||||
let result = BotConfig::load(tmp.path());
|
let result = BotConfig::load(tmp.path());
|
||||||
assert!(result.is_some());
|
assert!(result.is_some());
|
||||||
let config = result.unwrap();
|
let config = result.unwrap();
|
||||||
assert_eq!(config.homeserver, "https://matrix.example.com");
|
assert_eq!(config.homeserver.as_deref(), Some("https://matrix.example.com"));
|
||||||
assert_eq!(config.username, "@bot:example.com");
|
assert_eq!(config.username.as_deref(), Some("@bot:example.com"));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
config.effective_room_ids(),
|
config.effective_room_ids(),
|
||||||
&["!abc:example.com", "!def:example.com"]
|
&["!abc:example.com", "!def:example.com"]
|
||||||
@@ -722,6 +798,128 @@ whatsapp_access_token = "EAAtoken"
|
|||||||
assert!(BotConfig::load(tmp.path()).is_none());
|
assert!(BotConfig::load(tmp.path()).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Twilio config tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_twilio_whatsapp_reads_config() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".storkit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_provider = "twilio"
|
||||||
|
twilio_account_sid = "ACtest"
|
||||||
|
twilio_auth_token = "authtest"
|
||||||
|
twilio_whatsapp_number = "+14155551234"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.transport, "whatsapp");
|
||||||
|
assert_eq!(config.whatsapp_provider, "twilio");
|
||||||
|
assert_eq!(config.twilio_account_sid.as_deref(), Some("ACtest"));
|
||||||
|
assert_eq!(config.twilio_auth_token.as_deref(), Some("authtest"));
|
||||||
|
assert_eq!(
|
||||||
|
config.twilio_whatsapp_number.as_deref(),
|
||||||
|
Some("+14155551234")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_whatsapp_provider_defaults_to_meta() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".storkit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_phone_number_id = "123456"
|
||||||
|
whatsapp_access_token = "EAAtoken"
|
||||||
|
whatsapp_verify_token = "my-verify"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let config = BotConfig::load(tmp.path()).unwrap();
|
||||||
|
assert_eq!(config.whatsapp_provider, "meta");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_twilio_returns_none_when_missing_account_sid() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".storkit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_provider = "twilio"
|
||||||
|
twilio_auth_token = "authtest"
|
||||||
|
twilio_whatsapp_number = "+14155551234"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(BotConfig::load(tmp.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_twilio_returns_none_when_missing_auth_token() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".storkit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_provider = "twilio"
|
||||||
|
twilio_account_sid = "ACtest"
|
||||||
|
twilio_whatsapp_number = "+14155551234"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(BotConfig::load(tmp.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_twilio_returns_none_when_missing_whatsapp_number() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let sk = tmp.path().join(".storkit");
|
||||||
|
fs::create_dir_all(&sk).unwrap();
|
||||||
|
fs::write(
|
||||||
|
sk.join("bot.toml"),
|
||||||
|
r#"
|
||||||
|
homeserver = "https://matrix.example.com"
|
||||||
|
username = "@bot:example.com"
|
||||||
|
password = "secret"
|
||||||
|
enabled = true
|
||||||
|
transport = "whatsapp"
|
||||||
|
whatsapp_provider = "twilio"
|
||||||
|
twilio_account_sid = "ACtest"
|
||||||
|
twilio_auth_token = "authtest"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(BotConfig::load(tmp.path()).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
// ── Slack config tests ─────────────────────────────────────────────
|
// ── Slack config tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -14,7 +14,7 @@ use tokio::sync::{Mutex as TokioMutex, watch};
|
|||||||
|
|
||||||
use crate::agents::{AgentPool, AgentStatus};
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::transport::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
|
|
||||||
use super::bot::markdown_to_html;
|
use super::bot::markdown_to_html;
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
//! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in
|
//! Multi-room support: configure `room_ids = ["!room1:…", "!room2:…"]` in
|
||||||
//! `bot.toml`. Each room maintains its own independent conversation history.
|
//! `bot.toml`. Each room maintains its own independent conversation history.
|
||||||
|
|
||||||
|
pub mod assign;
|
||||||
mod bot;
|
mod bot;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -22,6 +23,7 @@ pub mod delete;
|
|||||||
pub mod htop;
|
pub mod htop;
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
pub mod reset;
|
pub mod reset;
|
||||||
|
pub mod rmtree;
|
||||||
pub mod start;
|
pub mod start;
|
||||||
pub mod notifications;
|
pub mod notifications;
|
||||||
pub mod transport_impl;
|
pub mod transport_impl;
|
||||||
@@ -82,7 +84,7 @@ pub fn spawn_bot(
|
|||||||
|
|
||||||
crate::slog!(
|
crate::slog!(
|
||||||
"[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}",
|
"[matrix-bot] Starting Matrix bot → homeserver={} rooms={:?}",
|
||||||
config.homeserver,
|
config.homeserver.as_deref().unwrap_or("(none)"),
|
||||||
config.effective_room_ids()
|
config.effective_room_ids()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
use crate::io::story_metadata::parse_front_matter;
|
use crate::io::story_metadata::parse_front_matter;
|
||||||
use crate::io::watcher::WatcherEvent;
|
use crate::io::watcher::WatcherEvent;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::transport::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -265,7 +265,7 @@ pub fn spawn_notification_listener(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use crate::transport::MessageId;
|
use crate::chat::MessageId;
|
||||||
|
|
||||||
// ── MockTransport ───────────────────────────────────────────────────────
|
// ── MockTransport ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl crate::transport::ChatTransport for MockTransport {
|
impl crate::chat::ChatTransport for MockTransport {
|
||||||
async fn send_message(&self, room_id: &str, plain: &str, html: &str) -> Result<MessageId, String> {
|
async fn send_message(&self, room_id: &str, plain: &str, html: &str) -> Result<MessageId, String> {
|
||||||
self.calls.lock().unwrap().push((room_id.to_string(), plain.to_string(), html.to_string()));
|
self.calls.lock().unwrap().push((room_id.to_string(), plain.to_string(), html.to_string()));
|
||||||
Ok("mock-msg-id".to_string())
|
Ok("mock-msg-id".to_string())
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
//! with clean context. File-system memories (auto-memory directory) are not
|
//! with clean context. File-system memories (auto-memory directory) are not
|
||||||
//! affected — only the in-memory/persisted conversation state is cleared.
|
//! affected — only the in-memory/persisted conversation state is cleared.
|
||||||
|
|
||||||
use crate::matrix::bot::{ConversationHistory, RoomConversation};
|
use crate::chat::transport::matrix::bot::{ConversationHistory, RoomConversation};
|
||||||
use matrix_sdk::ruma::OwnedRoomId;
|
use matrix_sdk::ruma::OwnedRoomId;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ pub async fn handle_reset(
|
|||||||
let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default);
|
let conv = guard.entry(room_id.clone()).or_insert_with(RoomConversation::default);
|
||||||
conv.session_id = None;
|
conv.session_id = None;
|
||||||
conv.entries.clear();
|
conv.entries.clear();
|
||||||
crate::matrix::bot::save_history(project_root, &guard);
|
crate::chat::transport::matrix::bot::save_history(project_root, &guard);
|
||||||
}
|
}
|
||||||
crate::slog!("[matrix-bot] reset command: cleared session for room {room_id} (bot={bot_name})");
|
crate::slog!("[matrix-bot] reset command: cleared session for room {room_id} (bot={bot_name})");
|
||||||
"Session reset. Starting fresh — previous context has been cleared.".to_string()
|
"Session reset. Starting fresh — previous context has been cleared.".to_string()
|
||||||
@@ -138,7 +138,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn handle_reset_clears_session_and_entries() {
|
async fn handle_reset_clears_session_and_entries() {
|
||||||
use crate::matrix::bot::{ConversationEntry, ConversationRole};
|
use crate::chat::transport::matrix::bot::{ConversationEntry, ConversationRole};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
282
server/src/chat/transport/matrix/rmtree.rs
Normal file
282
server/src/chat/transport/matrix/rmtree.rs
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
//! Rmtree command: delete the worktree for a story without deleting the story file.
|
||||||
|
//!
|
||||||
|
//! `{bot_name} rmtree {number}` finds the worktree for the given story number,
|
||||||
|
//! stops any running agent, and removes the worktree directory and branch.
|
||||||
|
//! The story file in the pipeline is left untouched.
|
||||||
|
|
||||||
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// A parsed rmtree command from a Matrix message body.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum RmtreeCommand {
|
||||||
|
/// Remove the worktree for the story with this number.
|
||||||
|
Rmtree { story_number: String },
|
||||||
|
/// The user typed `rmtree` but without a valid numeric argument.
|
||||||
|
BadArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse an rmtree command from a raw Matrix message body.
|
||||||
|
///
|
||||||
|
/// Strips the bot mention prefix and checks whether the first word is `rmtree`.
|
||||||
|
/// Returns `None` when the message is not an rmtree command at all.
|
||||||
|
pub fn extract_rmtree_command(
|
||||||
|
message: &str,
|
||||||
|
bot_name: &str,
|
||||||
|
bot_user_id: &str,
|
||||||
|
) -> Option<RmtreeCommand> {
|
||||||
|
let stripped = strip_mention(message, bot_name, bot_user_id);
|
||||||
|
let trimmed = stripped
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
|
|
||||||
|
let (cmd, args) = match trimmed.split_once(char::is_whitespace) {
|
||||||
|
Some((c, a)) => (c, a.trim()),
|
||||||
|
None => (trimmed, ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !cmd.eq_ignore_ascii_case("rmtree") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !args.is_empty() && args.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
Some(RmtreeCommand::Rmtree {
|
||||||
|
story_number: args.to_string(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Some(RmtreeCommand::BadArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle an rmtree command asynchronously.
|
||||||
|
///
|
||||||
|
/// Finds the worktree for `story_number` under `.storkit/worktrees/`, stops any
|
||||||
|
/// running agent, and removes the worktree directory and its feature branch.
|
||||||
|
/// Returns a markdown-formatted response string.
|
||||||
|
pub async fn handle_rmtree(
|
||||||
|
bot_name: &str,
|
||||||
|
story_number: &str,
|
||||||
|
project_root: &Path,
|
||||||
|
agents: &AgentPool,
|
||||||
|
) -> String {
|
||||||
|
// Find the story_id by listing worktree directories.
|
||||||
|
let worktrees = match crate::worktree::list_worktrees(project_root) {
|
||||||
|
Ok(wt) => wt,
|
||||||
|
Err(e) => return format!("Failed to list worktrees: {e}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry = worktrees.into_iter().find(|e| {
|
||||||
|
e.story_id
|
||||||
|
.split('_')
|
||||||
|
.next()
|
||||||
|
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||||
|
.map(|n| n == story_number)
|
||||||
|
.unwrap_or(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
let story_id = match entry {
|
||||||
|
Some(e) => e.story_id,
|
||||||
|
None => {
|
||||||
|
return format!("No worktree found for story **{story_number}**.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stop any running or pending agents for this story.
|
||||||
|
let running_agents: Vec<(String, String)> = agents
|
||||||
|
.list_agents()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|a| {
|
||||||
|
a.story_id == story_id
|
||||||
|
&& matches!(a.status, AgentStatus::Running | AgentStatus::Pending)
|
||||||
|
})
|
||||||
|
.map(|a| (a.story_id.clone(), a.agent_name.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut stopped_agents: Vec<String> = Vec::new();
|
||||||
|
for (sid, agent_name) in &running_agents {
|
||||||
|
if let Err(e) = agents.stop_agent(project_root, sid, agent_name).await {
|
||||||
|
return format!("Failed to stop agent '{agent_name}' for story {story_number}: {e}");
|
||||||
|
}
|
||||||
|
stopped_agents.push(agent_name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the worktree.
|
||||||
|
if let Err(e) = crate::worktree::prune_worktree_sync(project_root, &story_id) {
|
||||||
|
return format!("Failed to remove worktree for story {story_number}: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::slog!(
|
||||||
|
"[matrix-bot] rmtree command: removed worktree for {story_id} (bot={bot_name})"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut response = format!("Removed worktree for **{story_id}**.");
|
||||||
|
if !stopped_agents.is_empty() {
|
||||||
|
let agent_list = stopped_agents.join(", ");
|
||||||
|
response.push_str(&format!(" Stopped agent(s): {agent_list}."));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip the bot mention prefix from a raw Matrix message body.
|
||||||
|
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
|
||||||
|
let trimmed = message.trim();
|
||||||
|
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
if let Some(localpart) = bot_user_id.split(':').next()
|
||||||
|
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
|
||||||
|
{
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
trimmed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||||
|
if text.len() < prefix.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let rest = &text[prefix.len()..];
|
||||||
|
match rest.chars().next() {
|
||||||
|
None => Some(rest),
|
||||||
|
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
|
||||||
|
_ => Some(rest),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// -- extract_rmtree_command ---------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_full_user_id() {
|
||||||
|
let cmd = extract_rmtree_command(
|
||||||
|
"@timmy:home.local rmtree 42",
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:home.local",
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(RmtreeCommand::Rmtree {
|
||||||
|
story_number: "42".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_display_name() {
|
||||||
|
let cmd = extract_rmtree_command("Timmy rmtree 310", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(RmtreeCommand::Rmtree {
|
||||||
|
story_number: "310".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_with_localpart() {
|
||||||
|
let cmd = extract_rmtree_command("@timmy rmtree 7", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(RmtreeCommand::Rmtree {
|
||||||
|
story_number: "7".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_case_insensitive_command() {
|
||||||
|
let cmd = extract_rmtree_command("Timmy RMTREE 99", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(
|
||||||
|
cmd,
|
||||||
|
Some(RmtreeCommand::Rmtree {
|
||||||
|
story_number: "99".to_string()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_no_args_is_bad_args() {
|
||||||
|
let cmd = extract_rmtree_command("Timmy rmtree", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, Some(RmtreeCommand::BadArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_non_numeric_arg_is_bad_args() {
|
||||||
|
let cmd = extract_rmtree_command("Timmy rmtree foo", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, Some(RmtreeCommand::BadArgs));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_non_rmtree_command_returns_none() {
|
||||||
|
let cmd = extract_rmtree_command("Timmy help", "Timmy", "@timmy:home.local");
|
||||||
|
assert_eq!(cmd, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- handle_rmtree (integration-style, uses temp filesystem) -----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_rmtree_returns_not_found_for_unknown_number() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_root = tmp.path();
|
||||||
|
std::fs::create_dir_all(project_root.join(".storkit").join("worktrees")).unwrap();
|
||||||
|
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||||
|
let response = handle_rmtree("Timmy", "999", project_root, &agents).await;
|
||||||
|
assert!(
|
||||||
|
response.contains("No worktree found") && response.contains("999"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn handle_rmtree_removes_worktree_and_confirms() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let project_root = tmp.path().join("my-project");
|
||||||
|
std::fs::create_dir_all(&project_root).unwrap();
|
||||||
|
|
||||||
|
// Init a git repo so worktree ops work.
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["init"])
|
||||||
|
.current_dir(&project_root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["commit", "--allow-empty", "-m", "init"])
|
||||||
|
.current_dir(&project_root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Create a real git worktree for story 42.
|
||||||
|
let story_id = "42_story_some_feature";
|
||||||
|
let wt_path = crate::worktree::worktree_path(&project_root, story_id);
|
||||||
|
let branch = format!("feature/story-{story_id}");
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["worktree", "add", &wt_path.to_string_lossy(), "-b", &branch])
|
||||||
|
.current_dir(&project_root)
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
assert!(wt_path.exists(), "worktree should exist before rmtree");
|
||||||
|
|
||||||
|
let agents = std::sync::Arc::new(crate::agents::AgentPool::new_test(3000));
|
||||||
|
let response = handle_rmtree("Timmy", "42", &project_root, &agents).await;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
response.contains("42_story_some_feature"),
|
||||||
|
"unexpected response: {response}"
|
||||||
|
);
|
||||||
|
assert!(!wt_path.exists(), "worktree directory should be removed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -356,14 +356,14 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn start_command_is_registered() {
|
fn start_command_is_registered() {
|
||||||
use crate::matrix::commands::commands;
|
use crate::chat::transport::matrix::commands::commands;
|
||||||
let found = commands().iter().any(|c| c.name == "start");
|
let found = commands().iter().any(|c| c.name == "start");
|
||||||
assert!(found, "start command must be in the registry");
|
assert!(found, "start command must be in the registry");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn start_command_appears_in_help() {
|
fn start_command_appears_in_help() {
|
||||||
let result = crate::matrix::commands::tests::try_cmd_addressed(
|
let result = crate::chat::transport::matrix::commands::tests::try_cmd_addressed(
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"@timmy:homeserver.local",
|
"@timmy:homeserver.local",
|
||||||
"@timmy help",
|
"@timmy help",
|
||||||
@@ -378,7 +378,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn start_command_falls_through_to_none_in_registry() {
|
fn start_command_falls_through_to_none_in_registry() {
|
||||||
// The start handler in the registry returns None (handled async in bot.rs).
|
// The start handler in the registry returns None (handled async in bot.rs).
|
||||||
let result = crate::matrix::commands::tests::try_cmd_addressed(
|
let result = crate::chat::transport::matrix::commands::tests::try_cmd_addressed(
|
||||||
"Timmy",
|
"Timmy",
|
||||||
"@timmy:homeserver.local",
|
"@timmy:homeserver.local",
|
||||||
"@timmy start 42",
|
"@timmy start 42",
|
||||||
@@ -10,7 +10,7 @@ use matrix_sdk::ruma::events::room::message::{
|
|||||||
ReplacementMetadata, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
|
ReplacementMetadata, RoomMessageEventContent, RoomMessageEventContentWithoutRelation,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::transport::{ChatTransport, MessageId};
|
use crate::chat::{ChatTransport, MessageId};
|
||||||
|
|
||||||
/// Matrix-backed [`ChatTransport`] implementation.
|
/// Matrix-backed [`ChatTransport`] implementation.
|
||||||
///
|
///
|
||||||
3
server/src/chat/transport/mod.rs
Normal file
3
server/src/chat/transport/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod matrix;
|
||||||
|
pub mod slack;
|
||||||
|
pub mod whatsapp;
|
||||||
@@ -14,9 +14,9 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use crate::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::transport::{ChatTransport, MessageId};
|
use crate::chat::{ChatTransport, MessageId};
|
||||||
|
|
||||||
// ── Slack API base URL (overridable for tests) ──────────────────────────
|
// ── Slack API base URL (overridable for tests) ──────────────────────────
|
||||||
|
|
||||||
@@ -669,7 +669,7 @@ pub async fn slash_command_receive(
|
|||||||
format!("{} {keyword} {}", ctx.bot_name, payload.text)
|
format!("{} {keyword} {}", ctx.bot_name, payload.text)
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::matrix::commands::{CommandDispatch, try_handle_command};
|
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
|
||||||
|
|
||||||
let dispatch = CommandDispatch {
|
let dispatch = CommandDispatch {
|
||||||
bot_name: &ctx.bot_name,
|
bot_name: &ctx.bot_name,
|
||||||
@@ -701,7 +701,7 @@ async fn handle_incoming_message(
|
|||||||
user: &str,
|
user: &str,
|
||||||
message: &str,
|
message: &str,
|
||||||
) {
|
) {
|
||||||
use crate::matrix::commands::{CommandDispatch, try_handle_command};
|
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
|
||||||
|
|
||||||
let dispatch = CommandDispatch {
|
let dispatch = CommandDispatch {
|
||||||
bot_name: &ctx.bot_name,
|
bot_name: &ctx.bot_name,
|
||||||
@@ -721,12 +721,12 @@ async fn handle_incoming_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for async commands (htop, delete).
|
// Check for async commands (htop, delete).
|
||||||
if let Some(htop_cmd) = crate::matrix::htop::extract_htop_command(
|
if let Some(htop_cmd) = crate::chat::transport::matrix::htop::extract_htop_command(
|
||||||
message,
|
message,
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&ctx.bot_user_id,
|
&ctx.bot_user_id,
|
||||||
) {
|
) {
|
||||||
use crate::matrix::htop::HtopCommand;
|
use crate::chat::transport::matrix::htop::HtopCommand;
|
||||||
slog!("[slack] Handling htop command from {user} in {channel}");
|
slog!("[slack] Handling htop command from {user} in {channel}");
|
||||||
match htop_cmd {
|
match htop_cmd {
|
||||||
HtopCommand::Stop => {
|
HtopCommand::Stop => {
|
||||||
@@ -738,7 +738,7 @@ async fn handle_incoming_message(
|
|||||||
HtopCommand::Start { duration_secs } => {
|
HtopCommand::Start { duration_secs } => {
|
||||||
// On Slack, htop uses native message editing for live updates.
|
// On Slack, htop uses native message editing for live updates.
|
||||||
let snapshot =
|
let snapshot =
|
||||||
crate::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
|
crate::chat::transport::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
|
||||||
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
|
let msg_id = match ctx.transport.send_message(channel, &snapshot, "").await {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -755,7 +755,7 @@ async fn handle_incoming_message(
|
|||||||
let total_ticks = (duration_secs as usize) / 2;
|
let total_ticks = (duration_secs as usize) / 2;
|
||||||
for tick in 1..=total_ticks {
|
for tick in 1..=total_ticks {
|
||||||
tokio::time::sleep(interval).await;
|
tokio::time::sleep(interval).await;
|
||||||
let updated = crate::matrix::htop::build_htop_message(
|
let updated = crate::chat::transport::matrix::htop::build_htop_message(
|
||||||
&agents,
|
&agents,
|
||||||
(tick * 2) as u32,
|
(tick * 2) as u32,
|
||||||
duration_secs,
|
duration_secs,
|
||||||
@@ -773,15 +773,15 @@ async fn handle_incoming_message(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(del_cmd) = crate::matrix::delete::extract_delete_command(
|
if let Some(del_cmd) = crate::chat::transport::matrix::delete::extract_delete_command(
|
||||||
message,
|
message,
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&ctx.bot_user_id,
|
&ctx.bot_user_id,
|
||||||
) {
|
) {
|
||||||
let response = match del_cmd {
|
let response = match del_cmd {
|
||||||
crate::matrix::delete::DeleteCommand::Delete { story_number } => {
|
crate::chat::transport::matrix::delete::DeleteCommand::Delete { story_number } => {
|
||||||
slog!("[slack] Handling delete command from {user}: story {story_number}");
|
slog!("[slack] Handling delete command from {user}: story {story_number}");
|
||||||
crate::matrix::delete::handle_delete(
|
crate::chat::transport::matrix::delete::handle_delete(
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
&ctx.project_root,
|
&ctx.project_root,
|
||||||
@@ -789,7 +789,7 @@ async fn handle_incoming_message(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
crate::matrix::delete::DeleteCommand::BadArgs => {
|
crate::chat::transport::matrix::delete::DeleteCommand::BadArgs => {
|
||||||
format!("Usage: `{} delete <number>`", ctx.bot_name)
|
format!("Usage: `{} delete <number>`", ctx.bot_name)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -810,7 +810,7 @@ async fn handle_llm_message(
|
|||||||
user_message: &str,
|
user_message: &str,
|
||||||
) {
|
) {
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use crate::matrix::drain_complete_paragraphs;
|
use crate::chat::transport::matrix::drain_complete_paragraphs;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
@@ -1408,7 +1408,7 @@ mod tests {
|
|||||||
fn slash_command_dispatches_through_command_registry() {
|
fn slash_command_dispatches_through_command_registry() {
|
||||||
// Verify that the synthetic message built by the slash handler
|
// Verify that the synthetic message built by the slash handler
|
||||||
// correctly dispatches through try_handle_command.
|
// correctly dispatches through try_handle_command.
|
||||||
use crate::matrix::commands::{CommandDispatch, try_handle_command};
|
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
|
||||||
|
|
||||||
let agents = test_agents();
|
let agents = test_agents();
|
||||||
let ambient_rooms = test_ambient_rooms();
|
let ambient_rooms = test_ambient_rooms();
|
||||||
@@ -1435,7 +1435,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn slash_command_show_passes_args_through_registry() {
|
fn slash_command_show_passes_args_through_registry() {
|
||||||
use crate::matrix::commands::{CommandDispatch, try_handle_command};
|
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
|
||||||
|
|
||||||
let agents = test_agents();
|
let agents = test_agents();
|
||||||
let ambient_rooms = test_ambient_rooms();
|
let ambient_rooms = test_ambient_rooms();
|
||||||
@@ -14,13 +14,14 @@ use std::sync::Arc;
|
|||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use crate::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
|
use crate::chat::{ChatTransport, MessageId};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::transport::{ChatTransport, MessageId};
|
|
||||||
|
|
||||||
// ── Graph API base URL (overridable for tests) ──────────────────────────
|
// ── API base URLs (overridable for tests) ────────────────────────────────
|
||||||
|
|
||||||
const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0";
|
const GRAPH_API_BASE: &str = "https://graph.facebook.com/v21.0";
|
||||||
|
const TWILIO_API_BASE: &str = "https://api.twilio.com";
|
||||||
|
|
||||||
/// Graph API error code indicating the 24-hour messaging window has elapsed.
|
/// Graph API error code indicating the 24-hour messaging window has elapsed.
|
||||||
///
|
///
|
||||||
@@ -45,6 +46,7 @@ const OUTSIDE_WINDOW_ERR: &str = "OUTSIDE_MESSAGING_WINDOW";
|
|||||||
/// between free-form text and a template message.
|
/// between free-form text and a template message.
|
||||||
pub struct MessagingWindowTracker {
|
pub struct MessagingWindowTracker {
|
||||||
last_message: std::sync::Mutex<HashMap<String, std::time::Instant>>,
|
last_message: std::sync::Mutex<HashMap<String, std::time::Instant>>,
|
||||||
|
#[allow(dead_code)] // Used by Meta provider path (is_within_window → send_notification)
|
||||||
window_duration: std::time::Duration,
|
window_duration: std::time::Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +84,7 @@ impl MessagingWindowTracker {
|
|||||||
|
|
||||||
/// Returns `true` when the last inbound message from `phone` arrived within
|
/// Returns `true` when the last inbound message from `phone` arrived within
|
||||||
/// the 24-hour window, meaning free-form replies are still permitted.
|
/// the 24-hour window, meaning free-form replies are still permitted.
|
||||||
|
#[allow(dead_code)] // Used by Meta provider path (send_notification)
|
||||||
pub fn is_within_window(&self, phone: &str) -> bool {
|
pub fn is_within_window(&self, phone: &str) -> bool {
|
||||||
let map = self.last_message.lock().unwrap();
|
let map = self.last_message.lock().unwrap();
|
||||||
match map.get(phone) {
|
match map.get(phone) {
|
||||||
@@ -104,6 +107,7 @@ pub struct WhatsAppTransport {
|
|||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
/// Name of the approved Meta message template used for notifications
|
/// Name of the approved Meta message template used for notifications
|
||||||
/// outside the 24-hour messaging window.
|
/// outside the 24-hour messaging window.
|
||||||
|
#[allow(dead_code)] // Used by Meta provider path (send_template_notification)
|
||||||
notification_template_name: String,
|
notification_template_name: String,
|
||||||
/// Optional base URL override for tests.
|
/// Optional base URL override for tests.
|
||||||
api_base: String,
|
api_base: String,
|
||||||
@@ -125,11 +129,7 @@ impl WhatsAppTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
fn with_api_base(
|
fn with_api_base(phone_number_id: String, access_token: String, api_base: String) -> Self {
|
||||||
phone_number_id: String,
|
|
||||||
access_token: String,
|
|
||||||
api_base: String,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
phone_number_id,
|
phone_number_id,
|
||||||
access_token,
|
access_token,
|
||||||
@@ -208,6 +208,7 @@ impl WhatsAppTransport {
|
|||||||
///
|
///
|
||||||
/// The template body is expected to accept two positional parameters:
|
/// The template body is expected to accept two positional parameters:
|
||||||
/// `{{1}}` = story name, `{{2}}` = pipeline stage.
|
/// `{{1}}` = story name, `{{2}}` = pipeline stage.
|
||||||
|
#[allow(dead_code)] // Meta provider path — template fallback for expired 24h window
|
||||||
pub async fn send_template_notification(
|
pub async fn send_template_notification(
|
||||||
&self,
|
&self,
|
||||||
to: &str,
|
to: &str,
|
||||||
@@ -281,6 +282,7 @@ impl WhatsAppTransport {
|
|||||||
///
|
///
|
||||||
/// This method never crashes on a messaging-window error — it always
|
/// This method never crashes on a messaging-window error — it always
|
||||||
/// attempts the template fallback and logs what happened.
|
/// attempts the template fallback and logs what happened.
|
||||||
|
#[allow(dead_code)] // Meta provider path — window-aware notification dispatch
|
||||||
pub async fn send_notification(
|
pub async fn send_notification(
|
||||||
&self,
|
&self,
|
||||||
to: &str,
|
to: &str,
|
||||||
@@ -357,6 +359,183 @@ impl ChatTransport for WhatsAppTransport {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Twilio Transport ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// WhatsApp transport that routes through Twilio's REST API.
|
||||||
|
///
|
||||||
|
/// Sends messages via `POST {TWILIO_API_BASE}/2010-04-01/Accounts/{account_sid}/Messages.json`
|
||||||
|
/// using HTTP Basic Auth (Account SID as username, Auth Token as password).
|
||||||
|
///
|
||||||
|
/// Inbound messages from Twilio arrive as `application/x-www-form-urlencoded`
|
||||||
|
/// POST bodies; use [`extract_twilio_text_messages`] to parse them.
|
||||||
|
pub struct TwilioWhatsAppTransport {
|
||||||
|
account_sid: String,
|
||||||
|
auth_token: String,
|
||||||
|
/// Sender number in E.164 format, e.g. `+14155551234`.
|
||||||
|
from_number: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
/// Optional base URL override for tests.
|
||||||
|
api_base: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TwilioWhatsAppTransport {
|
||||||
|
pub fn new(account_sid: String, auth_token: String, from_number: String) -> Self {
|
||||||
|
Self {
|
||||||
|
account_sid,
|
||||||
|
auth_token,
|
||||||
|
from_number,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
api_base: TWILIO_API_BASE.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn with_api_base(
|
||||||
|
account_sid: String,
|
||||||
|
auth_token: String,
|
||||||
|
from_number: String,
|
||||||
|
api_base: String,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
account_sid,
|
||||||
|
auth_token,
|
||||||
|
from_number,
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
api_base,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a WhatsApp message via Twilio's Messaging REST API.
|
||||||
|
async fn send_text(&self, to: &str, body: &str) -> Result<String, String> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/2010-04-01/Accounts/{}/Messages.json",
|
||||||
|
self.api_base, self.account_sid
|
||||||
|
);
|
||||||
|
|
||||||
|
// Twilio expects the WhatsApp number with a "whatsapp:" prefix.
|
||||||
|
let from = if self.from_number.starts_with("whatsapp:") {
|
||||||
|
self.from_number.clone()
|
||||||
|
} else {
|
||||||
|
format!("whatsapp:{}", self.from_number)
|
||||||
|
};
|
||||||
|
let to_wa = if to.starts_with("whatsapp:") {
|
||||||
|
to.to_string()
|
||||||
|
} else {
|
||||||
|
format!("whatsapp:{}", to)
|
||||||
|
};
|
||||||
|
|
||||||
|
let params = [
|
||||||
|
("From", from.as_str()),
|
||||||
|
("To", to_wa.as_str()),
|
||||||
|
("Body", body),
|
||||||
|
];
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(&url)
|
||||||
|
.basic_auth(&self.account_sid, Some(&self.auth_token))
|
||||||
|
.form(¶ms)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Twilio API request failed: {e}"))?;
|
||||||
|
|
||||||
|
let status = resp.status();
|
||||||
|
let resp_text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| "<no body>".to_string());
|
||||||
|
|
||||||
|
if !status.is_success() {
|
||||||
|
return Err(format!("Twilio API returned {status}: {resp_text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: TwilioSendResponse = serde_json::from_str(&resp_text)
|
||||||
|
.map_err(|e| format!("Failed to parse Twilio API response: {e} — body: {resp_text}"))?;
|
||||||
|
|
||||||
|
Ok(parsed.sid.unwrap_or_default())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ChatTransport for TwilioWhatsAppTransport {
|
||||||
|
async fn send_message(
|
||||||
|
&self,
|
||||||
|
recipient: &str,
|
||||||
|
plain: &str,
|
||||||
|
_html: &str,
|
||||||
|
) -> Result<MessageId, String> {
|
||||||
|
slog!("[whatsapp/twilio] send_message to {recipient}: {plain:.80}");
|
||||||
|
self.send_text(recipient, plain).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit_message(
|
||||||
|
&self,
|
||||||
|
recipient: &str,
|
||||||
|
_original_message_id: &str,
|
||||||
|
plain: &str,
|
||||||
|
html: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
// Twilio does not support message editing — send a new message.
|
||||||
|
slog!(
|
||||||
|
"[whatsapp/twilio] edit_message — Twilio does not support edits, sending new message"
|
||||||
|
);
|
||||||
|
self.send_message(recipient, plain, html).await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_typing(&self, _recipient: &str, _typing: bool) -> Result<(), String> {
|
||||||
|
// Twilio WhatsApp API does not expose typing indicators.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Twilio API request/response types ──────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct TwilioSendResponse {
|
||||||
|
sid: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Twilio webhook types (Twilio → us) ─────────────────────────────────
|
||||||
|
|
||||||
|
/// Form-encoded fields from a Twilio WhatsApp inbound webhook POST.
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub struct TwilioWebhookForm {
|
||||||
|
/// Sender number with `whatsapp:` prefix, e.g. `whatsapp:+15551234567`.
|
||||||
|
#[serde(rename = "From")]
|
||||||
|
pub from: Option<String>,
|
||||||
|
/// Message body text.
|
||||||
|
#[serde(rename = "Body")]
|
||||||
|
pub body: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract text messages from a Twilio form-encoded webhook body.
|
||||||
|
///
|
||||||
|
/// Returns `(sender_phone, message_body)` pairs, with the `whatsapp:` prefix
|
||||||
|
/// stripped from the sender number.
|
||||||
|
pub fn extract_twilio_text_messages(bytes: &[u8]) -> Vec<(String, String)> {
|
||||||
|
let form: TwilioWebhookForm = match serde_urlencoded::from_bytes(bytes) {
|
||||||
|
Ok(f) => f,
|
||||||
|
Err(e) => {
|
||||||
|
slog!("[whatsapp/twilio] Failed to parse webhook form body: {e}");
|
||||||
|
return vec![];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let from = match form.from {
|
||||||
|
Some(f) => f,
|
||||||
|
None => return vec![],
|
||||||
|
};
|
||||||
|
let body = match form.body {
|
||||||
|
Some(b) if !b.is_empty() => b,
|
||||||
|
_ => return vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Strip the "whatsapp:" prefix so the sender is stored as a plain phone number.
|
||||||
|
let sender = from.strip_prefix("whatsapp:").unwrap_or(&from).to_string();
|
||||||
|
|
||||||
|
vec![(sender, body)]
|
||||||
|
}
|
||||||
|
|
||||||
// ── Graph API request/response types ────────────────────────────────────
|
// ── Graph API request/response types ────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -399,6 +578,7 @@ struct GraphApiError {
|
|||||||
|
|
||||||
// ── Template message types ──────────────────────────────────────────────
|
// ── Template message types ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[allow(dead_code)] // Meta provider path — template message types
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GraphTemplateMessage<'a> {
|
struct GraphTemplateMessage<'a> {
|
||||||
messaging_product: &'a str,
|
messaging_product: &'a str,
|
||||||
@@ -407,6 +587,7 @@ struct GraphTemplateMessage<'a> {
|
|||||||
template: GraphTemplate<'a>,
|
template: GraphTemplate<'a>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GraphTemplate<'a> {
|
struct GraphTemplate<'a> {
|
||||||
name: &'a str,
|
name: &'a str,
|
||||||
@@ -414,17 +595,20 @@ struct GraphTemplate<'a> {
|
|||||||
components: Vec<GraphTemplateComponent>,
|
components: Vec<GraphTemplateComponent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GraphLanguage {
|
struct GraphLanguage {
|
||||||
code: &'static str,
|
code: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GraphTemplateComponent {
|
struct GraphTemplateComponent {
|
||||||
r#type: &'static str,
|
r#type: &'static str,
|
||||||
parameters: Vec<GraphTemplateParameter>,
|
parameters: Vec<GraphTemplateParameter>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct GraphTemplateParameter {
|
struct GraphTemplateParameter {
|
||||||
r#type: &'static str,
|
r#type: &'static str,
|
||||||
@@ -455,11 +639,13 @@ pub struct WebhookChange {
|
|||||||
pub struct WebhookValue {
|
pub struct WebhookValue {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub messages: Vec<WebhookMessage>,
|
pub messages: Vec<WebhookMessage>,
|
||||||
|
#[allow(dead_code)] // Present in Meta webhook JSON, kept for deserialization
|
||||||
pub metadata: Option<WebhookMetadata>,
|
pub metadata: Option<WebhookMetadata>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct WebhookMetadata {
|
pub struct WebhookMetadata {
|
||||||
|
#[allow(dead_code)]
|
||||||
pub phone_number_id: Option<String>,
|
pub phone_number_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,9 +743,7 @@ struct PersistedWhatsAppHistory {
|
|||||||
const WHATSAPP_HISTORY_FILE: &str = ".storkit/whatsapp_history.json";
|
const WHATSAPP_HISTORY_FILE: &str = ".storkit/whatsapp_history.json";
|
||||||
|
|
||||||
/// Load WhatsApp conversation history from disk.
|
/// Load WhatsApp conversation history from disk.
|
||||||
pub fn load_whatsapp_history(
|
pub fn load_whatsapp_history(project_root: &std::path::Path) -> HashMap<String, RoomConversation> {
|
||||||
project_root: &std::path::Path,
|
|
||||||
) -> HashMap<String, RoomConversation> {
|
|
||||||
let path = project_root.join(WHATSAPP_HISTORY_FILE);
|
let path = project_root.join(WHATSAPP_HISTORY_FILE);
|
||||||
let data = match std::fs::read_to_string(&path) {
|
let data = match std::fs::read_to_string(&path) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
@@ -615,7 +799,9 @@ pub struct VerifyQuery {
|
|||||||
/// Shared context for webhook handlers, injected via Poem's `Data` extractor.
|
/// Shared context for webhook handlers, injected via Poem's `Data` extractor.
|
||||||
pub struct WhatsAppWebhookContext {
|
pub struct WhatsAppWebhookContext {
|
||||||
pub verify_token: String,
|
pub verify_token: String,
|
||||||
pub transport: Arc<WhatsAppTransport>,
|
/// Active provider: `"meta"` (Meta Graph API) or `"twilio"` (Twilio REST API).
|
||||||
|
pub provider: String,
|
||||||
|
pub transport: Arc<dyn ChatTransport>,
|
||||||
pub project_root: PathBuf,
|
pub project_root: PathBuf,
|
||||||
pub agents: Arc<AgentPool>,
|
pub agents: Arc<AgentPool>,
|
||||||
pub bot_name: String,
|
pub bot_name: String,
|
||||||
@@ -630,23 +816,27 @@ pub struct WhatsAppWebhookContext {
|
|||||||
pub window_tracker: Arc<MessagingWindowTracker>,
|
pub window_tracker: Arc<MessagingWindowTracker>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /webhook/whatsapp — Meta verification handshake.
|
/// GET /webhook/whatsapp — webhook verification.
|
||||||
///
|
///
|
||||||
/// Meta sends `hub.mode=subscribe&hub.verify_token=<token>&hub.challenge=<challenge>`.
|
/// For Meta: responds to the `hub.mode=subscribe` challenge handshake.
|
||||||
/// We return the challenge if the token matches.
|
/// For Twilio: Twilio does not send GET verification; always returns 200 OK.
|
||||||
#[handler]
|
#[handler]
|
||||||
pub async fn webhook_verify(
|
pub async fn webhook_verify(
|
||||||
Query(q): Query<VerifyQuery>,
|
Query(q): Query<VerifyQuery>,
|
||||||
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
|
ctx: poem::web::Data<&Arc<WhatsAppWebhookContext>>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
// Twilio does not use a GET challenge; just acknowledge.
|
||||||
|
if ctx.provider == "twilio" {
|
||||||
|
return Response::builder().status(StatusCode::OK).body("ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta verification handshake.
|
||||||
if q.hub_mode.as_deref() == Some("subscribe")
|
if q.hub_mode.as_deref() == Some("subscribe")
|
||||||
&& q.hub_verify_token.as_deref() == Some(&ctx.verify_token)
|
&& q.hub_verify_token.as_deref() == Some(&ctx.verify_token)
|
||||||
&& let Some(challenge) = q.hub_challenge
|
&& let Some(challenge) = q.hub_challenge
|
||||||
{
|
{
|
||||||
slog!("[whatsapp] Webhook verification succeeded");
|
slog!("[whatsapp] Webhook verification succeeded");
|
||||||
return Response::builder()
|
return Response::builder().status(StatusCode::OK).body(challenge);
|
||||||
.status(StatusCode::OK)
|
|
||||||
.body(challenge);
|
|
||||||
}
|
}
|
||||||
slog!("[whatsapp] Webhook verification failed");
|
slog!("[whatsapp] Webhook verification failed");
|
||||||
Response::builder()
|
Response::builder()
|
||||||
@@ -654,7 +844,13 @@ pub async fn webhook_verify(
|
|||||||
.body("Verification failed")
|
.body("Verification failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /webhook/whatsapp — receive incoming messages from Meta.
|
/// POST /webhook/whatsapp — receive incoming messages.
|
||||||
|
///
|
||||||
|
/// Dispatches to the appropriate parser based on the configured provider:
|
||||||
|
/// - `"meta"`: parses Meta's JSON `WebhookPayload`.
|
||||||
|
/// - `"twilio"`: parses Twilio's `application/x-www-form-urlencoded` body.
|
||||||
|
///
|
||||||
|
/// Both providers expect a `200 OK` response, even on parse errors.
|
||||||
#[handler]
|
#[handler]
|
||||||
pub async fn webhook_receive(
|
pub async fn webhook_receive(
|
||||||
req: &Request,
|
req: &Request,
|
||||||
@@ -672,23 +868,31 @@ pub async fn webhook_receive(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let messages = if ctx.provider == "twilio" {
|
||||||
|
let msgs = extract_twilio_text_messages(&bytes);
|
||||||
|
if msgs.is_empty() {
|
||||||
|
slog!("[whatsapp/twilio] No text messages in webhook body; ignoring");
|
||||||
|
}
|
||||||
|
msgs
|
||||||
|
} else {
|
||||||
let payload: WebhookPayload = match serde_json::from_slice(&bytes) {
|
let payload: WebhookPayload = match serde_json::from_slice(&bytes) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog!("[whatsapp] Failed to parse webhook payload: {e}");
|
slog!("[whatsapp] Failed to parse webhook payload: {e}");
|
||||||
// Meta expects 200 even on parse errors to avoid retries.
|
// Meta expects 200 even on parse errors to avoid retries.
|
||||||
return Response::builder()
|
return Response::builder().status(StatusCode::OK).body("ok");
|
||||||
.status(StatusCode::OK)
|
|
||||||
.body("ok");
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let msgs = extract_text_messages(&payload);
|
||||||
let messages = extract_text_messages(&payload);
|
if msgs.is_empty() {
|
||||||
if messages.is_empty() {
|
|
||||||
// Status updates, read receipts, etc. — acknowledge silently.
|
// Status updates, read receipts, etc. — acknowledge silently.
|
||||||
return Response::builder()
|
return Response::builder().status(StatusCode::OK).body("ok");
|
||||||
.status(StatusCode::OK)
|
}
|
||||||
.body("ok");
|
msgs
|
||||||
|
};
|
||||||
|
|
||||||
|
if messages.is_empty() {
|
||||||
|
return Response::builder().status(StatusCode::OK).body("ok");
|
||||||
}
|
}
|
||||||
|
|
||||||
let ctx = Arc::clone(*ctx);
|
let ctx = Arc::clone(*ctx);
|
||||||
@@ -699,18 +903,12 @@ pub async fn webhook_receive(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Response::builder()
|
Response::builder().status(StatusCode::OK).body("ok")
|
||||||
.status(StatusCode::OK)
|
|
||||||
.body("ok")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dispatch an incoming WhatsApp message to bot commands.
|
/// Dispatch an incoming WhatsApp message to bot commands.
|
||||||
async fn handle_incoming_message(
|
async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
|
||||||
ctx: &WhatsAppWebhookContext,
|
use crate::chat::transport::matrix::commands::{CommandDispatch, try_handle_command};
|
||||||
sender: &str,
|
|
||||||
message: &str,
|
|
||||||
) {
|
|
||||||
use crate::matrix::commands::{CommandDispatch, try_handle_command};
|
|
||||||
|
|
||||||
// Record this inbound message to keep the 24-hour window open.
|
// Record this inbound message to keep the 24-hour window open.
|
||||||
ctx.window_tracker.record_message(sender);
|
ctx.window_tracker.record_message(sender);
|
||||||
@@ -733,12 +931,12 @@ async fn handle_incoming_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for async commands (htop, delete).
|
// Check for async commands (htop, delete).
|
||||||
if let Some(htop_cmd) = crate::matrix::htop::extract_htop_command(
|
if let Some(htop_cmd) = crate::chat::transport::matrix::htop::extract_htop_command(
|
||||||
message,
|
message,
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&ctx.bot_user_id,
|
&ctx.bot_user_id,
|
||||||
) {
|
) {
|
||||||
use crate::matrix::htop::HtopCommand;
|
use crate::chat::transport::matrix::htop::HtopCommand;
|
||||||
slog!("[whatsapp] Handling htop command from {sender}");
|
slog!("[whatsapp] Handling htop command from {sender}");
|
||||||
match htop_cmd {
|
match htop_cmd {
|
||||||
HtopCommand::Stop => {
|
HtopCommand::Stop => {
|
||||||
@@ -752,26 +950,26 @@ async fn handle_incoming_message(
|
|||||||
HtopCommand::Start { duration_secs } => {
|
HtopCommand::Start { duration_secs } => {
|
||||||
// On WhatsApp, send a single snapshot instead of a live-updating
|
// On WhatsApp, send a single snapshot instead of a live-updating
|
||||||
// dashboard since we can't edit messages.
|
// dashboard since we can't edit messages.
|
||||||
let snapshot =
|
let snapshot = crate::chat::transport::matrix::htop::build_htop_message(
|
||||||
crate::matrix::htop::build_htop_message(&ctx.agents, 0, duration_secs);
|
&ctx.agents,
|
||||||
let _ = ctx
|
0,
|
||||||
.transport
|
duration_secs,
|
||||||
.send_message(sender, &snapshot, "")
|
);
|
||||||
.await;
|
let _ = ctx.transport.send_message(sender, &snapshot, "").await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(del_cmd) = crate::matrix::delete::extract_delete_command(
|
if let Some(del_cmd) = crate::chat::transport::matrix::delete::extract_delete_command(
|
||||||
message,
|
message,
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&ctx.bot_user_id,
|
&ctx.bot_user_id,
|
||||||
) {
|
) {
|
||||||
let response = match del_cmd {
|
let response = match del_cmd {
|
||||||
crate::matrix::delete::DeleteCommand::Delete { story_number } => {
|
crate::chat::transport::matrix::delete::DeleteCommand::Delete { story_number } => {
|
||||||
slog!("[whatsapp] Handling delete command from {sender}: story {story_number}");
|
slog!("[whatsapp] Handling delete command from {sender}: story {story_number}");
|
||||||
crate::matrix::delete::handle_delete(
|
crate::chat::transport::matrix::delete::handle_delete(
|
||||||
&ctx.bot_name,
|
&ctx.bot_name,
|
||||||
&story_number,
|
&story_number,
|
||||||
&ctx.project_root,
|
&ctx.project_root,
|
||||||
@@ -779,7 +977,7 @@ async fn handle_incoming_message(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
crate::matrix::delete::DeleteCommand::BadArgs => {
|
crate::chat::transport::matrix::delete::DeleteCommand::BadArgs => {
|
||||||
format!("Usage: `{} delete <number>`", ctx.bot_name)
|
format!("Usage: `{} delete <number>`", ctx.bot_name)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -793,22 +991,16 @@ async fn handle_incoming_message(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Forward a message to Claude Code and send the response back via WhatsApp.
|
/// Forward a message to Claude Code and send the response back via WhatsApp.
|
||||||
async fn handle_llm_message(
|
async fn handle_llm_message(ctx: &WhatsAppWebhookContext, sender: &str, user_message: &str) {
|
||||||
ctx: &WhatsAppWebhookContext,
|
use crate::chat::transport::matrix::drain_complete_paragraphs;
|
||||||
sender: &str,
|
|
||||||
user_message: &str,
|
|
||||||
) {
|
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use crate::matrix::drain_complete_paragraphs;
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::sync::watch;
|
use tokio::sync::watch;
|
||||||
|
|
||||||
// Look up existing session ID for this sender.
|
// Look up existing session ID for this sender.
|
||||||
let resume_session_id: Option<String> = {
|
let resume_session_id: Option<String> = {
|
||||||
let guard = ctx.history.lock().await;
|
let guard = ctx.history.lock().await;
|
||||||
guard
|
guard.get(sender).and_then(|conv| conv.session_id.clone())
|
||||||
.get(sender)
|
|
||||||
.and_then(|conv| conv.session_id.clone())
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let bot_name = &ctx.bot_name;
|
let bot_name = &ctx.bot_name;
|
||||||
@@ -879,9 +1071,7 @@ async fn handle_llm_message(
|
|||||||
let last_text = messages
|
let last_text = messages
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|m| {
|
.find(|m| m.role == crate::llm::types::Role::Assistant && !m.content.is_empty())
|
||||||
m.role == crate::llm::types::Role::Assistant && !m.content.is_empty()
|
|
||||||
})
|
|
||||||
.map(|m| m.content.clone())
|
.map(|m| m.content.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if !last_text.is_empty() {
|
if !last_text.is_empty() {
|
||||||
@@ -1022,7 +1212,10 @@ mod tests {
|
|||||||
let result = transport.send_message("15551234567", "hello", "").await;
|
let result = transport.send_message("15551234567", "hello", "").await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
let msg = result.unwrap_err();
|
let msg = result.unwrap_err();
|
||||||
assert!(msg.contains("24-hour messaging window"), "unexpected: {msg}");
|
assert!(
|
||||||
|
msg.contains("24-hour messaging window"),
|
||||||
|
"unexpected: {msg}"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── send_template_notification ────────────────────────────────────
|
// ── send_template_notification ────────────────────────────────────
|
||||||
@@ -1356,6 +1549,133 @@ mod tests {
|
|||||||
assert_eq!(conv.entries[1].content, "hi there!");
|
assert_eq!(conv.entries[1].content, "hi there!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── TwilioWhatsAppTransport tests ─────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn twilio_send_message_calls_twilio_api() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
|
||||||
|
.with_body(r#"{"sid": "SMtest123"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let transport = TwilioWhatsAppTransport::with_api_base(
|
||||||
|
"ACtest".to_string(),
|
||||||
|
"authtoken".to_string(),
|
||||||
|
"+14155551234".to_string(),
|
||||||
|
server.url(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = transport.send_message("+15551234567", "hello", "").await;
|
||||||
|
assert!(result.is_ok(), "unexpected err: {:?}", result.err());
|
||||||
|
assert_eq!(result.unwrap(), "SMtest123");
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn twilio_send_message_returns_err_on_api_error() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
server
|
||||||
|
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
|
||||||
|
.with_status(401)
|
||||||
|
.with_body(r#"{"message": "Unauthorized"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let transport = TwilioWhatsAppTransport::with_api_base(
|
||||||
|
"ACtest".to_string(),
|
||||||
|
"badtoken".to_string(),
|
||||||
|
"+14155551234".to_string(),
|
||||||
|
server.url(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = transport.send_message("+15551234567", "hello", "").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().contains("401"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn twilio_edit_message_sends_new_message() {
|
||||||
|
let mut server = mockito::Server::new_async().await;
|
||||||
|
let mock = server
|
||||||
|
.mock("POST", "/2010-04-01/Accounts/ACtest/Messages.json")
|
||||||
|
.with_body(r#"{"sid": "SMedit456"}"#)
|
||||||
|
.create_async()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let transport = TwilioWhatsAppTransport::with_api_base(
|
||||||
|
"ACtest".to_string(),
|
||||||
|
"authtoken".to_string(),
|
||||||
|
"+14155551234".to_string(),
|
||||||
|
server.url(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = transport
|
||||||
|
.edit_message("+15551234567", "old-sid", "updated text", "")
|
||||||
|
.await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
mock.assert_async().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn twilio_send_typing_is_noop() {
|
||||||
|
let transport = TwilioWhatsAppTransport::new(
|
||||||
|
"ACtest".to_string(),
|
||||||
|
"authtoken".to_string(),
|
||||||
|
"+14155551234".to_string(),
|
||||||
|
);
|
||||||
|
assert!(transport.send_typing("+15551234567", true).await.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── extract_twilio_text_messages tests ────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_twilio_text_messages_parses_valid_form() {
|
||||||
|
let body = b"From=whatsapp%3A%2B15551234567&Body=hello+world&To=whatsapp%3A%2B14155551234&MessageSid=SMtest";
|
||||||
|
let msgs = extract_twilio_text_messages(body);
|
||||||
|
assert_eq!(msgs.len(), 1);
|
||||||
|
assert_eq!(msgs[0].0, "+15551234567");
|
||||||
|
assert_eq!(msgs[0].1, "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_twilio_text_messages_strips_whatsapp_prefix() {
|
||||||
|
let body = b"From=whatsapp%3A%2B15551234567&Body=hi";
|
||||||
|
let msgs = extract_twilio_text_messages(body);
|
||||||
|
assert_eq!(msgs.len(), 1);
|
||||||
|
assert_eq!(msgs[0].0, "+15551234567");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_twilio_text_messages_returns_empty_on_missing_from() {
|
||||||
|
let body = b"Body=hello";
|
||||||
|
let msgs = extract_twilio_text_messages(body);
|
||||||
|
assert!(msgs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_twilio_text_messages_returns_empty_on_missing_body() {
|
||||||
|
let body = b"From=whatsapp%3A%2B15551234567";
|
||||||
|
let msgs = extract_twilio_text_messages(body);
|
||||||
|
assert!(msgs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_twilio_text_messages_returns_empty_on_empty_body() {
|
||||||
|
let body = b"From=whatsapp%3A%2B15551234567&Body=";
|
||||||
|
let msgs = extract_twilio_text_messages(body);
|
||||||
|
assert!(msgs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_twilio_text_messages_returns_empty_on_invalid_form() {
|
||||||
|
let body = b"not valid form encoded {{{{";
|
||||||
|
// serde_urlencoded is lenient, so this might parse or return empty
|
||||||
|
// Either way it must not panic.
|
||||||
|
let _msgs = extract_twilio_text_messages(body);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn load_whatsapp_history_returns_empty_when_file_missing() {
|
fn load_whatsapp_history_returns_empty_when_file_missing() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
305
server/src/http/bot_command.rs
Normal file
305
server/src/http/bot_command.rs
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
//! Bot command HTTP endpoint.
|
||||||
|
//!
|
||||||
|
//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot
|
||||||
|
//! commands available in Matrix without going through the LLM.
|
||||||
|
//!
|
||||||
|
//! Synchronous commands (status, git, cost, move, show, overview, help) are
|
||||||
|
//! dispatched directly through the matrix command registry.
|
||||||
|
//! Asynchronous commands (assign, start, delete, rebuild) are dispatched to
|
||||||
|
//! their dedicated async handlers. The `reset` command is handled by the frontend
|
||||||
|
//! (it clears local session state and message history) and is not routed here.
|
||||||
|
|
||||||
|
use crate::http::context::{AppContext, OpenApiResult};
|
||||||
|
use crate::chat::transport::matrix::commands::CommandDispatch;
|
||||||
|
use poem::http::StatusCode;
|
||||||
|
use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
#[derive(Tags)]
|
||||||
|
enum BotCommandTags {
|
||||||
|
BotCommand,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Body for `POST /api/bot/command`.
|
||||||
|
#[derive(Object, Deserialize)]
|
||||||
|
struct BotCommandRequest {
|
||||||
|
/// The command keyword without the leading slash (e.g. `"status"`, `"start"`).
|
||||||
|
command: String,
|
||||||
|
/// Any text after the command keyword, trimmed (may be empty).
|
||||||
|
#[oai(default)]
|
||||||
|
args: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Response body for `POST /api/bot/command`.
|
||||||
|
#[derive(Object, Serialize)]
|
||||||
|
struct BotCommandResponse {
|
||||||
|
/// Markdown-formatted response text.
|
||||||
|
response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BotCommandApi {
|
||||||
|
pub ctx: Arc<AppContext>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OpenApi(tag = "BotCommandTags::BotCommand")]
|
||||||
|
impl BotCommandApi {
|
||||||
|
/// Execute a slash command without LLM invocation.
|
||||||
|
///
|
||||||
|
/// Dispatches to the same handlers used by the Matrix and Slack bots.
|
||||||
|
/// Returns a markdown-formatted response that the frontend can display
|
||||||
|
/// directly in the chat panel.
|
||||||
|
#[oai(path = "/bot/command", method = "post")]
|
||||||
|
async fn run_command(
|
||||||
|
&self,
|
||||||
|
body: Json<BotCommandRequest>,
|
||||||
|
) -> OpenApiResult<Json<BotCommandResponse>> {
|
||||||
|
let project_root = self.ctx.state.get_project_root().map_err(|e| {
|
||||||
|
poem::Error::from_string(e, StatusCode::BAD_REQUEST)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let cmd = body.command.trim().to_ascii_lowercase();
|
||||||
|
let args = body.args.trim();
|
||||||
|
let response = dispatch_command(&cmd, args, &project_root, &self.ctx.agents).await;
|
||||||
|
|
||||||
|
Ok(Json(BotCommandResponse { response }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch a command keyword + args to the appropriate handler.
|
||||||
|
async fn dispatch_command(
|
||||||
|
cmd: &str,
|
||||||
|
args: &str,
|
||||||
|
project_root: &std::path::Path,
|
||||||
|
agents: &Arc<crate::agents::AgentPool>,
|
||||||
|
) -> String {
|
||||||
|
match cmd {
|
||||||
|
"assign" => dispatch_assign(args, project_root, agents).await,
|
||||||
|
"start" => dispatch_start(args, project_root, agents).await,
|
||||||
|
"delete" => dispatch_delete(args, project_root, agents).await,
|
||||||
|
"rebuild" => dispatch_rebuild(project_root, agents).await,
|
||||||
|
// All other commands go through the synchronous command registry.
|
||||||
|
_ => dispatch_sync(cmd, args, project_root, agents),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_sync(
|
||||||
|
cmd: &str,
|
||||||
|
args: &str,
|
||||||
|
project_root: &std::path::Path,
|
||||||
|
agents: &Arc<crate::agents::AgentPool>,
|
||||||
|
) -> String {
|
||||||
|
let ambient_rooms: Arc<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
// Use a synthetic bot name/id so strip_bot_mention passes through.
|
||||||
|
let bot_name = "__web_ui__";
|
||||||
|
let bot_user_id = "@__web_ui__:localhost";
|
||||||
|
let room_id = "__web_ui__";
|
||||||
|
|
||||||
|
let dispatch = CommandDispatch {
|
||||||
|
bot_name,
|
||||||
|
bot_user_id,
|
||||||
|
project_root,
|
||||||
|
agents,
|
||||||
|
ambient_rooms: &ambient_rooms,
|
||||||
|
room_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a synthetic message that the registry can parse.
|
||||||
|
let synthetic = if args.is_empty() {
|
||||||
|
format!("{bot_name} {cmd}")
|
||||||
|
} else {
|
||||||
|
format!("{bot_name} {cmd} {args}")
|
||||||
|
};
|
||||||
|
|
||||||
|
match crate::chat::transport::matrix::commands::try_handle_command(&dispatch, &synthetic) {
|
||||||
|
Some(response) => response,
|
||||||
|
None => {
|
||||||
|
// Command exists in the registry but its fallback handler returns None
|
||||||
|
// (start, delete, rebuild, reset, htop — handled elsewhere or in
|
||||||
|
// the frontend). Should not be reached for those since we intercept
|
||||||
|
// them above. For genuinely unknown commands, tell the user.
|
||||||
|
format!("Unknown command: `/{cmd}`. Type `/help` to see available commands.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_assign(
|
||||||
|
args: &str,
|
||||||
|
project_root: &std::path::Path,
|
||||||
|
agents: &Arc<crate::agents::AgentPool>,
|
||||||
|
) -> String {
|
||||||
|
// args: "<number> <model>"
|
||||||
|
let mut parts = args.splitn(2, char::is_whitespace);
|
||||||
|
let number_str = parts.next().unwrap_or("").trim();
|
||||||
|
let model_str = parts.next().unwrap_or("").trim();
|
||||||
|
|
||||||
|
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) || model_str.is_empty() {
|
||||||
|
return "Usage: `/assign <number> <model>` (e.g. `/assign 42 opus`)".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
crate::chat::transport::matrix::assign::handle_assign("web-ui", number_str, model_str, project_root, agents)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_start(
|
||||||
|
args: &str,
|
||||||
|
project_root: &std::path::Path,
|
||||||
|
agents: &Arc<crate::agents::AgentPool>,
|
||||||
|
) -> String {
|
||||||
|
// args: "<number>" or "<number> <model_hint>"
|
||||||
|
let mut parts = args.splitn(2, char::is_whitespace);
|
||||||
|
let number_str = parts.next().unwrap_or("").trim();
|
||||||
|
let hint_str = parts.next().unwrap_or("").trim();
|
||||||
|
|
||||||
|
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return "Usage: `/start <number>` or `/start <number> <model>` (e.g. `/start 42 opus`)"
|
||||||
|
.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let agent_hint = if hint_str.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(hint_str)
|
||||||
|
};
|
||||||
|
|
||||||
|
crate::chat::transport::matrix::start::handle_start("web-ui", number_str, agent_hint, project_root, agents)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_delete(
|
||||||
|
args: &str,
|
||||||
|
project_root: &std::path::Path,
|
||||||
|
agents: &Arc<crate::agents::AgentPool>,
|
||||||
|
) -> String {
|
||||||
|
let number_str = args.trim();
|
||||||
|
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
|
||||||
|
return "Usage: `/delete <number>` (e.g. `/delete 42`)".to_string();
|
||||||
|
}
|
||||||
|
crate::chat::transport::matrix::delete::handle_delete("web-ui", number_str, project_root, agents).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch_rebuild(
|
||||||
|
project_root: &std::path::Path,
|
||||||
|
agents: &Arc<crate::agents::AgentPool>,
|
||||||
|
) -> String {
|
||||||
|
crate::chat::transport::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn test_api(dir: &TempDir) -> BotCommandApi {
|
||||||
|
BotCommandApi {
|
||||||
|
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn help_command_returns_response() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let api = test_api(&dir);
|
||||||
|
let body = BotCommandRequest {
|
||||||
|
command: "help".to_string(),
|
||||||
|
args: String::new(),
|
||||||
|
};
|
||||||
|
let result = api.run_command(Json(body)).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let resp = result.unwrap().0;
|
||||||
|
assert!(!resp.response.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_command_returns_error_message() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let api = test_api(&dir);
|
||||||
|
let body = BotCommandRequest {
|
||||||
|
command: "nonexistent_xyz".to_string(),
|
||||||
|
args: String::new(),
|
||||||
|
};
|
||||||
|
let result = api.run_command(Json(body)).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let resp = result.unwrap().0;
|
||||||
|
assert!(
|
||||||
|
resp.response.contains("Unknown command"),
|
||||||
|
"expected 'Unknown command' in: {}",
|
||||||
|
resp.response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_without_number_returns_usage() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let api = test_api(&dir);
|
||||||
|
let body = BotCommandRequest {
|
||||||
|
command: "start".to_string(),
|
||||||
|
args: String::new(),
|
||||||
|
};
|
||||||
|
let result = api.run_command(Json(body)).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let resp = result.unwrap().0;
|
||||||
|
assert!(
|
||||||
|
resp.response.contains("Usage"),
|
||||||
|
"expected usage hint in: {}",
|
||||||
|
resp.response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_without_number_returns_usage() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let api = test_api(&dir);
|
||||||
|
let body = BotCommandRequest {
|
||||||
|
command: "delete".to_string(),
|
||||||
|
args: String::new(),
|
||||||
|
};
|
||||||
|
let result = api.run_command(Json(body)).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let resp = result.unwrap().0;
|
||||||
|
assert!(
|
||||||
|
resp.response.contains("Usage"),
|
||||||
|
"expected usage hint in: {}",
|
||||||
|
resp.response
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn git_command_returns_response() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
// Initialise a bare git repo so the git command has something to query.
|
||||||
|
std::process::Command::new("git")
|
||||||
|
.args(["init"])
|
||||||
|
.current_dir(dir.path())
|
||||||
|
.output()
|
||||||
|
.ok();
|
||||||
|
let api = test_api(&dir);
|
||||||
|
let body = BotCommandRequest {
|
||||||
|
command: "git".to_string(),
|
||||||
|
args: String::new(),
|
||||||
|
};
|
||||||
|
let result = api.run_command(Json(body)).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_command_requires_project_root() {
|
||||||
|
// Create a context with no project root set.
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let ctx = AppContext::new_test(dir.path().to_path_buf());
|
||||||
|
// Clear the project root.
|
||||||
|
*ctx.state.project_root.lock().unwrap() = None;
|
||||||
|
let api = BotCommandApi { ctx: Arc::new(ctx) };
|
||||||
|
let body = BotCommandRequest {
|
||||||
|
command: "status".to_string(),
|
||||||
|
args: String::new(),
|
||||||
|
};
|
||||||
|
let result = api.run_command(Json(body)).await;
|
||||||
|
assert!(result.is_err(), "should fail when no project root is set");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ pub mod merge_tools;
|
|||||||
pub mod qa_tools;
|
pub mod qa_tools;
|
||||||
pub mod shell_tools;
|
pub mod shell_tools;
|
||||||
pub mod story_tools;
|
pub mod story_tools;
|
||||||
pub mod whatsup_tools;
|
pub mod status_tools;
|
||||||
|
|
||||||
/// Returns true when the Accept header includes text/event-stream.
|
/// Returns true when the Accept header includes text/event-stream.
|
||||||
fn wants_sse(req: &Request) -> bool {
|
fn wants_sse(req: &Request) -> bool {
|
||||||
@@ -1124,7 +1124,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "whatsup",
|
"name": "status",
|
||||||
"description": "Get a full triage dump for an in-progress story: front matter, AC checklist, active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Returns a clear error if the story is not in work/2_current/.",
|
"description": "Get a full triage dump for an in-progress story: front matter, AC checklist, active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Returns a clear error if the story is not in work/2_current/.",
|
||||||
"inputSchema": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1225,7 +1225,7 @@ async fn handle_tools_call(
|
|||||||
"git_commit" => git_tools::tool_git_commit(&args, ctx).await,
|
"git_commit" => git_tools::tool_git_commit(&args, ctx).await,
|
||||||
"git_log" => git_tools::tool_git_log(&args, ctx).await,
|
"git_log" => git_tools::tool_git_log(&args, ctx).await,
|
||||||
// Story triage
|
// Story triage
|
||||||
"whatsup" => whatsup_tools::tool_whatsup(&args, ctx).await,
|
"status" => status_tools::tool_status(&args, ctx).await,
|
||||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1341,7 +1341,7 @@ mod tests {
|
|||||||
assert!(names.contains(&"git_add"));
|
assert!(names.contains(&"git_add"));
|
||||||
assert!(names.contains(&"git_commit"));
|
assert!(names.contains(&"git_commit"));
|
||||||
assert!(names.contains(&"git_log"));
|
assert!(names.contains(&"git_log"));
|
||||||
assert!(names.contains(&"whatsup"));
|
assert!(names.contains(&"status"));
|
||||||
assert_eq!(tools.len(), 49);
|
assert_eq!(tools.len(), 49);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ async fn git_branch(dir: &Path) -> Option<String> {
|
|||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) async fn tool_whatsup(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
let story_id = args
|
let story_id = args
|
||||||
.get("story_id")
|
.get("story_id")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
@@ -323,16 +323,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tool_whatsup_returns_error_for_missing_story() {
|
async fn tool_status_returns_error_for_missing_story() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||||
let result = tool_whatsup(&json!({"story_id": "999_story_nonexistent"}), &ctx).await;
|
let result = tool_status(&json!({"story_id": "999_story_nonexistent"}), &ctx).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("not found in work/2_current/"));
|
assert!(result.unwrap_err().contains("not found in work/2_current/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tool_whatsup_returns_story_data() {
|
async fn tool_status_returns_story_data() {
|
||||||
let tmp = tempdir().unwrap();
|
let tmp = tempdir().unwrap();
|
||||||
let current_dir = tmp
|
let current_dir = tmp
|
||||||
.path()
|
.path()
|
||||||
@@ -345,7 +345,7 @@ mod tests {
|
|||||||
fs::write(current_dir.join("42_story_test.md"), story_content).unwrap();
|
fs::write(current_dir.join("42_story_test.md"), story_content).unwrap();
|
||||||
|
|
||||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||||
let result = tool_whatsup(&json!({"story_id": "42_story_test"}), &ctx)
|
let result = tool_status(&json!({"story_id": "42_story_test"}), &ctx)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||||
@@ -2,6 +2,7 @@ pub mod agents;
|
|||||||
pub mod agents_sse;
|
pub mod agents_sse;
|
||||||
pub mod anthropic;
|
pub mod anthropic;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod bot_command;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod health;
|
pub mod health;
|
||||||
@@ -16,6 +17,7 @@ pub mod ws;
|
|||||||
|
|
||||||
use agents::AgentsApi;
|
use agents::AgentsApi;
|
||||||
use anthropic::AnthropicApi;
|
use anthropic::AnthropicApi;
|
||||||
|
use bot_command::BotCommandApi;
|
||||||
use chat::ChatApi;
|
use chat::ChatApi;
|
||||||
use context::AppContext;
|
use context::AppContext;
|
||||||
use health::HealthApi;
|
use health::HealthApi;
|
||||||
@@ -29,8 +31,8 @@ use settings::SettingsApi;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::slack::SlackWebhookContext;
|
use crate::chat::transport::slack::SlackWebhookContext;
|
||||||
use crate::whatsapp::WhatsAppWebhookContext;
|
use crate::chat::transport::whatsapp::WhatsAppWebhookContext;
|
||||||
|
|
||||||
const DEFAULT_PORT: u16 = 3001;
|
const DEFAULT_PORT: u16 = 3001;
|
||||||
|
|
||||||
@@ -83,8 +85,8 @@ pub fn build_routes(
|
|||||||
if let Some(wa_ctx) = whatsapp_ctx {
|
if let Some(wa_ctx) = whatsapp_ctx {
|
||||||
route = route.at(
|
route = route.at(
|
||||||
"/webhook/whatsapp",
|
"/webhook/whatsapp",
|
||||||
get(crate::whatsapp::webhook_verify)
|
get(crate::chat::transport::whatsapp::webhook_verify)
|
||||||
.post(crate::whatsapp::webhook_receive)
|
.post(crate::chat::transport::whatsapp::webhook_receive)
|
||||||
.data(wa_ctx),
|
.data(wa_ctx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,11 +95,11 @@ pub fn build_routes(
|
|||||||
route = route
|
route = route
|
||||||
.at(
|
.at(
|
||||||
"/webhook/slack",
|
"/webhook/slack",
|
||||||
post(crate::slack::webhook_receive).data(sl_ctx.clone()),
|
post(crate::chat::transport::slack::webhook_receive).data(sl_ctx.clone()),
|
||||||
)
|
)
|
||||||
.at(
|
.at(
|
||||||
"/webhook/slack/command",
|
"/webhook/slack/command",
|
||||||
post(crate::slack::slash_command_receive).data(sl_ctx),
|
post(crate::chat::transport::slack::slash_command_receive).data(sl_ctx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +115,7 @@ type ApiTuple = (
|
|||||||
AgentsApi,
|
AgentsApi,
|
||||||
SettingsApi,
|
SettingsApi,
|
||||||
HealthApi,
|
HealthApi,
|
||||||
|
BotCommandApi,
|
||||||
);
|
);
|
||||||
|
|
||||||
type ApiService = OpenApiService<ApiTuple, ()>;
|
type ApiService = OpenApiService<ApiTuple, ()>;
|
||||||
@@ -128,6 +131,7 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
AgentsApi { ctx: ctx.clone() },
|
AgentsApi { ctx: ctx.clone() },
|
||||||
SettingsApi { ctx: ctx.clone() },
|
SettingsApi { ctx: ctx.clone() },
|
||||||
HealthApi,
|
HealthApi,
|
||||||
|
BotCommandApi { ctx: ctx.clone() },
|
||||||
);
|
);
|
||||||
|
|
||||||
let api_service =
|
let api_service =
|
||||||
@@ -140,8 +144,9 @@ pub fn build_openapi_service(ctx: Arc<AppContext>) -> (ApiService, ApiService) {
|
|||||||
IoApi { ctx: ctx.clone() },
|
IoApi { ctx: ctx.clone() },
|
||||||
ChatApi { ctx: ctx.clone() },
|
ChatApi { ctx: ctx.clone() },
|
||||||
AgentsApi { ctx: ctx.clone() },
|
AgentsApi { ctx: ctx.clone() },
|
||||||
SettingsApi { ctx },
|
SettingsApi { ctx: ctx.clone() },
|
||||||
HealthApi,
|
HealthApi,
|
||||||
|
BotCommandApi { ctx },
|
||||||
);
|
);
|
||||||
|
|
||||||
let docs_service =
|
let docs_service =
|
||||||
|
|||||||
@@ -183,6 +183,18 @@ pub fn add_criterion_to_file(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode a string value as a YAML scalar.
|
||||||
|
///
|
||||||
|
/// Booleans (`true`/`false`) and integers are written as native YAML types (unquoted).
|
||||||
|
/// Everything else is written as a quoted string to avoid ambiguity.
|
||||||
|
fn yaml_encode_scalar(value: &str) -> String {
|
||||||
|
match value {
|
||||||
|
"true" | "false" => value.to_string(),
|
||||||
|
s if s.parse::<i64>().is_ok() => s.to_string(),
|
||||||
|
s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Update the user story text and/or description in a story file.
|
/// Update the user story text and/or description in a story file.
|
||||||
///
|
///
|
||||||
/// At least one of `user_story` or `description` must be provided.
|
/// At least one of `user_story` or `description` must be provided.
|
||||||
@@ -209,7 +221,7 @@ pub fn update_story_in_file(
|
|||||||
|
|
||||||
if let Some(fields) = front_matter {
|
if let Some(fields) = front_matter {
|
||||||
for (key, value) in fields {
|
for (key, value) in fields {
|
||||||
let yaml_value = format!("\"{}\"", value.replace('"', "\\\"").replace('\n', " ").replace('\r', ""));
|
let yaml_value = yaml_encode_scalar(value);
|
||||||
contents = set_front_matter_field(&contents, key, &yaml_value);
|
contents = set_front_matter_field(&contents, key, &yaml_value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -589,4 +601,55 @@ mod tests {
|
|||||||
let contents = fs::read_to_string(&filepath).unwrap();
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
assert!(contents.contains("agent: \"dev\""));
|
assert!(contents.contains("agent: \"dev\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_bool_front_matter_written_unquoted() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".storkit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("27_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("blocked".to_string(), "false".to_string());
|
||||||
|
update_story_in_file(tmp.path(), "27_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(result.contains("blocked: false"), "bool should be unquoted: {result}");
|
||||||
|
assert!(!result.contains("blocked: \"false\""), "bool must not be quoted: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_integer_front_matter_written_unquoted() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".storkit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("28_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("retry_count".to_string(), "0".to_string());
|
||||||
|
update_story_in_file(tmp.path(), "28_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
|
let result = fs::read_to_string(&filepath).unwrap();
|
||||||
|
assert!(result.contains("retry_count: 0"), "integer should be unquoted: {result}");
|
||||||
|
assert!(!result.contains("retry_count: \"0\""), "integer must not be quoted: {result}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_story_bool_front_matter_parseable_after_write() {
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let current = tmp.path().join(".storkit/work/2_current");
|
||||||
|
fs::create_dir_all(¤t).unwrap();
|
||||||
|
let filepath = current.join("29_test.md");
|
||||||
|
fs::write(&filepath, "---\nname: My Story\n---\n\nNo sections.\n").unwrap();
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
fields.insert("blocked".to_string(), "false".to_string());
|
||||||
|
update_story_in_file(tmp.path(), "29_test", None, None, Some(&fields)).unwrap();
|
||||||
|
|
||||||
|
let contents = fs::read_to_string(&filepath).unwrap();
|
||||||
|
let meta = parse_front_matter(&contents).expect("front matter should parse");
|
||||||
|
assert_eq!(meta.name.as_deref(), Some("My Story"), "name preserved after writing bool field");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ const KEY_KNOWN_PROJECTS: &str = "known_projects";
|
|||||||
|
|
||||||
const STORY_KIT_README: &str = include_str!("../../../.storkit/README.md");
|
const STORY_KIT_README: &str = include_str!("../../../.storkit/README.md");
|
||||||
|
|
||||||
|
const BOT_TOML_MATRIX_EXAMPLE: &str = include_str!("../../../.storkit/bot.toml.matrix.example");
|
||||||
|
const BOT_TOML_WHATSAPP_META_EXAMPLE: &str =
|
||||||
|
include_str!("../../../.storkit/bot.toml.whatsapp-meta.example");
|
||||||
|
const BOT_TOML_WHATSAPP_TWILIO_EXAMPLE: &str =
|
||||||
|
include_str!("../../../.storkit/bot.toml.whatsapp-twilio.example");
|
||||||
|
const BOT_TOML_SLACK_EXAMPLE: &str = include_str!("../../../.storkit/bot.toml.slack.example");
|
||||||
|
|
||||||
const STORY_KIT_CONTEXT: &str = "<!-- storkit:scaffold-template -->\n\
|
const STORY_KIT_CONTEXT: &str = "<!-- storkit:scaffold-template -->\n\
|
||||||
# Project Context\n\
|
# Project Context\n\
|
||||||
\n\
|
\n\
|
||||||
@@ -110,7 +117,7 @@ role = "Full-stack engineer. Implements features across all components."
|
|||||||
model = "sonnet"
|
model = "sonnet"
|
||||||
max_turns = 50
|
max_turns = 50
|
||||||
max_budget_usd = 5.00
|
max_budget_usd = 5.00
|
||||||
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .storkit/README.md to understand the dev process. Follow the workflow through implementation and verification. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits."
|
prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .storkit/README.md to understand the dev process. Follow the workflow through implementation and verification. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop.\n\nIMPORTANT: Commit all your work before your process exits. The server will automatically run acceptance gates when your process exits.\n\nIf `script/test` still contains the generic 'No tests configured' stub, update it to run the project's actual test suite before starting implementation."
|
||||||
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Commit all your work before finishing. Do not accept stories, move them to archived, or merge to master."
|
system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Commit all your work before finishing. Do not accept stories, move them to archived, or merge to master."
|
||||||
|
|
||||||
[[agent]]
|
[[agent]]
|
||||||
@@ -184,37 +191,58 @@ pub fn detect_components_toml(root: &Path) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if sections.is_empty() {
|
if sections.is_empty() {
|
||||||
// No tech stack markers detected — emit two example components so that
|
// No tech stack markers detected — emit a single generic component
|
||||||
// the scaffold is immediately usable and agents can see the expected
|
// with an empty setup list. The ONBOARDING_PROMPT instructs the chat
|
||||||
// format. The ONBOARDING_PROMPT instructs the chat agent to inspect
|
// agent to inspect the project and replace this with real definitions.
|
||||||
// the project and replace these placeholders with real definitions.
|
|
||||||
sections.push(
|
sections.push(
|
||||||
"# EXAMPLE: Replace with your actual backend component.\n\
|
"[[component]]\nname = \"app\"\npath = \".\"\nsetup = []\n".to_string(),
|
||||||
# Common patterns: \"cargo check\" (Rust), \"go build ./...\" (Go),\n\
|
|
||||||
# \"python -m pytest\" (Python), \"mvn verify\" (Java)\n\
|
|
||||||
[[component]]\n\
|
|
||||||
name = \"backend\"\n\
|
|
||||||
path = \".\"\n\
|
|
||||||
setup = [\"cargo check\"]\n\
|
|
||||||
teardown = []\n"
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
sections.push(
|
|
||||||
"# EXAMPLE: Replace with your actual frontend component.\n\
|
|
||||||
# Common patterns: \"pnpm install\" (pnpm), \"npm install\" (npm),\n\
|
|
||||||
# \"yarn\" (Yarn), \"bun install\" (Bun)\n\
|
|
||||||
[[component]]\n\
|
|
||||||
name = \"frontend\"\n\
|
|
||||||
path = \".\"\n\
|
|
||||||
setup = [\"pnpm install\"]\n\
|
|
||||||
teardown = []\n"
|
|
||||||
.to_string(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
sections.join("\n")
|
sections.join("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate `script/test` content for a new project at `root`.
|
||||||
|
///
|
||||||
|
/// Inspects well-known marker files to identify which tech stacks are present
|
||||||
|
/// and emits the appropriate test commands. Multi-stack projects get combined
|
||||||
|
/// commands run sequentially. Falls back to the generic stub when no markers
|
||||||
|
/// are found so the scaffold is always valid.
|
||||||
|
pub fn detect_script_test(root: &Path) -> String {
|
||||||
|
let mut commands: Vec<&str> = Vec::new();
|
||||||
|
|
||||||
|
if root.join("Cargo.toml").exists() {
|
||||||
|
commands.push("cargo test");
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.join("package.json").exists() {
|
||||||
|
if root.join("pnpm-lock.yaml").exists() {
|
||||||
|
commands.push("pnpm test");
|
||||||
|
} else {
|
||||||
|
commands.push("npm test");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||||
|
commands.push("pytest");
|
||||||
|
}
|
||||||
|
|
||||||
|
if root.join("go.mod").exists() {
|
||||||
|
commands.push("go test ./...");
|
||||||
|
}
|
||||||
|
|
||||||
|
if commands.is_empty() {
|
||||||
|
return STORY_KIT_SCRIPT_TEST.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut script = "#!/usr/bin/env bash\nset -euo pipefail\n\n".to_string();
|
||||||
|
for cmd in commands {
|
||||||
|
script.push_str(cmd);
|
||||||
|
script.push('\n');
|
||||||
|
}
|
||||||
|
script
|
||||||
|
}
|
||||||
|
|
||||||
/// Generate a complete `project.toml` for a new project at `root`.
|
/// Generate a complete `project.toml` for a new project at `root`.
|
||||||
///
|
///
|
||||||
/// Detects the tech stack via [`detect_components_toml`] and prepends the
|
/// Detects the tech stack via [`detect_components_toml`] and prepends the
|
||||||
@@ -329,6 +357,11 @@ fn write_story_kit_gitignore(root: &Path) -> Result<(), String> {
|
|||||||
"worktrees/",
|
"worktrees/",
|
||||||
"merge_workspace/",
|
"merge_workspace/",
|
||||||
"coverage/",
|
"coverage/",
|
||||||
|
"work/2_current/",
|
||||||
|
"work/3_qa/",
|
||||||
|
"work/4_merge/",
|
||||||
|
"logs/",
|
||||||
|
"token_usage.jsonl",
|
||||||
];
|
];
|
||||||
|
|
||||||
let gitignore_path = root.join(".storkit").join(".gitignore");
|
let gitignore_path = root.join(".storkit").join(".gitignore");
|
||||||
@@ -437,9 +470,28 @@ fn scaffold_story_kit(root: &Path, port: u16) -> Result<(), String> {
|
|||||||
write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?;
|
write_file_if_missing(&story_kit_root.join("project.toml"), &project_toml_content)?;
|
||||||
write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?;
|
write_file_if_missing(&specs_root.join("00_CONTEXT.md"), STORY_KIT_CONTEXT)?;
|
||||||
write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?;
|
write_file_if_missing(&tech_root.join("STACK.md"), STORY_KIT_STACK)?;
|
||||||
write_script_if_missing(&script_root.join("test"), STORY_KIT_SCRIPT_TEST)?;
|
let script_test_content = detect_script_test(root);
|
||||||
|
write_script_if_missing(&script_root.join("test"), &script_test_content)?;
|
||||||
write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?;
|
write_file_if_missing(&root.join("CLAUDE.md"), STORY_KIT_CLAUDE_MD)?;
|
||||||
|
|
||||||
|
// Write per-transport bot.toml example files so users can see all options.
|
||||||
|
write_file_if_missing(
|
||||||
|
&story_kit_root.join("bot.toml.matrix.example"),
|
||||||
|
BOT_TOML_MATRIX_EXAMPLE,
|
||||||
|
)?;
|
||||||
|
write_file_if_missing(
|
||||||
|
&story_kit_root.join("bot.toml.whatsapp-meta.example"),
|
||||||
|
BOT_TOML_WHATSAPP_META_EXAMPLE,
|
||||||
|
)?;
|
||||||
|
write_file_if_missing(
|
||||||
|
&story_kit_root.join("bot.toml.whatsapp-twilio.example"),
|
||||||
|
BOT_TOML_WHATSAPP_TWILIO_EXAMPLE,
|
||||||
|
)?;
|
||||||
|
write_file_if_missing(
|
||||||
|
&story_kit_root.join("bot.toml.slack.example"),
|
||||||
|
BOT_TOML_SLACK_EXAMPLE,
|
||||||
|
)?;
|
||||||
|
|
||||||
// Write .mcp.json at the project root so agents can find the MCP server.
|
// Write .mcp.json at the project root so agents can find the MCP server.
|
||||||
// Only written when missing — never overwrites an existing file, because
|
// Only written when missing — never overwrites an existing file, because
|
||||||
// the port is environment-specific and must not clobber a running instance.
|
// the port is environment-specific and must not clobber a running instance.
|
||||||
@@ -876,6 +928,39 @@ mod tests {
|
|||||||
assert!(content.contains("localhost"), "mcp.json should reference localhost");
|
assert!(content.contains("localhost"), "mcp.json should reference localhost");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for bug 371: no-arg `storkit` in empty directory skips scaffold.
|
||||||
|
/// `open_project` on a directory without `.storkit/` must create all required scaffold
|
||||||
|
/// files — the same files that `storkit .` produces.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn open_project_on_empty_dir_creates_full_scaffold() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let project_dir = dir.path().join("myproject");
|
||||||
|
fs::create_dir_all(&project_dir).unwrap();
|
||||||
|
let store = make_store(&dir);
|
||||||
|
let state = SessionState::default();
|
||||||
|
|
||||||
|
open_project(project_dir.to_string_lossy().to_string(), &state, &store, 3001)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
project_dir.join(".storkit/project.toml").exists(),
|
||||||
|
"open_project must create .storkit/project.toml"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
project_dir.join(".mcp.json").exists(),
|
||||||
|
"open_project must create .mcp.json"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
project_dir.join("CLAUDE.md").exists(),
|
||||||
|
"open_project must create CLAUDE.md"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
project_dir.join("script/test").exists(),
|
||||||
|
"open_project must create script/test"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn close_project_clears_root() {
|
async fn close_project_clears_root() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -1496,10 +1581,19 @@ mod tests {
|
|||||||
toml.contains("[[component]]"),
|
toml.contains("[[component]]"),
|
||||||
"should always emit at least one component"
|
"should always emit at least one component"
|
||||||
);
|
);
|
||||||
// The fallback should include example backend and frontend entries
|
// Fallback should use a generic app component with empty setup
|
||||||
assert!(
|
assert!(
|
||||||
toml.contains("name = \"backend\"") || toml.contains("name = \"frontend\""),
|
toml.contains("name = \"app\""),
|
||||||
"fallback should include example component entries"
|
"fallback should use generic 'app' component name"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
toml.contains("setup = []"),
|
||||||
|
"fallback should have empty setup list"
|
||||||
|
);
|
||||||
|
// Must not contain Rust-specific commands in a non-Rust project
|
||||||
|
assert!(
|
||||||
|
!toml.contains("cargo"),
|
||||||
|
"fallback must not contain Rust-specific commands"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1586,6 +1680,38 @@ mod tests {
|
|||||||
assert!(toml.contains("setup = [\"bundle install\"]"));
|
assert!(toml.contains("setup = [\"bundle install\"]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Bug 375: no Rust-specific commands for non-Rust projects ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_rust_commands_in_go_project() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
|
||||||
|
|
||||||
|
let toml = detect_components_toml(dir.path());
|
||||||
|
assert!(!toml.contains("cargo"), "go project must not contain cargo commands");
|
||||||
|
assert!(toml.contains("go build"), "go project must use Go tooling");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_rust_commands_in_node_project() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("package.json"), "{}").unwrap();
|
||||||
|
|
||||||
|
let toml = detect_components_toml(dir.path());
|
||||||
|
assert!(!toml.contains("cargo"), "node project must not contain cargo commands");
|
||||||
|
assert!(toml.contains("npm install"), "node project must use npm tooling");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_rust_commands_when_no_stack_detected() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
|
||||||
|
let toml = detect_components_toml(dir.path());
|
||||||
|
assert!(!toml.contains("cargo"), "unknown stack must not contain cargo commands");
|
||||||
|
// setup list must be empty
|
||||||
|
assert!(toml.contains("setup = []"), "unknown stack must have empty setup list");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn detect_multiple_markers_generates_multiple_components() {
|
fn detect_multiple_markers_generates_multiple_components() {
|
||||||
let dir = tempdir().unwrap();
|
let dir = tempdir().unwrap();
|
||||||
@@ -1614,6 +1740,124 @@ mod tests {
|
|||||||
assert!(!toml.contains("name = \"app\""));
|
assert!(!toml.contains("name = \"app\""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- detect_script_test ---
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_no_markers_returns_stub() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(
|
||||||
|
script.contains("No tests configured"),
|
||||||
|
"fallback should contain the generic stub message"
|
||||||
|
);
|
||||||
|
assert!(script.starts_with("#!/usr/bin/env bash"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_cargo_toml_adds_cargo_test() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
|
||||||
|
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(script.contains("cargo test"), "Rust project should run cargo test");
|
||||||
|
assert!(!script.contains("No tests configured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_package_json_npm_adds_npm_test() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("package.json"), "{}").unwrap();
|
||||||
|
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(script.contains("npm test"), "Node project without pnpm-lock should run npm test");
|
||||||
|
assert!(!script.contains("No tests configured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_package_json_pnpm_adds_pnpm_test() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("package.json"), "{}").unwrap();
|
||||||
|
fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
|
||||||
|
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(script.contains("pnpm test"), "Node project with pnpm-lock should run pnpm test");
|
||||||
|
// "pnpm test" is a substring of itself; verify there's no bare "npm test" line
|
||||||
|
assert!(!script.lines().any(|l| l.trim() == "npm test"), "should not use npm when pnpm-lock.yaml is present");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_pyproject_toml_adds_pytest() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("pyproject.toml"), "[project]\nname = \"x\"\n").unwrap();
|
||||||
|
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(script.contains("pytest"), "Python project should run pytest");
|
||||||
|
assert!(!script.contains("No tests configured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_requirements_txt_adds_pytest() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("requirements.txt"), "flask\n").unwrap();
|
||||||
|
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(script.contains("pytest"), "Python project (requirements.txt) should run pytest");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_go_mod_adds_go_test() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
|
||||||
|
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(script.contains("go test ./..."), "Go project should run go test ./...");
|
||||||
|
assert!(!script.contains("No tests configured"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_multi_stack_combines_commands() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("go.mod"), "module example.com/app\n").unwrap();
|
||||||
|
fs::write(dir.path().join("package.json"), "{}").unwrap();
|
||||||
|
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(script.contains("go test ./..."), "multi-stack should include Go test command");
|
||||||
|
assert!(script.contains("npm test"), "multi-stack should include Node test command");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_script_test_output_starts_with_shebang() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").unwrap();
|
||||||
|
|
||||||
|
let script = detect_script_test(dir.path());
|
||||||
|
assert!(
|
||||||
|
script.starts_with("#!/usr/bin/env bash\nset -euo pipefail\n"),
|
||||||
|
"generated script should start with bash shebang and set -euo pipefail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scaffold_script_test_contains_detected_commands_for_rust() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
fs::write(dir.path().join("Cargo.toml"), "[package]\nname = \"myapp\"\n").unwrap();
|
||||||
|
|
||||||
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||||
|
|
||||||
|
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
|
||||||
|
assert!(content.contains("cargo test"), "Rust project scaffold should set cargo test in script/test");
|
||||||
|
assert!(!content.contains("No tests configured"), "should not use stub when stack is detected");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scaffold_script_test_fallback_stub_when_no_stack() {
|
||||||
|
let dir = tempdir().unwrap();
|
||||||
|
scaffold_story_kit(dir.path(), 3001).unwrap();
|
||||||
|
|
||||||
|
let content = fs::read_to_string(dir.path().join("script/test")).unwrap();
|
||||||
|
assert!(content.contains("No tests configured"), "unknown stack should use the generic stub");
|
||||||
|
}
|
||||||
|
|
||||||
// --- generate_project_toml ---
|
// --- generate_project_toml ---
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1669,10 +1913,14 @@ mod tests {
|
|||||||
content.contains("[[component]]"),
|
content.contains("[[component]]"),
|
||||||
"project.toml should always have at least one component"
|
"project.toml should always have at least one component"
|
||||||
);
|
);
|
||||||
// Fallback emits example components so the scaffold is immediately usable
|
// Fallback uses generic app component with empty setup — no Rust-specific commands
|
||||||
assert!(
|
assert!(
|
||||||
content.contains("name = \"backend\"") || content.contains("name = \"frontend\""),
|
content.contains("name = \"app\""),
|
||||||
"fallback should include example component entries"
|
"fallback should use generic 'app' component name"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!content.contains("cargo"),
|
||||||
|
"fallback must not contain Rust-specific commands for non-Rust projects"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ mod http;
|
|||||||
mod io;
|
mod io;
|
||||||
mod llm;
|
mod llm;
|
||||||
pub mod log_buffer;
|
pub mod log_buffer;
|
||||||
mod matrix;
|
mod chat;
|
||||||
pub mod rebuild;
|
pub mod rebuild;
|
||||||
pub mod slack;
|
|
||||||
mod state;
|
mod state;
|
||||||
mod store;
|
mod store;
|
||||||
pub mod transport;
|
|
||||||
pub mod whatsapp;
|
|
||||||
mod workflow;
|
mod workflow;
|
||||||
mod worktree;
|
mod worktree;
|
||||||
|
|
||||||
@@ -177,13 +174,19 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
config::ProjectConfig::load(&project_root)
|
config::ProjectConfig::load(&project_root)
|
||||||
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
||||||
} else {
|
} else {
|
||||||
// No .storkit/ found — fall back to cwd so existing behaviour is preserved.
|
// No .storkit/ found in cwd or parents — scaffold cwd as a new
|
||||||
// TRACE:MERGE-DEBUG — remove once root cause is found
|
// project, exactly like `storkit .` does.
|
||||||
slog!(
|
io::fs::open_project(
|
||||||
"[MERGE-DEBUG] main: no .storkit/ found, falling back to cwd {:?}",
|
cwd.to_string_lossy().to_string(),
|
||||||
cwd
|
&app_state,
|
||||||
);
|
store.as_ref(),
|
||||||
*app_state.project_root.lock().unwrap() = Some(cwd.clone());
|
port,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| {
|
||||||
|
slog!("Warning: failed to scaffold project at {cwd:?}: {e}");
|
||||||
|
cwd.to_string_lossy().to_string()
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,28 +264,39 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
let agents_for_shutdown = Arc::clone(&agents);
|
let agents_for_shutdown = Arc::clone(&agents);
|
||||||
|
|
||||||
// Build WhatsApp webhook context if bot.toml configures transport = "whatsapp".
|
// Build WhatsApp webhook context if bot.toml configures transport = "whatsapp".
|
||||||
let whatsapp_ctx: Option<Arc<whatsapp::WhatsAppWebhookContext>> = startup_root
|
let whatsapp_ctx: Option<Arc<chat::transport::whatsapp::WhatsAppWebhookContext>> = startup_root
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|root| matrix::BotConfig::load(root))
|
.and_then(|root| chat::transport::matrix::BotConfig::load(root))
|
||||||
.filter(|cfg| cfg.transport == "whatsapp")
|
.filter(|cfg| cfg.transport == "whatsapp")
|
||||||
.map(|cfg| {
|
.map(|cfg| {
|
||||||
|
let provider = cfg.whatsapp_provider.clone();
|
||||||
|
let transport: Arc<dyn crate::chat::ChatTransport> =
|
||||||
|
if provider == "twilio" {
|
||||||
|
Arc::new(chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
||||||
|
cfg.twilio_account_sid.clone().unwrap_or_default(),
|
||||||
|
cfg.twilio_auth_token.clone().unwrap_or_default(),
|
||||||
|
cfg.twilio_whatsapp_number.clone().unwrap_or_default(),
|
||||||
|
))
|
||||||
|
} else {
|
||||||
let template_name = cfg
|
let template_name = cfg
|
||||||
.whatsapp_notification_template
|
.whatsapp_notification_template
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "pipeline_notification".to_string());
|
.unwrap_or_else(|| "pipeline_notification".to_string());
|
||||||
let transport = Arc::new(whatsapp::WhatsAppTransport::new(
|
Arc::new(chat::transport::whatsapp::WhatsAppTransport::new(
|
||||||
cfg.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
cfg.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
||||||
cfg.whatsapp_access_token.clone().unwrap_or_default(),
|
cfg.whatsapp_access_token.clone().unwrap_or_default(),
|
||||||
template_name,
|
template_name,
|
||||||
));
|
))
|
||||||
|
};
|
||||||
let bot_name = cfg
|
let bot_name = cfg
|
||||||
.display_name
|
.display_name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "Assistant".to_string());
|
.unwrap_or_else(|| "Assistant".to_string());
|
||||||
let root = startup_root.clone().unwrap();
|
let root = startup_root.clone().unwrap();
|
||||||
let history = whatsapp::load_whatsapp_history(&root);
|
let history = chat::transport::whatsapp::load_whatsapp_history(&root);
|
||||||
Arc::new(whatsapp::WhatsAppWebhookContext {
|
Arc::new(chat::transport::whatsapp::WhatsAppWebhookContext {
|
||||||
verify_token: cfg.whatsapp_verify_token.clone().unwrap_or_default(),
|
verify_token: cfg.whatsapp_verify_token.clone().unwrap_or_default(),
|
||||||
|
provider,
|
||||||
transport,
|
transport,
|
||||||
project_root: root,
|
project_root: root,
|
||||||
agents: Arc::clone(&startup_agents),
|
agents: Arc::clone(&startup_agents),
|
||||||
@@ -291,17 +305,17 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
||||||
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
||||||
history_size: cfg.history_size,
|
history_size: cfg.history_size,
|
||||||
window_tracker: Arc::new(whatsapp::MessagingWindowTracker::new()),
|
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build Slack webhook context if bot.toml configures transport = "slack".
|
// Build Slack webhook context if bot.toml configures transport = "slack".
|
||||||
let slack_ctx: Option<Arc<slack::SlackWebhookContext>> = startup_root
|
let slack_ctx: Option<Arc<chat::transport::slack::SlackWebhookContext>> = startup_root
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|root| matrix::BotConfig::load(root))
|
.and_then(|root| chat::transport::matrix::BotConfig::load(root))
|
||||||
.filter(|cfg| cfg.transport == "slack")
|
.filter(|cfg| cfg.transport == "slack")
|
||||||
.map(|cfg| {
|
.map(|cfg| {
|
||||||
let transport = Arc::new(slack::SlackTransport::new(
|
let transport = Arc::new(chat::transport::slack::SlackTransport::new(
|
||||||
cfg.slack_bot_token.clone().unwrap_or_default(),
|
cfg.slack_bot_token.clone().unwrap_or_default(),
|
||||||
));
|
));
|
||||||
let bot_name = cfg
|
let bot_name = cfg
|
||||||
@@ -309,10 +323,10 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| "Assistant".to_string());
|
.unwrap_or_else(|| "Assistant".to_string());
|
||||||
let root = startup_root.clone().unwrap();
|
let root = startup_root.clone().unwrap();
|
||||||
let history = slack::load_slack_history(&root);
|
let history = chat::transport::slack::load_slack_history(&root);
|
||||||
let channel_ids: std::collections::HashSet<String> =
|
let channel_ids: std::collections::HashSet<String> =
|
||||||
cfg.slack_channel_ids.iter().cloned().collect();
|
cfg.slack_channel_ids.iter().cloned().collect();
|
||||||
Arc::new(slack::SlackWebhookContext {
|
Arc::new(chat::transport::slack::SlackWebhookContext {
|
||||||
signing_secret: cfg.slack_signing_secret.clone().unwrap_or_default(),
|
signing_secret: cfg.slack_signing_secret.clone().unwrap_or_default(),
|
||||||
transport,
|
transport,
|
||||||
project_root: root,
|
project_root: root,
|
||||||
@@ -336,7 +350,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
if let Some(ref ctx) = slack_ctx {
|
if let Some(ref ctx) = slack_ctx {
|
||||||
let channels: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
let channels: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
||||||
Some(Arc::new(BotShutdownNotifier::new(
|
Some(Arc::new(BotShutdownNotifier::new(
|
||||||
Arc::clone(&ctx.transport) as Arc<dyn crate::transport::ChatTransport>,
|
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
|
||||||
channels,
|
channels,
|
||||||
ctx.bot_name.clone(),
|
ctx.bot_name.clone(),
|
||||||
)))
|
)))
|
||||||
@@ -345,7 +359,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
};
|
};
|
||||||
// Retain a reference to the WhatsApp context for shutdown notifications.
|
// Retain a reference to the WhatsApp context for shutdown notifications.
|
||||||
// At shutdown time we read ambient_rooms to get the current set of active senders.
|
// At shutdown time we read ambient_rooms to get the current set of active senders.
|
||||||
let whatsapp_ctx_for_shutdown: Option<Arc<whatsapp::WhatsAppWebhookContext>> =
|
let whatsapp_ctx_for_shutdown: Option<Arc<chat::transport::whatsapp::WhatsAppWebhookContext>> =
|
||||||
whatsapp_ctx.clone();
|
whatsapp_ctx.clone();
|
||||||
|
|
||||||
// Watch channel: signals the Matrix bot task to send a shutdown announcement.
|
// Watch channel: signals the Matrix bot task to send a shutdown announcement.
|
||||||
@@ -374,7 +388,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 `.storkit/bot.toml` is present and enabled.
|
// messages if `.storkit/bot.toml` is present and enabled.
|
||||||
if let Some(ref root) = startup_root {
|
if let Some(ref root) = startup_root {
|
||||||
matrix::spawn_bot(
|
chat::transport::matrix::spawn_bot(
|
||||||
root,
|
root,
|
||||||
watcher_tx_for_bot,
|
watcher_tx_for_bot,
|
||||||
perm_rx_for_bot,
|
perm_rx_for_bot,
|
||||||
@@ -400,7 +414,8 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
startup_agents.auto_assign_available_work(&root).await;
|
startup_agents.auto_assign_available_work(&root).await;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let addr = format!("127.0.0.1:{port}");
|
let host = std::env::var("STORKIT_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
||||||
|
let addr = format!("{host}:{port}");
|
||||||
|
|
||||||
println!(
|
println!(
|
||||||
"\x1b[95;1m ____ _ _ ___ _ \n / ___|| |_ ___ _ __| | _|_ _| |_ \n \\___ \\| __/ _ \\| '__| |/ /| || __|\n ___) | || (_) | | | < | || |_ \n |____/ \\__\\___/|_| |_|\\_\\___|\\__|\n\x1b[0m"
|
"\x1b[95;1m ____ _ _ ___ _ \n / ___|| |_ ___ _ __| | _|_ _| |_ \n \\___ \\| __/ _ \\| '__| |/ /| || __|\n ___) | || (_) | | | < | || |_ \n |____/ \\__\\___/|_| |_|\\_\\___|\\__|\n\x1b[0m"
|
||||||
@@ -429,7 +444,7 @@ async fn main() -> Result<(), std::io::Error> {
|
|||||||
let rooms: Vec<String> = ctx.ambient_rooms.lock().unwrap().iter().cloned().collect();
|
let rooms: Vec<String> = ctx.ambient_rooms.lock().unwrap().iter().cloned().collect();
|
||||||
if !rooms.is_empty() {
|
if !rooms.is_empty() {
|
||||||
let wa_notifier = BotShutdownNotifier::new(
|
let wa_notifier = BotShutdownNotifier::new(
|
||||||
Arc::clone(&ctx.transport) as Arc<dyn crate::transport::ChatTransport>,
|
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
|
||||||
rooms,
|
rooms,
|
||||||
ctx.bot_name.clone(),
|
ctx.bot_name.clone(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,385 +0,0 @@
|
|||||||
//! Handler for the `assign` command.
|
|
||||||
//!
|
|
||||||
//! `assign <number> <model>` pre-assigns a coder model (e.g. `opus`, `sonnet`)
|
|
||||||
//! to a story before it starts. The assignment persists in the story file's
|
|
||||||
//! front matter as `agent: coder-<model>` so that when the pipeline picks up
|
|
||||||
//! the story — either via auto-assign or the `start` command — it uses the
|
|
||||||
//! assigned model instead of the default.
|
|
||||||
|
|
||||||
use super::CommandContext;
|
|
||||||
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field};
|
|
||||||
|
|
||||||
/// All pipeline stage directories to search when finding a work item by number.
|
|
||||||
const STAGES: &[&str] = &[
|
|
||||||
"1_backlog",
|
|
||||||
"2_current",
|
|
||||||
"3_qa",
|
|
||||||
"4_merge",
|
|
||||||
"5_done",
|
|
||||||
"6_archived",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Resolve a model name hint (e.g. `"opus"`) to a full agent name
|
|
||||||
/// (e.g. `"coder-opus"`). If the hint already starts with `"coder-"`,
|
|
||||||
/// it is returned unchanged to prevent double-prefixing.
|
|
||||||
fn resolve_agent_name(model: &str) -> String {
|
|
||||||
if model.starts_with("coder-") {
|
|
||||||
model.to_string()
|
|
||||||
} else {
|
|
||||||
format!("coder-{model}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn handle_assign(ctx: &CommandContext) -> Option<String> {
|
|
||||||
let args = ctx.args.trim();
|
|
||||||
|
|
||||||
// Parse `<number> <model>` from args.
|
|
||||||
let (number_str, model_str) = match args.split_once(char::is_whitespace) {
|
|
||||||
Some((n, m)) => (n.trim(), m.trim()),
|
|
||||||
None => {
|
|
||||||
return Some(format!(
|
|
||||||
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
|
|
||||||
ctx.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
|
|
||||||
return Some(format!(
|
|
||||||
"Invalid story number `{number_str}`. Usage: `{} assign <number> <model>`",
|
|
||||||
ctx.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if model_str.is_empty() {
|
|
||||||
return Some(format!(
|
|
||||||
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
|
|
||||||
ctx.bot_name
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the story file across all pipeline stages.
|
|
||||||
let mut found: Option<(std::path::PathBuf, String)> = None;
|
|
||||||
'outer: for stage in STAGES {
|
|
||||||
let dir = ctx.project_root.join(".storkit").join("work").join(stage);
|
|
||||||
if !dir.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(stem) = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
{
|
|
||||||
let file_num = stem
|
|
||||||
.split('_')
|
|
||||||
.next()
|
|
||||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_string();
|
|
||||||
if file_num == number_str {
|
|
||||||
found = Some((path, stem));
|
|
||||||
break 'outer;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (path, story_id) = match found {
|
|
||||||
Some(f) => f,
|
|
||||||
None => {
|
|
||||||
return Some(format!(
|
|
||||||
"No story, bug, or spike with number **{number_str}** found."
|
|
||||||
));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Read the human-readable name from front matter for the response.
|
|
||||||
let story_name = std::fs::read_to_string(&path)
|
|
||||||
.ok()
|
|
||||||
.and_then(|contents| {
|
|
||||||
parse_front_matter(&contents)
|
|
||||||
.ok()
|
|
||||||
.and_then(|m| m.name)
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| story_id.clone());
|
|
||||||
|
|
||||||
let agent_name = resolve_agent_name(model_str);
|
|
||||||
|
|
||||||
// Write `agent: <agent_name>` into the story's front matter.
|
|
||||||
let result = std::fs::read_to_string(&path)
|
|
||||||
.map_err(|e| format!("Failed to read story file: {e}"))
|
|
||||||
.and_then(|contents| {
|
|
||||||
let updated = set_front_matter_field(&contents, "agent", &agent_name);
|
|
||||||
std::fs::write(&path, &updated)
|
|
||||||
.map_err(|e| format!("Failed to write story file: {e}"))
|
|
||||||
});
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => Some(format!(
|
|
||||||
"Assigned **{agent_name}** to **{story_name}** (story {number_str}). \
|
|
||||||
The model will be used when the story starts."
|
|
||||||
)),
|
|
||||||
Err(e) => Some(format!(
|
|
||||||
"Failed to assign model to **{story_name}**: {e}"
|
|
||||||
)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use crate::agents::AgentPool;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
use super::super::{CommandDispatch, try_handle_command};
|
|
||||||
|
|
||||||
fn assign_cmd_with_root(root: &std::path::Path, args: &str) -> Option<String> {
|
|
||||||
let agents = Arc::new(AgentPool::new_test(3000));
|
|
||||||
let ambient_rooms = Arc::new(Mutex::new(HashSet::new()));
|
|
||||||
let room_id = "!test:example.com".to_string();
|
|
||||||
let dispatch = CommandDispatch {
|
|
||||||
bot_name: "Timmy",
|
|
||||||
bot_user_id: "@timmy:homeserver.local",
|
|
||||||
project_root: root,
|
|
||||||
agents: &agents,
|
|
||||||
ambient_rooms: &ambient_rooms,
|
|
||||||
room_id: &room_id,
|
|
||||||
};
|
|
||||||
try_handle_command(&dispatch, &format!("@timmy assign {args}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
|
||||||
let dir = root.join(".storkit/work").join(stage);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join(filename), content).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- registration / help ------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_command_is_registered() {
|
|
||||||
use super::super::commands;
|
|
||||||
let found = commands().iter().any(|c| c.name == "assign");
|
|
||||||
assert!(found, "assign command must be in the registry");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_command_appears_in_help() {
|
|
||||||
let result = super::super::tests::try_cmd_addressed(
|
|
||||||
"Timmy",
|
|
||||||
"@timmy:homeserver.local",
|
|
||||||
"@timmy help",
|
|
||||||
);
|
|
||||||
let output = result.unwrap();
|
|
||||||
assert!(
|
|
||||||
output.contains("assign"),
|
|
||||||
"help should list assign command: {output}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- argument validation ------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_no_args_returns_usage() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let output = assign_cmd_with_root(tmp.path(), "").unwrap();
|
|
||||||
assert!(
|
|
||||||
output.contains("Usage"),
|
|
||||||
"no args should show usage: {output}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_missing_model_returns_usage() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let output = assign_cmd_with_root(tmp.path(), "42").unwrap();
|
|
||||||
assert!(
|
|
||||||
output.contains("Usage"),
|
|
||||||
"missing model should show usage: {output}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_non_numeric_number_returns_error() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
let output = assign_cmd_with_root(tmp.path(), "abc opus").unwrap();
|
|
||||||
assert!(
|
|
||||||
output.contains("Invalid story number"),
|
|
||||||
"non-numeric number should return error: {output}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- story not found ----------------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_unknown_story_returns_friendly_message() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
// Create stage dirs but no matching story.
|
|
||||||
for stage in &["1_backlog", "2_current"] {
|
|
||||||
std::fs::create_dir_all(tmp.path().join(".storkit/work").join(stage)).unwrap();
|
|
||||||
}
|
|
||||||
let output = assign_cmd_with_root(tmp.path(), "999 opus").unwrap();
|
|
||||||
assert!(
|
|
||||||
output.contains("999") && output.contains("found"),
|
|
||||||
"not-found message should include number and 'found': {output}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- successful assignment ----------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_writes_agent_field_to_front_matter() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
write_story_file(
|
|
||||||
tmp.path(),
|
|
||||||
"1_backlog",
|
|
||||||
"42_story_test_feature.md",
|
|
||||||
"---\nname: Test Feature\n---\n\n# Story 42\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = assign_cmd_with_root(tmp.path(), "42 opus").unwrap();
|
|
||||||
assert!(
|
|
||||||
output.contains("coder-opus"),
|
|
||||||
"confirmation should include resolved agent name: {output}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
output.contains("Test Feature"),
|
|
||||||
"confirmation should include story name: {output}"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify the file was updated.
|
|
||||||
let contents = std::fs::read_to_string(
|
|
||||||
tmp.path()
|
|
||||||
.join(".storkit/work/1_backlog/42_story_test_feature.md"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
contents.contains("agent: coder-opus"),
|
|
||||||
"front matter should contain agent field: {contents}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_with_sonnet_writes_coder_sonnet() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
write_story_file(
|
|
||||||
tmp.path(),
|
|
||||||
"2_current",
|
|
||||||
"10_story_current.md",
|
|
||||||
"---\nname: Current Story\n---\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
assign_cmd_with_root(tmp.path(), "10 sonnet").unwrap();
|
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(
|
|
||||||
tmp.path()
|
|
||||||
.join(".storkit/work/2_current/10_story_current.md"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
contents.contains("agent: coder-sonnet"),
|
|
||||||
"front matter should contain agent: coder-sonnet: {contents}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_with_already_prefixed_name_does_not_double_prefix() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
write_story_file(
|
|
||||||
tmp.path(),
|
|
||||||
"1_backlog",
|
|
||||||
"7_story_small.md",
|
|
||||||
"---\nname: Small Story\n---\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = assign_cmd_with_root(tmp.path(), "7 coder-opus").unwrap();
|
|
||||||
assert!(
|
|
||||||
output.contains("coder-opus"),
|
|
||||||
"should not double-prefix: {output}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!output.contains("coder-coder-opus"),
|
|
||||||
"must not double-prefix: {output}"
|
|
||||||
);
|
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(
|
|
||||||
tmp.path().join(".storkit/work/1_backlog/7_story_small.md"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
contents.contains("agent: coder-opus"),
|
|
||||||
"must write coder-opus, not coder-coder-opus: {contents}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_overwrites_existing_agent_field() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
write_story_file(
|
|
||||||
tmp.path(),
|
|
||||||
"1_backlog",
|
|
||||||
"5_story_existing.md",
|
|
||||||
"---\nname: Existing\nagent: coder-sonnet\n---\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
assign_cmd_with_root(tmp.path(), "5 opus").unwrap();
|
|
||||||
|
|
||||||
let contents = std::fs::read_to_string(
|
|
||||||
tmp.path()
|
|
||||||
.join(".storkit/work/1_backlog/5_story_existing.md"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
assert!(
|
|
||||||
contents.contains("agent: coder-opus"),
|
|
||||||
"should overwrite old agent with new: {contents}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!contents.contains("coder-sonnet"),
|
|
||||||
"old agent should no longer appear: {contents}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn assign_finds_story_in_any_stage() {
|
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
|
||||||
// Story is in 3_qa/, not backlog.
|
|
||||||
write_story_file(
|
|
||||||
tmp.path(),
|
|
||||||
"3_qa",
|
|
||||||
"99_story_in_qa.md",
|
|
||||||
"---\nname: In QA\n---\n",
|
|
||||||
);
|
|
||||||
|
|
||||||
let output = assign_cmd_with_root(tmp.path(), "99 opus").unwrap();
|
|
||||||
assert!(
|
|
||||||
output.contains("coder-opus"),
|
|
||||||
"should find story in qa stage: {output}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- resolve_agent_name unit tests --------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_agent_name_prefixes_bare_model() {
|
|
||||||
assert_eq!(super::resolve_agent_name("opus"), "coder-opus");
|
|
||||||
assert_eq!(super::resolve_agent_name("sonnet"), "coder-sonnet");
|
|
||||||
assert_eq!(super::resolve_agent_name("haiku"), "coder-haiku");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn resolve_agent_name_does_not_double_prefix() {
|
|
||||||
assert_eq!(super::resolve_agent_name("coder-opus"), "coder-opus");
|
|
||||||
assert_eq!(super::resolve_agent_name("coder-sonnet"), "coder-sonnet");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::transport::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -186,7 +186,7 @@ pub async fn rebuild_and_restart(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use crate::transport::MessageId;
|
use crate::chat::MessageId;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// In-memory transport that records sent messages.
|
/// In-memory transport that records sent messages.
|
||||||
|
|||||||
Reference in New Issue
Block a user