Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b75679175b | |||
| 440081016d | |||
| e8f3629c76 | |||
| c5cdc0f594 | |||
| fec417cb16 | |||
| a70a06a5fb | |||
| 0a617e1c18 | |||
| 4527f71857 | |||
| 6e0d12d145 | |||
| d471d29c72 | |||
| 0b652eec21 | |||
| b32fdf7d65 | |||
| 2da0e1eb55 | |||
| 269124a1fd | |||
| 5992f9bd19 | |||
| a53967453e | |||
| ab4b218ac7 | |||
| d5b936c88d | |||
| 07cc0e3f29 | |||
| db4a84c70f | |||
| 3048d26e66 | |||
| 8e45b2a08d | |||
| ddc4a57cd2 | |||
| d216f3c267 | |||
| 8cd881c8f1 | |||
| 2867e1d15f | |||
| c2c9d3f9cb | |||
| f734b4a3c6 | |||
| 890693efda | |||
| 5403b29261 | |||
| 8ee59f5dc1 | |||
| 5dcc35a1b3 | |||
| af70b68cd1 | |||
| e356f9b2dd | |||
| 96793de11b | |||
| bfe70f5599 | |||
| 98aedaddf0 | |||
| 496ce864d7 | |||
| 243738551c | |||
| 20f2d97f06 | |||
| b6edc1bff7 | |||
| c45613a3ad | |||
| 7efed33851 | |||
| b00a477070 | |||
| 52f2e89659 | |||
| 08db28d9d6 | |||
| 77ff0ce093 | |||
| 0ab1b1232b | |||
| 209e01bc06 | |||
| 2650b1a42e |
+2
-1
@@ -13,7 +13,8 @@ When you start a new session with this project:
|
|||||||
- **Be conversational.** Don't show tool names, step numbers, or raw wizard output to the user.
|
- **Be conversational.** Don't show tool names, step numbers, or raw wizard output to the user.
|
||||||
- **On projects with existing code:** Read the codebase and generate each file, then show the user what you wrote and ask if it looks right.
|
- **On projects with existing code:** Read the codebase and generate each file, then show the user what you wrote and ask if it looks right.
|
||||||
- **On bare projects with no code:** Ask the user what they want to build, what language/framework they plan to use, and generate files from their answers.
|
- **On bare projects with no code:** Ask the user what they want to build, what language/framework they plan to use, and generate files from their answers.
|
||||||
- Use `wizard_generate` to create content, show it to the user, then call `wizard_confirm` (they approve), `wizard_retry` (they want changes), or `wizard_skip` (they want to skip this step).
|
- **You must actually generate the files.** The workflow for each step is: (1) call `wizard_generate` with no args to get a hint, (2) write the file content yourself based on the conversation, (3) call `wizard_generate` again with the `content` argument containing the full file body, (4) show the user what you wrote, (5) call `wizard_confirm` (they approve), `wizard_retry` (they want changes), or `wizard_skip` (they want to skip). Do not stop after discussing — follow through and write the files.
|
||||||
|
- **Keep moving.** After each step is confirmed, immediately proceed to the next wizard step without waiting for the user to ask.
|
||||||
2. **Check for MCP Tools:** Read `.mcp.json` to discover the MCP server endpoint. Then list available tools by calling:
|
2. **Check for MCP Tools:** Read `.mcp.json` to discover the MCP server endpoint. Then list available tools by calling:
|
||||||
```bash
|
```bash
|
||||||
curl -s "$(jq -r '.mcpServers["storkit"].url' .mcp.json)" \
|
curl -s "$(jq -r '.mcpServers["storkit"].url' .mcp.json)" \
|
||||||
|
|||||||
+1
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: "Setup wizard interviews user on bare projects with no existing code"
|
name: "Setup wizard interviews user on bare projects with no existing code"
|
||||||
|
agent: coder-opus
|
||||||
---
|
---
|
||||||
|
|
||||||
# Story 433: Setup wizard interviews user on bare projects with no existing code
|
# Story 433: Setup wizard interviews user on bare projects with no existing code
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: "strip_prefix_ci panics on multi-byte UTF-8 input"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 437: strip_prefix_ci panics on multi-byte UTF-8 input
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
The `strip_prefix_ci` function in `server/src/chat/transport/matrix/assign.rs` slices the input string at `prefix.len()` bytes without checking that the offset is a valid UTF-8 char boundary. When the input message starts with multi-byte characters (e.g. `⏺` which is 3 bytes), the slice can land mid-character, causing a panic.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
Send a Matrix message to the bot that starts with a multi-byte UTF-8 character (e.g. `⏺ storkit - wizard_confirm`) where the bot name byte length falls inside a multi-byte character.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Thread panics: `byte index 6 is not a char boundary; it is inside '⏺' (bytes 4..7)`
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
The function should return `None` (no match) without panicking, since an ASCII bot name cannot match a slice containing multi-byte characters.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] strip_prefix_ci checks is_char_boundary before slicing
|
||||||
|
- [ ] No panic when input contains multi-byte UTF-8 characters at the prefix boundary
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "Slash command autocomplete in web UI text input"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 438: Slash command autocomplete in web UI text input
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user, I want to type `/` at the start of the text box and see a filtered list of available slash commands, so that I can discover and quickly invoke commands without memorizing them.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Typing `/` at position 0 in the ChatInput textarea shows a command picker overlay above the input
|
||||||
|
- [ ] The overlay lists all slash commands with name and description
|
||||||
|
- [ ] Typing further characters after `/` fuzzy-filters the list
|
||||||
|
- [ ] Arrow keys navigate the list, Tab/Enter selects, Escape dismisses
|
||||||
|
- [ ] Selecting a command inserts `/<command> ` into the input (with trailing space)
|
||||||
|
- [ ] Command list is a single shared source of truth used by both the picker and HelpOverlay
|
||||||
|
- [ ] The overlay follows the same visual style as the existing file picker (@-mention overlay)
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
name: "Deduplicate strip_prefix_ci / strip_bot_mention into chat::util"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor 439: Deduplicate strip_prefix_ci / strip_bot_mention into chat::util
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
Eight Matrix transport files (assign.rs, delete.rs, start.rs, rebuild.rs, reset.rs, rmtree.rs, htop.rs, timer.rs) each contain their own private copies of `strip_prefix_ci` and `strip_bot_mention`. The canonical versions already live in `chat::util` with the correct `is_char_boundary` guard. The duplicates should be removed and all call sites should use `util::strip_bot_mention` instead.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] All 8 private copies of strip_prefix_ci are removed
|
||||||
|
- [ ] All 8 private copies of strip_bot_mention are removed
|
||||||
|
- [ ] All call sites use chat::util::strip_bot_mention instead
|
||||||
|
- [ ] Existing tests in util.rs continue to pass
|
||||||
|
- [ ] No new copies of strip_prefix_ci exist outside util.rs
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
name: "Consolidate is_permission_approval into chat::util"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor 440: Consolidate is_permission_approval into chat::util
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
Three copies of `is_permission_approval` exist across Slack (`chat/transport/slack/commands.rs`), WhatsApp (`chat/transport/whatsapp/commands.rs`), and Matrix (`chat/transport/matrix/bot/messages.rs`). The Slack and WhatsApp versions are identical; the Matrix version is a superset that also strips @mentions. Consolidate into a single `pub` function in `chat::util` using the Matrix superset behavior, then delete the 3 private copies.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Single pub fn is_permission_approval exists in chat::util
|
||||||
|
- [ ] All 3 private copies are removed
|
||||||
|
- [ ] Matrix @mention-stripping behavior is preserved in the shared version
|
||||||
|
- [ ] All call sites use the shared version
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: "Deduplicate get_project_root wrappers in io modules"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor 441: Deduplicate get_project_root wrappers in io modules
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
Both `io/shell.rs` and `io/search.rs` contain identical private one-liner wrappers around `state.get_project_root()`. Either inline the call at each usage site or create a single shared helper, then delete the duplicate wrappers.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] No duplicate private get_project_root wrappers in io/shell.rs and io/search.rs
|
||||||
|
- [ ] All call sites use the canonical version or inline the call
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "Deduplicate stage_display_name into shared module"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor 442: Deduplicate stage_display_name into shared module
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
`stage_display_name` has a `pub fn` in `chat/transport/matrix/notifications.rs` and a private copy in `chat/transport/matrix/delete.rs` with slightly different casing ("backlog" vs "Backlog", "in-progress" vs "Current"). The delete.rs copy should use the canonical version from notifications.rs, adjusting the callsite if the casing difference matters.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Private stage_display_name in delete.rs is removed
|
||||||
|
- [ ] delete.rs uses the pub version from notifications.rs
|
||||||
|
- [ ] Display casing is consistent or callsite is adjusted to handle the difference
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: "Extract shared find_story_name from commands"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor 443: Extract shared find_story_name from commands
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
`find_story_name` is nearly identical in `chat/commands/overview.rs` and `chat/commands/unreleased.rs` (minor style diff: `let stages` vs `const STAGES`). Extract to a shared location (e.g. `chat::commands::util` or `io::stories`) and have both callers use it.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Single shared find_story_name function exists
|
||||||
|
- [ ] Both overview.rs and unreleased.rs use the shared version
|
||||||
|
- [ ] Private copies are removed
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
---
|
||||||
|
name: "Extract shared test helpers (test_ctx, write_story_file, make_api)"
|
||||||
|
agent: "coder-opus"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Refactor 444: Extract shared test helpers (test_ctx, write_story_file, make_api)
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- TBD
|
||||||
|
|
||||||
|
## Desired State
|
||||||
|
|
||||||
|
Several test helper functions are copy-pasted across many test modules: `test_ctx` (10 copies across http/ modules), `write_story_file` (5 copies across chat/commands/ and matrix/), `make_api` (5 copies across http/ modules), `setup_project` (3 copies in io/). Extract each into a shared `#[cfg(test)]` utility module so test scaffolding is maintained in one place.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] test_ctx has a single shared definition used by all 10 http test modules
|
||||||
|
- [ ] write_story_file has a single shared definition used by all 5 callers
|
||||||
|
- [ ] make_api has a single shared definition used by all 5 callers
|
||||||
|
- [ ] setup_project has a single shared definition used by all 3 callers
|
||||||
|
- [ ] All private copies in individual test modules are removed
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: "Rate-limited mergemaster exits advance stories to done without merging"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bug 445: Rate-limited mergemaster exits advance stories to done without merging
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
When the mergemaster agent is immediately rate-limited (zero turns, zero tool calls), it exits and run_server_owned_completion runs acceptance gates on the existing worktree. Since the coder already committed working code, the gates pass, and the pipeline advances the story to done — even though the mergemaster never executed run_squash_merge and the code was never cherry-picked onto master.
|
||||||
|
|
||||||
|
## How to Reproduce
|
||||||
|
|
||||||
|
Observed on stories 439 and 442. All mergemaster log entries show: init → rate_limit_event → error result. Zero turns, zero MCP tool calls, duration under 350ms. Yet both stories ended up in done with no merge commit on master.
|
||||||
|
|
||||||
|
## Actual Result
|
||||||
|
|
||||||
|
Stories advance to done with no code on master. The mergemaster never ran but the pipeline treated its exit as a successful completion.
|
||||||
|
|
||||||
|
## Expected Result
|
||||||
|
|
||||||
|
If the mergemaster exits without completing its work (no merge commit produced), the story should stay in the merge stage for retry, not advance to done.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] run_server_owned_completion must not run for mergemaster agents — mergemaster has its own completion path via start_merge_agent_work
|
||||||
|
- [ ] If the mergemaster process exits without producing a SquashMergeResult, the story stays in merge stage
|
||||||
|
- [ ] Rate-limited mergemaster exits are treated as transient failures, not gate-passing completions
|
||||||
|
- [ ] Story remains eligible for retry when mergemaster fails due to rate limiting
|
||||||
Generated
+7
-7
@@ -1774,9 +1774,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iri-string"
|
name = "iri-string"
|
||||||
version = "0.7.11"
|
version = "0.7.12"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
|
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"memchr",
|
"memchr",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -4019,7 +4019,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "storkit"
|
name = "storkit"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -5618,18 +5618,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy"
|
name = "zerocopy"
|
||||||
version = "0.8.47"
|
version = "0.8.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"zerocopy-derive",
|
"zerocopy-derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zerocopy-derive"
|
name = "zerocopy-derive"
|
||||||
version = "0.8.47"
|
version = "0.8.48"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "living-spec-standalone",
|
"name": "living-spec-standalone",
|
||||||
"version": "0.8.1",
|
"version": "0.8.2",
|
||||||
"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.8.1",
|
"version": "0.8.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1481,6 +1481,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/status" } });
|
fireEvent.change(input, { target: { value: "/status" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1551,6 +1555,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/git" } });
|
fireEvent.change(input, { target: { value: "/git" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1569,6 +1577,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/cost" } });
|
fireEvent.change(input, { target: { value: "/cost" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1595,6 +1607,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/reset" } });
|
fireEvent.change(input, { target: { value: "/reset" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1634,6 +1650,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/help" } });
|
fireEvent.change(input, { target: { value: "/help" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
@@ -1652,6 +1672,10 @@ describe("Slash command handling (Story 374)", () => {
|
|||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.change(input, { target: { value: "/git" } });
|
fireEvent.change(input, { target: { value: "/git" } });
|
||||||
});
|
});
|
||||||
|
// First Enter selects the command from the picker; second Enter submits it
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1059,6 +1059,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
)}
|
)}
|
||||||
{messages.map((msg: Message, idx: number) => (
|
{messages.map((msg: Message, idx: number) => (
|
||||||
<MessageItem
|
<MessageItem
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: Message has no stable ID
|
||||||
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
|
import { SLASH_COMMANDS, type SlashCommand } from "../slashCommands";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
@@ -113,6 +114,83 @@ function FilePickerOverlay({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SlashCommandPickerOverlayProps {
|
||||||
|
query: string;
|
||||||
|
selectedIndex: number;
|
||||||
|
onSelect: (cmd: SlashCommand) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SlashCommandPickerOverlay({
|
||||||
|
query,
|
||||||
|
selectedIndex,
|
||||||
|
onSelect,
|
||||||
|
}: SlashCommandPickerOverlayProps) {
|
||||||
|
const filtered = SLASH_COMMANDS.filter((cmd) =>
|
||||||
|
fuzzyMatch(cmd.name, query),
|
||||||
|
).sort((a, b) => fuzzyScore(a.name, query) - fuzzyScore(b.name, query));
|
||||||
|
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="slash-command-picker"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "100%",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
background: "#1e1e1e",
|
||||||
|
border: "1px solid #444",
|
||||||
|
borderRadius: "8px",
|
||||||
|
marginBottom: "6px",
|
||||||
|
overflow: "hidden",
|
||||||
|
zIndex: 100,
|
||||||
|
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
|
||||||
|
maxHeight: "300px",
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{filtered.map((cmd, idx) => (
|
||||||
|
<button
|
||||||
|
key={cmd.name}
|
||||||
|
type="button"
|
||||||
|
data-testid={`slash-command-item-${idx}`}
|
||||||
|
onClick={() => onSelect(cmd)}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
width: "100%",
|
||||||
|
textAlign: "left",
|
||||||
|
padding: "10px 14px",
|
||||||
|
background: idx === selectedIndex ? "#2d4a6e" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
gap: "2px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<code
|
||||||
|
style={{
|
||||||
|
fontSize: "0.88rem",
|
||||||
|
color: idx === selectedIndex ? "#ececec" : "#e0e0e0",
|
||||||
|
fontFamily: "monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cmd.name}
|
||||||
|
</code>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "0.78rem",
|
||||||
|
color: idx === selectedIndex ? "#b0c0d0" : "#888",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cmd.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
function ChatInput(
|
function ChatInput(
|
||||||
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
|
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
|
||||||
@@ -127,6 +205,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
|
const [pickerSelectedIndex, setPickerSelectedIndex] = useState(0);
|
||||||
const [pickerAtStart, setPickerAtStart] = useState(0);
|
const [pickerAtStart, setPickerAtStart] = useState(0);
|
||||||
|
|
||||||
|
// Slash command picker state
|
||||||
|
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
||||||
|
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
appendToInput(text: string) {
|
appendToInput(text: string) {
|
||||||
setInput((prev) => (prev ? `${prev}\n${text}` : text));
|
setInput((prev) => (prev ? `${prev}\n${text}` : text));
|
||||||
@@ -153,6 +235,31 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
setPickerSelectedIndex(0);
|
setPickerSelectedIndex(0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Compute filtered slash commands for current query
|
||||||
|
const filteredCommands =
|
||||||
|
slashQuery !== null
|
||||||
|
? SLASH_COMMANDS.filter((cmd) => fuzzyMatch(cmd.name, slashQuery)).sort(
|
||||||
|
(a, b) =>
|
||||||
|
fuzzyScore(a.name, slashQuery) - fuzzyScore(b.name, slashQuery),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const dismissSlashPicker = useCallback(() => {
|
||||||
|
setSlashQuery(null);
|
||||||
|
setSlashSelectedIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectCommand = useCallback(
|
||||||
|
(cmd: SlashCommand) => {
|
||||||
|
// Extract base command (first word, e.g. "/assign" from "/assign <number> <model>")
|
||||||
|
const baseCommand = cmd.name.split(" ")[0];
|
||||||
|
setInput(`${baseCommand} `);
|
||||||
|
dismissSlashPicker();
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 0);
|
||||||
|
},
|
||||||
|
[dismissSlashPicker],
|
||||||
|
);
|
||||||
|
|
||||||
const selectFile = useCallback(
|
const selectFile = useCallback(
|
||||||
(file: string) => {
|
(file: string) => {
|
||||||
// Replace the @query portion with @file
|
// Replace the @query portion with @file
|
||||||
@@ -173,11 +280,20 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
setInput(val);
|
setInput(val);
|
||||||
|
|
||||||
const cursor = e.target.selectionStart ?? val.length;
|
const cursor = e.target.selectionStart ?? val.length;
|
||||||
// Find the last @ before the cursor that starts a reference token
|
|
||||||
const textUpToCursor = val.slice(0, cursor);
|
const textUpToCursor = val.slice(0, cursor);
|
||||||
// Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline)
|
|
||||||
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
|
|
||||||
|
|
||||||
|
// Slash command picker: triggered when input starts with / and no space yet
|
||||||
|
const slashMatch = textUpToCursor.match(/^\/(\S*)$/);
|
||||||
|
if (slashMatch) {
|
||||||
|
setSlashQuery(slashMatch[1]);
|
||||||
|
setSlashSelectedIndex(0);
|
||||||
|
if (pickerQuery !== null) dismissPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (slashQuery !== null) dismissSlashPicker();
|
||||||
|
|
||||||
|
// File picker: triggered by @ at start or after whitespace
|
||||||
|
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
|
||||||
if (atMatch) {
|
if (atMatch) {
|
||||||
const query = atMatch[2];
|
const query = atMatch[2];
|
||||||
const atPos = textUpToCursor.lastIndexOf("@");
|
const atPos = textUpToCursor.lastIndexOf("@");
|
||||||
@@ -196,11 +312,50 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
if (pickerQuery !== null) dismissPicker();
|
if (pickerQuery !== null) dismissPicker();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[projectFiles.length, pickerQuery, dismissPicker],
|
[
|
||||||
|
projectFiles.length,
|
||||||
|
pickerQuery,
|
||||||
|
dismissPicker,
|
||||||
|
slashQuery,
|
||||||
|
dismissSlashPicker,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Slash command picker navigation
|
||||||
|
if (slashQuery !== null && filteredCommands.length > 0) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlashSelectedIndex((i) =>
|
||||||
|
Math.min(i + 1, filteredCommands.length - 1),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setSlashSelectedIndex((i) => Math.max(i - 1, 0));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Tab" || e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
selectCommand(
|
||||||
|
filteredCommands[slashSelectedIndex] ?? filteredCommands[0],
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
dismissSlashPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (e.key === "Escape" && slashQuery !== null) {
|
||||||
|
e.preventDefault();
|
||||||
|
dismissSlashPicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File picker navigation
|
||||||
if (pickerQuery !== null && filteredFiles.length > 0) {
|
if (pickerQuery !== null && filteredFiles.length > 0) {
|
||||||
if (e.key === "ArrowDown") {
|
if (e.key === "ArrowDown") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -236,6 +391,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
slashQuery,
|
||||||
|
filteredCommands,
|
||||||
|
slashSelectedIndex,
|
||||||
|
selectCommand,
|
||||||
|
dismissSlashPicker,
|
||||||
pickerQuery,
|
pickerQuery,
|
||||||
filteredFiles,
|
filteredFiles,
|
||||||
pickerSelectedIndex,
|
pickerSelectedIndex,
|
||||||
@@ -249,6 +409,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
onSubmit(input);
|
onSubmit(input);
|
||||||
setInput("");
|
setInput("");
|
||||||
dismissPicker();
|
dismissPicker();
|
||||||
|
dismissSlashPicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -357,6 +518,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{slashQuery !== null && (
|
||||||
|
<SlashCommandPickerOverlay
|
||||||
|
query={slashQuery}
|
||||||
|
selectedIndex={slashSelectedIndex}
|
||||||
|
onSelect={selectCommand}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{pickerQuery !== null && (
|
{pickerQuery !== null && (
|
||||||
<FilePickerOverlay
|
<FilePickerOverlay
|
||||||
query={pickerQuery}
|
query={pickerQuery}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ChatInput } from "./ChatInput";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
api: {
|
||||||
|
listProjectFiles: vi.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
loading: false,
|
||||||
|
queuedMessages: [],
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
onRemoveQueuedMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command picker overlay (Story 438 AC1)", () => {
|
||||||
|
it("shows slash command picker when / is typed at position 0", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show slash command picker for plain text", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "hello" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show slash command picker when / is not at position 0", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "hello /world" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command list (Story 438 AC2)", () => {
|
||||||
|
it("lists slash commands with name and description", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||||||
|
// First command should be /help
|
||||||
|
expect(screen.getByTestId("slash-command-item-0")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("slash-command-item-0")).toHaveTextContent(
|
||||||
|
"/help",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command fuzzy filter (Story 438 AC3)", () => {
|
||||||
|
it("filters commands when typing after /", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||||||
|
// /help should match "hel"
|
||||||
|
expect(screen.getByTestId("slash-command-item-0")).toHaveTextContent(
|
||||||
|
"/help",
|
||||||
|
);
|
||||||
|
// /rebuild should not be visible (no match for "hel")
|
||||||
|
const items = screen.queryAllByTestId(/^slash-command-item-/);
|
||||||
|
const texts = items.map((el) => el.textContent ?? "");
|
||||||
|
expect(texts.some((t) => t.includes("/rebuild"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows no picker when query matches nothing", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/zzzzz" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command keyboard navigation (Story 438 AC4)", () => {
|
||||||
|
it("ArrowDown navigates to next item", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const item0 = screen.getByTestId("slash-command-item-0");
|
||||||
|
expect(item0).toHaveStyle({ background: "#2d4a6e" });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const item1 = screen.getByTestId("slash-command-item-1");
|
||||||
|
expect(item1).toHaveStyle({ background: "#2d4a6e" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ArrowUp stays at 0 when already at top", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowUp" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const item0 = screen.getByTestId("slash-command-item-0");
|
||||||
|
expect(item0).toHaveStyle({ background: "#2d4a6e" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter selects the highlighted command and inserts it", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("/help ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Tab selects the highlighted command and inserts it", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/hel" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Tab" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("/help ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Escape dismisses the picker", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("slash-command-picker")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Slash command selection inserts with trailing space (Story 438 AC5)", () => {
|
||||||
|
it("clicking a command inserts /<command> with trailing space", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("slash-command-item-0"));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("slash-command-picker"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
const val = (textarea as HTMLTextAreaElement).value;
|
||||||
|
expect(val).toMatch(/^\/\w+ $/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selection inserts only the base command (no argument placeholders)", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/ass" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("/assign ");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,75 +1,8 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { SLASH_COMMANDS } from "../slashCommands";
|
||||||
|
|
||||||
const { useEffect, useRef } = React;
|
const { useEffect, useRef } = React;
|
||||||
|
|
||||||
interface SlashCommand {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SLASH_COMMANDS: SlashCommand[] = [
|
|
||||||
{
|
|
||||||
name: "/help",
|
|
||||||
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>",
|
|
||||||
description:
|
|
||||||
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
interface HelpOverlayProps {
|
interface HelpOverlayProps {
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ function MessageItemInner({ msg }: MessageItemProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: ToolCall has no stable ID
|
||||||
key={`tool-${i}-${tc.function.name}`}
|
key={`tool-${i}-${tc.function.name}`}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
|
|||||||
) : (
|
) : (
|
||||||
filteredLogs.map((entry, idx) => (
|
filteredLogs.map((entry, idx) => (
|
||||||
<div
|
<div
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: log entries have no stable ID
|
||||||
key={`${entry.timestamp}-${idx}`}
|
key={`${entry.timestamp}-${idx}`}
|
||||||
style={{
|
style={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
export interface SlashCommand {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SLASH_COMMANDS: SlashCommand[] = [
|
||||||
|
{
|
||||||
|
name: "/help",
|
||||||
|
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>",
|
||||||
|
description:
|
||||||
|
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -4,6 +4,9 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
|
|
||||||
|
echo "=== Running cargo clippy ==="
|
||||||
|
cargo clippy --manifest-path "$PROJECT_ROOT/Cargo.toml" --all-targets --all-features
|
||||||
|
|
||||||
echo "=== Running Rust tests ==="
|
echo "=== Running Rust tests ==="
|
||||||
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml"
|
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "storkit"
|
name = "storkit"
|
||||||
version = "0.8.1"
|
version = "0.8.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
|
|||||||
@@ -171,39 +171,12 @@ fn run_command_with_timeout(
|
|||||||
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
|
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
|
||||||
/// Returns `(gates_passed, combined_output)`.
|
/// Returns `(gates_passed, combined_output)`.
|
||||||
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
|
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
|
||||||
let mut all_output = String::new();
|
// Run script/test (or fallback to cargo test). This is the sole
|
||||||
let mut all_passed = true;
|
// acceptance gate — project-specific linting and test commands belong
|
||||||
|
// in script/test, not hardcoded here.
|
||||||
// ── cargo clippy ──────────────────────────────────────────────
|
|
||||||
let clippy = Command::new("cargo")
|
|
||||||
.args(["clippy", "--all-targets", "--all-features"])
|
|
||||||
.current_dir(path)
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("Failed to run cargo clippy: {e}"))?;
|
|
||||||
|
|
||||||
all_output.push_str("=== cargo clippy ===\n");
|
|
||||||
let clippy_stdout = String::from_utf8_lossy(&clippy.stdout);
|
|
||||||
let clippy_stderr = String::from_utf8_lossy(&clippy.stderr);
|
|
||||||
if !clippy_stdout.is_empty() {
|
|
||||||
all_output.push_str(&clippy_stdout);
|
|
||||||
}
|
|
||||||
if !clippy_stderr.is_empty() {
|
|
||||||
all_output.push_str(&clippy_stderr);
|
|
||||||
}
|
|
||||||
all_output.push('\n');
|
|
||||||
|
|
||||||
if !clippy.status.success() {
|
|
||||||
all_passed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── tests (script/test if available, else cargo nextest/test) ─
|
|
||||||
let (test_success, test_out) = run_project_tests(path)?;
|
let (test_success, test_out) = run_project_tests(path)?;
|
||||||
all_output.push_str(&test_out);
|
|
||||||
if !test_success {
|
|
||||||
all_passed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((all_passed, all_output))
|
Ok((test_success, test_out))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run `script/test_coverage` in the given directory if the script exists.
|
/// Run `script/test_coverage` in the given directory if the script exists.
|
||||||
|
|||||||
@@ -498,6 +498,50 @@ impl AgentPool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mergemaster agents have their own completion path via
|
||||||
|
// start_merge_agent_work / run_merge_pipeline and must NOT go
|
||||||
|
// through server-owned gates. When a mergemaster exits early
|
||||||
|
// (e.g. rate-limited before calling start_merge_agent_work) the
|
||||||
|
// feature-branch worktree compiles fine and post-merge tests on
|
||||||
|
// master pass (nothing changed), which would wrongly advance the
|
||||||
|
// story to 5_done/ without any squash merge having occurred.
|
||||||
|
// Instead: just remove the agent from the pool and let
|
||||||
|
// auto-assign restart a new mergemaster for the story.
|
||||||
|
let stage = config_clone
|
||||||
|
.find_agent(&aname)
|
||||||
|
.map(agent_config_stage)
|
||||||
|
.unwrap_or_else(|| pipeline_stage(&aname));
|
||||||
|
if stage == PipelineStage::Mergemaster {
|
||||||
|
let (tx_done, done_session_id) = {
|
||||||
|
let mut lock = match agents_ref.lock() {
|
||||||
|
Ok(a) => a,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
if let Some(agent) = lock.remove(&key_clone) {
|
||||||
|
(agent.tx, agent.session_id.or(result.session_id))
|
||||||
|
} else {
|
||||||
|
(tx_clone.clone(), result.session_id)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = tx_done.send(AgentEvent::Done {
|
||||||
|
story_id: sid.clone(),
|
||||||
|
agent_name: aname.clone(),
|
||||||
|
session_id: done_session_id,
|
||||||
|
});
|
||||||
|
AgentPool::notify_agent_state_changed(&watcher_tx_clone);
|
||||||
|
// Send a WorkItem event so the auto-assign watcher loop
|
||||||
|
// re-dispatches a new mergemaster if the story still needs
|
||||||
|
// merging. This avoids an async call to start_agent inside
|
||||||
|
// a tokio::spawn (which would require Send).
|
||||||
|
let _ = watcher_tx_clone.send(
|
||||||
|
crate::io::watcher::WatcherEvent::WorkItem {
|
||||||
|
stage: "4_merge".to_string(),
|
||||||
|
item_id: sid.clone(),
|
||||||
|
action: "reassign".to_string(),
|
||||||
|
commit_msg: String::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
// Server-owned completion: run acceptance gates automatically
|
// Server-owned completion: run acceptance gates automatically
|
||||||
// when the agent process exits normally.
|
// when the agent process exits normally.
|
||||||
super::pipeline::run_server_owned_completion(
|
super::pipeline::run_server_owned_completion(
|
||||||
@@ -511,6 +555,7 @@ impl AgentPool {
|
|||||||
.await;
|
.await;
|
||||||
AgentPool::notify_agent_state_changed(&watcher_tx_clone);
|
AgentPool::notify_agent_state_changed(&watcher_tx_clone);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
slog_error!("[agents] Agent process error for {aname} on {sid}: {e}");
|
slog_error!("[agents] Agent process error for {aname} on {sid}: {e}");
|
||||||
let event = AgentEvent::Error {
|
let event = AgentEvent::Error {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::collections::HashMap;
|
|||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use super::super::super::{AgentEvent, AgentStatus, CompletionReport};
|
use super::super::super::{AgentEvent, AgentStatus, CompletionReport, PipelineStage, pipeline_stage};
|
||||||
use super::super::{AgentPool, StoryAgent, composite_key};
|
use super::super::{AgentPool, StoryAgent, composite_key};
|
||||||
use super::advance::spawn_pipeline_advance;
|
use super::advance::spawn_pipeline_advance;
|
||||||
|
|
||||||
@@ -155,6 +155,21 @@ pub(in crate::agents::pool) async fn run_server_owned_completion(
|
|||||||
) {
|
) {
|
||||||
let key = composite_key(story_id, agent_name);
|
let key = composite_key(story_id, agent_name);
|
||||||
|
|
||||||
|
// Guard: mergemaster agents have their own completion path via
|
||||||
|
// start_merge_agent_work / run_merge_pipeline. Running server-owned gates
|
||||||
|
// for a mergemaster would wrongly advance the story to 5_done/ even when
|
||||||
|
// no squash merge has occurred (e.g. rate-limited exit before the agent
|
||||||
|
// called start_merge_agent_work). The lifecycle caller is responsible for
|
||||||
|
// cleaning up the agent entry and triggering auto-assign.
|
||||||
|
if pipeline_stage(agent_name) == PipelineStage::Mergemaster {
|
||||||
|
slog!(
|
||||||
|
"[agents] run_server_owned_completion skipped for mergemaster \
|
||||||
|
'{story_id}:{agent_name}'; mergemaster completion is handled by \
|
||||||
|
start_merge_agent_work."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Guard: skip if completion was already recorded (legacy path).
|
// Guard: skip if completion was already recorded (legacy path).
|
||||||
{
|
{
|
||||||
let lock = match agents.lock() {
|
let lock = match agents.lock() {
|
||||||
@@ -516,4 +531,83 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression test for bug 445: a rate-limited mergemaster exits before
|
||||||
|
/// calling start_merge_agent_work. run_server_owned_completion must be a
|
||||||
|
/// no-op for mergemaster agents — it must not run acceptance gates and must
|
||||||
|
/// not advance the story to 5_done/ even when a passing script/test exists.
|
||||||
|
///
|
||||||
|
/// Before the fix: run_server_owned_completion would call run_pipeline_advance
|
||||||
|
/// for the Mergemaster stage, which ran post-merge tests on master (they pass
|
||||||
|
/// because nothing changed), then called move_story_to_done — advancing the
|
||||||
|
/// story without any squash merge having occurred.
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn server_owned_completion_is_noop_for_mergemaster() {
|
||||||
|
use std::fs;
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
use tempfile::tempdir;
|
||||||
|
|
||||||
|
let tmp = tempdir().unwrap();
|
||||||
|
let root = tmp.path();
|
||||||
|
init_git_repo(root);
|
||||||
|
|
||||||
|
// Create a passing script/test so post-merge tests would succeed if
|
||||||
|
// run_pipeline_advance were incorrectly called for this mergemaster.
|
||||||
|
let script_dir = root.join("script");
|
||||||
|
fs::create_dir_all(&script_dir).unwrap();
|
||||||
|
let script_test = script_dir.join("test");
|
||||||
|
fs::write(&script_test, "#!/usr/bin/env sh\nexit 0\n").unwrap();
|
||||||
|
let mut perms = fs::metadata(&script_test).unwrap().permissions();
|
||||||
|
perms.set_mode(0o755);
|
||||||
|
fs::set_permissions(&script_test, perms).unwrap();
|
||||||
|
|
||||||
|
// Story in 4_merge/ — must NOT be moved to 5_done/.
|
||||||
|
let merge_dir = root.join(".storkit/work/4_merge");
|
||||||
|
fs::create_dir_all(&merge_dir).unwrap();
|
||||||
|
let story_path = merge_dir.join("99_story_merge445.md");
|
||||||
|
fs::write(&story_path, "---\nname: Merge 445 Test\n---\n").unwrap();
|
||||||
|
|
||||||
|
let pool = AgentPool::new_test(3001);
|
||||||
|
pool.inject_test_agent_with_path(
|
||||||
|
"99_story_merge445",
|
||||||
|
"mergemaster",
|
||||||
|
AgentStatus::Running,
|
||||||
|
root.to_path_buf(),
|
||||||
|
);
|
||||||
|
|
||||||
|
run_server_owned_completion(
|
||||||
|
&pool.agents,
|
||||||
|
pool.port,
|
||||||
|
"99_story_merge445",
|
||||||
|
"mergemaster",
|
||||||
|
None,
|
||||||
|
pool.watcher_tx.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Wait briefly in case any background task fires.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(150)).await;
|
||||||
|
|
||||||
|
// Story must remain in 4_merge/ — not moved to 5_done/.
|
||||||
|
let done_path = root.join(".storkit/work/5_done/99_story_merge445.md");
|
||||||
|
assert!(
|
||||||
|
!done_path.exists(),
|
||||||
|
"Story must NOT be moved to 5_done/ when run_server_owned_completion \
|
||||||
|
is (incorrectly) called for a mergemaster agent"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
story_path.exists(),
|
||||||
|
"Story must remain in 4_merge/ when mergemaster completion is a no-op"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The agent entry should remain in the pool (lifecycle cleanup is the
|
||||||
|
// caller's responsibility, not run_server_owned_completion's).
|
||||||
|
let agents = pool.agents.lock().unwrap();
|
||||||
|
let key = composite_key("99_story_merge445", "mergemaster");
|
||||||
|
assert!(
|
||||||
|
agents.get(&key).is_some(),
|
||||||
|
"Agent must remain in pool — run_server_owned_completion is a no-op for mergemaster"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ pub fn commands() -> &'static [BotCommand] {
|
|||||||
},
|
},
|
||||||
BotCommand {
|
BotCommand {
|
||||||
name: "setup",
|
name: "setup",
|
||||||
description: "Show setup wizard progress; or `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
|
description: "Show setup wizard progress; or `setup generate` / `setup confirm` / `setup skip` / `setup retry` to drive the wizard from chat",
|
||||||
handler: setup::handle_setup,
|
handler: setup::handle_setup,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -142,11 +142,7 @@ mod tests {
|
|||||||
try_handle_command(&dispatch, &format!("@timmy move {args}"))
|
try_handle_command(&dispatch, &format!("@timmy move {args}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
use crate::chat::test_helpers::write_story_file;
|
||||||
let dir = root.join(".storkit/work").join(stage);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join(filename), content).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn move_command_is_registered() {
|
fn move_command_is_registered() {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
//! - `setup retry` — discard staged content and reset the current step
|
//! - `setup retry` — discard staged content and reset the current step
|
||||||
|
|
||||||
use super::CommandContext;
|
use super::CommandContext;
|
||||||
use crate::http::mcp::wizard_tools::{is_script_step, step_output_path, write_if_missing};
|
use crate::http::mcp::wizard_tools::{
|
||||||
|
generation_hint, is_script_step, step_output_path, write_if_missing,
|
||||||
|
};
|
||||||
use crate::io::wizard::{format_wizard_state, StepStatus, WizardState};
|
use crate::io::wizard::{format_wizard_state, StepStatus, WizardState};
|
||||||
|
|
||||||
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
||||||
@@ -17,15 +19,45 @@ pub(super) fn handle_setup(ctx: &CommandContext) -> Option<String> {
|
|||||||
|
|
||||||
match sub.as_str() {
|
match sub.as_str() {
|
||||||
"" => Some(wizard_status_reply(ctx)),
|
"" => Some(wizard_status_reply(ctx)),
|
||||||
|
"generate" => Some(wizard_generate_reply(ctx)),
|
||||||
"confirm" => Some(wizard_confirm_reply(ctx)),
|
"confirm" => Some(wizard_confirm_reply(ctx)),
|
||||||
"skip" => Some(wizard_skip_reply(ctx)),
|
"skip" => Some(wizard_skip_reply(ctx)),
|
||||||
"retry" => Some(wizard_retry_reply(ctx)),
|
"retry" => Some(wizard_retry_reply(ctx)),
|
||||||
_ => Some(format!(
|
_ => Some(format!(
|
||||||
"Unknown sub-command `{sub}`. Usage: `setup`, `setup confirm`, `setup skip`, `setup retry`."
|
"Unknown sub-command `{sub}`. Usage: `setup`, `setup generate`, `setup confirm`, `setup skip`, `setup retry`."
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mark the current step as generating and return the generation hint.
|
||||||
|
///
|
||||||
|
/// This mirrors `wizard_generate` (with no content) from the MCP tools, making
|
||||||
|
/// the interview flow accessible from chat transports (Matrix, Slack, WhatsApp).
|
||||||
|
fn wizard_generate_reply(ctx: &CommandContext) -> String {
|
||||||
|
let root = ctx.project_root;
|
||||||
|
let mut state = match WizardState::load(root) {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return "No wizard active.".to_string(),
|
||||||
|
};
|
||||||
|
if state.completed {
|
||||||
|
return "Wizard is already complete.".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = state.current_step_index();
|
||||||
|
let step = state.steps[idx].step;
|
||||||
|
|
||||||
|
state.set_step_status(step, StepStatus::Generating, None);
|
||||||
|
if let Err(e) = state.save(root) {
|
||||||
|
return format!("Failed to save wizard state: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
let hint = generation_hint(step, root);
|
||||||
|
format!(
|
||||||
|
"Step '{}' marked as generating.\n\n{hint}\n\nOnce you have the content, stage it via the API and then run `setup confirm` to write it to disk.",
|
||||||
|
step.label()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Compose a status reply for the `setup` command (no args).
|
/// Compose a status reply for the `setup` command (no args).
|
||||||
fn wizard_status_reply(ctx: &CommandContext) -> String {
|
fn wizard_status_reply(ctx: &CommandContext) -> String {
|
||||||
match WizardState::load(ctx.project_root) {
|
match WizardState::load(ctx.project_root) {
|
||||||
@@ -263,4 +295,45 @@ mod tests {
|
|||||||
assert!(result.contains("Unknown sub-command"));
|
assert!(result.contains("Unknown sub-command"));
|
||||||
assert!(result.contains("Usage"));
|
assert!(result.contains("Usage"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_generate_marks_generating_and_returns_hint() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4006));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("generate", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("generating"));
|
||||||
|
let state = WizardState::load(dir.path()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
state.steps[1].status,
|
||||||
|
crate::io::wizard::StepStatus::Generating
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_generate_bare_project_asks_user() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
// Bare project — only scaffolding files
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
WizardState::init_if_missing(dir.path());
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4007));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("generate", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("bare project"));
|
||||||
|
assert!(result.contains("Ask the user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn setup_generate_no_wizard_returns_error() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let agents = Arc::new(crate::agents::AgentPool::new_test(4008));
|
||||||
|
let rooms = Arc::new(Mutex::new(HashSet::new()));
|
||||||
|
let ctx = make_ctx("generate", dir.path(), &agents, &rooms);
|
||||||
|
let result = handle_setup(&ctx).unwrap();
|
||||||
|
assert!(result.contains("No wizard active"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,11 +91,7 @@ mod tests {
|
|||||||
try_handle_command(&dispatch, &format!("@timmy show {args}"))
|
try_handle_command(&dispatch, &format!("@timmy show {args}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
use crate::chat::test_helpers::write_story_file;
|
||||||
let dir = root.join(".storkit/work").join(stage);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join(filename), content).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn show_command_is_registered() {
|
fn show_command_is_registered() {
|
||||||
|
|||||||
@@ -296,11 +296,7 @@ mod tests {
|
|||||||
try_handle_command(&dispatch, &format!("@timmy status {args}"))
|
try_handle_command(&dispatch, &format!("@timmy status {args}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
|
use crate::chat::test_helpers::write_story_file;
|
||||||
let dir = root.join(".storkit/work").join(stage);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join(filename), content).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- registration -------------------------------------------------------
|
// -- registration -------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -108,18 +108,14 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear the blocked flag if present.
|
// Clear the blocked flag if present.
|
||||||
if has_blocked {
|
if has_blocked && let Err(e) = clear_front_matter_field(path, "blocked") {
|
||||||
if let Err(e) = clear_front_matter_field(path, "blocked") {
|
|
||||||
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
|
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Clear merge_failure if present.
|
// Clear merge_failure if present.
|
||||||
if has_merge_failure {
|
if has_merge_failure && let Err(e) = clear_front_matter_field(path, "merge_failure") {
|
||||||
if let Err(e) = clear_front_matter_field(path, "merge_failure") {
|
|
||||||
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
|
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Reset retry_count to 0 (re-read the updated file, modify, write).
|
// Reset retry_count to 0 (re-read the updated file, modify, write).
|
||||||
let updated_contents = match std::fs::read_to_string(path) {
|
let updated_contents = match std::fs::read_to_string(path) {
|
||||||
@@ -164,11 +160,7 @@ mod tests {
|
|||||||
try_handle_command(&dispatch, &format!("@timmy unblock {args}"))
|
try_handle_command(&dispatch, &format!("@timmy unblock {args}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
|
use crate::chat::test_helpers::write_story_file;
|
||||||
let dir = root.join(".storkit/work").join(stage);
|
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
|
||||||
std::fs::write(dir.join(filename), content).unwrap();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unblock_command_is_registered() {
|
fn unblock_command_is_registered() {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ pub mod commands;
|
|||||||
pub mod timer;
|
pub mod timer;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
pub mod util;
|
pub mod util;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_helpers;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! Shared test utilities for chat handler tests.
|
||||||
|
//!
|
||||||
|
//! Import with `use crate::chat::test_helpers::write_story_file;`
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Write a work-item file into the standard pipeline directory structure.
|
||||||
|
///
|
||||||
|
/// Creates `.storkit/work/{stage}/{filename}` under `root`, creating any
|
||||||
|
/// missing parent directories.
|
||||||
|
pub(crate) 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();
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use crate::chat::util::strip_bot_mention;
|
||||||
|
|
||||||
// ── Data types ─────────────────────────────────────────────────────────────
|
// ── Data types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A single scheduled timer entry.
|
/// A single scheduled timer entry.
|
||||||
@@ -256,7 +258,7 @@ pub fn extract_timer_command(
|
|||||||
bot_name: &str,
|
bot_name: &str,
|
||||||
bot_user_id: &str,
|
bot_user_id: &str,
|
||||||
) -> Option<TimerCommand> {
|
) -> Option<TimerCommand> {
|
||||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped
|
let trimmed = stripped
|
||||||
.trim()
|
.trim()
|
||||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
@@ -466,37 +468,6 @@ fn resolve_story_id(number_or_id: &str, project_root: &Path) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ──────────────────────────────────────────────────────────────────
|
// ── Tests ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! that the next `start` invocation picks it up automatically.
|
//! that the next `start` invocation picks it up automatically.
|
||||||
|
|
||||||
use crate::agents::{AgentPool, AgentStatus};
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
|
use crate::chat::util::strip_bot_mention;
|
||||||
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field};
|
use crate::io::story_metadata::{parse_front_matter, set_front_matter_field};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -43,7 +44,7 @@ pub fn extract_assign_command(
|
|||||||
bot_name: &str,
|
bot_name: &str,
|
||||||
bot_user_id: &str,
|
bot_user_id: &str,
|
||||||
) -> Option<AssignCommand> {
|
) -> Option<AssignCommand> {
|
||||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped
|
let trimmed = stripped
|
||||||
.trim()
|
.trim()
|
||||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
@@ -234,40 +235,6 @@ pub async fn handle_assign(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -354,6 +321,18 @@ mod tests {
|
|||||||
assert_eq!(cmd, None);
|
assert_eq!(cmd, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_assign_command_multibyte_prefix_no_panic() {
|
||||||
|
// "xxxx⏺ assign 42 opus" — ⏺ (U+23FA) is 3 bytes, starting at byte 4.
|
||||||
|
// "@timmy" has len 6 so text[..6] lands inside ⏺ — panics without the fix.
|
||||||
|
let cmd = extract_assign_command(
|
||||||
|
"xxxx\u{23FA} assign 42 opus",
|
||||||
|
"Timmy",
|
||||||
|
"@timmy:home.local",
|
||||||
|
);
|
||||||
|
assert_eq!(cmd, None);
|
||||||
|
}
|
||||||
|
|
||||||
// -- resolve_agent_name --------------------------------------------------
|
// -- resolve_agent_name --------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -371,11 +350,7 @@ mod tests {
|
|||||||
|
|
||||||
// -- handle_assign (no running coder) ------------------------------------
|
// -- handle_assign (no running coder) ------------------------------------
|
||||||
|
|
||||||
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
|
use crate::chat::test_helpers::write_story_file;
|
||||||
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]
|
#[tokio::test]
|
||||||
async fn handle_assign_returns_not_found_for_unknown_number() {
|
async fn handle_assign_returns_not_found_for_unknown_number() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use crate::chat::util::drain_complete_paragraphs;
|
use crate::chat::util::{drain_complete_paragraphs, is_permission_approval};
|
||||||
use crate::http::context::PermissionDecision;
|
use crate::http::context::PermissionDecision;
|
||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
@@ -22,24 +22,6 @@ use super::history::{ConversationEntry, ConversationRole, save_history};
|
|||||||
use super::mentions::{is_reply_to_bot, mentions_bot};
|
use super::mentions::{is_reply_to_bot, mentions_bot};
|
||||||
use super::verification::check_sender_verified;
|
use super::verification::check_sender_verified;
|
||||||
|
|
||||||
/// Returns `true` if the message body is an affirmative permission response.
|
|
||||||
///
|
|
||||||
/// Recognised affirmative tokens (case-insensitive): `yes`, `y`, `approve`,
|
|
||||||
/// `allow`, `ok`. Anything else — including ambiguous text — is treated as
|
|
||||||
/// denial (fail-closed).
|
|
||||||
pub(super) fn is_permission_approval(body: &str) -> bool {
|
|
||||||
// Strip a leading @mention (e.g. "@timmy yes") so the bot name doesn't
|
|
||||||
// interfere with the check.
|
|
||||||
let trimmed = body
|
|
||||||
.trim()
|
|
||||||
.trim_start_matches('@')
|
|
||||||
.split_whitespace()
|
|
||||||
.last()
|
|
||||||
.unwrap_or("")
|
|
||||||
.to_ascii_lowercase();
|
|
||||||
matches!(trimmed.as_str(), "yes" | "y" | "approve" | "allow" | "ok")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the user-facing prompt for a single turn. In multi-user rooms the
|
/// Build the user-facing prompt for a single turn. In multi-user rooms the
|
||||||
/// sender is included so the LLM can distinguish participants.
|
/// sender is included so the LLM can distinguish participants.
|
||||||
pub(super) fn format_user_prompt(sender: &str, message: &str) -> String {
|
pub(super) fn format_user_prompt(sender: &str, message: &str) -> String {
|
||||||
@@ -704,45 +686,6 @@ mod tests {
|
|||||||
assert_eq!(prompt, "@bob:example.com: What's up?");
|
assert_eq!(prompt, "@bob:example.com: What's up?");
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- is_permission_approval -----------------------------------------------
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_permission_approval_accepts_yes_variants() {
|
|
||||||
assert!(is_permission_approval("yes"));
|
|
||||||
assert!(is_permission_approval("Yes"));
|
|
||||||
assert!(is_permission_approval("YES"));
|
|
||||||
assert!(is_permission_approval("y"));
|
|
||||||
assert!(is_permission_approval("Y"));
|
|
||||||
assert!(is_permission_approval("approve"));
|
|
||||||
assert!(is_permission_approval("allow"));
|
|
||||||
assert!(is_permission_approval("ok"));
|
|
||||||
assert!(is_permission_approval("OK"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_permission_approval_denies_no_and_other() {
|
|
||||||
assert!(!is_permission_approval("no"));
|
|
||||||
assert!(!is_permission_approval("No"));
|
|
||||||
assert!(!is_permission_approval("n"));
|
|
||||||
assert!(!is_permission_approval("deny"));
|
|
||||||
assert!(!is_permission_approval("reject"));
|
|
||||||
assert!(!is_permission_approval("maybe"));
|
|
||||||
assert!(!is_permission_approval(""));
|
|
||||||
assert!(!is_permission_approval("yes please do it"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_permission_approval_strips_at_mention_prefix() {
|
|
||||||
assert!(is_permission_approval("@timmy yes"));
|
|
||||||
assert!(!is_permission_approval("@timmy no"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn is_permission_approval_handles_whitespace() {
|
|
||||||
assert!(is_permission_approval(" yes "));
|
|
||||||
assert!(is_permission_approval("\tyes\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- bot_name / system prompt -------------------------------------------
|
// -- bot_name / system prompt -------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! commits the change to git.
|
//! commits the change to git.
|
||||||
|
|
||||||
use crate::agents::{AgentPool, AgentStatus};
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
|
use crate::chat::util::strip_bot_mention;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// A parsed delete command from a Matrix message body.
|
/// A parsed delete command from a Matrix message body.
|
||||||
@@ -25,7 +26,7 @@ pub fn extract_delete_command(
|
|||||||
bot_name: &str,
|
bot_name: &str,
|
||||||
bot_user_id: &str,
|
bot_user_id: &str,
|
||||||
) -> Option<DeleteCommand> {
|
) -> Option<DeleteCommand> {
|
||||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped
|
let trimmed = stripped
|
||||||
.trim()
|
.trim()
|
||||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
@@ -185,41 +186,6 @@ fn stage_display_name(stage: &str) -> &str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip the bot mention prefix from a raw Matrix message body.
|
|
||||||
///
|
|
||||||
/// Mirrors the logic in `commands::strip_bot_mention` and `htop::strip_mention`
|
|
||||||
/// so delete detection works without depending on private symbols.
|
|
||||||
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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use std::time::Duration;
|
|||||||
use tokio::sync::{Mutex as TokioMutex, watch};
|
use tokio::sync::{Mutex as TokioMutex, watch};
|
||||||
|
|
||||||
use crate::agents::{AgentPool, AgentStatus};
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
|
use crate::chat::util::strip_bot_mention;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::chat::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
|
|
||||||
@@ -51,7 +52,7 @@ pub type HtopSessions = Arc<TokioMutex<HashMap<String, HtopSession>>>;
|
|||||||
/// - `htop 10m` → `Start { duration_secs: 600 }`
|
/// - `htop 10m` → `Start { duration_secs: 600 }`
|
||||||
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
|
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
|
||||||
pub fn extract_htop_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option<HtopCommand> {
|
pub fn extract_htop_command(message: &str, bot_name: &str, bot_user_id: &str) -> Option<HtopCommand> {
|
||||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped.trim();
|
let trimmed = stripped.trim();
|
||||||
|
|
||||||
// Strip leading punctuation (e.g. the comma in "@timmy, htop")
|
// Strip leading punctuation (e.g. the comma in "@timmy, htop")
|
||||||
@@ -88,42 +89,6 @@ fn parse_duration(s: &str) -> Option<u64> {
|
|||||||
s.parse::<u64>().ok()
|
s.parse::<u64>().ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip the bot mention prefix from a raw Matrix message body.
|
|
||||||
///
|
|
||||||
/// Mirrors the logic in `commands::strip_bot_mention` so htop detection works
|
|
||||||
/// without depending on private symbols in that module.
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// System stats
|
// System stats
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! running.
|
//! running.
|
||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
|
use crate::chat::util::strip_bot_mention;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ pub fn extract_rebuild_command(
|
|||||||
bot_name: &str,
|
bot_name: &str,
|
||||||
bot_user_id: &str,
|
bot_user_id: &str,
|
||||||
) -> Option<RebuildCommand> {
|
) -> Option<RebuildCommand> {
|
||||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped
|
let trimmed = stripped
|
||||||
.trim()
|
.trim()
|
||||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
@@ -56,38 +57,6 @@ pub async fn handle_rebuild(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! affected — only the in-memory/persisted conversation state is cleared.
|
//! affected — only the in-memory/persisted conversation state is cleared.
|
||||||
|
|
||||||
use crate::chat::transport::matrix::bot::{ConversationHistory, RoomConversation};
|
use crate::chat::transport::matrix::bot::{ConversationHistory, RoomConversation};
|
||||||
|
use crate::chat::util::strip_bot_mention;
|
||||||
use matrix_sdk::ruma::OwnedRoomId;
|
use matrix_sdk::ruma::OwnedRoomId;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ pub fn extract_reset_command(
|
|||||||
bot_name: &str,
|
bot_name: &str,
|
||||||
bot_user_id: &str,
|
bot_user_id: &str,
|
||||||
) -> Option<ResetCommand> {
|
) -> Option<ResetCommand> {
|
||||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped
|
let trimmed = stripped
|
||||||
.trim()
|
.trim()
|
||||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
@@ -58,38 +59,6 @@ pub async fn handle_reset(
|
|||||||
"Session reset. Starting fresh — previous context has been cleared.".to_string()
|
"Session reset. Starting fresh — previous context has been cleared.".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! The story file in the pipeline is left untouched.
|
//! The story file in the pipeline is left untouched.
|
||||||
|
|
||||||
use crate::agents::{AgentPool, AgentStatus};
|
use crate::agents::{AgentPool, AgentStatus};
|
||||||
|
use crate::chat::util::strip_bot_mention;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// A parsed rmtree command from a Matrix message body.
|
/// A parsed rmtree command from a Matrix message body.
|
||||||
@@ -25,7 +26,7 @@ pub fn extract_rmtree_command(
|
|||||||
bot_name: &str,
|
bot_name: &str,
|
||||||
bot_user_id: &str,
|
bot_user_id: &str,
|
||||||
) -> Option<RmtreeCommand> {
|
) -> Option<RmtreeCommand> {
|
||||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped
|
let trimmed = stripped
|
||||||
.trim()
|
.trim()
|
||||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
@@ -118,38 +119,6 @@ pub async fn handle_rmtree(
|
|||||||
response
|
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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! name ends with the supplied hint, e.g. `coder-{hint}`).
|
//! name ends with the supplied hint, e.g. `coder-{hint}`).
|
||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
|
use crate::chat::util::strip_bot_mention;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// A parsed start command from a Matrix message body.
|
/// A parsed start command from a Matrix message body.
|
||||||
@@ -31,7 +32,7 @@ pub fn extract_start_command(
|
|||||||
bot_name: &str,
|
bot_name: &str,
|
||||||
bot_user_id: &str,
|
bot_user_id: &str,
|
||||||
) -> Option<StartCommand> {
|
) -> Option<StartCommand> {
|
||||||
let stripped = strip_mention(message, bot_name, bot_user_id);
|
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||||
let trimmed = stripped
|
let trimmed = stripped
|
||||||
.trim()
|
.trim()
|
||||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||||
@@ -177,40 +178,6 @@ pub async fn handle_start(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Strip the bot mention prefix from a raw Matrix message body.
|
|
||||||
///
|
|
||||||
/// Mirrors the logic in `commands::strip_bot_mention` and `delete::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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::agents::AgentPool;
|
use crate::agents::AgentPool;
|
||||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
|
use crate::chat::util::is_permission_approval;
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use crate::chat::ChatTransport;
|
use crate::chat::ChatTransport;
|
||||||
use crate::http::context::{PermissionDecision, PermissionForward};
|
use crate::http::context::{PermissionDecision, PermissionForward};
|
||||||
@@ -86,17 +87,6 @@ pub struct SlackWebhookContext {
|
|||||||
pub permission_timeout_secs: u64,
|
pub permission_timeout_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Permission approval detection ──────────────────────────────────────
|
|
||||||
|
|
||||||
/// Returns `true` if the message body should be interpreted as permission approval.
|
|
||||||
fn is_permission_approval(body: &str) -> bool {
|
|
||||||
let trimmed = body.trim().to_ascii_lowercase();
|
|
||||||
matches!(
|
|
||||||
trimmed.as_str(),
|
|
||||||
"yes" | "y" | "approve" | "allow" | "ok"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Incoming message dispatch ───────────────────────────────────────────
|
// ── Incoming message dispatch ───────────────────────────────────────────
|
||||||
|
|
||||||
pub(super) async fn handle_incoming_message(
|
pub(super) async fn handle_incoming_message(
|
||||||
|
|||||||
@@ -1,21 +1,13 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
|
||||||
|
use crate::chat::util::is_permission_approval;
|
||||||
use crate::http::context::{PermissionDecision};
|
use crate::http::context::{PermissionDecision};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
use super::WhatsAppWebhookContext;
|
use super::WhatsAppWebhookContext;
|
||||||
use super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
|
use super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
|
||||||
use super::history::save_whatsapp_history;
|
use super::history::save_whatsapp_history;
|
||||||
|
|
||||||
/// Returns `true` if the message body should be interpreted as permission approval.
|
|
||||||
fn is_permission_approval(body: &str) -> bool {
|
|
||||||
let trimmed = body.trim().to_ascii_lowercase();
|
|
||||||
matches!(
|
|
||||||
trimmed.as_str(),
|
|
||||||
"yes" | "y" | "approve" | "allow" | "ok"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dispatch an incoming WhatsApp message to bot commands.
|
/// Dispatch an incoming WhatsApp message to bot commands.
|
||||||
pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
|
pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
|
||||||
use crate::chat::commands::{CommandDispatch, try_handle_command};
|
use crate::chat::commands::{CommandDispatch, try_handle_command};
|
||||||
|
|||||||
@@ -3,6 +3,27 @@
|
|||||||
//! These functions are transport-agnostic helpers for processing chat messages:
|
//! These functions are transport-agnostic helpers for processing chat messages:
|
||||||
//! prefix stripping, bot-mention handling, and paragraph buffering.
|
//! prefix stripping, bot-mention handling, and paragraph buffering.
|
||||||
|
|
||||||
|
/// Returns `true` if the message body is an affirmative permission response.
|
||||||
|
///
|
||||||
|
/// Recognised affirmative tokens (case-insensitive): `yes`, `y`, `approve`,
|
||||||
|
/// `allow`, `ok`. Anything else — including ambiguous text — is treated as
|
||||||
|
/// denial (fail-closed).
|
||||||
|
///
|
||||||
|
/// A leading `@mention` (e.g. `"@timmy yes"`) is stripped before checking, so
|
||||||
|
/// the bot name does not interfere with the result.
|
||||||
|
pub fn is_permission_approval(body: &str) -> bool {
|
||||||
|
// Strip a leading @mention (e.g. "@timmy yes") so the bot name doesn't
|
||||||
|
// interfere with the check.
|
||||||
|
let trimmed = body
|
||||||
|
.trim()
|
||||||
|
.trim_start_matches('@')
|
||||||
|
.split_whitespace()
|
||||||
|
.last()
|
||||||
|
.unwrap_or("")
|
||||||
|
.to_ascii_lowercase();
|
||||||
|
matches!(trimmed.as_str(), "yes" | "y" | "approve" | "allow" | "ok")
|
||||||
|
}
|
||||||
|
|
||||||
/// Case-insensitive prefix strip that also requires the match to end at a
|
/// Case-insensitive prefix strip that also requires the match to end at a
|
||||||
/// word boundary (whitespace, punctuation, or end-of-string).
|
/// word boundary (whitespace, punctuation, or end-of-string).
|
||||||
pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
|
||||||
@@ -190,6 +211,45 @@ pub fn normalize_line_breaks(text: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// -- is_permission_approval ---------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_permission_approval_accepts_yes_variants() {
|
||||||
|
assert!(is_permission_approval("yes"));
|
||||||
|
assert!(is_permission_approval("Yes"));
|
||||||
|
assert!(is_permission_approval("YES"));
|
||||||
|
assert!(is_permission_approval("y"));
|
||||||
|
assert!(is_permission_approval("Y"));
|
||||||
|
assert!(is_permission_approval("approve"));
|
||||||
|
assert!(is_permission_approval("allow"));
|
||||||
|
assert!(is_permission_approval("ok"));
|
||||||
|
assert!(is_permission_approval("OK"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_permission_approval_denies_no_and_other() {
|
||||||
|
assert!(!is_permission_approval("no"));
|
||||||
|
assert!(!is_permission_approval("No"));
|
||||||
|
assert!(!is_permission_approval("n"));
|
||||||
|
assert!(!is_permission_approval("deny"));
|
||||||
|
assert!(!is_permission_approval("reject"));
|
||||||
|
assert!(!is_permission_approval("maybe"));
|
||||||
|
assert!(!is_permission_approval(""));
|
||||||
|
assert!(!is_permission_approval("yes please do it"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_permission_approval_strips_at_mention_prefix() {
|
||||||
|
assert!(is_permission_approval("@timmy yes"));
|
||||||
|
assert!(!is_permission_approval("@timmy no"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_permission_approval_handles_whitespace() {
|
||||||
|
assert!(is_permission_approval(" yes "));
|
||||||
|
assert!(is_permission_approval("\tyes\n"));
|
||||||
|
}
|
||||||
|
|
||||||
// -- strip_prefix_ci ----------------------------------------------------
|
// -- strip_prefix_ci ----------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ impl AnthropicApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl From<Arc<AppContext>> for AnthropicApi {
|
||||||
|
fn from(ctx: Arc<AppContext>) -> Self {
|
||||||
|
Self::new(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[OpenApi(tag = "AnthropicTags::Anthropic")]
|
#[OpenApi(tag = "AnthropicTags::Anthropic")]
|
||||||
impl AnthropicApi {
|
impl AnthropicApi {
|
||||||
/// Check whether an Anthropic API key is stored.
|
/// Check whether an Anthropic API key is stored.
|
||||||
@@ -151,25 +158,16 @@ impl AnthropicApi {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::{make_api, test_ctx};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::Arc;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn test_ctx(dir: &TempDir) -> AppContext {
|
|
||||||
AppContext::new_test(dir.path().to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_api(dir: &TempDir) -> AnthropicApi {
|
|
||||||
AnthropicApi::new(Arc::new(test_ctx(dir)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// -- get_anthropic_api_key (private helper) --
|
// -- get_anthropic_api_key (private helper) --
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_api_key_returns_err_when_not_set() {
|
fn get_api_key_returns_err_when_not_set() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
let result = get_anthropic_api_key(&ctx);
|
let result = get_anthropic_api_key(&ctx);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
assert!(result.unwrap_err().contains("not found"));
|
assert!(result.unwrap_err().contains("not found"));
|
||||||
@@ -178,7 +176,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn get_api_key_returns_err_when_empty() {
|
fn get_api_key_returns_err_when_empty() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(""));
|
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(""));
|
||||||
let result = get_anthropic_api_key(&ctx);
|
let result = get_anthropic_api_key(&ctx);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
@@ -188,7 +186,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn get_api_key_returns_err_when_not_string() {
|
fn get_api_key_returns_err_when_not_string() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(12345));
|
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(12345));
|
||||||
let result = get_anthropic_api_key(&ctx);
|
let result = get_anthropic_api_key(&ctx);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
@@ -198,7 +196,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn get_api_key_returns_key_when_set() {
|
fn get_api_key_returns_key_when_set() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
|
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!("sk-ant-test123"));
|
||||||
let result = get_anthropic_api_key(&ctx);
|
let result = get_anthropic_api_key(&ctx);
|
||||||
assert_eq!(result.unwrap(), "sk-ant-test123");
|
assert_eq!(result.unwrap(), "sk-ant-test123");
|
||||||
@@ -209,7 +207,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn key_exists_returns_false_when_not_set() {
|
async fn key_exists_returns_false_when_not_set() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<AnthropicApi>(&dir);
|
||||||
let result = api.get_anthropic_api_key_exists().await.unwrap();
|
let result = api.get_anthropic_api_key_exists().await.unwrap();
|
||||||
assert!(!result.0);
|
assert!(!result.0);
|
||||||
}
|
}
|
||||||
@@ -229,7 +227,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_api_key_returns_true() {
|
async fn set_api_key_returns_true() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<AnthropicApi>(&dir);
|
||||||
let payload = Json(ApiKeyPayload {
|
let payload = Json(ApiKeyPayload {
|
||||||
api_key: "sk-ant-test123".to_string(),
|
api_key: "sk-ant-test123".to_string(),
|
||||||
});
|
});
|
||||||
@@ -256,7 +254,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_models_fails_when_no_key() {
|
async fn list_models_fails_when_no_key() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<AnthropicApi>(&dir);
|
||||||
let result = api.list_anthropic_models().await;
|
let result = api.list_anthropic_models().await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
@@ -288,7 +286,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn new_creates_api_instance() {
|
fn new_creates_api_instance() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let _api = make_api(&dir);
|
let _api = make_api::<AnthropicApi>(&dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+25
-24
@@ -138,18 +138,19 @@ impl IoApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl From<std::sync::Arc<AppContext>> for IoApi {
|
||||||
|
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
||||||
|
Self { ctx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::make_api;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn make_api(dir: &TempDir) -> IoApi {
|
|
||||||
IoApi {
|
|
||||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- list_directory_absolute ---
|
// --- list_directory_absolute ---
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -158,7 +159,7 @@ mod tests {
|
|||||||
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
||||||
std::fs::write(dir.path().join("file.txt"), "content").unwrap();
|
std::fs::write(dir.path().join("file.txt"), "content").unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(FilePathPayload {
|
let payload = Json(FilePathPayload {
|
||||||
path: dir.path().to_string_lossy().to_string(),
|
path: dir.path().to_string_lossy().to_string(),
|
||||||
});
|
});
|
||||||
@@ -176,7 +177,7 @@ mod tests {
|
|||||||
let empty = dir.path().join("empty");
|
let empty = dir.path().join("empty");
|
||||||
std::fs::create_dir(&empty).unwrap();
|
std::fs::create_dir(&empty).unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(FilePathPayload {
|
let payload = Json(FilePathPayload {
|
||||||
path: empty.to_string_lossy().to_string(),
|
path: empty.to_string_lossy().to_string(),
|
||||||
});
|
});
|
||||||
@@ -187,7 +188,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_directory_absolute_errors_on_nonexistent_path() {
|
async fn list_directory_absolute_errors_on_nonexistent_path() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(FilePathPayload {
|
let payload = Json(FilePathPayload {
|
||||||
path: dir.path().join("nonexistent").to_string_lossy().to_string(),
|
path: dir.path().join("nonexistent").to_string_lossy().to_string(),
|
||||||
});
|
});
|
||||||
@@ -201,7 +202,7 @@ mod tests {
|
|||||||
let file = dir.path().join("not_a_dir.txt");
|
let file = dir.path().join("not_a_dir.txt");
|
||||||
std::fs::write(&file, "content").unwrap();
|
std::fs::write(&file, "content").unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(FilePathPayload {
|
let payload = Json(FilePathPayload {
|
||||||
path: file.to_string_lossy().to_string(),
|
path: file.to_string_lossy().to_string(),
|
||||||
});
|
});
|
||||||
@@ -216,7 +217,7 @@ mod tests {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let new_dir = dir.path().join("new_dir");
|
let new_dir = dir.path().join("new_dir");
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(CreateDirectoryPayload {
|
let payload = Json(CreateDirectoryPayload {
|
||||||
path: new_dir.to_string_lossy().to_string(),
|
path: new_dir.to_string_lossy().to_string(),
|
||||||
});
|
});
|
||||||
@@ -231,7 +232,7 @@ mod tests {
|
|||||||
let existing = dir.path().join("existing");
|
let existing = dir.path().join("existing");
|
||||||
std::fs::create_dir(&existing).unwrap();
|
std::fs::create_dir(&existing).unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(CreateDirectoryPayload {
|
let payload = Json(CreateDirectoryPayload {
|
||||||
path: existing.to_string_lossy().to_string(),
|
path: existing.to_string_lossy().to_string(),
|
||||||
});
|
});
|
||||||
@@ -244,7 +245,7 @@ mod tests {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let nested = dir.path().join("a").join("b").join("c");
|
let nested = dir.path().join("a").join("b").join("c");
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(CreateDirectoryPayload {
|
let payload = Json(CreateDirectoryPayload {
|
||||||
path: nested.to_string_lossy().to_string(),
|
path: nested.to_string_lossy().to_string(),
|
||||||
});
|
});
|
||||||
@@ -258,7 +259,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_home_directory_returns_a_path() {
|
async fn get_home_directory_returns_a_path() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let result = api.get_home_directory().await.unwrap();
|
let result = api.get_home_directory().await.unwrap();
|
||||||
let home = &result.0;
|
let home = &result.0;
|
||||||
assert!(!home.is_empty());
|
assert!(!home.is_empty());
|
||||||
@@ -272,7 +273,7 @@ mod tests {
|
|||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap();
|
std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(FilePathPayload {
|
let payload = Json(FilePathPayload {
|
||||||
path: "hello.txt".to_string(),
|
path: "hello.txt".to_string(),
|
||||||
});
|
});
|
||||||
@@ -283,7 +284,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn read_file_errors_on_missing_file() {
|
async fn read_file_errors_on_missing_file() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(FilePathPayload {
|
let payload = Json(FilePathPayload {
|
||||||
path: "nonexistent.txt".to_string(),
|
path: "nonexistent.txt".to_string(),
|
||||||
});
|
});
|
||||||
@@ -296,7 +297,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_file_creates_file() {
|
async fn write_file_creates_file() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(WriteFilePayload {
|
let payload = Json(WriteFilePayload {
|
||||||
path: "output.txt".to_string(),
|
path: "output.txt".to_string(),
|
||||||
content: "written content".to_string(),
|
content: "written content".to_string(),
|
||||||
@@ -312,7 +313,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_file_creates_parent_dirs() {
|
async fn write_file_creates_parent_dirs() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(WriteFilePayload {
|
let payload = Json(WriteFilePayload {
|
||||||
path: "sub/dir/file.txt".to_string(),
|
path: "sub/dir/file.txt".to_string(),
|
||||||
content: "nested".to_string(),
|
content: "nested".to_string(),
|
||||||
@@ -334,7 +335,7 @@ mod tests {
|
|||||||
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
|
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
|
||||||
std::fs::write(dir.path().join("README.md"), "# readme").unwrap();
|
std::fs::write(dir.path().join("README.md"), "# readme").unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let result = api.list_project_files().await.unwrap();
|
let result = api.list_project_files().await.unwrap();
|
||||||
let files = &result.0;
|
let files = &result.0;
|
||||||
|
|
||||||
@@ -348,7 +349,7 @@ mod tests {
|
|||||||
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
std::fs::create_dir(dir.path().join("subdir")).unwrap();
|
||||||
std::fs::write(dir.path().join("file.txt"), "").unwrap();
|
std::fs::write(dir.path().join("file.txt"), "").unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let result = api.list_project_files().await.unwrap();
|
let result = api.list_project_files().await.unwrap();
|
||||||
let files = &result.0;
|
let files = &result.0;
|
||||||
|
|
||||||
@@ -363,7 +364,7 @@ mod tests {
|
|||||||
std::fs::write(dir.path().join("z_last.txt"), "").unwrap();
|
std::fs::write(dir.path().join("z_last.txt"), "").unwrap();
|
||||||
std::fs::write(dir.path().join("a_first.txt"), "").unwrap();
|
std::fs::write(dir.path().join("a_first.txt"), "").unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let result = api.list_project_files().await.unwrap();
|
let result = api.list_project_files().await.unwrap();
|
||||||
let files = &result.0;
|
let files = &result.0;
|
||||||
|
|
||||||
@@ -380,7 +381,7 @@ mod tests {
|
|||||||
std::fs::create_dir(dir.path().join("adir")).unwrap();
|
std::fs::create_dir(dir.path().join("adir")).unwrap();
|
||||||
std::fs::write(dir.path().join("bfile.txt"), "").unwrap();
|
std::fs::write(dir.path().join("bfile.txt"), "").unwrap();
|
||||||
|
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(FilePathPayload {
|
let payload = Json(FilePathPayload {
|
||||||
path: ".".to_string(),
|
path: ".".to_string(),
|
||||||
});
|
});
|
||||||
@@ -394,7 +395,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_directory_errors_on_nonexistent() {
|
async fn list_directory_errors_on_nonexistent() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<IoApi>(&dir);
|
||||||
let payload = Json(FilePathPayload {
|
let payload = Json(FilePathPayload {
|
||||||
path: "nonexistent_dir".to_string(),
|
path: "nonexistent_dir".to_string(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -370,13 +370,9 @@ pub(super) async fn get_worktree_commits(worktree_path: &str, base_branch: &str)
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::test_ctx;
|
||||||
use crate::store::StoreOps;
|
use crate::store::StoreOps;
|
||||||
|
|
||||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
|
||||||
AppContext::new_test(dir.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_list_agents_empty() {
|
fn tool_list_agents_empty() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -279,11 +279,7 @@ pub(super) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, St
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::test_ctx;
|
||||||
|
|
||||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
|
||||||
AppContext::new_test(dir.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_get_server_logs_no_args_returns_string() {
|
fn tool_get_server_logs_no_args_returns_string() {
|
||||||
|
|||||||
@@ -304,12 +304,9 @@ pub(super) async fn tool_git_log(args: &Value, ctx: &AppContext) -> Result<Strin
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
|
use crate::http::test_helpers::test_ctx;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
|
||||||
AppContext::new_test(dir.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a temp directory with a git worktree structure and init a repo.
|
/// Create a temp directory with a git worktree structure and init a repo.
|
||||||
fn setup_worktree() -> (tempfile::TempDir, PathBuf, AppContext) {
|
fn setup_worktree() -> (tempfile::TempDir, PathBuf, AppContext) {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -164,11 +164,7 @@ pub(super) fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Resul
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::test_ctx;
|
||||||
|
|
||||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
|
||||||
AppContext::new_test(dir.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn setup_git_repo_in(dir: &std::path::Path) {
|
fn setup_git_repo_in(dir: &std::path::Path) {
|
||||||
std::process::Command::new("git")
|
std::process::Command::new("git")
|
||||||
|
|||||||
@@ -1336,11 +1336,7 @@ async fn handle_tools_call(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::test_ctx;
|
||||||
|
|
||||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
|
||||||
AppContext::new_test(dir.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn json_rpc_response_serializes_success() {
|
fn json_rpc_response_serializes_success() {
|
||||||
|
|||||||
@@ -194,11 +194,7 @@ pub(super) fn find_free_port(start: u16) -> u16 {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::test_ctx;
|
||||||
|
|
||||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
|
||||||
AppContext::new_test(dir.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn request_qa_in_tools_list() {
|
fn request_qa_in_tools_list() {
|
||||||
|
|||||||
@@ -331,13 +331,9 @@ pub(super) fn handle_run_command_sse(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::test_ctx;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
|
||||||
AppContext::new_test(dir.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── is_dangerous ─────────────────────────────────────────────────
|
// ── is_dangerous ─────────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -549,11 +549,7 @@ pub(super) fn parse_test_cases(value: Option<&Value>) -> Result<Vec<TestCaseResu
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::test_ctx;
|
||||||
|
|
||||||
fn test_ctx(dir: &std::path::Path) -> AppContext {
|
|
||||||
AppContext::new_test(dir.to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_test_cases_empty() {
|
fn parse_test_cases_empty() {
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ pub(super) fn tool_wizard_generate(args: &Value, ctx: &AppContext) -> Result<Str
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Return true if the project directory has no meaningful source files.
|
/// Return true if the project directory has no meaningful source files.
|
||||||
fn is_bare_project(project_root: &Path) -> bool {
|
pub(crate) fn is_bare_project(project_root: &Path) -> bool {
|
||||||
let dominated_by_storkit = std::fs::read_dir(project_root)
|
std::fs::read_dir(project_root)
|
||||||
.ok()
|
.ok()
|
||||||
.map(|entries| {
|
.map(|entries| {
|
||||||
let names: Vec<String> = entries
|
let names: Vec<String> = entries
|
||||||
@@ -171,12 +171,11 @@ fn is_bare_project(project_root: &Path) -> bool {
|
|||||||
|| n == "store.json"
|
|| n == "store.json"
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.unwrap_or(true);
|
.unwrap_or(true)
|
||||||
dominated_by_storkit
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a generation hint for a step based on the project root.
|
/// Return a generation hint for a step based on the project root.
|
||||||
fn generation_hint(step: WizardStep, project_root: &Path) -> String {
|
pub(crate) fn generation_hint(step: WizardStep, project_root: &Path) -> String {
|
||||||
let bare = is_bare_project(project_root);
|
let bare = is_bare_project(project_root);
|
||||||
|
|
||||||
match step {
|
match step {
|
||||||
@@ -215,6 +214,13 @@ fn generation_hint(step: WizardStep, project_root: &Path) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
WizardStep::TestScript => {
|
WizardStep::TestScript => {
|
||||||
|
if bare {
|
||||||
|
"This is a bare project with no existing code. Read the STACK.md generated \
|
||||||
|
in the previous step (or ask the user about their stack if it was skipped) \
|
||||||
|
and generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) \
|
||||||
|
with appropriate test commands for their chosen language and framework."
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
let has_cargo = project_root.join("Cargo.toml").exists();
|
let has_cargo = project_root.join("Cargo.toml").exists();
|
||||||
let has_pkg = project_root.join("package.json").exists();
|
let has_pkg = project_root.join("package.json").exists();
|
||||||
let has_pnpm = project_root.join("pnpm-lock.yaml").exists();
|
let has_pnpm = project_root.join("pnpm-lock.yaml").exists();
|
||||||
@@ -234,12 +240,29 @@ fn generation_hint(step: WizardStep, project_root: &Path) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
WizardStep::ReleaseScript => {
|
WizardStep::ReleaseScript => {
|
||||||
|
if bare {
|
||||||
|
"This is a bare project with no existing code. Read the STACK.md generated \
|
||||||
|
in the previous step (or ask the user about their stack if it was skipped) \
|
||||||
|
and generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) \
|
||||||
|
with appropriate build/release commands for their chosen language and framework."
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
"Generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds and releases the project (e.g. `cargo build --release` or `npm run build`).".to_string()
|
"Generate a `script/release` shell script (#!/usr/bin/env bash, set -euo pipefail) that builds and releases the project (e.g. `cargo build --release` or `npm run build`).".to_string()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
WizardStep::TestCoverage => {
|
WizardStep::TestCoverage => {
|
||||||
|
if bare {
|
||||||
|
"This is a bare project with no existing code. Read the STACK.md generated \
|
||||||
|
in the previous step (or ask the user about their stack if it was skipped) \
|
||||||
|
and generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) \
|
||||||
|
with appropriate test coverage commands for their chosen language and framework."
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
"Generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) that generates a test coverage report (e.g. `cargo llvm-cov nextest` or `npm run coverage`).".to_string()
|
"Generate a `script/test_coverage` shell script (#!/usr/bin/env bash, set -euo pipefail) that generates a test coverage report (e.g. `cargo llvm-cov nextest` or `npm run coverage`).".to_string()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
WizardStep::Scaffold => "Scaffold step is handled automatically by `storkit init`.".to_string(),
|
WizardStep::Scaffold => "Scaffold step is handled automatically by `storkit init`.".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,4 +541,99 @@ mod tests {
|
|||||||
assert!(output.contains("Scaffold"));
|
assert!(output.contains("Scaffold"));
|
||||||
assert!(output.contains("← current"));
|
assert!(output.contains("← current"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_bare_project_detects_empty_dir() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
assert!(is_bare_project(dir.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_bare_project_detects_scaffold_only_dir() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("CLAUDE.md"), "# Claude").unwrap();
|
||||||
|
std::fs::write(dir.path().join("README.md"), "# Readme").unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("script")).unwrap();
|
||||||
|
assert!(is_bare_project(dir.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_bare_project_false_when_source_files_exist() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
||||||
|
assert!(!is_bare_project(dir.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_bare_project_false_with_src_directory() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join("src")).unwrap();
|
||||||
|
assert!(!is_bare_project(dir.path()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_hint_bare_context_asks_user() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
// Bare project — only scaffolding
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
let hint = generation_hint(WizardStep::Context, dir.path());
|
||||||
|
assert!(hint.contains("bare project"));
|
||||||
|
assert!(hint.contains("Ask the user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_hint_bare_stack_asks_user() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
let hint = generation_hint(WizardStep::Stack, dir.path());
|
||||||
|
assert!(hint.contains("bare project"));
|
||||||
|
assert!(hint.contains("Ask the user"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_hint_bare_test_script_references_stack() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
let hint = generation_hint(WizardStep::TestScript, dir.path());
|
||||||
|
assert!(hint.contains("bare project"));
|
||||||
|
assert!(hint.contains("STACK.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_hint_bare_release_script_references_stack() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
let hint = generation_hint(WizardStep::ReleaseScript, dir.path());
|
||||||
|
assert!(hint.contains("bare project"));
|
||||||
|
assert!(hint.contains("STACK.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_hint_bare_test_coverage_references_stack() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::create_dir_all(dir.path().join(".storkit")).unwrap();
|
||||||
|
let hint = generation_hint(WizardStep::TestCoverage, dir.path());
|
||||||
|
assert!(hint.contains("bare project"));
|
||||||
|
assert!(hint.contains("STACK.md"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_hint_existing_project_reads_code() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
||||||
|
let hint = generation_hint(WizardStep::Context, dir.path());
|
||||||
|
assert!(hint.contains("Read the project"));
|
||||||
|
assert!(!hint.contains("bare project"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generation_hint_existing_project_test_script_detects_cargo() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
|
||||||
|
let hint = generation_hint(WizardStep::TestScript, dir.path());
|
||||||
|
assert!(hint.contains("cargo nextest"));
|
||||||
|
assert!(!hint.contains("bare project"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod agents;
|
pub mod agents;
|
||||||
pub mod agents_sse;
|
pub mod agents_sse;
|
||||||
pub mod anthropic;
|
pub mod anthropic;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_helpers;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod bot_command;
|
pub mod bot_command;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
|
|||||||
+13
-12
@@ -50,22 +50,23 @@ impl ModelApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl From<std::sync::Arc<AppContext>> for ModelApi {
|
||||||
|
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
||||||
|
Self { ctx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::make_api;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn make_api(dir: &TempDir) -> ModelApi {
|
|
||||||
ModelApi {
|
|
||||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_model_preference_returns_none_when_unset() {
|
async fn get_model_preference_returns_none_when_unset() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ModelApi>(&dir);
|
||||||
let result = api.get_model_preference().await.unwrap();
|
let result = api.get_model_preference().await.unwrap();
|
||||||
assert!(result.0.is_none());
|
assert!(result.0.is_none());
|
||||||
}
|
}
|
||||||
@@ -73,7 +74,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_model_preference_returns_true() {
|
async fn set_model_preference_returns_true() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ModelApi>(&dir);
|
||||||
let payload = Json(ModelPayload {
|
let payload = Json(ModelPayload {
|
||||||
model: "claude-3-sonnet".to_string(),
|
model: "claude-3-sonnet".to_string(),
|
||||||
});
|
});
|
||||||
@@ -84,7 +85,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_model_preference_returns_value_after_set() {
|
async fn get_model_preference_returns_value_after_set() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ModelApi>(&dir);
|
||||||
|
|
||||||
let payload = Json(ModelPayload {
|
let payload = Json(ModelPayload {
|
||||||
model: "claude-3-sonnet".to_string(),
|
model: "claude-3-sonnet".to_string(),
|
||||||
@@ -98,7 +99,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_model_preference_overwrites_previous_value() {
|
async fn set_model_preference_overwrites_previous_value() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ModelApi>(&dir);
|
||||||
|
|
||||||
api.set_model_preference(Json(ModelPayload {
|
api.set_model_preference(Json(ModelPayload {
|
||||||
model: "model-a".to_string(),
|
model: "model-a".to_string(),
|
||||||
@@ -119,7 +120,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_ollama_models_returns_empty_list_for_unreachable_url() {
|
async fn get_ollama_models_returns_empty_list_for_unreachable_url() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ModelApi>(&dir);
|
||||||
// Port 1 is reserved and should immediately refuse the connection.
|
// Port 1 is reserved and should immediately refuse the connection.
|
||||||
let base_url = Query(Some("http://127.0.0.1:1".to_string()));
|
let base_url = Query(Some("http://127.0.0.1:1".to_string()));
|
||||||
let result = api.get_ollama_models(base_url).await;
|
let result = api.get_ollama_models(base_url).await;
|
||||||
|
|||||||
+18
-17
@@ -73,22 +73,23 @@ impl ProjectApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl From<std::sync::Arc<AppContext>> for ProjectApi {
|
||||||
|
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
||||||
|
Self { ctx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::make_api;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn make_api(dir: &TempDir) -> ProjectApi {
|
|
||||||
ProjectApi {
|
|
||||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_current_project_returns_none_when_unset() {
|
async fn get_current_project_returns_none_when_unset() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
// Clear the project root that new_test sets
|
// Clear the project root that new_test sets
|
||||||
api.close_project().await.unwrap();
|
api.close_project().await.unwrap();
|
||||||
let result = api.get_current_project().await.unwrap();
|
let result = api.get_current_project().await.unwrap();
|
||||||
@@ -98,7 +99,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_current_project_returns_path_from_state() {
|
async fn get_current_project_returns_path_from_state() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
let result = api.get_current_project().await.unwrap();
|
let result = api.get_current_project().await.unwrap();
|
||||||
assert_eq!(result.0, Some(dir.path().to_string_lossy().to_string()));
|
assert_eq!(result.0, Some(dir.path().to_string_lossy().to_string()));
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_project_succeeds_with_valid_directory() {
|
async fn open_project_succeeds_with_valid_directory() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
let path = dir.path().to_string_lossy().to_string();
|
let path = dir.path().to_string_lossy().to_string();
|
||||||
let payload = Json(PathPayload { path: path.clone() });
|
let payload = Json(PathPayload { path: path.clone() });
|
||||||
let result = api.open_project(payload).await.unwrap();
|
let result = api.open_project(payload).await.unwrap();
|
||||||
@@ -116,7 +117,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_project_fails_with_nonexistent_file_path() {
|
async fn open_project_fails_with_nonexistent_file_path() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
// Create a file (not a directory) to trigger validation error
|
// Create a file (not a directory) to trigger validation error
|
||||||
let file_path = dir.path().join("not_a_dir.txt");
|
let file_path = dir.path().join("not_a_dir.txt");
|
||||||
std::fs::write(&file_path, "content").unwrap();
|
std::fs::write(&file_path, "content").unwrap();
|
||||||
@@ -130,7 +131,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn close_project_returns_true() {
|
async fn close_project_returns_true() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
let result = api.close_project().await.unwrap();
|
let result = api.close_project().await.unwrap();
|
||||||
assert!(result.0);
|
assert!(result.0);
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn close_project_clears_current_project() {
|
async fn close_project_clears_current_project() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
|
|
||||||
// Verify project is set initially
|
// Verify project is set initially
|
||||||
let before = api.get_current_project().await.unwrap();
|
let before = api.get_current_project().await.unwrap();
|
||||||
@@ -155,7 +156,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_known_projects_returns_empty_initially() {
|
async fn list_known_projects_returns_empty_initially() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
// Close the project so the store has no known projects
|
// Close the project so the store has no known projects
|
||||||
api.close_project().await.unwrap();
|
api.close_project().await.unwrap();
|
||||||
let result = api.list_known_projects().await.unwrap();
|
let result = api.list_known_projects().await.unwrap();
|
||||||
@@ -165,7 +166,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_known_projects_returns_project_after_open() {
|
async fn list_known_projects_returns_project_after_open() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
let path = dir.path().to_string_lossy().to_string();
|
let path = dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
api.open_project(Json(PathPayload { path: path.clone() }))
|
api.open_project(Json(PathPayload { path: path.clone() }))
|
||||||
@@ -179,7 +180,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn forget_known_project_removes_project() {
|
async fn forget_known_project_removes_project() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
let path = dir.path().to_string_lossy().to_string();
|
let path = dir.path().to_string_lossy().to_string();
|
||||||
|
|
||||||
api.open_project(Json(PathPayload { path: path.clone() }))
|
api.open_project(Json(PathPayload { path: path.clone() }))
|
||||||
@@ -202,7 +203,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn forget_known_project_returns_true_for_nonexistent_path() {
|
async fn forget_known_project_returns_true_for_nonexistent_path() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<ProjectApi>(&dir);
|
||||||
let result = api
|
let result = api
|
||||||
.forget_known_project(Json(PathPayload {
|
.forget_known_project(Json(PathPayload {
|
||||||
path: "/some/unknown/path".to_string(),
|
path: "/some/unknown/path".to_string(),
|
||||||
|
|||||||
+25
-29
@@ -104,27 +104,23 @@ pub fn get_editor_command_from_store(ctx: &AppContext) -> Option<String> {
|
|||||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
impl From<std::sync::Arc<AppContext>> for SettingsApi {
|
||||||
|
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
|
||||||
|
Self { ctx }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::test_helpers::{make_api, test_ctx};
|
||||||
use std::sync::Arc;
|
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn test_ctx(dir: &TempDir) -> AppContext {
|
|
||||||
AppContext::new_test(dir.path().to_path_buf())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_api(dir: &TempDir) -> SettingsApi {
|
|
||||||
SettingsApi {
|
|
||||||
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_editor_returns_none_when_unset() {
|
async fn get_editor_returns_none_when_unset() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
let result = api.get_editor().await.unwrap();
|
let result = api.get_editor().await.unwrap();
|
||||||
assert!(result.0.editor_command.is_none());
|
assert!(result.0.editor_command.is_none());
|
||||||
}
|
}
|
||||||
@@ -132,7 +128,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_editor_stores_command() {
|
async fn set_editor_stores_command() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
let payload = Json(EditorCommandPayload {
|
let payload = Json(EditorCommandPayload {
|
||||||
editor_command: Some("zed".to_string()),
|
editor_command: Some("zed".to_string()),
|
||||||
});
|
});
|
||||||
@@ -143,7 +139,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_editor_clears_command_on_null() {
|
async fn set_editor_clears_command_on_null() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
api.set_editor(Json(EditorCommandPayload {
|
api.set_editor(Json(EditorCommandPayload {
|
||||||
editor_command: Some("zed".to_string()),
|
editor_command: Some("zed".to_string()),
|
||||||
}))
|
}))
|
||||||
@@ -161,7 +157,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_editor_clears_command_on_empty_string() {
|
async fn set_editor_clears_command_on_empty_string() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
let result = api
|
let result = api
|
||||||
.set_editor(Json(EditorCommandPayload {
|
.set_editor(Json(EditorCommandPayload {
|
||||||
editor_command: Some(String::new()),
|
editor_command: Some(String::new()),
|
||||||
@@ -174,7 +170,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_editor_trims_whitespace_only() {
|
async fn set_editor_trims_whitespace_only() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
let result = api
|
let result = api
|
||||||
.set_editor(Json(EditorCommandPayload {
|
.set_editor(Json(EditorCommandPayload {
|
||||||
editor_command: Some(" ".to_string()),
|
editor_command: Some(" ".to_string()),
|
||||||
@@ -187,7 +183,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_editor_returns_value_after_set() {
|
async fn get_editor_returns_value_after_set() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
api.set_editor(Json(EditorCommandPayload {
|
api.set_editor(Json(EditorCommandPayload {
|
||||||
editor_command: Some("cursor".to_string()),
|
editor_command: Some("cursor".to_string()),
|
||||||
}))
|
}))
|
||||||
@@ -200,7 +196,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn editor_command_defaults_to_null() {
|
fn editor_command_defaults_to_null() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
let result = get_editor_command_from_store(&ctx);
|
let result = get_editor_command_from_store(&ctx);
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
@@ -208,7 +204,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn set_editor_command_persists_in_store() {
|
fn set_editor_command_persists_in_store() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
|
|
||||||
ctx.store.set(EDITOR_COMMAND_KEY, json!("zed"));
|
ctx.store.set(EDITOR_COMMAND_KEY, json!("zed"));
|
||||||
ctx.store.save().unwrap();
|
ctx.store.save().unwrap();
|
||||||
@@ -220,7 +216,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn get_editor_command_from_store_returns_value() {
|
fn get_editor_command_from_store_returns_value() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
|
|
||||||
ctx.store.set(EDITOR_COMMAND_KEY, json!("code"));
|
ctx.store.set(EDITOR_COMMAND_KEY, json!("code"));
|
||||||
let result = get_editor_command_from_store(&ctx);
|
let result = get_editor_command_from_store(&ctx);
|
||||||
@@ -230,7 +226,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn delete_editor_command_returns_none() {
|
fn delete_editor_command_returns_none() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
|
|
||||||
ctx.store.set(EDITOR_COMMAND_KEY, json!("cursor"));
|
ctx.store.set(EDITOR_COMMAND_KEY, json!("cursor"));
|
||||||
ctx.store.delete(EDITOR_COMMAND_KEY);
|
ctx.store.delete(EDITOR_COMMAND_KEY);
|
||||||
@@ -258,7 +254,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn get_editor_http_handler_returns_null_when_not_set() {
|
async fn get_editor_http_handler_returns_null_when_not_set() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
let api = SettingsApi {
|
let api = SettingsApi {
|
||||||
ctx: Arc::new(ctx),
|
ctx: Arc::new(ctx),
|
||||||
};
|
};
|
||||||
@@ -269,7 +265,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_editor_http_handler_stores_value() {
|
async fn set_editor_http_handler_stores_value() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
let api = SettingsApi {
|
let api = SettingsApi {
|
||||||
ctx: Arc::new(ctx),
|
ctx: Arc::new(ctx),
|
||||||
};
|
};
|
||||||
@@ -286,7 +282,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn set_editor_http_handler_clears_value_when_null() {
|
async fn set_editor_http_handler_clears_value_when_null() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let ctx = test_ctx(&dir);
|
let ctx = test_ctx(dir.path());
|
||||||
let api = SettingsApi {
|
let api = SettingsApi {
|
||||||
ctx: Arc::new(ctx),
|
ctx: Arc::new(ctx),
|
||||||
};
|
};
|
||||||
@@ -310,7 +306,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_file_returns_error_when_no_editor_configured() {
|
async fn open_file_returns_error_when_no_editor_configured() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
let result = api
|
let result = api
|
||||||
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
|
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
|
||||||
.await;
|
.await;
|
||||||
@@ -322,7 +318,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_file_spawns_editor_with_path_and_line() {
|
async fn open_file_spawns_editor_with_path_and_line() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
// Configure the editor to "echo" which is a safe no-op command
|
// Configure the editor to "echo" which is a safe no-op command
|
||||||
api.set_editor(Json(EditorCommandPayload {
|
api.set_editor(Json(EditorCommandPayload {
|
||||||
editor_command: Some("echo".to_string()),
|
editor_command: Some("echo".to_string()),
|
||||||
@@ -339,7 +335,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_file_spawns_editor_with_path_only_when_no_line() {
|
async fn open_file_spawns_editor_with_path_only_when_no_line() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
api.set_editor(Json(EditorCommandPayload {
|
api.set_editor(Json(EditorCommandPayload {
|
||||||
editor_command: Some("echo".to_string()),
|
editor_command: Some("echo".to_string()),
|
||||||
}))
|
}))
|
||||||
@@ -355,7 +351,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn open_file_returns_error_for_nonexistent_editor() {
|
async fn open_file_returns_error_for_nonexistent_editor() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let api = make_api(&dir);
|
let api = make_api::<SettingsApi>(&dir);
|
||||||
api.set_editor(Json(EditorCommandPayload {
|
api.set_editor(Json(EditorCommandPayload {
|
||||||
editor_command: Some("this_editor_does_not_exist_xyz_abc".to_string()),
|
editor_command: Some("this_editor_does_not_exist_xyz_abc".to_string()),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! Shared test utilities for HTTP handler tests.
|
||||||
|
//!
|
||||||
|
//! Import with `use crate::http::test_helpers::{make_api, test_ctx};`
|
||||||
|
|
||||||
|
use crate::http::context::AppContext;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// Build an [`AppContext`] rooted at `dir` for use in tests.
|
||||||
|
pub(crate) fn test_ctx(dir: &Path) -> AppContext {
|
||||||
|
AppContext::new_test(dir.to_path_buf())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build an API struct rooted in `dir` for use in tests.
|
||||||
|
///
|
||||||
|
/// Requires the API type to implement `From<Arc<AppContext>>`. Add a
|
||||||
|
/// `#[cfg(test)]` impl block to each API struct to opt in.
|
||||||
|
pub(crate) fn make_api<T: From<Arc<AppContext>>>(dir: &TempDir) -> T {
|
||||||
|
Arc::new(test_ctx(dir.path())).into()
|
||||||
|
}
|
||||||
@@ -760,6 +760,10 @@ mod tests {
|
|||||||
content.contains("Never chain shell commands"),
|
content.contains("Never chain shell commands"),
|
||||||
"CLAUDE.md should include command chaining rule"
|
"CLAUDE.md should include command chaining rule"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
content.contains("wizard_status"),
|
||||||
|
"CLAUDE.md should instruct Claude to call wizard_status on first conversation"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -5,3 +5,5 @@ pub mod shell;
|
|||||||
pub mod story_metadata;
|
pub mod story_metadata;
|
||||||
pub mod watcher;
|
pub mod watcher;
|
||||||
pub mod wizard;
|
pub mod wizard;
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) mod test_helpers;
|
||||||
|
|||||||
@@ -74,17 +74,10 @@ fn needs_project_toml(story_kit: &Path) -> bool {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::io::test_helpers::setup_project;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup_project(dir: &TempDir) -> std::path::PathBuf {
|
|
||||||
let root = dir.path().to_path_buf();
|
|
||||||
let sk = root.join(".storkit");
|
|
||||||
fs::create_dir_all(sk.join("specs").join("tech")).unwrap();
|
|
||||||
fs::create_dir_all(root.join("script")).unwrap();
|
|
||||||
root
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── needs_onboarding ──────────────────────────────────────────
|
// ── needs_onboarding ──────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+3
-13
@@ -11,15 +11,11 @@ pub struct SearchResult {
|
|||||||
pub matches: usize,
|
pub matches: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
|
|
||||||
state.get_project_root()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn search_files(
|
pub async fn search_files(
|
||||||
query: String,
|
query: String,
|
||||||
state: &SessionState,
|
state: &SessionState,
|
||||||
) -> Result<Vec<SearchResult>, String> {
|
) -> Result<Vec<SearchResult>, String> {
|
||||||
let root = get_project_root(state)?;
|
let root = state.get_project_root()?;
|
||||||
search_files_impl(query, root).await
|
search_files_impl(query, root).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,18 +64,12 @@ pub async fn search_files_impl(query: String, root: PathBuf) -> Result<Vec<Searc
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::fs;
|
use crate::io::test_helpers::create_test_files;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup_project(files: &[(&str, &str)]) -> TempDir {
|
fn setup_project(files: &[(&str, &str)]) -> TempDir {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
for (path, content) in files {
|
create_test_files(&dir, files);
|
||||||
let full = dir.path().join(path);
|
|
||||||
if let Some(parent) = full.parent() {
|
|
||||||
fs::create_dir_all(parent).unwrap();
|
|
||||||
}
|
|
||||||
fs::write(full, content).unwrap();
|
|
||||||
}
|
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ use serde::Serialize;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Helper to get the root path (cloned) without joining
|
|
||||||
fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
|
|
||||||
state.get_project_root()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize, Debug, poem_openapi::Object)]
|
#[derive(Serialize, Debug, poem_openapi::Object)]
|
||||||
pub struct CommandOutput {
|
pub struct CommandOutput {
|
||||||
pub stdout: String,
|
pub stdout: String,
|
||||||
@@ -53,7 +48,7 @@ pub async fn exec_shell(
|
|||||||
args: Vec<String>,
|
args: Vec<String>,
|
||||||
state: &SessionState,
|
state: &SessionState,
|
||||||
) -> Result<CommandOutput, String> {
|
) -> Result<CommandOutput, String> {
|
||||||
let root = get_project_root(state)?;
|
let root = state.get_project_root()?;
|
||||||
exec_shell_impl(command, args, root).await
|
exec_shell_impl(command, args, root).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! Shared test utilities for I/O module tests.
|
||||||
|
//!
|
||||||
|
//! Import with `use crate::io::test_helpers::{create_test_files, setup_project};`
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
/// Create a minimal storkit project directory structure under `dir`.
|
||||||
|
///
|
||||||
|
/// Creates `.storkit/specs/tech/` and `script/`, then returns the root path.
|
||||||
|
/// Used by onboarding and wizard tests.
|
||||||
|
pub(crate) fn setup_project(dir: &TempDir) -> PathBuf {
|
||||||
|
let root = dir.path().to_path_buf();
|
||||||
|
let sk = root.join(".storkit");
|
||||||
|
fs::create_dir_all(sk.join("specs").join("tech")).unwrap();
|
||||||
|
fs::create_dir_all(root.join("script")).unwrap();
|
||||||
|
root
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a set of files into `dir` at the given relative paths.
|
||||||
|
///
|
||||||
|
/// Parent directories are created automatically. Used by search tests.
|
||||||
|
pub(crate) fn create_test_files(dir: &TempDir, files: &[(&str, &str)]) {
|
||||||
|
for (path, content) in files {
|
||||||
|
let full = dir.path().join(path);
|
||||||
|
if let Some(parent) = full.parent() {
|
||||||
|
fs::create_dir_all(parent).unwrap();
|
||||||
|
}
|
||||||
|
fs::write(full, content).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -255,15 +255,9 @@ pub fn format_wizard_state(state: &WizardState) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::io::test_helpers::setup_project;
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn setup_project(dir: &TempDir) -> std::path::PathBuf {
|
|
||||||
let root = dir.path().to_path_buf();
|
|
||||||
let sk = root.join(".storkit");
|
|
||||||
std::fs::create_dir_all(&sk).unwrap();
|
|
||||||
root
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn default_state_has_all_steps_pending() {
|
fn default_state_has_all_steps_pending() {
|
||||||
let state = WizardState::default();
|
let state = WizardState::default();
|
||||||
|
|||||||
Reference in New Issue
Block a user