story-kit: merge 245_bug_chat_history_persistence_lost_on_page_refresh_story_145_regression

This commit is contained in:
Dave
2026-03-17 14:36:02 +00:00
parent d3786253ef
commit 5a4a2aaa17
2 changed files with 211 additions and 3 deletions

View File

@@ -26,6 +26,8 @@ type WsHandlers = {
) => void; ) => void;
}; };
let capturedWsHandlers: WsHandlers | null = null; let capturedWsHandlers: WsHandlers | null = null;
// Captures the last sendChat call's arguments for assertion.
let lastSendChatArgs: { messages: Message[]; config: unknown } | null = null;
vi.mock("../api/client", () => { vi.mock("../api/client", () => {
const api = { const api = {
@@ -42,7 +44,9 @@ vi.mock("../api/client", () => {
capturedWsHandlers = handlers; capturedWsHandlers = handlers;
} }
close() {} close() {}
sendChat() {} sendChat(messages: Message[], config: unknown) {
lastSendChatArgs = { messages, config };
}
cancel() {} cancel() {}
} }
return { api, ChatWebSocket }; return { api, ChatWebSocket };
@@ -580,6 +584,62 @@ describe("Chat localStorage persistence (Story 145)", () => {
expect(storedAfterRemount).toEqual(history); expect(storedAfterRemount).toEqual(history);
}); });
it("Bug 245: after refresh, sendChat includes full prior history", async () => {
// Step 1: Render, populate messages via onUpdate, then unmount (simulate refresh)
const { unmount } = render(
<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />,
);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
const priorHistory: Message[] = [
{ role: "user", content: "What is Rust?" },
{ role: "assistant", content: "Rust is a systems programming language." },
];
act(() => {
capturedWsHandlers?.onUpdate(priorHistory);
});
// Verify localStorage has the prior history
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "[]");
expect(stored).toEqual(priorHistory);
unmount();
// Step 2: Remount (simulates page reload) — messages load from localStorage
capturedWsHandlers = null;
lastSendChatArgs = null;
render(<Chat projectPath={PROJECT_PATH} onCloseProject={vi.fn()} />);
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
// Verify prior messages are displayed
expect(await screen.findByText("What is Rust?")).toBeInTheDocument();
// Step 3: Send a new message — sendChat should include the full prior history
const input = screen.getByPlaceholderText("Send a message...");
await act(async () => {
fireEvent.change(input, { target: { value: "Tell me more" } });
});
await act(async () => {
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
});
// Verify sendChat was called with ALL prior messages + the new one
expect(lastSendChatArgs).not.toBeNull();
expect(lastSendChatArgs?.messages).toHaveLength(3);
expect(lastSendChatArgs?.messages[0]).toEqual({
role: "user",
content: "What is Rust?",
});
expect(lastSendChatArgs?.messages[1]).toEqual({
role: "assistant",
content: "Rust is a systems programming language.",
});
expect(lastSendChatArgs?.messages[2]).toEqual({
role: "user",
content: "Tell me more",
});
});
it("AC5: uses project-scoped storage key", async () => { it("AC5: uses project-scoped storage key", async () => {
const otherKey = "storykit-chat-history:/other/project"; const otherKey = "storykit-chat-history:/other/project";
localStorage.setItem( localStorage.setItem(

View File

@@ -179,6 +179,44 @@ pub fn set_anthropic_api_key(store: &dyn StoreOps, api_key: String) -> Result<()
set_anthropic_api_key_impl(store, &api_key) set_anthropic_api_key_impl(store, &api_key)
} }
/// Build a prompt for Claude Code that includes prior conversation history.
///
/// When a Claude Code session cannot be resumed (no session_id), we embed
/// the prior messages as a structured preamble so the LLM retains context.
/// If there is only one user message (the current one), the content is
/// returned as-is with no preamble.
fn build_claude_code_context_prompt(messages: &[Message], latest_user_content: &str) -> String {
// Collect prior messages (everything except the trailing user message).
let prior: Vec<&Message> = messages
.iter()
.rev()
.skip(1) // skip the latest user message
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
if prior.is_empty() {
return latest_user_content.to_string();
}
let mut parts = Vec::new();
parts.push("<conversation_history>".to_string());
for msg in &prior {
let label = match msg.role {
Role::User => "User",
Role::Assistant => "Assistant",
Role::Tool => "Tool",
Role::System => continue,
};
parts.push(format!("[{}]: {}", label, msg.content));
}
parts.push("</conversation_history>".to_string());
parts.push(String::new());
parts.push(latest_user_content.to_string());
parts.join("\n")
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub async fn chat<F, U, T, A>( pub async fn chat<F, U, T, A>(
messages: Vec<Message>, messages: Vec<Message>,
@@ -224,13 +262,25 @@ where
if is_claude_code { if is_claude_code {
use crate::llm::providers::claude_code::ClaudeCodeProvider; use crate::llm::providers::claude_code::ClaudeCodeProvider;
let user_message = messages let latest_user_content = messages
.iter() .iter()
.rev() .rev()
.find(|m| m.role == Role::User) .find(|m| m.role == Role::User)
.map(|m| m.content.clone()) .map(|m| m.content.clone())
.ok_or_else(|| "No user message found".to_string())?; .ok_or_else(|| "No user message found".to_string())?;
// When resuming with a session_id, Claude Code loads its own transcript
// from disk — the latest user message is sufficient. Without a
// session_id (e.g. after a page refresh) the prior conversation context
// would be lost because Claude Code only receives a single prompt
// string. In that case, prepend the conversation history so the LLM
// retains full context even though the session cannot be resumed.
let user_message = if config.session_id.is_some() {
latest_user_content
} else {
build_claude_code_context_prompt(&messages, &latest_user_content)
};
let project_root = state let project_root = state
.get_project_root() .get_project_root()
.unwrap_or_else(|_| std::path::PathBuf::from(".")); .unwrap_or_else(|_| std::path::PathBuf::from("."));
@@ -404,7 +454,7 @@ where
} }
Ok(ChatResult { Ok(ChatResult {
messages: new_messages, messages: current_history[2..].to_vec(),
session_id: None, session_id: None,
}) })
} }
@@ -1095,4 +1145,102 @@ mod tests {
let result = execute_tool(&call, &state).await; let result = execute_tool(&call, &state).await;
assert!(result.starts_with("Error:"), "unexpected result: {result}"); assert!(result.starts_with("Error:"), "unexpected result: {result}");
} }
// ---------------------------------------------------------------------------
// build_claude_code_context_prompt (Bug 245)
// ---------------------------------------------------------------------------
#[test]
fn context_prompt_single_message_returns_content_as_is() {
let messages = vec![Message {
role: Role::User,
content: "hello".to_string(),
tool_calls: None,
tool_call_id: None,
}];
let result = build_claude_code_context_prompt(&messages, "hello");
assert_eq!(result, "hello");
}
#[test]
fn context_prompt_includes_prior_conversation() {
let messages = vec![
Message {
role: Role::User,
content: "What is Rust?".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::Assistant,
content: "Rust is a systems language.".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::User,
content: "Tell me more".to_string(),
tool_calls: None,
tool_call_id: None,
},
];
let result = build_claude_code_context_prompt(&messages, "Tell me more");
assert!(
result.contains("<conversation_history>"),
"should have history preamble"
);
assert!(
result.contains("[User]: What is Rust?"),
"should include prior user message"
);
assert!(
result.contains("[Assistant]: Rust is a systems language."),
"should include prior assistant message"
);
assert!(
result.contains("</conversation_history>"),
"should close history block"
);
assert!(
result.ends_with("Tell me more"),
"should end with latest user message"
);
}
#[test]
fn context_prompt_skips_system_messages() {
let messages = vec![
Message {
role: Role::System,
content: "You are a helpful assistant.".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::User,
content: "hi".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::Assistant,
content: "hello".to_string(),
tool_calls: None,
tool_call_id: None,
},
Message {
role: Role::User,
content: "bye".to_string(),
tool_calls: None,
tool_call_id: None,
},
];
let result = build_claude_code_context_prompt(&messages, "bye");
assert!(
!result.contains("helpful assistant"),
"should not include system messages"
);
assert!(result.contains("[User]: hi"));
assert!(result.contains("[Assistant]: hello"));
}
} }