Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b75679175b | |||
| 440081016d | |||
| e8f3629c76 | |||
| c5cdc0f594 | |||
| fec417cb16 | |||
| a70a06a5fb | |||
| 0a617e1c18 | |||
| 4527f71857 | |||
| 6e0d12d145 | |||
| d471d29c72 | |||
| 0b652eec21 | |||
| b32fdf7d65 | |||
| 2da0e1eb55 | |||
| 269124a1fd | |||
| 5992f9bd19 | |||
| a53967453e | |||
| ab4b218ac7 | |||
| d5b936c88d | |||
| 07cc0e3f29 | |||
| db4a84c70f | |||
| 3048d26e66 | |||
| 8e45b2a08d | |||
| ddc4a57cd2 | |||
| d216f3c267 | |||
| 8cd881c8f1 | |||
| 2867e1d15f | |||
| c2c9d3f9cb | |||
| f734b4a3c6 | |||
| 890693efda | |||
| 5403b29261 | |||
| 8ee59f5dc1 | |||
| 5dcc35a1b3 | |||
| af70b68cd1 | |||
| e356f9b2dd | |||
| 96793de11b | |||
| bfe70f5599 | |||
| 98aedaddf0 | |||
| 496ce864d7 | |||
| 243738551c | |||
| 20f2d97f06 | |||
| b6edc1bff7 | |||
| c45613a3ad | |||
| 7efed33851 | |||
| b00a477070 | |||
| 52f2e89659 | |||
| 08db28d9d6 | |||
| 77ff0ce093 | |||
| 0ab1b1232b | |||
| 209e01bc06 | |||
| 2650b1a42e |
+2
-1
@@ -13,7 +13,8 @@ When you start a new session with this project:
|
||||
- **Be conversational.** Don't show tool names, step numbers, or raw wizard output to the user.
|
||||
- **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)" \
|
||||
|
||||
+1
@@ -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
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: "Deduplicate strip_prefix_ci / strip_bot_mention into chat::util"
|
||||
---
|
||||
|
||||
# Refactor 439: Deduplicate strip_prefix_ci / strip_bot_mention into chat::util
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Eight Matrix transport files (assign.rs, delete.rs, start.rs, rebuild.rs, reset.rs, rmtree.rs, htop.rs, timer.rs) each contain their own private copies of `strip_prefix_ci` and `strip_bot_mention`. The canonical versions already live in `chat::util` with the correct `is_char_boundary` guard. The duplicates should be removed and all call sites should use `util::strip_bot_mention` instead.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] All 8 private copies of strip_prefix_ci are removed
|
||||
- [ ] All 8 private copies of strip_bot_mention are removed
|
||||
- [ ] All call sites use chat::util::strip_bot_mention instead
|
||||
- [ ] Existing tests in util.rs continue to pass
|
||||
- [ ] No new copies of strip_prefix_ci exist outside util.rs
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: "Consolidate is_permission_approval into chat::util"
|
||||
---
|
||||
|
||||
# Refactor 440: Consolidate is_permission_approval into chat::util
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Three copies of `is_permission_approval` exist across Slack (`chat/transport/slack/commands.rs`), WhatsApp (`chat/transport/whatsapp/commands.rs`), and Matrix (`chat/transport/matrix/bot/messages.rs`). The Slack and WhatsApp versions are identical; the Matrix version is a superset that also strips @mentions. Consolidate into a single `pub` function in `chat::util` using the Matrix superset behavior, then delete the 3 private copies.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Single pub fn is_permission_approval exists in chat::util
|
||||
- [ ] All 3 private copies are removed
|
||||
- [ ] Matrix @mention-stripping behavior is preserved in the shared version
|
||||
- [ ] All call sites use the shared version
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: "Deduplicate get_project_root wrappers in io modules"
|
||||
---
|
||||
|
||||
# Refactor 441: Deduplicate get_project_root wrappers in io modules
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Both `io/shell.rs` and `io/search.rs` contain identical private one-liner wrappers around `state.get_project_root()`. Either inline the call at each usage site or create a single shared helper, then delete the duplicate wrappers.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] No duplicate private get_project_root wrappers in io/shell.rs and io/search.rs
|
||||
- [ ] All call sites use the canonical version or inline the call
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: "Deduplicate stage_display_name into shared module"
|
||||
---
|
||||
|
||||
# Refactor 442: Deduplicate stage_display_name into shared module
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
`stage_display_name` has a `pub fn` in `chat/transport/matrix/notifications.rs` and a private copy in `chat/transport/matrix/delete.rs` with slightly different casing ("backlog" vs "Backlog", "in-progress" vs "Current"). The delete.rs copy should use the canonical version from notifications.rs, adjusting the callsite if the casing difference matters.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Private stage_display_name in delete.rs is removed
|
||||
- [ ] delete.rs uses the pub version from notifications.rs
|
||||
- [ ] Display casing is consistent or callsite is adjusted to handle the difference
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: "Extract shared find_story_name from commands"
|
||||
---
|
||||
|
||||
# Refactor 443: Extract shared find_story_name from commands
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
`find_story_name` is nearly identical in `chat/commands/overview.rs` and `chat/commands/unreleased.rs` (minor style diff: `let stages` vs `const STAGES`). Extract to a shared location (e.g. `chat::commands::util` or `io::stories`) and have both callers use it.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] Single shared find_story_name function exists
|
||||
- [ ] Both overview.rs and unreleased.rs use the shared version
|
||||
- [ ] Private copies are removed
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: "Extract shared test helpers (test_ctx, write_story_file, make_api)"
|
||||
agent: "coder-opus"
|
||||
---
|
||||
|
||||
# Refactor 444: Extract shared test helpers (test_ctx, write_story_file, make_api)
|
||||
|
||||
## Current State
|
||||
|
||||
- TBD
|
||||
|
||||
## Desired State
|
||||
|
||||
Several test helper functions are copy-pasted across many test modules: `test_ctx` (10 copies across http/ modules), `write_story_file` (5 copies across chat/commands/ and matrix/), `make_api` (5 copies across http/ modules), `setup_project` (3 copies in io/). Extract each into a shared `#[cfg(test)]` utility module so test scaffolding is maintained in one place.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] test_ctx has a single shared definition used by all 10 http test modules
|
||||
- [ ] write_story_file has a single shared definition used by all 5 callers
|
||||
- [ ] make_api has a single shared definition used by all 5 callers
|
||||
- [ ] setup_project has a single shared definition used by all 3 callers
|
||||
- [ ] All private copies in individual test modules are removed
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- TBD
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: "Rate-limited mergemaster exits advance stories to done without merging"
|
||||
---
|
||||
|
||||
# Bug 445: Rate-limited mergemaster exits advance stories to done without merging
|
||||
|
||||
## Description
|
||||
|
||||
When the mergemaster agent is immediately rate-limited (zero turns, zero tool calls), it exits and run_server_owned_completion runs acceptance gates on the existing worktree. Since the coder already committed working code, the gates pass, and the pipeline advances the story to done — even though the mergemaster never executed run_squash_merge and the code was never cherry-picked onto master.
|
||||
|
||||
## How to Reproduce
|
||||
|
||||
Observed on stories 439 and 442. All mergemaster log entries show: init → rate_limit_event → error result. Zero turns, zero MCP tool calls, duration under 350ms. Yet both stories ended up in done with no merge commit on master.
|
||||
|
||||
## Actual Result
|
||||
|
||||
Stories advance to done with no code on master. The mergemaster never ran but the pipeline treated its exit as a successful completion.
|
||||
|
||||
## Expected Result
|
||||
|
||||
If the mergemaster exits without completing its work (no merge commit produced), the story should stay in the merge stage for retry, not advance to done.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] run_server_owned_completion must not run for mergemaster agents — mergemaster has its own completion path via start_merge_agent_work
|
||||
- [ ] If the mergemaster process exits without producing a SquashMergeResult, the story stays in merge stage
|
||||
- [ ] Rate-limited mergemaster exits are treated as transient failures, not gate-passing completions
|
||||
- [ ] Story remains eligible for retry when mergemaster fails due to rate limiting
|
||||
Generated
+7
-7
@@ -1774,9 +1774,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
|
||||
|
||||
[[package]]
|
||||
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",
|
||||
@@ -4019,7 +4019,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "storkit"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
@@ -5618,18 +5618,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",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "living-spec-standalone",
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"react": "^19.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "living-spec-standalone",
|
||||
"private": true,
|
||||
"version": "0.8.1",
|
||||
"version": "0.8.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const SLASH_COMMANDS: SlashCommand[] = [
|
||||
{
|
||||
name: "/help",
|
||||
description: "Show this list of available slash commands.",
|
||||
},
|
||||
{
|
||||
name: "/status",
|
||||
description:
|
||||
"Show pipeline status and agent availability. `/status <number>` shows a story triage dump.",
|
||||
},
|
||||
{
|
||||
name: "/assign <number> <model>",
|
||||
description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
|
||||
},
|
||||
{
|
||||
name: "/start <number>",
|
||||
description:
|
||||
"Start a coder on a story. Optionally specify a model: `/start <number> opus`.",
|
||||
},
|
||||
{
|
||||
name: "/show <number>",
|
||||
description: "Display the full text of a work item.",
|
||||
},
|
||||
{
|
||||
name: "/move <number> <stage>",
|
||||
description:
|
||||
"Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
|
||||
},
|
||||
{
|
||||
name: "/delete <number>",
|
||||
description:
|
||||
"Remove a work item from the pipeline and stop any running agent.",
|
||||
},
|
||||
{
|
||||
name: "/cost",
|
||||
description:
|
||||
"Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
|
||||
},
|
||||
{
|
||||
name: "/git",
|
||||
description:
|
||||
"Show git status: branch, uncommitted changes, and ahead/behind remote.",
|
||||
},
|
||||
{
|
||||
name: "/overview <number>",
|
||||
description: "Show the implementation summary for a merged story.",
|
||||
},
|
||||
{
|
||||
name: "/rebuild",
|
||||
description: "Rebuild the server binary and restart.",
|
||||
},
|
||||
{
|
||||
name: "/reset",
|
||||
description:
|
||||
"Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
|
||||
},
|
||||
{
|
||||
name: "/btw <question>",
|
||||
description:
|
||||
"Ask a side question using the current conversation as context. The question and answer are not added to the conversation history.",
|
||||
},
|
||||
];
|
||||
@@ -4,6 +4,9 @@ set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
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
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "storkit"
|
||||
version = "0.8.1"
|
||||
version = "0.8.2"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -498,18 +498,63 @@ impl AgentPool {
|
||||
}
|
||||
}
|
||||
|
||||
// Server-owned completion: run acceptance gates automatically
|
||||
// when the agent process exits normally.
|
||||
super::pipeline::run_server_owned_completion(
|
||||
&agents_ref,
|
||||
port_for_task,
|
||||
&sid,
|
||||
&aname,
|
||||
result.session_id,
|
||||
watcher_tx_clone.clone(),
|
||||
)
|
||||
.await;
|
||||
AgentPool::notify_agent_state_changed(&watcher_tx_clone);
|
||||
// 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(
|
||||
&agents_ref,
|
||||
port_for_task,
|
||||
&sid,
|
||||
&aname,
|
||||
result.session_id,
|
||||
watcher_tx_clone.clone(),
|
||||
)
|
||||
.await;
|
||||
AgentPool::notify_agent_state_changed(&watcher_tx_clone);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
slog_error!("[agents] Agent process error for {aname} on {sid}: {e}");
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 -------------------------------------------------------
|
||||
|
||||
|
||||
@@ -108,17 +108,13 @@ 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") {
|
||||
return format!("Failed to clear blocked flag on **{story_id}**: {e}");
|
||||
}
|
||||
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") {
|
||||
return format!("Failed to clear merge_failure on **{story_id}**: {e}");
|
||||
}
|
||||
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).
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Shared test utilities for chat handler tests.
|
||||
//!
|
||||
//! Import with `use crate::chat::test_helpers::write_story_file;`
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Write a work-item file into the standard pipeline directory structure.
|
||||
///
|
||||
/// Creates `.storkit/work/{stage}/{filename}` under `root`, creating any
|
||||
/// missing parent directories.
|
||||
pub(crate) fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
|
||||
let dir = root.join(".storkit/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(filename), content).unwrap();
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,30 +214,54 @@ fn generation_hint(step: WizardStep, project_root: &Path) -> String {
|
||||
}
|
||||
}
|
||||
WizardStep::TestScript => {
|
||||
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();
|
||||
let mut cmds = Vec::new();
|
||||
if has_cargo {
|
||||
cmds.push("cargo nextest run");
|
||||
}
|
||||
if has_pkg {
|
||||
cmds.push(if has_pnpm { "pnpm test" } else { "npm test" });
|
||||
}
|
||||
if cmds.is_empty() {
|
||||
"Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's test suite.".to_string()
|
||||
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 {
|
||||
format!(
|
||||
"Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}",
|
||||
cmds.join(", ")
|
||||
)
|
||||
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();
|
||||
let mut cmds = Vec::new();
|
||||
if has_cargo {
|
||||
cmds.push("cargo nextest run");
|
||||
}
|
||||
if has_pkg {
|
||||
cmds.push(if has_pnpm { "pnpm test" } else { "npm test" });
|
||||
}
|
||||
if cmds.is_empty() {
|
||||
"Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs the project's test suite.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Generate a `script/test` shell script (#!/usr/bin/env bash, set -euo pipefail) that runs: {}",
|
||||
cmds.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
WizardStep::ReleaseScript => {
|
||||
"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()
|
||||
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 => {
|
||||
"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()
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()),
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Shared test utilities for HTTP handler tests.
|
||||
//!
|
||||
//! Import with `use crate::http::test_helpers::{make_api, test_ctx};`
|
||||
|
||||
use crate::http::context::AppContext;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Build an [`AppContext`] rooted at `dir` for use in tests.
|
||||
pub(crate) fn test_ctx(dir: &Path) -> AppContext {
|
||||
AppContext::new_test(dir.to_path_buf())
|
||||
}
|
||||
|
||||
/// Build an API struct rooted in `dir` for use in tests.
|
||||
///
|
||||
/// Requires the API type to implement `From<Arc<AppContext>>`. Add a
|
||||
/// `#[cfg(test)]` impl block to each API struct to opt in.
|
||||
pub(crate) fn make_api<T: From<Arc<AppContext>>>(dir: &TempDir) -> T {
|
||||
Arc::new(test_ctx(dir.path())).into()
|
||||
}
|
||||
@@ -760,6 +760,10 @@ mod tests {
|
||||
content.contains("Never chain shell commands"),
|
||||
"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]
|
||||
|
||||
@@ -5,3 +5,5 @@ pub mod shell;
|
||||
pub mod story_metadata;
|
||||
pub mod watcher;
|
||||
pub mod wizard;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_helpers;
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Shared test utilities for I/O module tests.
|
||||
//!
|
||||
//! Import with `use crate::io::test_helpers::{create_test_files, setup_project};`
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Create a minimal storkit project directory structure under `dir`.
|
||||
///
|
||||
/// Creates `.storkit/specs/tech/` and `script/`, then returns the root path.
|
||||
/// Used by onboarding and wizard tests.
|
||||
pub(crate) fn setup_project(dir: &TempDir) -> PathBuf {
|
||||
let root = dir.path().to_path_buf();
|
||||
let sk = root.join(".storkit");
|
||||
fs::create_dir_all(sk.join("specs").join("tech")).unwrap();
|
||||
fs::create_dir_all(root.join("script")).unwrap();
|
||||
root
|
||||
}
|
||||
|
||||
/// Write a set of files into `dir` at the given relative paths.
|
||||
///
|
||||
/// Parent directories are created automatically. Used by search tests.
|
||||
pub(crate) fn create_test_files(dir: &TempDir, files: &[(&str, &str)]) {
|
||||
for (path, content) in files {
|
||||
let full = dir.path().join(path);
|
||||
if let Some(parent) = full.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(full, content).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -255,15 +255,9 @@ pub fn format_wizard_state(state: &WizardState) -> String {
|
||||
#[cfg(test)]
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user