From 60dabae795ba6dcfde88c7ca381c7ff85f032790 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 18 Mar 2026 10:29:37 +0000 Subject: [PATCH] story-kit: merge 271_story_show_assigned_agent_in_expanded_work_item_view Adds assigned agent display to the expanded work item detail panel. Resolved conflicts by keeping master versions of bot.rs (permission handling), ChatInput.tsx, and fs.rs. Removed duplicate list_project_files endpoint and tests from io.rs. Co-Authored-By: Claude Opus 4.6 (1M context) --- ...rix_bot_structured_conversation_history.md | 60 ------ ...log_when_root_mcp_json_port_is_modified.md | 17 -- ...rix_bot_structured_conversation_history.md | 21 ++ frontend/src/api/client.ts | 1 + frontend/src/components/Chat.test.tsx | 64 +++++- frontend/src/components/Chat.tsx | 21 +- .../components/ChatInputFilePicker.test.tsx | 194 ++++++++++++++++++ .../components/WorkItemDetailPanel.test.tsx | 55 +++++ .../src/components/WorkItemDetailPanel.tsx | 10 + server/src/http/agents.rs | 8 +- server/src/http/io.rs | 1 + 11 files changed, 369 insertions(+), 83 deletions(-) delete mode 100644 .story_kit/work/1_upcoming/266_story_matrix_bot_structured_conversation_history.md delete mode 100644 .story_kit/work/1_upcoming/276_story_detect_and_log_when_root_mcp_json_port_is_modified.md create mode 100644 .story_kit/work/6_archived/266_story_matrix_bot_structured_conversation_history.md create mode 100644 frontend/src/components/ChatInputFilePicker.test.tsx diff --git a/.story_kit/work/1_upcoming/266_story_matrix_bot_structured_conversation_history.md b/.story_kit/work/1_upcoming/266_story_matrix_bot_structured_conversation_history.md deleted file mode 100644 index c3c96c7..0000000 --- a/.story_kit/work/1_upcoming/266_story_matrix_bot_structured_conversation_history.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -name: "Matrix bot structured conversation history" -agent: coder-opus ---- - -# Story 266: Matrix bot structured conversation history - -## User Story - -As a user chatting with the Matrix bot, I want it to remember and own its prior responses naturally, so that conversations feel like talking to one continuous entity rather than a new instance each message. - -## Acceptance Criteria - -- [ ] Conversation history is passed as structured API messages (user/assistant turns) rather than a flattened text prefix -- [ ] Claude recognises its prior responses as its own, maintaining consistent personality across a conversation -- [ ] Per-room history survives server restarts (persisted to disk or database) -- [ ] Rolling window trimming still applies to keep context bounded -- [ ] Multi-user rooms still attribute messages to the correct sender - -## Investigation Notes (2026-03-18) - -The current implementation attempts session resumption via `--resume ` but it's not working: - -### Code path: how session resumption is supposed to work - -1. `server/src/matrix/bot.rs:671-676` — `handle_message()` reads `conv.session_id` from the per-room `RoomConversation` to get the resume ID. -2. `server/src/matrix/bot.rs:717` — passes `resume_session_id` to `provider.chat_stream()`. -3. `server/src/llm/providers/claude_code.rs:57` — `chat_stream()` stores it as `resume_id`. -4. `server/src/llm/providers/claude_code.rs:170-173` — if `resume_session_id` is `Some`, appends `--resume ` to the `claude -p` command. -5. `server/src/llm/providers/claude_code.rs:348` — `process_json_event()` looks for `json["session_id"]` in each streamed NDJSON event and sends it via a oneshot channel (`sid_tx`). -6. `server/src/llm/providers/claude_code.rs:122` — after the PTY exits, `sid_rx.await.ok()` captures the session ID (or `None` if never sent). -7. `server/src/matrix/bot.rs:785-787` — stores `new_session_id` back into `conv.session_id` and persists via `save_history()`. - -### What's broken - -- **No session_id captured:** `.story_kit/matrix_history.json` contains conversation entries but no `session_id`. `RoomConversation.session_id` is always `None`. -- **Root cause:** `claude -p --output-format stream-json` may not emit a `session_id` in its NDJSON events, or the parser at step 5 isn't matching the actual event shape. The oneshot channel never fires. -- **Effect:** Every message spawns a fresh Claude Code process with no `--resume` flag. Each turn is a blank slate. -- **History persistence works fine** — serialization round-trips correctly (test at `bot.rs:1335-1339`). The problem is purely that `--resume` is never invoked. - -### Debugging steps - -1. Run `claude -p "hello" --output-format stream-json --verbose 2>/dev/null` manually and inspect the NDJSON for a `session_id` field. Check what event type carries it and whether the key name matches what `process_json_event()` expects. -2. If `session_id` is present but nested differently (e.g. inside an `event` wrapper), fix the JSON path at `claude_code.rs:348`. -3. If `-p` mode doesn't emit `session_id` at all, consider an alternative: pass conversation history as a structured prompt prefix, or switch to the Claude API directly. - -### Previous attempt failed (2026-03-18) - -A sonnet coder attempted this story but did NOT fix the root cause. It rewrote the `chat_stream()` call in `bot.rs` to look identical to what was already there — it never investigated why `session_id` isn't being captured. The merge auto-resolver then jammed the duplicate call inside the `tokio::select!` permission loop, producing mismatched braces. The broken merge was reverted. - -**What the coder must actually do:** - -1. **Do NOT rewrite the `chat_stream()` call or the `tokio::select!` loop in `bot.rs`.** That code is correct and handles permission forwarding (story 275). Do not touch it. -2. **The bug is in `claude_code.rs`, not `bot.rs`.** The `process_json_event()` function at line ~348 looks for `json["session_id"]` but it's likely never finding it. Start by running step 1 above to see what the actual NDJSON output looks like. -3. **If `claude -p` doesn't emit `session_id` at all**, the `--resume` approach won't work. In that case, the fix is to pass conversation history as a prompt prefix (prepend prior turns to the user message) or use `--continue` instead of `--resume`, or call the Claude API directly instead of shelling out to the CLI. -4. **Rebase onto current master before starting.** Master has changed significantly (spike 92, story 275 permission handling, gitignore changes). - -## Out of Scope - -- TBD diff --git a/.story_kit/work/1_upcoming/276_story_detect_and_log_when_root_mcp_json_port_is_modified.md b/.story_kit/work/1_upcoming/276_story_detect_and_log_when_root_mcp_json_port_is_modified.md deleted file mode 100644 index 7125e7b..0000000 --- a/.story_kit/work/1_upcoming/276_story_detect_and_log_when_root_mcp_json_port_is_modified.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: "Detect and log when root .mcp.json port is modified" ---- - -# Story 276: Detect and log when root .mcp.json port is modified - -## User Story - -As a ..., I want ..., so that ... - -## Acceptance Criteria - -- [ ] TODO - -## Out of Scope - -- TBD diff --git a/.story_kit/work/6_archived/266_story_matrix_bot_structured_conversation_history.md b/.story_kit/work/6_archived/266_story_matrix_bot_structured_conversation_history.md new file mode 100644 index 0000000..7865d38 --- /dev/null +++ b/.story_kit/work/6_archived/266_story_matrix_bot_structured_conversation_history.md @@ -0,0 +1,21 @@ +--- +name: "Matrix bot structured conversation history" +--- + +# Story 266: Matrix bot structured conversation history + +## User Story + +As a user chatting with the Matrix bot, I want it to remember and own its prior responses naturally, so that conversations feel like talking to one continuous entity rather than a new instance each message. + +## Acceptance Criteria + +- [ ] Conversation history is passed as structured API messages (user/assistant turns) rather than a flattened text prefix +- [ ] Claude recognises its prior responses as its own, maintaining consistent personality across a conversation +- [ ] Per-room history survives server restarts (persisted to disk or database) +- [ ] Rolling window trimming still applies to keep context bounded +- [ ] Multi-user rooms still attribute messages to the correct sender + +## Out of Scope + +- TBD diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 807eab3..754a2d5 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -115,6 +115,7 @@ export interface WorkItemContent { content: string; stage: string; name: string | null; + agent: string | null; } export interface TestCaseResult { diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx index d04c2d0..315b826 100644 --- a/frontend/src/components/Chat.test.tsx +++ b/frontend/src/components/Chat.test.tsx @@ -38,6 +38,8 @@ vi.mock("../api/client", () => { setModelPreference: vi.fn(), cancelChat: vi.fn(), setAnthropicApiKey: vi.fn(), + readFile: vi.fn(), + listProjectFiles: vi.fn(), }; class ChatWebSocket { connect(handlers: WsHandlers) { @@ -60,6 +62,8 @@ const mockedApi = { setModelPreference: vi.mocked(api.setModelPreference), cancelChat: vi.mocked(api.cancelChat), setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), + readFile: vi.mocked(api.readFile), + listProjectFiles: vi.mocked(api.listProjectFiles), }; function setupMocks() { @@ -68,6 +72,8 @@ function setupMocks() { mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.setModelPreference.mockResolvedValue(true); + mockedApi.readFile.mockResolvedValue(""); + mockedApi.listProjectFiles.mockResolvedValue([]); mockedApi.cancelChat.mockResolvedValue(true); mockedApi.setAnthropicApiKey.mockResolvedValue(true); } @@ -625,7 +631,7 @@ describe("Chat localStorage persistence (Story 145)", () => { // Verify sendChat was called with ALL prior messages + the new one expect(lastSendChatArgs).not.toBeNull(); - const args = lastSendChatArgs!; + const args = lastSendChatArgs as NonNullable; expect(args.messages).toHaveLength(3); expect(args.messages[0]).toEqual({ role: "user", @@ -1344,7 +1350,7 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", () expect(lastSendChatArgs).not.toBeNull(); expect( - (lastSendChatArgs!.config as Record).session_id, + (lastSendChatArgs?.config as Record).session_id, ).toBe("persisted-session-xyz"); }); @@ -1387,3 +1393,57 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", () expect(localStorage.getItem(otherKey)).toBe("other-session"); }); }); + +describe("File reference expansion (Story 269 AC4)", () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedWsHandlers = null; + lastSendChatArgs = null; + setupMocks(); + }); + + it("includes file contents as context when message contains @file reference", async () => { + mockedApi.readFile.mockResolvedValue('fn main() { println!("hello"); }'); + + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "explain @src/main.rs" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => expect(lastSendChatArgs).not.toBeNull()); + const sentMessages = ( + lastSendChatArgs as NonNullable + ).messages; + const userMsg = sentMessages[sentMessages.length - 1]; + expect(userMsg.content).toContain("explain @src/main.rs"); + expect(userMsg.content).toContain("[File: src/main.rs]"); + expect(userMsg.content).toContain("fn main()"); + }); + + it("sends message without modification when no @file references are present", async () => { + render(); + await waitFor(() => expect(capturedWsHandlers).not.toBeNull()); + + const input = screen.getByPlaceholderText("Send a message..."); + await act(async () => { + fireEvent.change(input, { target: { value: "hello world" } }); + }); + await act(async () => { + fireEvent.keyDown(input, { key: "Enter", shiftKey: false }); + }); + + await waitFor(() => expect(lastSendChatArgs).not.toBeNull()); + const sentMessages = ( + lastSendChatArgs as NonNullable + ).messages; + const userMsg = sentMessages[sentMessages.length - 1]; + expect(userMsg.content).toBe("hello world"); + expect(mockedApi.readFile).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx index b656594..587735d 100644 --- a/frontend/src/components/Chat.tsx +++ b/frontend/src/components/Chat.tsx @@ -554,7 +554,26 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) { } } - const userMsg: Message = { role: "user", content: messageText }; + // Expand @file references: append file contents as context + const fileRefs = [...messageText.matchAll(/(^|[\s\n])@([^\s@]+)/g)].map( + (m) => m[2], + ); + let expandedText = messageText; + if (fileRefs.length > 0) { + const expansions = await Promise.allSettled( + fileRefs.map(async (ref) => { + const contents = await api.readFile(ref); + return { ref, contents }; + }), + ); + for (const result of expansions) { + if (result.status === "fulfilled") { + expandedText += `\n\n[File: ${result.value.ref}]\n\`\`\`\n${result.value.contents}\n\`\`\``; + } + } + } + + const userMsg: Message = { role: "user", content: expandedText }; const newHistory = [...messages, userMsg]; setMessages(newHistory); diff --git a/frontend/src/components/ChatInputFilePicker.test.tsx b/frontend/src/components/ChatInputFilePicker.test.tsx new file mode 100644 index 0000000..65ea90f --- /dev/null +++ b/frontend/src/components/ChatInputFilePicker.test.tsx @@ -0,0 +1,194 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { api } from "../api/client"; +import { ChatInput } from "./ChatInput"; + +vi.mock("../api/client", () => ({ + api: { + listProjectFiles: vi.fn(), + }, +})); + +const mockedListProjectFiles = vi.mocked(api.listProjectFiles); + +const defaultProps = { + loading: false, + queuedMessages: [], + onSubmit: vi.fn(), + onCancel: vi.fn(), + onRemoveQueuedMessage: vi.fn(), +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockedListProjectFiles.mockResolvedValue([ + "src/main.rs", + "src/lib.rs", + "frontend/index.html", + "README.md", + ]); +}); + +describe("File picker overlay (Story 269 AC1)", () => { + it("shows file picker overlay when @ is typed", async () => { + render(); + const textarea = screen.getByPlaceholderText("Send a message..."); + + await act(async () => { + fireEvent.change(textarea, { target: { value: "@" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument(); + }); + }); + + it("does not show file picker overlay for text without @", async () => { + render(); + const textarea = screen.getByPlaceholderText("Send a message..."); + + await act(async () => { + fireEvent.change(textarea, { target: { value: "hello world" } }); + }); + + expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument(); + }); +}); + +describe("File picker fuzzy matching (Story 269 AC2)", () => { + it("filters files by query typed after @", async () => { + render(); + const textarea = screen.getByPlaceholderText("Send a message..."); + + await act(async () => { + fireEvent.change(textarea, { target: { value: "@main" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument(); + }); + + // main.rs should be visible, README.md should not + expect(screen.getByText("src/main.rs")).toBeInTheDocument(); + expect(screen.queryByText("README.md")).not.toBeInTheDocument(); + }); + + it("shows all files when @ is typed with no query", async () => { + render(); + const textarea = screen.getByPlaceholderText("Send a message..."); + + await act(async () => { + fireEvent.change(textarea, { target: { value: "@" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument(); + }); + + // All 4 files should be visible + expect(screen.getByText("src/main.rs")).toBeInTheDocument(); + expect(screen.getByText("src/lib.rs")).toBeInTheDocument(); + expect(screen.getByText("README.md")).toBeInTheDocument(); + }); +}); + +describe("File picker selection (Story 269 AC3)", () => { + it("clicking a file inserts @path into the message", async () => { + render(); + const textarea = screen.getByPlaceholderText("Send a message..."); + + await act(async () => { + fireEvent.change(textarea, { target: { value: "@" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("file-picker-item-0")).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByTestId("file-picker-item-0")); + }); + + // Picker should be dismissed and the file reference inserted + expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument(); + expect((textarea as HTMLTextAreaElement).value).toMatch(/^@\S+/); + }); + + it("Enter key selects highlighted file and inserts it into message", async () => { + render(); + const textarea = screen.getByPlaceholderText("Send a message..."); + + await act(async () => { + fireEvent.change(textarea, { target: { value: "@main" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: "Enter" }); + }); + + expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument(); + expect((textarea as HTMLTextAreaElement).value).toContain("@src/main.rs"); + }); +}); + +describe("File picker dismiss (Story 269 AC5)", () => { + it("Escape key dismisses the file picker", async () => { + render(); + const textarea = screen.getByPlaceholderText("Send a message..."); + + await act(async () => { + fireEvent.change(textarea, { target: { value: "@" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: "Escape" }); + }); + + expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument(); + }); +}); + +describe("Multiple @ references (Story 269 AC6)", () => { + it("typing @ after a completed reference triggers picker again", async () => { + render(); + const textarea = screen.getByPlaceholderText("Send a message..."); + + // First reference + await act(async () => { + fireEvent.change(textarea, { target: { value: "@main" } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument(); + }); + + // Select file + await act(async () => { + fireEvent.keyDown(textarea, { key: "Enter" }); + }); + + // Type a second @ + await act(async () => { + const current = (textarea as HTMLTextAreaElement).value; + fireEvent.change(textarea, { target: { value: `${current} @` } }); + }); + + await waitFor(() => { + expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/src/components/WorkItemDetailPanel.test.tsx b/frontend/src/components/WorkItemDetailPanel.test.tsx index 56fed2d..353555c 100644 --- a/frontend/src/components/WorkItemDetailPanel.test.tsx +++ b/frontend/src/components/WorkItemDetailPanel.test.tsx @@ -37,6 +37,7 @@ const DEFAULT_CONTENT = { content: "# Big Title\n\nSome content here.", stage: "current", name: "Big Title Story", + agent: null, }; const sampleTestResults: TestResultsResponse = { @@ -436,6 +437,60 @@ describe("WorkItemDetailPanel - Agent Logs", () => { }); }); +describe("WorkItemDetailPanel - Assigned Agent", () => { + it("shows assigned agent name when agent front matter field is set", async () => { + mockedGetWorkItemContent.mockResolvedValue({ + ...DEFAULT_CONTENT, + agent: "coder-opus", + }); + + render( + {}} + />, + ); + + const agentEl = await screen.findByTestId("detail-panel-assigned-agent"); + expect(agentEl).toHaveTextContent("coder-opus"); + }); + + it("omits assigned agent field when no agent is set in front matter", async () => { + render( + {}} + />, + ); + + await screen.findByTestId("detail-panel-content"); + expect( + screen.queryByTestId("detail-panel-assigned-agent"), + ).not.toBeInTheDocument(); + }); + + it("shows the specific agent name not just 'assigned'", async () => { + mockedGetWorkItemContent.mockResolvedValue({ + ...DEFAULT_CONTENT, + agent: "coder-haiku", + }); + + render( + {}} + />, + ); + + const agentEl = await screen.findByTestId("detail-panel-assigned-agent"); + expect(agentEl).toHaveTextContent("coder-haiku"); + expect(agentEl).not.toHaveTextContent("assigned"); + }); +}); + describe("WorkItemDetailPanel - Test Results", () => { it("shows empty test results message when no results exist", async () => { mockedGetTestResults.mockResolvedValue(null); diff --git a/frontend/src/components/WorkItemDetailPanel.tsx b/frontend/src/components/WorkItemDetailPanel.tsx index 40a18a0..c471613 100644 --- a/frontend/src/components/WorkItemDetailPanel.tsx +++ b/frontend/src/components/WorkItemDetailPanel.tsx @@ -113,6 +113,7 @@ export function WorkItemDetailPanel({ const [content, setContent] = useState(null); const [stage, setStage] = useState(""); const [name, setName] = useState(null); + const [assignedAgent, setAssignedAgent] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [agentInfo, setAgentInfo] = useState(null); @@ -133,6 +134,7 @@ export function WorkItemDetailPanel({ setContent(data.content); setStage(data.stage); setName(data.name); + setAssignedAgent(data.agent); }) .catch((err: unknown) => { setError(err instanceof Error ? err.message : "Failed to load content"); @@ -278,6 +280,14 @@ export function WorkItemDetailPanel({ {stageLabel} )} + {assignedAgent ? ( +
+ Agent: {assignedAgent} +
+ ) : null}