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) <noreply@anthropic.com>
This commit is contained in:
Dave
2026-03-18 10:29:37 +00:00
parent 1bae7bd223
commit 60dabae795
11 changed files with 369 additions and 83 deletions

View File

@@ -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 <session_id>` 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 <id>` 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

View File

@@ -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

View File

@@ -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

View File

@@ -115,6 +115,7 @@ export interface WorkItemContent {
content: string; content: string;
stage: string; stage: string;
name: string | null; name: string | null;
agent: string | null;
} }
export interface TestCaseResult { export interface TestCaseResult {

View File

@@ -38,6 +38,8 @@ vi.mock("../api/client", () => {
setModelPreference: vi.fn(), setModelPreference: vi.fn(),
cancelChat: vi.fn(), cancelChat: vi.fn(),
setAnthropicApiKey: vi.fn(), setAnthropicApiKey: vi.fn(),
readFile: vi.fn(),
listProjectFiles: vi.fn(),
}; };
class ChatWebSocket { class ChatWebSocket {
connect(handlers: WsHandlers) { connect(handlers: WsHandlers) {
@@ -60,6 +62,8 @@ const mockedApi = {
setModelPreference: vi.mocked(api.setModelPreference), setModelPreference: vi.mocked(api.setModelPreference),
cancelChat: vi.mocked(api.cancelChat), cancelChat: vi.mocked(api.cancelChat),
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey), setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
readFile: vi.mocked(api.readFile),
listProjectFiles: vi.mocked(api.listProjectFiles),
}; };
function setupMocks() { function setupMocks() {
@@ -68,6 +72,8 @@ function setupMocks() {
mockedApi.getAnthropicModels.mockResolvedValue([]); mockedApi.getAnthropicModels.mockResolvedValue([]);
mockedApi.getModelPreference.mockResolvedValue(null); mockedApi.getModelPreference.mockResolvedValue(null);
mockedApi.setModelPreference.mockResolvedValue(true); mockedApi.setModelPreference.mockResolvedValue(true);
mockedApi.readFile.mockResolvedValue("");
mockedApi.listProjectFiles.mockResolvedValue([]);
mockedApi.cancelChat.mockResolvedValue(true); mockedApi.cancelChat.mockResolvedValue(true);
mockedApi.setAnthropicApiKey.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 // Verify sendChat was called with ALL prior messages + the new one
expect(lastSendChatArgs).not.toBeNull(); expect(lastSendChatArgs).not.toBeNull();
const args = lastSendChatArgs!; const args = lastSendChatArgs as NonNullable<typeof lastSendChatArgs>;
expect(args.messages).toHaveLength(3); expect(args.messages).toHaveLength(3);
expect(args.messages[0]).toEqual({ expect(args.messages[0]).toEqual({
role: "user", role: "user",
@@ -1344,7 +1350,7 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", ()
expect(lastSendChatArgs).not.toBeNull(); expect(lastSendChatArgs).not.toBeNull();
expect( expect(
(lastSendChatArgs!.config as Record<string, unknown>).session_id, (lastSendChatArgs?.config as Record<string, unknown>).session_id,
).toBe("persisted-session-xyz"); ).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"); 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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "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<typeof lastSendChatArgs>
).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(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "hello world" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
const sentMessages = (
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
).messages;
const userMsg = sentMessages[sentMessages.length - 1];
expect(userMsg.content).toBe("hello world");
expect(mockedApi.readFile).not.toHaveBeenCalled();
});
});

View File

@@ -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]; const newHistory = [...messages, userMsg];
setMessages(newHistory); setMessages(newHistory);

View File

@@ -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(<ChatInput {...defaultProps} />);
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(<ChatInput {...defaultProps} />);
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(<ChatInput {...defaultProps} />);
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(<ChatInput {...defaultProps} />);
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(<ChatInput {...defaultProps} />);
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(<ChatInput {...defaultProps} />);
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(<ChatInput {...defaultProps} />);
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(<ChatInput {...defaultProps} />);
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();
});
});
});

View File

@@ -37,6 +37,7 @@ const DEFAULT_CONTENT = {
content: "# Big Title\n\nSome content here.", content: "# Big Title\n\nSome content here.",
stage: "current", stage: "current",
name: "Big Title Story", name: "Big Title Story",
agent: null,
}; };
const sampleTestResults: TestResultsResponse = { 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(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
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(
<WorkItemDetailPanel
storyId="271_story_test"
pipelineVersion={0}
onClose={() => {}}
/>,
);
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
expect(agentEl).toHaveTextContent("coder-haiku");
expect(agentEl).not.toHaveTextContent("assigned");
});
});
describe("WorkItemDetailPanel - Test Results", () => { describe("WorkItemDetailPanel - Test Results", () => {
it("shows empty test results message when no results exist", async () => { it("shows empty test results message when no results exist", async () => {
mockedGetTestResults.mockResolvedValue(null); mockedGetTestResults.mockResolvedValue(null);

View File

@@ -113,6 +113,7 @@ export function WorkItemDetailPanel({
const [content, setContent] = useState<string | null>(null); const [content, setContent] = useState<string | null>(null);
const [stage, setStage] = useState<string>(""); const [stage, setStage] = useState<string>("");
const [name, setName] = useState<string | null>(null); const [name, setName] = useState<string | null>(null);
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null); const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
@@ -133,6 +134,7 @@ export function WorkItemDetailPanel({
setContent(data.content); setContent(data.content);
setStage(data.stage); setStage(data.stage);
setName(data.name); setName(data.name);
setAssignedAgent(data.agent);
}) })
.catch((err: unknown) => { .catch((err: unknown) => {
setError(err instanceof Error ? err.message : "Failed to load content"); setError(err instanceof Error ? err.message : "Failed to load content");
@@ -278,6 +280,14 @@ export function WorkItemDetailPanel({
{stageLabel} {stageLabel}
</div> </div>
)} )}
{assignedAgent ? (
<div
data-testid="detail-panel-assigned-agent"
style={{ fontSize: "0.75em", color: "#888" }}
>
Agent: {assignedAgent}
</div>
) : null}
</div> </div>
<button <button
type="button" type="button"

View File

@@ -68,6 +68,7 @@ struct WorkItemContentResponse {
content: String, content: String,
stage: String, stage: String,
name: Option<String>, name: Option<String>,
agent: Option<String>,
} }
/// A single test case result for the OpenAPI response. /// A single test case result for the OpenAPI response.
@@ -354,13 +355,14 @@ impl AgentsApi {
if file_path.exists() { if file_path.exists() {
let content = std::fs::read_to_string(&file_path) let content = std::fs::read_to_string(&file_path)
.map_err(|e| bad_request(format!("Failed to read work item: {e}")))?; .map_err(|e| bad_request(format!("Failed to read work item: {e}")))?;
let name = crate::io::story_metadata::parse_front_matter(&content) let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
.ok() let name = metadata.as_ref().and_then(|m| m.name.clone());
.and_then(|m| m.name); let agent = metadata.and_then(|m| m.agent);
return Ok(Json(WorkItemContentResponse { return Ok(Json(WorkItemContentResponse {
content, content,
stage: stage_name.to_string(), stage: stage_name.to_string(),
name, name,
agent,
})); }));
} }
} }

View File

@@ -401,4 +401,5 @@ mod tests {
let result = api.list_directory(payload).await; let result = api.list_directory(payload).await;
assert!(result.is_err()); assert!(result.is_err());
} }
} }