Compare commits

...

53 Commits

Author SHA1 Message Date
Timmy a006985faf Bump version to 0.8.3 2026-03-30 18:17:09 +01:00
dave 3fce9ec082 feat: add Linux arm64 build to release script
Builds aarch64-unknown-linux-musl via cross alongside the existing
x86_64 Linux and macOS arm64 targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 17:15:16 +00:00
dave 03026c70cc storkit: create 446_story_oauth_login_button_in_web_ui 2026-03-30 16:27:30 +00:00
Timmy b75679175b Bump version to 0.8.2 2026-03-30 11:57:05 +01:00
dave 440081016d storkit: accept 433_story_setup_wizard_interviews_user_on_bare_projects_with_no_existing_code 2026-03-29 04:29:58 +00:00
dave e8f3629c76 storkit: accept 438_story_slash_command_autocomplete_in_web_ui_text_input 2026-03-29 02:08:37 +00:00
dave c5cdc0f594 storkit: done 433_story_setup_wizard_interviews_user_on_bare_projects_with_no_existing_code 2026-03-29 00:46:08 +00:00
dave fec417cb16 storkit: merge 433_story_setup_wizard_interviews_user_on_bare_projects_with_no_existing_code 2026-03-29 00:46:05 +00:00
dave a70a06a5fb storkit: create 433_story_setup_wizard_interviews_user_on_bare_projects_with_no_existing_code 2026-03-29 00:29:17 +00:00
dave 0a617e1c18 storkit: accept 445_bug_rate_limited_mergemaster_exits_advance_stories_to_done_without_merging 2026-03-29 00:05:27 +00:00
dave 4527f71857 storkit: accept 444_refactor_extract_shared_test_helpers_test_ctx_write_story_file_make_api 2026-03-28 23:46:26 +00:00
dave 6e0d12d145 storkit: accept 440_refactor_consolidate_is_permission_approval_into_chat_util 2026-03-28 23:44:25 +00:00
dave d471d29c72 storkit: accept 434_story_wizard_auto_checks_completion_on_first_conversation 2026-03-28 23:34:10 +00:00
dave 0b652eec21 storkit: done 434_story_wizard_auto_checks_completion_on_first_conversation 2026-03-28 23:33:07 +00:00
dave b32fdf7d65 storkit: merge 434_story_wizard_auto_checks_completion_on_first_conversation 2026-03-28 23:33:05 +00:00
dave 2da0e1eb55 storkit: accept 442_refactor_deduplicate_stage_display_name_into_shared_module 2026-03-28 22:58:18 +00:00
dave 269124a1fd storkit: accept 443_refactor_extract_shared_find_story_name_from_commands 2026-03-28 22:40:14 +00:00
dave 5992f9bd19 storkit: merge 438_story_slash_command_autocomplete_in_web_ui_text_input 2026-03-28 22:27:40 +00:00
dave a53967453e storkit: done 438_story_slash_command_autocomplete_in_web_ui_text_input 2026-03-28 22:26:16 +00:00
dave ab4b218ac7 storkit: accept 441_refactor_deduplicate_get_project_root_wrappers_in_io_modules 2026-03-28 20:35:05 +00:00
dave d5b936c88d storkit: accept 439_refactor_unify_story_stuck_states_into_a_single_status_field 2026-03-28 20:28:04 +00:00
dave 07cc0e3f29 storkit: accept 437_bug_strip_prefix_ci_panics_on_multi_byte_utf_8_input 2026-03-28 20:22:04 +00:00
dave db4a84c70f storkit: done 445_bug_rate_limited_mergemaster_exits_advance_stories_to_done_without_merging 2026-03-28 20:08:18 +00:00
dave 3048d26e66 storkit: merge 445_bug_rate_limited_mergemaster_exits_advance_stories_to_done_without_merging 2026-03-28 20:08:15 +00:00
dave 8e45b2a08d storkit: done 444_refactor_extract_shared_test_helpers_test_ctx_write_story_file_make_api 2026-03-28 19:51:20 +00:00
dave ddc4a57cd2 storkit: merge 444_refactor_extract_shared_test_helpers_test_ctx_write_story_file_make_api 2026-03-28 19:51:17 +00:00
dave d216f3c267 storkit: done 440_refactor_consolidate_is_permission_approval_into_chat_util 2026-03-28 19:47:36 +00:00
dave 8cd881c8f1 storkit: merge 440_refactor_consolidate_is_permission_approval_into_chat_util 2026-03-28 19:47:33 +00:00
dave 2867e1d15f storkit: accept 431_story_qa_agent_reviews_code_changes_against_acceptance_criteria 2026-03-28 19:30:48 +00:00
dave c2c9d3f9cb storkit: create 445_bug_rate_limited_mergemaster_exits_advance_stories_to_done_without_merging 2026-03-28 19:19:17 +00:00
dave f734b4a3c6 storkit: done 443_refactor_extract_shared_find_story_name_from_commands 2026-03-28 19:09:13 +00:00
dave 890693efda storkit: done 442_refactor_deduplicate_stage_display_name_into_shared_module 2026-03-28 18:57:31 +00:00
dave 5403b29261 storkit: done 439_refactor_unify_story_stuck_states_into_a_single_status_field 2026-03-28 18:36:45 +00:00
dave 8ee59f5dc1 storkit: merge 439_refactor_unify_story_stuck_states_into_a_single_status_field 2026-03-28 18:36:42 +00:00
dave 5dcc35a1b3 fix: gate runner delegates to script/test instead of hardcoding cargo clippy
The acceptance gate was hardcoded to run cargo clippy, which fails on
non-Rust projects (Go, Node, etc.). Now the gate only runs script/test
which is project-specific. Clippy is added to storkit's own script/test
so Rust linting is preserved for this project.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:15:29 +00:00
dave af70b68cd1 storkit: accept 432_story_complete_setup_wizard_with_mcp_tools_and_agent_driven_file_generation 2026-03-28 18:12:43 +00:00
dave e356f9b2dd storkit: accept 423_story_auto_schedule_timer_on_rate_limit_to_resume_after_reset 2026-03-28 17:42:42 +00:00
dave 96793de11b storkit: merge 441_refactor_deduplicate_get_project_root_wrappers_in_io_modules 2026-03-28 16:48:49 +00:00
dave bfe70f5599 storkit: done 439_refactor_unify_story_stuck_states_into_a_single_status_field 2026-03-28 16:48:42 +00:00
dave 98aedaddf0 storkit: done 442_refactor_deduplicate_stage_display_name_into_shared_module 2026-03-28 16:47:58 +00:00
dave 496ce864d7 storkit: done 441_refactor_deduplicate_get_project_root_wrappers_in_io_modules 2026-03-28 16:46:18 +00:00
dave 243738551c fix: wizard README instructions explicitly require LLM to generate and write files
The LLM was having the conversation with the user but never following
through with wizard_generate calls. The instructions now spell out
the full workflow: get hint, write content, stage it, show user, confirm.
Also adds "keep moving" instruction so the LLM auto-advances to the
next step after confirmation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 16:37:25 +00:00
dave 20f2d97f06 storkit: create 444_refactor_extract_shared_test_helpers_test_ctx_write_story_file_make_api 2026-03-28 16:34:45 +00:00
dave b6edc1bff7 storkit: create 443_refactor_extract_shared_find_story_name_from_commands 2026-03-28 16:34:41 +00:00
dave c45613a3ad storkit: create 442_refactor_deduplicate_stage_display_name_into_shared_module 2026-03-28 16:34:39 +00:00
dave 7efed33851 storkit: create 441_refactor_deduplicate_get_project_root_wrappers_in_io_modules 2026-03-28 16:34:36 +00:00
dave b00a477070 storkit: create 440_refactor_consolidate_is_permission_approval_into_chat_util 2026-03-28 16:34:35 +00:00
dave 52f2e89659 storkit: done 437_bug_strip_prefix_ci_panics_on_multi_byte_utf_8_input 2026-03-28 16:33:29 +00:00
dave 08db28d9d6 storkit: merge 437_bug_strip_prefix_ci_panics_on_multi_byte_utf_8_input 2026-03-28 16:33:26 +00:00
dave 77ff0ce093 storkit: create 439_refactor_unify_story_stuck_states_into_a_single_status_field 2026-03-28 16:27:51 +00:00
dave 0ab1b1232b storkit: create 439_refactor_unify_story_stuck_states_into_a_single_status_field 2026-03-28 16:27:36 +00:00
dave 209e01bc06 storkit: create 438_story_slash_command_autocomplete_in_web_ui_text_input 2026-03-28 16:24:44 +00:00
dave 2650b1a42e storkit: create 437_bug_strip_prefix_ci_panics_on_multi_byte_utf_8_input 2026-03-28 16:21:19 +00:00
89 changed files with 1531 additions and 748 deletions
+2 -1
View File
@@ -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.
- **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.
- 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:
```bash
curl -s "$(jq -r '.mcpServers["storkit"].url' .mcp.json)" \
@@ -0,0 +1,20 @@
---
name: "OAuth login button in web UI"
---
# Story 446: OAuth login button in web UI
## User Story
As a user of the storkit web UI, I want a login button that triggers the Anthropic OAuth flow, so that I can authenticate without manually navigating to /oauth/authorize.
## Acceptance Criteria
- [ ] Web UI shows a login/authenticate button when no OAuth token is active
- [ ] Clicking the button navigates to /oauth/authorize which starts the Anthropic OAuth flow
- [ ] After successful OAuth callback, the UI updates to show the authenticated state
- [ ] If already authenticated, the button is hidden or shows the current auth status
## Out of Scope
- TBD
@@ -1,5 +1,6 @@
---
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+106 -42
View File
@@ -26,7 +26,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"generic-array",
]
@@ -38,7 +38,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -265,16 +265,16 @@ checksum = "a1d084b0137aaa901caf9f1e8b21daa6aa24d41cd806e111335541eff9683bd6"
[[package]]
name = "blake3"
version = "1.8.3"
version = "1.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e"
dependencies = [
"arrayref",
"arrayvec",
"cc",
"cfg-if",
"constant_time_eq",
"cpufeatures",
"cpufeatures 0.3.0",
]
[[package]]
@@ -286,6 +286,15 @@ dependencies = [
"generic-array",
]
[[package]]
name = "block-buffer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
@@ -391,7 +400,7 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
"cpufeatures 0.2.17",
]
[[package]]
@@ -427,7 +436,7 @@ version = "0.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"inout",
"zeroize",
]
@@ -492,6 +501,12 @@ version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "const_panic"
version = "0.2.15"
@@ -551,6 +566,15 @@ dependencies = [
"libc",
]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.0"
@@ -596,6 +620,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "crypto-common"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
dependencies = [
"hybrid-array",
]
[[package]]
name = "ctr"
version = "0.9.2"
@@ -612,9 +645,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"serde",
@@ -740,7 +773,7 @@ version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"const-oid 0.9.6",
"zeroize",
]
@@ -813,11 +846,22 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"block-buffer 0.10.4",
"crypto-common 0.1.7",
"subtle",
]
[[package]]
name = "digest"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c"
dependencies = [
"block-buffer 0.12.0",
"const-oid 0.10.2",
"crypto-common 0.2.1",
]
[[package]]
name = "displaydoc"
version = "0.2.5"
@@ -862,7 +906,7 @@ dependencies = [
"ed25519",
"rand_core 0.6.4",
"serde",
"sha2",
"sha2 0.10.9",
"subtle",
"zeroize",
]
@@ -1371,7 +1415,7 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
"digest 0.10.7",
]
[[package]]
@@ -1451,6 +1495,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a79f2aff40c18ab8615ddc5caa9eb5b96314aef18fe5823090f204ad988e813"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.8.1"
@@ -1774,9 +1827,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
[[package]]
name = "iri-string"
version = "0.7.11"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20"
dependencies = [
"memchr",
"serde",
@@ -2158,7 +2211,7 @@ dependencies = [
"serde",
"serde_html_form",
"serde_json",
"sha2",
"sha2 0.10.9",
"tempfile",
"thiserror 2.0.18",
"tokio",
@@ -2251,7 +2304,7 @@ dependencies = [
"ruma",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"subtle",
"thiserror 2.0.18",
"time",
@@ -2286,7 +2339,7 @@ dependencies = [
"serde",
"serde-wasm-bindgen",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -2340,7 +2393,7 @@ dependencies = [
"rmp-serde",
"serde",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
"zeroize",
]
@@ -2599,7 +2652,7 @@ dependencies = [
"serde",
"serde_json",
"serde_path_to_error",
"sha2",
"sha2 0.10.9",
"thiserror 1.0.69",
"url",
]
@@ -2657,7 +2710,7 @@ version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
"digest 0.10.7",
"hmac",
]
@@ -2842,7 +2895,7 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
dependencies = [
"cpufeatures",
"cpufeatures 0.2.17",
"opaque-debug",
"universal-hash",
]
@@ -3504,7 +3557,7 @@ dependencies = [
"rand 0.8.5",
"ruma-common",
"serde_json",
"sha2",
"sha2 0.10.9",
"thiserror 2.0.18",
]
@@ -3552,7 +3605,7 @@ version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2",
"sha2 0.10.9",
"walkdir",
]
@@ -3876,8 +3929,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
@@ -3887,8 +3940,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
"cpufeatures 0.2.17",
"digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.2",
]
[[package]]
@@ -4019,7 +4083,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "storkit"
version = "0.8.1"
version = "0.8.3"
dependencies = [
"async-stream",
"async-trait",
@@ -4046,7 +4110,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"serde_yaml",
"sha2",
"sha2 0.11.0",
"strip-ansi-escapes",
"tempfile",
"tokio",
@@ -4408,7 +4472,7 @@ dependencies = [
"toml_datetime 1.1.0+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.0",
"winnow 1.0.1",
]
[[package]]
@@ -4438,7 +4502,7 @@ dependencies = [
"indexmap",
"toml_datetime 1.1.0+spec-1.1.0",
"toml_parser",
"winnow 1.0.0",
"winnow 1.0.1",
]
[[package]]
@@ -4447,7 +4511,7 @@ version = "1.1.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
dependencies = [
"winnow 1.0.0",
"winnow 1.0.1",
]
[[package]]
@@ -4681,7 +4745,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
dependencies = [
"crypto-common",
"crypto-common 0.1.7",
"subtle",
]
@@ -4781,7 +4845,7 @@ dependencies = [
"serde",
"serde_bytes",
"serde_json",
"sha2",
"sha2 0.10.9",
"subtle",
"thiserror 2.0.18",
"x25519-dalek",
@@ -5465,9 +5529,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
[[package]]
name = "winnow"
version = "1.0.0"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5"
dependencies = [
"memchr",
]
@@ -5618,18 +5682,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "living-spec-standalone",
"version": "0.8.1",
"version": "0.8.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "living-spec-standalone",
"version": "0.8.1",
"version": "0.8.3",
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"react": "^19.1.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "living-spec-standalone",
"private": true,
"version": "0.8.1",
"version": "0.8.3",
"type": "module",
"scripts": {
"dev": "vite",
+24
View File
@@ -1481,6 +1481,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
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 () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1551,6 +1555,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
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 () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1569,6 +1577,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
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 () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1595,6 +1607,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
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 () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1634,6 +1650,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
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 () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
@@ -1652,6 +1672,10 @@ describe("Slash command handling (Story 374)", () => {
await act(async () => {
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 () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
+1
View File
@@ -1059,6 +1059,7 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
)}
{messages.map((msg: Message, idx: number) => (
<MessageItem
// biome-ignore lint/suspicious/noArrayIndexKey: Message has no stable ID
key={`msg-${idx}-${msg.role}-${msg.content.substring(0, 20)}`}
msg={msg}
/>
+172 -4
View File
@@ -1,5 +1,6 @@
import * as React from "react";
import { api } from "../api/client";
import { SLASH_COMMANDS, type SlashCommand } from "../slashCommands";
const {
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>(
function ChatInput(
{ loading, queuedMessages, onSubmit, onCancel, onRemoveQueuedMessage },
@@ -127,6 +205,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
const [pickerSelectedIndex, setPickerSelectedIndex] = 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, () => ({
appendToInput(text: string) {
setInput((prev) => (prev ? `${prev}\n${text}` : text));
@@ -153,6 +235,31 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
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(
(file: string) => {
// Replace the @query portion with @file
@@ -173,11 +280,20 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
setInput(val);
const cursor = e.target.selectionStart ?? val.length;
// Find the last @ before the cursor that starts a reference token
const textUpToCursor = val.slice(0, cursor);
// Match @ not preceded by non-whitespace (i.e. @ at start or after space/newline)
const atMatch = textUpToCursor.match(/(^|[\s\n])@([^\s@]*)$/);
// 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) {
const query = atMatch[2];
const atPos = textUpToCursor.lastIndexOf("@");
@@ -196,11 +312,50 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
if (pickerQuery !== null) dismissPicker();
}
},
[projectFiles.length, pickerQuery, dismissPicker],
[
projectFiles.length,
pickerQuery,
dismissPicker,
slashQuery,
dismissSlashPicker,
],
);
const handleKeyDown = useCallback(
(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 (e.key === "ArrowDown") {
e.preventDefault();
@@ -236,6 +391,11 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
}
},
[
slashQuery,
filteredCommands,
slashSelectedIndex,
selectCommand,
dismissSlashPicker,
pickerQuery,
filteredFiles,
pickerSelectedIndex,
@@ -249,6 +409,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
onSubmit(input);
setInput("");
dismissPicker();
dismissSlashPicker();
};
return (
@@ -357,6 +518,13 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
position: "relative",
}}
>
{slashQuery !== null && (
<SlashCommandPickerOverlay
query={slashQuery}
selectedIndex={slashSelectedIndex}
onSelect={selectCommand}
/>
)}
{pickerQuery !== null && (
<FilePickerOverlay
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 -68
View File
@@ -1,75 +1,8 @@
import * as React from "react";
import { SLASH_COMMANDS } from "../slashCommands";
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 {
onDismiss: () => void;
}
+1
View File
@@ -136,6 +136,7 @@ function MessageItemInner({ msg }: MessageItemProps) {
return (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: ToolCall has no stable ID
key={`tool-${i}-${tc.function.name}`}
style={{
display: "flex",
@@ -202,6 +202,7 @@ export function ServerLogsPanel({ logs }: ServerLogsPanelProps) {
) : (
filteredLogs.map((entry, idx) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: log entries have no stable ID
key={`${entry.timestamp}-${idx}`}
style={{
display: "flex",
+67
View File
@@ -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.",
},
];
+5 -1
View File
@@ -81,9 +81,12 @@ echo "==> Releasing ${TAG}"
echo "==> Building macOS (native)..."
cargo build --release
echo "==> Building Linux (static musl via cross)..."
echo "==> Building Linux amd64 (static musl via cross)..."
cross build --release --target x86_64-unknown-linux-musl
echo "==> Building Linux arm64 (static musl via cross)..."
cross build --release --target aarch64-unknown-linux-musl
# ── Package ────────────────────────────────────────────────────
DIST="target/dist"
rm -rf "$DIST"
@@ -91,6 +94,7 @@ mkdir -p "$DIST"
cp "target/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-macos-arm64"
cp "target/x86_64-unknown-linux-musl/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-linux-amd64"
cp "target/aarch64-unknown-linux-musl/release/${BINARY_NAME}" "${DIST}/${BINARY_NAME}-linux-arm64"
chmod +x "${DIST}"/*
echo "==> Binaries:"
+3
View File
@@ -4,6 +4,9 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && 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 ==="
cargo test --manifest-path "$PROJECT_ROOT/Cargo.toml"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "storkit"
version = "0.8.1"
version = "0.8.3"
edition = "2024"
build = "build.rs"
+4 -31
View File
@@ -171,39 +171,12 @@ fn run_command_with_timeout(
/// otherwise `cargo nextest run` / `cargo test`) in the given directory.
/// Returns `(gates_passed, combined_output)`.
pub(crate) fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> {
let mut all_output = String::new();
let mut all_passed = true;
// ── 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) ─
// Run script/test (or fallback to cargo test). This is the sole
// acceptance gate — project-specific linting and test commands belong
// in script/test, not hardcoded here.
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.
+45
View File
@@ -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
// when the agent process exits normally.
super::pipeline::run_server_owned_completion(
@@ -511,6 +555,7 @@ impl AgentPool {
.await;
AgentPool::notify_agent_state_changed(&watcher_tx_clone);
}
}
Err(e) => {
slog_error!("[agents] Agent process error for {aname} on {sid}: {e}");
let event = AgentEvent::Error {
+95 -1
View File
@@ -4,7 +4,7 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
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::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);
// 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).
{
let lock = match agents.lock() {
@@ -516,4 +531,83 @@ mod tests {
)
.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"
);
}
}
+1 -1
View File
@@ -180,7 +180,7 @@ pub fn commands() -> &'static [BotCommand] {
},
BotCommand {
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,
},
]
+1 -5
View File
@@ -142,11 +142,7 @@ mod tests {
try_handle_command(&dispatch, &format!("@timmy move {args}"))
}
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
use crate::chat::test_helpers::write_story_file;
#[test]
fn move_command_is_registered() {
+75 -2
View File
@@ -9,7 +9,9 @@
//! - `setup retry` — discard staged content and reset the current step
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};
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() {
"" => Some(wizard_status_reply(ctx)),
"generate" => Some(wizard_generate_reply(ctx)),
"confirm" => Some(wizard_confirm_reply(ctx)),
"skip" => Some(wizard_skip_reply(ctx)),
"retry" => Some(wizard_retry_reply(ctx)),
_ => 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).
fn wizard_status_reply(ctx: &CommandContext) -> String {
match WizardState::load(ctx.project_root) {
@@ -263,4 +295,45 @@ mod tests {
assert!(result.contains("Unknown sub-command"));
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"));
}
}
+1 -5
View File
@@ -91,11 +91,7 @@ mod tests {
try_handle_command(&dispatch, &format!("@timmy show {args}"))
}
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
use crate::chat::test_helpers::write_story_file;
#[test]
fn show_command_is_registered() {
+1 -5
View File
@@ -296,11 +296,7 @@ mod tests {
try_handle_command(&dispatch, &format!("@timmy status {args}"))
}
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();
}
use crate::chat::test_helpers::write_story_file;
// -- registration -------------------------------------------------------
+3 -11
View File
@@ -108,18 +108,14 @@ pub(crate) fn unblock_by_path(path: &Path, story_id: &str) -> String {
}
// Clear the blocked flag if present.
if has_blocked {
if let Err(e) = clear_front_matter_field(path, "blocked") {
if has_blocked && let Err(e) = clear_front_matter_field(path, "blocked") {
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
}
}
// Clear merge_failure if present.
if has_merge_failure {
if let Err(e) = clear_front_matter_field(path, "merge_failure") {
if has_merge_failure && let Err(e) = clear_front_matter_field(path, "merge_failure") {
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
}
}
// Reset retry_count to 0 (re-read the updated file, modify, write).
let updated_contents = match std::fs::read_to_string(path) {
@@ -164,11 +160,7 @@ mod tests {
try_handle_command(&dispatch, &format!("@timmy unblock {args}"))
}
fn write_story_file(root: &std::path::Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
use crate::chat::test_helpers::write_story_file;
#[test]
fn unblock_command_is_registered() {
+2
View File
@@ -8,6 +8,8 @@ pub mod commands;
pub mod timer;
pub mod transport;
pub mod util;
#[cfg(test)]
pub(crate) mod test_helpers;
use async_trait::async_trait;
+15
View File
@@ -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();
}
+3 -32
View File
@@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crate::chat::util::strip_bot_mention;
// ── Data types ─────────────────────────────────────────────────────────────
/// A single scheduled timer entry.
@@ -256,7 +258,7 @@ pub fn extract_timer_command(
bot_name: &str,
bot_user_id: &str,
) -> 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
.trim()
.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
}
fn strip_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str {
let trimmed = message.trim();
if let Some(rest) = strip_prefix_ci(trimmed, bot_user_id) {
return rest;
}
if let Some(localpart) = bot_user_id.split(':').next()
&& let Some(rest) = strip_prefix_ci(trimmed, localpart)
{
return rest;
}
if let Some(rest) = strip_prefix_ci(trimmed, bot_name) {
return rest;
}
trimmed
}
fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> {
if text.len() < prefix.len() {
return None;
}
if !text[..prefix.len()].eq_ignore_ascii_case(prefix) {
return None;
}
let rest = &text[prefix.len()..];
match rest.chars().next() {
None => Some(rest),
Some(c) if c.is_alphanumeric() || c == '-' || c == '_' => None,
_ => Some(rest),
}
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
+15 -40
View File
@@ -9,6 +9,7 @@
//! that the next `start` invocation picks it up automatically.
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 std::path::Path;
@@ -43,7 +44,7 @@ pub fn extract_assign_command(
bot_name: &str,
bot_user_id: &str,
) -> 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
.trim()
.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
// ---------------------------------------------------------------------------
@@ -354,6 +321,18 @@ mod tests {
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 --------------------------------------------------
#[test]
@@ -371,11 +350,7 @@ mod tests {
// -- handle_assign (no running coder) ------------------------------------
fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
let dir = root.join(".storkit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(filename), content).unwrap();
}
use crate::chat::test_helpers::write_story_file;
#[tokio::test]
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::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
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::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
/// sender is included so the LLM can distinguish participants.
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?");
}
// -- 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 -------------------------------------------
#[test]
+2 -36
View File
@@ -5,6 +5,7 @@
//! commits the change to git.
use crate::agents::{AgentPool, AgentStatus};
use crate::chat::util::strip_bot_mention;
use std::path::Path;
/// A parsed delete command from a Matrix message body.
@@ -25,7 +26,7 @@ pub fn extract_delete_command(
bot_name: &str,
bot_user_id: &str,
) -> 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
.trim()
.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
// ---------------------------------------------------------------------------
+2 -37
View File
@@ -13,6 +13,7 @@ use std::time::Duration;
use tokio::sync::{Mutex as TokioMutex, watch};
use crate::agents::{AgentPool, AgentStatus};
use crate::chat::util::strip_bot_mention;
use crate::slog;
use crate::chat::ChatTransport;
@@ -51,7 +52,7 @@ pub type HtopSessions = Arc<TokioMutex<HashMap<String, HtopSession>>>;
/// - `htop 10m` → `Start { duration_secs: 600 }`
/// - `htop 120` → `Start { duration_secs: 120 }` (bare seconds)
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();
// 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()
}
/// 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
// ---------------------------------------------------------------------------
+2 -33
View File
@@ -6,6 +6,7 @@
//! running.
use crate::agents::AgentPool;
use crate::chat::util::strip_bot_mention;
use std::path::Path;
use std::sync::Arc;
@@ -22,7 +23,7 @@ pub fn extract_rebuild_command(
bot_name: &str,
bot_user_id: &str,
) -> 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
.trim()
.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
// ---------------------------------------------------------------------------
+2 -33
View File
@@ -6,6 +6,7 @@
//! affected — only the in-memory/persisted conversation state is cleared.
use crate::chat::transport::matrix::bot::{ConversationHistory, RoomConversation};
use crate::chat::util::strip_bot_mention;
use matrix_sdk::ruma::OwnedRoomId;
use std::path::Path;
@@ -22,7 +23,7 @@ pub fn extract_reset_command(
bot_name: &str,
bot_user_id: &str,
) -> 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
.trim()
.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()
}
/// 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
// ---------------------------------------------------------------------------
+2 -33
View File
@@ -5,6 +5,7 @@
//! The story file in the pipeline is left untouched.
use crate::agents::{AgentPool, AgentStatus};
use crate::chat::util::strip_bot_mention;
use std::path::Path;
/// A parsed rmtree command from a Matrix message body.
@@ -25,7 +26,7 @@ pub fn extract_rmtree_command(
bot_name: &str,
bot_user_id: &str,
) -> 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
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
@@ -118,38 +119,6 @@ pub async fn handle_rmtree(
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
// ---------------------------------------------------------------------------
+2 -35
View File
@@ -7,6 +7,7 @@
//! name ends with the supplied hint, e.g. `coder-{hint}`).
use crate::agents::AgentPool;
use crate::chat::util::strip_bot_mention;
use std::path::Path;
/// A parsed start command from a Matrix message body.
@@ -31,7 +32,7 @@ pub fn extract_start_command(
bot_name: &str,
bot_user_id: &str,
) -> 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
.trim()
.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
// ---------------------------------------------------------------------------
+1 -11
View File
@@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize};
use crate::agents::AgentPool;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::util::is_permission_approval;
use crate::slog;
use crate::chat::ChatTransport;
use crate::http::context::{PermissionDecision, PermissionForward};
@@ -86,17 +87,6 @@ pub struct SlackWebhookContext {
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 ───────────────────────────────────────────
pub(super) async fn handle_incoming_message(
@@ -1,21 +1,13 @@
use std::sync::Arc;
use crate::chat::transport::matrix::{ConversationEntry, ConversationRole, RoomConversation};
use crate::chat::util::is_permission_approval;
use crate::http::context::{PermissionDecision};
use crate::slog;
use super::WhatsAppWebhookContext;
use super::format::{chunk_for_whatsapp, markdown_to_whatsapp};
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.
pub(super) async fn handle_incoming_message(ctx: &WhatsAppWebhookContext, sender: &str, message: &str) {
use crate::chat::commands::{CommandDispatch, try_handle_command};
+60
View File
@@ -3,6 +3,27 @@
//! These functions are transport-agnostic helpers for processing chat messages:
//! 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
/// word boundary (whitespace, punctuation, or end-of-string).
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 {
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 ----------------------------------------------------
#[test]
+16 -18
View File
@@ -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")]
impl AnthropicApi {
/// Check whether an Anthropic API key is stored.
@@ -151,25 +158,16 @@ impl AnthropicApi {
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::http::test_helpers::{make_api, test_ctx};
use serde_json::json;
use std::sync::Arc;
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) --
#[test]
fn get_api_key_returns_err_when_not_set() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
let ctx = test_ctx(dir.path());
let result = get_anthropic_api_key(&ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
@@ -178,7 +176,7 @@ mod tests {
#[test]
fn get_api_key_returns_err_when_empty() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
let ctx = test_ctx(dir.path());
ctx.store.set(KEY_ANTHROPIC_API_KEY, json!(""));
let result = get_anthropic_api_key(&ctx);
assert!(result.is_err());
@@ -188,7 +186,7 @@ mod tests {
#[test]
fn get_api_key_returns_err_when_not_string() {
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));
let result = get_anthropic_api_key(&ctx);
assert!(result.is_err());
@@ -198,7 +196,7 @@ mod tests {
#[test]
fn get_api_key_returns_key_when_set() {
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"));
let result = get_anthropic_api_key(&ctx);
assert_eq!(result.unwrap(), "sk-ant-test123");
@@ -209,7 +207,7 @@ mod tests {
#[tokio::test]
async fn key_exists_returns_false_when_not_set() {
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();
assert!(!result.0);
}
@@ -229,7 +227,7 @@ mod tests {
#[tokio::test]
async fn set_api_key_returns_true() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<AnthropicApi>(&dir);
let payload = Json(ApiKeyPayload {
api_key: "sk-ant-test123".to_string(),
});
@@ -256,7 +254,7 @@ mod tests {
#[tokio::test]
async fn list_models_fails_when_no_key() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<AnthropicApi>(&dir);
let result = api.list_anthropic_models().await;
assert!(result.is_err());
}
@@ -288,7 +286,7 @@ mod tests {
#[test]
fn new_creates_api_instance() {
let dir = TempDir::new().unwrap();
let _api = make_api(&dir);
let _api = make_api::<AnthropicApi>(&dir);
}
#[test]
+25 -24
View File
@@ -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)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::http::test_helpers::make_api;
use tempfile::TempDir;
fn make_api(dir: &TempDir) -> IoApi {
IoApi {
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
}
}
// --- list_directory_absolute ---
#[tokio::test]
@@ -158,7 +159,7 @@ mod tests {
std::fs::create_dir(dir.path().join("subdir")).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 {
path: dir.path().to_string_lossy().to_string(),
});
@@ -176,7 +177,7 @@ mod tests {
let empty = dir.path().join("empty");
std::fs::create_dir(&empty).unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(FilePathPayload {
path: empty.to_string_lossy().to_string(),
});
@@ -187,7 +188,7 @@ mod tests {
#[tokio::test]
async fn list_directory_absolute_errors_on_nonexistent_path() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(FilePathPayload {
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");
std::fs::write(&file, "content").unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(FilePathPayload {
path: file.to_string_lossy().to_string(),
});
@@ -216,7 +217,7 @@ mod tests {
let dir = TempDir::new().unwrap();
let new_dir = dir.path().join("new_dir");
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(CreateDirectoryPayload {
path: new_dir.to_string_lossy().to_string(),
});
@@ -231,7 +232,7 @@ mod tests {
let existing = dir.path().join("existing");
std::fs::create_dir(&existing).unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(CreateDirectoryPayload {
path: existing.to_string_lossy().to_string(),
});
@@ -244,7 +245,7 @@ mod tests {
let dir = TempDir::new().unwrap();
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 {
path: nested.to_string_lossy().to_string(),
});
@@ -258,7 +259,7 @@ mod tests {
#[tokio::test]
async fn get_home_directory_returns_a_path() {
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 home = &result.0;
assert!(!home.is_empty());
@@ -272,7 +273,7 @@ mod tests {
let dir = TempDir::new().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 {
path: "hello.txt".to_string(),
});
@@ -283,7 +284,7 @@ mod tests {
#[tokio::test]
async fn read_file_errors_on_missing_file() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(FilePathPayload {
path: "nonexistent.txt".to_string(),
});
@@ -296,7 +297,7 @@ mod tests {
#[tokio::test]
async fn write_file_creates_file() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(WriteFilePayload {
path: "output.txt".to_string(),
content: "written content".to_string(),
@@ -312,7 +313,7 @@ mod tests {
#[tokio::test]
async fn write_file_creates_parent_dirs() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(WriteFilePayload {
path: "sub/dir/file.txt".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("README.md"), "# readme").unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let result = api.list_project_files().await.unwrap();
let files = &result.0;
@@ -348,7 +349,7 @@ mod tests {
std::fs::create_dir(dir.path().join("subdir")).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 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("a_first.txt"), "").unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let result = api.list_project_files().await.unwrap();
let files = &result.0;
@@ -380,7 +381,7 @@ mod tests {
std::fs::create_dir(dir.path().join("adir")).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 {
path: ".".to_string(),
});
@@ -394,7 +395,7 @@ mod tests {
#[tokio::test]
async fn list_directory_errors_on_nonexistent() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<IoApi>(&dir);
let payload = Json(FilePathPayload {
path: "nonexistent_dir".to_string(),
});
+1 -5
View File
@@ -370,13 +370,9 @@ pub(super) async fn get_worktree_commits(worktree_path: &str, base_branch: &str)
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::http::test_helpers::test_ctx;
use crate::store::StoreOps;
fn test_ctx(dir: &std::path::Path) -> AppContext {
AppContext::new_test(dir.to_path_buf())
}
#[test]
fn tool_list_agents_empty() {
let tmp = tempfile::tempdir().unwrap();
+1 -5
View File
@@ -279,11 +279,7 @@ pub(super) fn tool_loc_file(args: &Value, ctx: &AppContext) -> Result<String, St
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
fn test_ctx(dir: &std::path::Path) -> AppContext {
AppContext::new_test(dir.to_path_buf())
}
use crate::http::test_helpers::test_ctx;
#[test]
fn tool_get_server_logs_no_args_returns_string() {
+1 -4
View File
@@ -304,12 +304,9 @@ pub(super) async fn tool_git_log(args: &Value, ctx: &AppContext) -> Result<Strin
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::http::test_helpers::test_ctx;
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.
fn setup_worktree() -> (tempfile::TempDir, PathBuf, AppContext) {
let tmp = tempfile::tempdir().unwrap();
+1 -5
View File
@@ -164,11 +164,7 @@ pub(super) fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Resul
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
fn test_ctx(dir: &std::path::Path) -> AppContext {
AppContext::new_test(dir.to_path_buf())
}
use crate::http::test_helpers::test_ctx;
fn setup_git_repo_in(dir: &std::path::Path) {
std::process::Command::new("git")
+1 -5
View File
@@ -1336,11 +1336,7 @@ async fn handle_tools_call(
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
fn test_ctx(dir: &std::path::Path) -> AppContext {
AppContext::new_test(dir.to_path_buf())
}
use crate::http::test_helpers::test_ctx;
#[test]
fn json_rpc_response_serializes_success() {
+1 -5
View File
@@ -194,11 +194,7 @@ pub(super) fn find_free_port(start: u16) -> u16 {
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
fn test_ctx(dir: &std::path::Path) -> AppContext {
AppContext::new_test(dir.to_path_buf())
}
use crate::http::test_helpers::test_ctx;
#[test]
fn request_qa_in_tools_list() {
+1 -5
View File
@@ -331,13 +331,9 @@ pub(super) fn handle_run_command_sse(
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::http::test_helpers::test_ctx;
use serde_json::json;
fn test_ctx(dir: &std::path::Path) -> AppContext {
AppContext::new_test(dir.to_path_buf())
}
// ── is_dangerous ─────────────────────────────────────────────────
#[test]
+1 -5
View File
@@ -549,11 +549,7 @@ pub(super) fn parse_test_cases(value: Option<&Value>) -> Result<Vec<TestCaseResu
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
fn test_ctx(dir: &std::path::Path) -> AppContext {
AppContext::new_test(dir.to_path_buf())
}
use crate::http::test_helpers::test_ctx;
#[test]
fn parse_test_cases_empty() {
+123 -5
View File
@@ -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.
fn is_bare_project(project_root: &Path) -> bool {
let dominated_by_storkit = std::fs::read_dir(project_root)
pub(crate) fn is_bare_project(project_root: &Path) -> bool {
std::fs::read_dir(project_root)
.ok()
.map(|entries| {
let names: Vec<String> = entries
@@ -171,12 +171,11 @@ fn is_bare_project(project_root: &Path) -> bool {
|| n == "store.json"
})
})
.unwrap_or(true);
dominated_by_storkit
.unwrap_or(true)
}
/// 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);
match step {
@@ -215,6 +214,13 @@ fn generation_hint(step: WizardStep, project_root: &Path) -> String {
}
}
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_pkg = project_root.join("package.json").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 => {
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()
}
}
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()
}
}
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("← 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"));
}
}
+2
View File
@@ -1,6 +1,8 @@
pub mod agents;
pub mod agents_sse;
pub mod anthropic;
#[cfg(test)]
pub(crate) mod test_helpers;
pub mod assets;
pub mod bot_command;
pub mod chat;
+13 -12
View File
@@ -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)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::http::test_helpers::make_api;
use tempfile::TempDir;
fn make_api(dir: &TempDir) -> ModelApi {
ModelApi {
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
}
}
#[tokio::test]
async fn get_model_preference_returns_none_when_unset() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ModelApi>(&dir);
let result = api.get_model_preference().await.unwrap();
assert!(result.0.is_none());
}
@@ -73,7 +74,7 @@ mod tests {
#[tokio::test]
async fn set_model_preference_returns_true() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ModelApi>(&dir);
let payload = Json(ModelPayload {
model: "claude-3-sonnet".to_string(),
});
@@ -84,7 +85,7 @@ mod tests {
#[tokio::test]
async fn get_model_preference_returns_value_after_set() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ModelApi>(&dir);
let payload = Json(ModelPayload {
model: "claude-3-sonnet".to_string(),
@@ -98,7 +99,7 @@ mod tests {
#[tokio::test]
async fn set_model_preference_overwrites_previous_value() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ModelApi>(&dir);
api.set_model_preference(Json(ModelPayload {
model: "model-a".to_string(),
@@ -119,7 +120,7 @@ mod tests {
#[tokio::test]
async fn get_ollama_models_returns_empty_list_for_unreachable_url() {
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.
let base_url = Query(Some("http://127.0.0.1:1".to_string()));
let result = api.get_ollama_models(base_url).await;
+18 -17
View File
@@ -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)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::http::test_helpers::make_api;
use tempfile::TempDir;
fn make_api(dir: &TempDir) -> ProjectApi {
ProjectApi {
ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
}
}
#[tokio::test]
async fn get_current_project_returns_none_when_unset() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ProjectApi>(&dir);
// Clear the project root that new_test sets
api.close_project().await.unwrap();
let result = api.get_current_project().await.unwrap();
@@ -98,7 +99,7 @@ mod tests {
#[tokio::test]
async fn get_current_project_returns_path_from_state() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ProjectApi>(&dir);
let result = api.get_current_project().await.unwrap();
assert_eq!(result.0, Some(dir.path().to_string_lossy().to_string()));
}
@@ -106,7 +107,7 @@ mod tests {
#[tokio::test]
async fn open_project_succeeds_with_valid_directory() {
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 payload = Json(PathPayload { path: path.clone() });
let result = api.open_project(payload).await.unwrap();
@@ -116,7 +117,7 @@ mod tests {
#[tokio::test]
async fn open_project_fails_with_nonexistent_file_path() {
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
let file_path = dir.path().join("not_a_dir.txt");
std::fs::write(&file_path, "content").unwrap();
@@ -130,7 +131,7 @@ mod tests {
#[tokio::test]
async fn close_project_returns_true() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ProjectApi>(&dir);
let result = api.close_project().await.unwrap();
assert!(result.0);
}
@@ -138,7 +139,7 @@ mod tests {
#[tokio::test]
async fn close_project_clears_current_project() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ProjectApi>(&dir);
// Verify project is set initially
let before = api.get_current_project().await.unwrap();
@@ -155,7 +156,7 @@ mod tests {
#[tokio::test]
async fn list_known_projects_returns_empty_initially() {
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
api.close_project().await.unwrap();
let result = api.list_known_projects().await.unwrap();
@@ -165,7 +166,7 @@ mod tests {
#[tokio::test]
async fn list_known_projects_returns_project_after_open() {
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();
api.open_project(Json(PathPayload { path: path.clone() }))
@@ -179,7 +180,7 @@ mod tests {
#[tokio::test]
async fn forget_known_project_removes_project() {
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();
api.open_project(Json(PathPayload { path: path.clone() }))
@@ -202,7 +203,7 @@ mod tests {
#[tokio::test]
async fn forget_known_project_returns_true_for_nonexistent_path() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<ProjectApi>(&dir);
let result = api
.forget_known_project(Json(PathPayload {
path: "/some/unknown/path".to_string(),
+25 -29
View File
@@ -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()))
}
#[cfg(test)]
impl From<std::sync::Arc<AppContext>> for SettingsApi {
fn from(ctx: std::sync::Arc<AppContext>) -> Self {
Self { ctx }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use std::sync::Arc;
use crate::http::test_helpers::{make_api, test_ctx};
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]
async fn get_editor_returns_none_when_unset() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
let result = api.get_editor().await.unwrap();
assert!(result.0.editor_command.is_none());
}
@@ -132,7 +128,7 @@ mod tests {
#[tokio::test]
async fn set_editor_stores_command() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
let payload = Json(EditorCommandPayload {
editor_command: Some("zed".to_string()),
});
@@ -143,7 +139,7 @@ mod tests {
#[tokio::test]
async fn set_editor_clears_command_on_null() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
api.set_editor(Json(EditorCommandPayload {
editor_command: Some("zed".to_string()),
}))
@@ -161,7 +157,7 @@ mod tests {
#[tokio::test]
async fn set_editor_clears_command_on_empty_string() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
let result = api
.set_editor(Json(EditorCommandPayload {
editor_command: Some(String::new()),
@@ -174,7 +170,7 @@ mod tests {
#[tokio::test]
async fn set_editor_trims_whitespace_only() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
let result = api
.set_editor(Json(EditorCommandPayload {
editor_command: Some(" ".to_string()),
@@ -187,7 +183,7 @@ mod tests {
#[tokio::test]
async fn get_editor_returns_value_after_set() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
api.set_editor(Json(EditorCommandPayload {
editor_command: Some("cursor".to_string()),
}))
@@ -200,7 +196,7 @@ mod tests {
#[test]
fn editor_command_defaults_to_null() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
let ctx = test_ctx(dir.path());
let result = get_editor_command_from_store(&ctx);
assert!(result.is_none());
}
@@ -208,7 +204,7 @@ mod tests {
#[test]
fn set_editor_command_persists_in_store() {
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.save().unwrap();
@@ -220,7 +216,7 @@ mod tests {
#[test]
fn get_editor_command_from_store_returns_value() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
let ctx = test_ctx(dir.path());
ctx.store.set(EDITOR_COMMAND_KEY, json!("code"));
let result = get_editor_command_from_store(&ctx);
@@ -230,7 +226,7 @@ mod tests {
#[test]
fn delete_editor_command_returns_none() {
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.delete(EDITOR_COMMAND_KEY);
@@ -258,7 +254,7 @@ mod tests {
#[tokio::test]
async fn get_editor_http_handler_returns_null_when_not_set() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
let ctx = test_ctx(dir.path());
let api = SettingsApi {
ctx: Arc::new(ctx),
};
@@ -269,7 +265,7 @@ mod tests {
#[tokio::test]
async fn set_editor_http_handler_stores_value() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
let ctx = test_ctx(dir.path());
let api = SettingsApi {
ctx: Arc::new(ctx),
};
@@ -286,7 +282,7 @@ mod tests {
#[tokio::test]
async fn set_editor_http_handler_clears_value_when_null() {
let dir = TempDir::new().unwrap();
let ctx = test_ctx(&dir);
let ctx = test_ctx(dir.path());
let api = SettingsApi {
ctx: Arc::new(ctx),
};
@@ -310,7 +306,7 @@ mod tests {
#[tokio::test]
async fn open_file_returns_error_when_no_editor_configured() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
let result = api
.open_file(Query("src/main.rs".to_string()), Query(Some(42)))
.await;
@@ -322,7 +318,7 @@ mod tests {
#[tokio::test]
async fn open_file_spawns_editor_with_path_and_line() {
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
api.set_editor(Json(EditorCommandPayload {
editor_command: Some("echo".to_string()),
@@ -339,7 +335,7 @@ mod tests {
#[tokio::test]
async fn open_file_spawns_editor_with_path_only_when_no_line() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
api.set_editor(Json(EditorCommandPayload {
editor_command: Some("echo".to_string()),
}))
@@ -355,7 +351,7 @@ mod tests {
#[tokio::test]
async fn open_file_returns_error_for_nonexistent_editor() {
let dir = TempDir::new().unwrap();
let api = make_api(&dir);
let api = make_api::<SettingsApi>(&dir);
api.set_editor(Json(EditorCommandPayload {
editor_command: Some("this_editor_does_not_exist_xyz_abc".to_string()),
}))
+21
View File
@@ -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()
}
+4
View File
@@ -760,6 +760,10 @@ mod tests {
content.contains("Never chain shell commands"),
"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]
+2
View File
@@ -5,3 +5,5 @@ pub mod shell;
pub mod story_metadata;
pub mod watcher;
pub mod wizard;
#[cfg(test)]
pub(crate) mod test_helpers;
+1 -8
View File
@@ -74,17 +74,10 @@ fn needs_project_toml(story_kit: &Path) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use crate::io::test_helpers::setup_project;
use std::fs;
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 ──────────────────────────────────────────
#[test]
+3 -13
View File
@@ -11,15 +11,11 @@ pub struct SearchResult {
pub matches: usize,
}
fn get_project_root(state: &SessionState) -> Result<PathBuf, String> {
state.get_project_root()
}
pub async fn search_files(
query: String,
state: &SessionState,
) -> Result<Vec<SearchResult>, String> {
let root = get_project_root(state)?;
let root = state.get_project_root()?;
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)]
mod tests {
use super::*;
use std::fs;
use crate::io::test_helpers::create_test_files;
use tempfile::TempDir;
fn setup_project(files: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().unwrap();
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();
}
create_test_files(&dir, files);
dir
}
+1 -6
View File
@@ -3,11 +3,6 @@ use serde::Serialize;
use std::path::PathBuf;
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)]
pub struct CommandOutput {
pub stdout: String,
@@ -53,7 +48,7 @@ pub async fn exec_shell(
args: Vec<String>,
state: &SessionState,
) -> Result<CommandOutput, String> {
let root = get_project_root(state)?;
let root = state.get_project_root()?;
exec_shell_impl(command, args, root).await
}
+32
View File
@@ -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();
}
}
+1 -7
View File
@@ -255,15 +255,9 @@ pub fn format_wizard_state(state: &WizardState) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::io::test_helpers::setup_project;
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]
fn default_state_has_all_steps_pending() {
let state = WizardState::default();