Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 189 additions & 2 deletions crates/ironclaw_engine/src/runtime/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ impl ConversationManager {
user_id: &str,
thread_config: ThreadConfig,
user_timezone: Option<&str>,
raw_content_for_title: Option<&str>,
extra_initial_metadata: Option<serde_json::Map<String, serde_json::Value>>,
) -> Result<ThreadId, EngineError> {
let conv_arc = self.get_conversation_lock(conversation_id).await?;
Expand Down Expand Up @@ -351,7 +352,10 @@ impl ConversationManager {
// Spawn new foreground thread with conversation history.
// `goal` holds the full message (the orchestrator feeds it as
// the initial user turn); `title` is the short sidebar label.
let title = crate::types::thread::Thread::derive_title_from_message(content);
// For attachment-augmented turns, derive that title from the
// raw user text so engine thread surfaces do not expose the
// synthesized `<attachments>` block or extracted attachment text.
let title = thread_title_from_message_sources(content, raw_content_for_title);
self.thread_manager
.spawn_thread_with_history(
content, // use message as goal
Expand All @@ -372,7 +376,16 @@ impl ConversationManager {
// The user entry is added here — after the thread operation succeeded — to
// prevent orphaned entries if inject_message/resume_thread/spawn_thread_with_history
// returned an error above.
conv.add_entry(ConversationEntry::user(content));
// Record the user entry with a separate `title_source` so downstream
// consumers deriving a conversation title use the raw user text
// instead of the attachment-augmented payload. `content` is still
// the LLM-facing string (augmented with `<attachments>` / extracted
// OCR); only the title-derivation surface changes.
let entry = match raw_content_for_title {
Some(raw) if raw != content => ConversationEntry::user_with_title_source(content, raw),
_ => ConversationEntry::user(content),
};
conv.add_entry(entry);
match active_foreground {
Some(ActiveForeground::Running(_)) => {
// No additional in-memory mutation needed beyond the user entry above.
Expand Down Expand Up @@ -611,6 +624,18 @@ fn build_history_from_entries(
.collect()
}

fn thread_title_from_message_sources(
content: &str,
raw_content_for_title: Option<&str>,
) -> Option<String> {
let Some(raw) = raw_content_for_title else {
return crate::types::thread::Thread::derive_title_from_message(content);
};

crate::types::thread::Thread::derive_title_from_message(raw)
.or_else(|| Some("Untitled chat".to_string()))
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -905,6 +930,7 @@ mod tests {
ThreadConfig::default(),
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -974,6 +1000,7 @@ mod tests {
ThreadConfig::default(),
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -1074,6 +1101,7 @@ mod tests {
ThreadConfig::default(),
None,
None,
None,
)
.await
.unwrap();
Expand Down Expand Up @@ -1124,6 +1152,7 @@ mod tests {
ThreadConfig::default(),
None,
None,
None,
)
.await
});
Expand All @@ -1136,6 +1165,7 @@ mod tests {
ThreadConfig::default(),
None,
None,
None,
)
.await
});
Expand Down Expand Up @@ -1213,6 +1243,7 @@ mod tests {
ThreadConfig::default(),
None,
None,
None,
)
.await
.unwrap();
Expand All @@ -1226,6 +1257,162 @@ mod tests {
);
}

/// Engine-v2 `handle_user_message` must preserve the raw user text
/// on the `ConversationEntry` as `title_source` metadata when the
/// caller supplies attachment-augmented content. This closes the
/// Issue 2 regression where the sidebar / downstream title derivation
/// would see the synthesized `<attachments>` block rather than the
/// user-typed text.
#[tokio::test]
async fn handle_user_message_records_raw_title_source() {
let (_tm, cm) = make_conv_manager();
let conv_id = cm
.get_or_create_conversation("web", "user-raw")
.await
.unwrap();
let project = ProjectId::new();

let raw = "Summarise the attached doc";
let augmented = "Summarise the attached doc\n<attachments>\n<attachment name=\"r.pdf\">Q3 rev $4.2M</attachment>\n</attachments>";

let _tid = cm
.handle_user_message(
conv_id,
augmented,
project,
"user-raw",
ThreadConfig::default(),
None,
Some(raw),
None,
)
.await
.unwrap();

let conv = cm.get_conversation(conv_id).await.unwrap();
let user_entry = conv
.entries
.iter()
.find(|e| matches!(e.sender, crate::types::conversation::EntrySender::User))
.expect("user entry present");
// LLM-facing content stays augmented.
assert_eq!(user_entry.content, augmented);
// Title derivation surface is the raw user text.
let title_source = user_entry
.metadata
.get("title_source")
.and_then(|v| v.as_str());
assert_eq!(
title_source,
Some(raw),
"title_source must be the raw user text, not the augmented payload"
);
}

/// The engine thread's user-visible title must also be derived from raw
/// user text, while `goal` and the LLM-facing first message keep the
/// augmented payload. Otherwise thread-list/detail surfaces that expose
/// `Thread.title` can still leak `<attachments>` blocks even though the
/// conversation-entry title_source is correct.
#[tokio::test]
async fn handle_user_message_uses_raw_title_source_as_thread_title() {
let store = Arc::new(MockStore::new());
let tm = Arc::new(ThreadManager::new(
Arc::new(MockLlm(Mutex::new(vec![LlmOutput {
response: LlmResponse::Text("Hello!".into()),
usage: TokenUsage::default(),
}]))),
Arc::new(MockEffects),
store.clone(),
Arc::new(CapabilityRegistry::new()),
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
));
let cm = ConversationManager::new(Arc::clone(&tm), store.clone());
let conv_id = cm
.get_or_create_conversation("web", "user-goal")
.await
.unwrap();
let project = ProjectId::new();

let raw = "Summarise the attached doc";
let augmented = "Summarise the attached doc\n<attachments>\n<attachment name=\"r.pdf\">Q3 rev $4.2M</attachment>\n</attachments>";

let tid = cm
.handle_user_message(
conv_id,
augmented,
project,
"user-goal",
ThreadConfig::default(),
None,
Some(raw),
None,
)
.await
.unwrap();

let thread = store.load_thread(tid).await.unwrap().expect("thread");
assert_eq!(
thread.goal, augmented,
"thread goal should preserve the execution prompt"
);
assert_eq!(
thread.title.as_deref(),
Some(raw),
"thread title should be display-safe raw text"
);
let first_user_message = thread
.messages
.iter()
.find(|m| m.role == MessageRole::User)
.expect("first user message");
assert_eq!(
first_user_message.content, augmented,
"LLM-facing message must keep attachment context"
);
}

/// Plain-text turns (no augmentation) must NOT stamp a redundant
/// `title_source` — `content` already is the title source.
#[tokio::test]
async fn handle_user_message_plain_text_has_no_title_source_metadata() {
let (_tm, cm) = make_conv_manager();
let conv_id = cm
.get_or_create_conversation("web", "user-plain")
.await
.unwrap();
let project = ProjectId::new();

let plain = "what's the weather";
let _tid = cm
.handle_user_message(
conv_id,
plain,
project,
"user-plain",
ThreadConfig::default(),
None,
Some(plain),
None,
)
.await
.unwrap();

let conv = cm.get_conversation(conv_id).await.unwrap();
let user_entry = conv
.entries
.iter()
.find(|e| matches!(e.sender, crate::types::conversation::EntrySender::User))
.expect("user entry");
assert_eq!(user_entry.content, plain);
assert!(
user_entry.metadata.is_null(),
"plain-text turn must leave metadata unset, got {:?}",
user_entry.metadata
);
}

#[tokio::test]
async fn record_external_agent_message_rejects_wrong_user() {
let (_, cm) = make_conv_manager();
Expand Down
26 changes: 26 additions & 0 deletions crates/ironclaw_engine/src/types/conversation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,32 @@ impl ConversationEntry {
}
}

/// Create a user entry with a separate `title_source` — the raw
/// user-typed text before any attachment augmentation. `content`
/// remains the LLM-facing payload (augmented with attachment blocks /
/// extracted text); `title_source` is recorded in `metadata` so that
/// downstream consumers deriving a sidebar title use the raw text
/// rather than the synthesized attachment block.
pub fn user_with_title_source(
content: impl Into<String>,
title_source: impl Into<String>,
) -> Self {
let title_source = title_source.into();
let metadata = if title_source.is_empty() {
serde_json::Value::Null
} else {
serde_json::json!({ "title_source": title_source })
};
Self {
id: EntryId::new(),
sender: EntrySender::User,
content: content.into(),
origin_thread_id: None,
timestamp: Utc::now(),
metadata,
}
}

/// Create an agent entry from a thread.
pub fn agent(thread_id: ThreadId, content: impl Into<String>) -> Self {
Self {
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_gateway/static/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,8 @@ I18n.register('en', {
// Thread types
'thread.heartbeatAlerts': 'Heartbeat Alerts',
'thread.routine': 'Routine',
'thread.newChat': 'New chat',
'thread.untitled': 'Untitled chat',

// Extensions (dynamic)
'extensions.openingAuth': 'Opening authentication for {name}',
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_gateway/static/i18n/ko.js
Original file line number Diff line number Diff line change
Expand Up @@ -813,6 +813,8 @@ I18n.register('ko', {
// 스레드 유형
'thread.heartbeatAlerts': '하트비트 알림',
'thread.routine': '루틴',
'thread.newChat': '새 대화',
'thread.untitled': '제목 없는 대화',

// 확장 (동적)
'extensions.openingAuth': '{name}에 대한 인증을 여는 중',
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_gateway/static/i18n/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -839,6 +839,8 @@ I18n.register('zh-CN', {
// 线程类型
'thread.heartbeatAlerts': '心跳提醒',
'thread.routine': '定时任务',
'thread.newChat': '新对话',
'thread.untitled': '未命名对话',

// 扩展(动态)
'extensions.openingAuth': '正在为 {name} 打开认证',
Expand Down
4 changes: 2 additions & 2 deletions crates/ironclaw_gateway/static/js/core/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,8 @@ function threadTitle(thread) {
if (thread.thread_type === 'heartbeat') return I18n.t('thread.heartbeatAlerts');
if (thread.thread_type === 'routine') return I18n.t('thread.routine');
if (ch !== 'gateway') return ch.charAt(0).toUpperCase() + ch.slice(1);
if (thread.turn_count === 0) return 'New chat';
return thread.id.substring(0, 8);
if (thread.turn_count === 0) return I18n.t('thread.newChat');
return I18n.t('thread.untitled');
}

function relativeTime(isoStr) {
Expand Down
11 changes: 10 additions & 1 deletion src/agent/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -718,8 +718,16 @@ pub struct Turn {
/// Persisted user message ID when this turn has been written to the DB.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub user_message_id: Option<Uuid>,
/// User input that started this turn.
/// User input that started this turn. On the v1 attachment pipeline
/// this is the *augmented* payload (raw text + synthesized
/// `<attachments>` block) that is fed to the LLM.
pub user_input: String,
/// Raw user-typed text for the turn, before attachment augmentation.
/// Used for sidebar / title derivation so that attachment-only or
/// augmented turns do not surface the synthesized attachment block as
/// the conversation title. `None` for turns that predate this field.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw_user_input: Option<String>,
/// Agent response (if completed).
pub response: Option<String>,
/// Tool calls made during this turn.
Expand Down Expand Up @@ -750,6 +758,7 @@ impl Turn {
turn_number,
user_message_id: None,
user_input: user_input.into(),
raw_user_input: None,
response: None,
tool_calls: Vec::new(),
state: TurnState::Processing,
Expand Down
Loading
Loading