Skip to content

Commit 8c692aa

Browse files
fix: prompt caching with timestamp included (#2560)
1 parent 6279b47 commit 8c692aa

File tree

2 files changed

+87
-41
lines changed

2 files changed

+87
-41
lines changed

crates/chat-cli/src/cli/chat/conversation.rs

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use std::collections::{
66
use std::io::Write;
77
use std::sync::atomic::Ordering;
88

9+
use chrono::Utc;
910
use crossterm::style::Color;
1011
use crossterm::{
1112
execute,
@@ -188,7 +189,7 @@ impl ConversationState {
188189
let Prompt { role, content } = prompt;
189190
match role {
190191
crate::mcp_client::Role::User => {
191-
let user_msg = UserMessage::new_prompt(content.to_string());
192+
let user_msg = UserMessage::new_prompt(content.to_string(), None);
192193
candidate_user.replace(user_msg);
193194
},
194195
crate::mcp_client::Role::Assistant => {
@@ -231,7 +232,7 @@ impl ConversationState {
231232
input
232233
};
233234

234-
let msg = UserMessage::new_prompt(input);
235+
let msg = UserMessage::new_prompt(input, Some(Utc::now()));
235236
self.next_message = Some(msg);
236237
}
237238

@@ -320,14 +321,19 @@ impl ConversationState {
320321

321322
pub fn add_tool_results_with_images(&mut self, tool_results: Vec<ToolUseResult>, images: Vec<ImageBlock>) {
322323
debug_assert!(self.next_message.is_none());
323-
self.next_message = Some(UserMessage::new_tool_use_results_with_images(tool_results, images));
324+
self.next_message = Some(UserMessage::new_tool_use_results_with_images(
325+
tool_results,
326+
images,
327+
Some(Utc::now()),
328+
));
324329
}
325330

326331
/// Sets the next user message with "cancelled" tool results.
327332
pub fn abandon_tool_use(&mut self, tools_to_be_abandoned: &[QueuedTool], deny_input: String) {
328333
self.next_message = Some(UserMessage::new_cancelled_tool_uses(
329334
Some(deny_input),
330335
tools_to_be_abandoned.iter().map(|t| t.id.as_str()),
336+
Some(Utc::now()),
331337
));
332338
}
333339

@@ -502,7 +508,7 @@ impl ConversationState {
502508
}
503509

504510
let conv_state = self.backend_conversation_state(os, false, &mut vec![]).await?;
505-
let mut summary_message = Some(UserMessage::new_prompt(summary_content.clone()));
511+
let mut summary_message = Some(UserMessage::new_prompt(summary_content.clone(), None));
506512

507513
// Create the history according to the passed compact strategy.
508514
let mut history = conv_state.history.cloned().collect::<VecDeque<_>>();
@@ -531,7 +537,7 @@ impl ConversationState {
531537
Ok(FigConversationState {
532538
conversation_id: Some(self.conversation_id.clone()),
533539
user_input_message: summary_message
534-
.unwrap_or(UserMessage::new_prompt(summary_content)) // should not happen
540+
.unwrap_or(UserMessage::new_prompt(summary_content, None)) // should not happen
535541
.into_user_input_message(self.model.clone(), &tools),
536542
history: Some(flatten_history(history.iter())),
537543
})
@@ -614,7 +620,7 @@ impl ConversationState {
614620

615621
if !context_content.is_empty() {
616622
self.context_message_length = Some(context_content.len());
617-
let user = UserMessage::new_prompt(context_content);
623+
let user = UserMessage::new_prompt(context_content, None);
618624
let assistant = AssistantMessage::new_response(None, "I will fully incorporate this information when generating my responses, and explicitly acknowledge relevant parts of the summary when answering questions.".into());
619625
(
620626
Some(vec![HistoryEntry {
@@ -840,6 +846,7 @@ fn enforce_conversation_invariants(
840846
debug!("abandoning tool results");
841847
*next_message = Some(UserMessage::new_prompt(
842848
"The conversation history has overflowed, clearing state".to_string(),
849+
None,
843850
));
844851
}
845852
},
@@ -893,6 +900,7 @@ fn enforce_conversation_invariants(
893900
*user_msg = UserMessage::new_cancelled_tool_uses(
894901
user_msg.prompt().map(|p| p.to_string()),
895902
tool_uses.iter().map(|t| t.id.as_str()),
903+
None,
896904
);
897905
}
898906
}

crates/chat-cli/src/cli/chat/message.rs

Lines changed: 73 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ pub struct UserMessage {
5454
pub additional_context: String,
5555
pub env_context: UserEnvContext,
5656
pub content: UserMessageContent,
57-
pub timestamp: DateTime<Utc>,
57+
pub timestamp: Option<DateTime<Utc>>,
5858
pub images: Option<Vec<ImageBlock>>,
5959
}
6060

@@ -107,20 +107,24 @@ impl UserMessageContent {
107107
impl UserMessage {
108108
/// Creates a new [UserMessage::Prompt], automatically detecting and adding the user's
109109
/// environment [UserEnvContext].
110-
pub fn new_prompt(prompt: String) -> Self {
110+
pub fn new_prompt(prompt: String, timestamp: Option<DateTime<Utc>>) -> Self {
111111
Self {
112112
images: None,
113-
timestamp: Utc::now(),
113+
timestamp,
114114
additional_context: String::new(),
115115
env_context: UserEnvContext::generate_new(),
116116
content: UserMessageContent::Prompt { prompt },
117117
}
118118
}
119119

120-
pub fn new_cancelled_tool_uses<'a>(prompt: Option<String>, tool_use_ids: impl Iterator<Item = &'a str>) -> Self {
120+
pub fn new_cancelled_tool_uses<'a>(
121+
prompt: Option<String>,
122+
tool_use_ids: impl Iterator<Item = &'a str>,
123+
timestamp: Option<DateTime<Utc>>,
124+
) -> Self {
121125
Self {
122126
images: None,
123-
timestamp: Utc::now(),
127+
timestamp,
124128
additional_context: String::new(),
125129
env_context: UserEnvContext::generate_new(),
126130
content: UserMessageContent::CancelledToolUses {
@@ -141,7 +145,7 @@ impl UserMessage {
141145
pub fn new_tool_use_results(results: Vec<ToolUseResult>) -> Self {
142146
Self {
143147
additional_context: String::new(),
144-
timestamp: Utc::now(),
148+
timestamp: None,
145149
env_context: UserEnvContext::generate_new(),
146150
content: UserMessageContent::ToolUseResults {
147151
tool_use_results: results,
@@ -150,10 +154,14 @@ impl UserMessage {
150154
}
151155
}
152156

153-
pub fn new_tool_use_results_with_images(results: Vec<ToolUseResult>, images: Vec<ImageBlock>) -> Self {
157+
pub fn new_tool_use_results_with_images(
158+
results: Vec<ToolUseResult>,
159+
images: Vec<ImageBlock>,
160+
timestamp: Option<DateTime<Utc>>,
161+
) -> Self {
154162
Self {
155163
additional_context: String::new(),
156-
timestamp: Utc::now(),
164+
timestamp,
157165
env_context: UserEnvContext::generate_new(),
158166
content: UserMessageContent::ToolUseResults {
159167
tool_use_results: results,
@@ -278,31 +286,37 @@ impl UserMessage {
278286
}
279287
}
280288

281-
/// Returns a formatted [String] containing [Self::additional_context] and [Self::prompt].
289+
/// Returns a formatted [String] containing [Self::additional_context], [Self::timestamp], and
290+
/// [Self::prompt].
282291
fn content_with_context(&self) -> String {
283-
// Format the time with iso8601 format using Z, e.g. 2025-08-08T17:43:28.672Z
284-
let timestamp = self.timestamp.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
292+
let mut content = String::new();
285293

286-
let prompt_with_timestamp = self.prompt().map(|p| {
287-
format!(
288-
"{}Current UTC time: {}{}{}{}{}",
294+
if let Some(ts) = self.timestamp {
295+
content.push_str(&format!(
296+
"{}Current UTC time: {}{}\n",
289297
CONTEXT_ENTRY_START_HEADER,
290-
timestamp,
298+
// Format the time with iso8601 format using Z, e.g. 2025-08-08T17:43:28.672Z
299+
ts.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
291300
CONTEXT_ENTRY_END_HEADER,
292-
USER_ENTRY_START_HEADER,
293-
p,
294-
USER_ENTRY_END_HEADER
295-
)
296-
});
297-
298-
match (self.additional_context.is_empty(), prompt_with_timestamp) {
299-
// Only add special delimiters if we have both a prompt and additional context
300-
(false, Some(prompt)) => format!("{}\n{}", self.additional_context, prompt),
301-
(true, Some(prompt)) => prompt,
302-
_ => self.additional_context.clone(),
301+
));
302+
}
303+
304+
if !self.additional_context.is_empty() {
305+
content.push_str(&self.additional_context);
306+
content.push('\n');
303307
}
304-
.trim()
305-
.to_string()
308+
309+
// Only add special delimiters around the user's prompt if there is no timestamp or
310+
// additional context to add.
311+
match (content.is_empty(), self.prompt()) {
312+
(false, Some(p)) => {
313+
content.push_str(&format!("{}{}{}", USER_ENTRY_START_HEADER, p, USER_ENTRY_END_HEADER));
314+
},
315+
(true, Some(p)) => content.push_str(p),
316+
_ => (),
317+
};
318+
319+
content.trim().to_string()
306320
}
307321
}
308322

@@ -549,20 +563,44 @@ mod tests {
549563

550564
#[test]
551565
fn test_user_input_message_timestamp_formatting() {
552-
let msg = UserMessage::new_prompt("hello world".to_string());
566+
const USER_PROMPT: &str = "hello world";
567+
568+
let msg = UserMessage::new_prompt(USER_PROMPT.to_string(), Some(Utc::now()));
569+
570+
let msgs = [
571+
msg.clone().into_user_input_message(None, &HashMap::new()),
572+
msg.clone().into_history_entry(),
573+
];
574+
575+
for m in msgs {
576+
println!("checking {:?}", m);
577+
assert!(m.content.contains(CONTEXT_ENTRY_START_HEADER));
578+
assert!(m.content.contains("Current UTC time"));
579+
assert!(m.content.contains(CONTEXT_ENTRY_END_HEADER));
580+
assert!(m.content.contains(USER_ENTRY_START_HEADER));
581+
assert!(m.content.contains(USER_PROMPT));
582+
assert!(m.content.contains(USER_ENTRY_END_HEADER.trim()));
583+
}
584+
}
585+
586+
#[test]
587+
fn test_user_input_message_without_context() {
588+
const USER_PROMPT: &str = "hello world";
589+
590+
let msg = UserMessage::new_prompt(USER_PROMPT.to_string(), None);
553591

554592
let msgs = [
555593
msg.clone().into_user_input_message(None, &HashMap::new()),
556594
msg.clone().into_history_entry(),
557595
];
558596

559597
for m in msgs {
560-
m.content.contains(CONTEXT_ENTRY_START_HEADER);
561-
m.content.contains("Current UTC time");
562-
m.content.contains(CONTEXT_ENTRY_END_HEADER);
563-
m.content.contains(USER_ENTRY_START_HEADER);
564-
m.content.contains("hello world");
565-
m.content.contains(USER_ENTRY_END_HEADER);
598+
assert!(!m.content.contains(CONTEXT_ENTRY_START_HEADER));
599+
assert!(!m.content.contains("Current UTC time"));
600+
assert!(!m.content.contains(CONTEXT_ENTRY_END_HEADER));
601+
assert!(!m.content.contains(USER_ENTRY_START_HEADER));
602+
assert!(m.content.contains(USER_PROMPT));
603+
assert!(!m.content.contains(USER_ENTRY_END_HEADER.trim()));
566604
}
567605
}
568606
}

0 commit comments

Comments
 (0)