Skip to content

Commit 0771eff

Browse files
authored
Compact tool message history responses (#357)
## Summary - Add compact history response mode for oversized tool messages. - Add a full single-message endpoint for lazy detail loading. - Log compact-list and full-detail metadata for production diagnosis. ## Test Plan - cargo fmt --all -- --check - cargo clippy -p aionui-conversation -p aionui-app -- -D warnings - cargo test -p aionui-conversation t8_6_compact_mode_truncates_large_tool_content_only_for_list_response -- --nocapture - cargo test -p aionui-app t8_2 --test message_e2e -- --nocapture - just push -u origin feat/tool-message-compact --------- Co-authored-by: zynx <>
1 parent 35e62d7 commit 0771eff

8 files changed

Lines changed: 459 additions & 12 deletions

File tree

crates/aionui-api-types/src/conversation.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ pub struct ListMessagesQuery {
7777
pub page: Option<u32>,
7878
pub page_size: Option<u32>,
7979
pub order: Option<String>,
80+
pub content_mode: Option<String>,
8081
}
8182

8283
/// Body for `PATCH /api/conversations/:id/artifacts/:artifact_id`.
@@ -377,15 +378,17 @@ mod tests {
377378
assert!(q.page.is_none());
378379
assert!(q.page_size.is_none());
379380
assert!(q.order.is_none());
381+
assert!(q.content_mode.is_none());
380382
}
381383

382384
#[test]
383385
fn deserialize_messages_query_with_values() {
384-
let raw = json!({ "page": 2, "page_size": 30, "order": "ASC" });
386+
let raw = json!({ "page": 2, "page_size": 30, "order": "ASC", "content_mode": "compact" });
385387
let q: ListMessagesQuery = serde_json::from_value(raw).unwrap();
386388
assert_eq!(q.page, Some(2));
387389
assert_eq!(q.page_size, Some(30));
388390
assert_eq!(q.order.as_deref(), Some("ASC"));
391+
assert_eq!(q.content_mode.as_deref(), Some("compact"));
389392
}
390393

391394
// ── SearchMessagesQuery ─────────────────────────────────────────

crates/aionui-app/tests/message_e2e.rs

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,45 @@ async fn insert_message(
5050
.unwrap();
5151
}
5252

53+
async fn insert_acp_tool_message(
54+
services: &aionui_app::AppServices,
55+
conv_id: &str,
56+
msg_id: &str,
57+
output: &str,
58+
created_at: i64,
59+
) {
60+
let repo = aionui_db::SqliteConversationRepository::new(services.database.pool().clone());
61+
let msg = aionui_db::models::MessageRow {
62+
id: msg_id.into(),
63+
conversation_id: conv_id.into(),
64+
msg_id: Some(msg_id.into()),
65+
r#type: "acp_tool_call".into(),
66+
content: serde_json::json!({
67+
"session_id": "session-1",
68+
"update": {
69+
"session_update": "tool_call",
70+
"tool_call_id": msg_id,
71+
"status": "completed",
72+
"title": "rg",
73+
"kind": "search",
74+
"raw_input": { "pattern": "needle", "path": "." },
75+
"content": [{
76+
"type": "content",
77+
"content": { "type": "text", "text": output }
78+
}]
79+
}
80+
})
81+
.to_string(),
82+
position: Some("left".into()),
83+
status: Some("finish".into()),
84+
hidden: false,
85+
created_at,
86+
};
87+
aionui_db::IConversationRepository::insert_message(&repo, &msg)
88+
.await
89+
.unwrap();
90+
}
91+
5392
async fn upsert_artifact(services: &aionui_app::AppServices, artifact: aionui_db::ConversationArtifactRow) {
5493
let repo = aionui_db::SqliteConversationRepository::new(services.database.pool().clone());
5594
aionui_db::IConversationRepository::upsert_artifact(&repo, &artifact)
@@ -123,6 +162,132 @@ async fn t8_2_messages_pagination() {
123162
assert_eq!(json["data"]["has_more"], false);
124163
}
125164

165+
#[tokio::test]
166+
async fn t8_2b_messages_compact_mode_truncates_large_tool_payload() {
167+
let (mut app, services) = build_app().await;
168+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
169+
let conv_id = create_conversation(&mut app, &token, &csrf, "Compact Tool Conv").await;
170+
let large_output = "match line\n".repeat(10_000);
171+
172+
insert_acp_tool_message(&services, &conv_id, "tool-big", &large_output, 1000).await;
173+
174+
let resp = app
175+
.clone()
176+
.oneshot(get_with_token(
177+
&format!("/api/conversations/{conv_id}/messages?content_mode=compact"),
178+
&token,
179+
))
180+
.await
181+
.unwrap();
182+
assert_eq!(resp.status(), StatusCode::OK);
183+
let json = body_json(resp).await;
184+
let content = &json["data"]["items"][0]["content"];
185+
let preview = content["update"]["content"][0]["content"]["text"].as_str().unwrap();
186+
187+
assert_eq!(content["_compact"]["truncated"], true);
188+
assert!(preview.len() < large_output.len());
189+
assert!(!preview.contains(&large_output));
190+
}
191+
192+
#[tokio::test]
193+
async fn t8_2c_get_message_returns_full_tool_payload() {
194+
let (mut app, services) = build_app().await;
195+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
196+
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Conv").await;
197+
let large_output = "wide rg output\n".repeat(10_000);
198+
199+
insert_acp_tool_message(&services, &conv_id, "tool-detail", &large_output, 1000).await;
200+
201+
let resp = app
202+
.clone()
203+
.oneshot(get_with_token(
204+
&format!("/api/conversations/{conv_id}/messages/tool-detail"),
205+
&token,
206+
))
207+
.await
208+
.unwrap();
209+
assert_eq!(resp.status(), StatusCode::OK);
210+
let json = body_json(resp).await;
211+
212+
assert_eq!(
213+
json["data"]["content"]["update"]["content"][0]["content"]["text"]
214+
.as_str()
215+
.unwrap(),
216+
large_output
217+
);
218+
}
219+
220+
#[tokio::test]
221+
async fn t8_2d_get_message_requires_auth() {
222+
let (mut app, services) = build_app().await;
223+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
224+
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Auth Conv").await;
225+
226+
let resp = app
227+
.oneshot(get_request(&format!(
228+
"/api/conversations/{conv_id}/messages/tool-detail"
229+
)))
230+
.await
231+
.unwrap();
232+
233+
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
234+
}
235+
236+
#[tokio::test]
237+
async fn t8_2e_get_message_not_found_returns_specific_error() {
238+
let (mut app, services) = build_app().await;
239+
let (token, csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
240+
let conv_id = create_conversation(&mut app, &token, &csrf, "Tool Detail Missing Conv").await;
241+
242+
let resp = app
243+
.oneshot(get_with_token(
244+
&format!("/api/conversations/{conv_id}/messages/missing-message"),
245+
&token,
246+
))
247+
.await
248+
.unwrap();
249+
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
250+
let json = body_json(resp).await;
251+
252+
assert_eq!(json["code"], "NOT_FOUND");
253+
assert!(
254+
json["error"]
255+
.as_str()
256+
.unwrap()
257+
.contains("Message missing-message not found")
258+
);
259+
}
260+
261+
#[tokio::test]
262+
async fn t8_2f_get_message_does_not_leak_cross_user_conversation() {
263+
let (mut app, services) = build_app().await;
264+
let (owner_token, owner_csrf) = setup_and_login(&mut app, &services, "admin", "StrongP@ss1").await;
265+
let owner_conv_id = create_conversation(&mut app, &owner_token, &owner_csrf, "Owner Tool Conv").await;
266+
insert_acp_tool_message(&services, &owner_conv_id, "owner-tool", "private output", 1000).await;
267+
268+
let (other_token, _other_csrf) = setup_and_login(&mut app, &services, "other-user", "StrongP@ss2").await;
269+
270+
let resp = app
271+
.oneshot(get_with_token(
272+
&format!("/api/conversations/{owner_conv_id}/messages/owner-tool"),
273+
&other_token,
274+
))
275+
.await
276+
.unwrap();
277+
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
278+
let json = body_json(resp).await;
279+
280+
assert_eq!(json["code"], "NOT_FOUND");
281+
assert!(
282+
json["error"]
283+
.as_str()
284+
.unwrap()
285+
.contains(&format!("Conversation {owner_conv_id} not found"))
286+
);
287+
assert!(!json["error"].as_str().unwrap().contains("owner-tool"));
288+
assert!(!json["error"].as_str().unwrap().contains("private output"));
289+
}
290+
126291
#[tokio::test]
127292
async fn t8_3_messages_order_asc_default() {
128293
let (mut app, services) = build_app().await;

crates/aionui-conversation/src/convert.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ use aionui_common::{
88
use aionui_db::MessageSearchRow;
99
use aionui_db::models::{ConversationArtifactRow, ConversationRow, MessageRow};
1010

11+
pub(crate) const TOOL_CONTENT_COMPACT_THRESHOLD_BYTES: usize = 64 * 1024;
12+
const TOOL_CONTENT_PREVIEW_CHARS: usize = 4096;
13+
1114
/// Convert a database row into an API response DTO.
1215
///
1316
/// Parses string enum fields and JSON text fields back into typed values.
@@ -151,6 +154,58 @@ pub fn row_to_message_response(row: MessageRow) -> Result<MessageResponse, AppEr
151154
})
152155
}
153156

157+
/// Convert a message row for history-list use, compacting oversized tool payloads.
158+
pub fn row_to_message_response_compact(row: MessageRow) -> Result<MessageResponse, AppError> {
159+
let original_size = row.content.len();
160+
let mut response = row_to_message_response(row)?;
161+
if !is_tool_message(response.r#type) || original_size <= TOOL_CONTENT_COMPACT_THRESHOLD_BYTES {
162+
return Ok(response);
163+
}
164+
165+
let mut truncated = false;
166+
truncate_large_strings(&mut response.content, TOOL_CONTENT_PREVIEW_CHARS, &mut truncated);
167+
if truncated && let Some(obj) = response.content.as_object_mut() {
168+
obj.insert(
169+
"_compact".to_string(),
170+
serde_json::json!({
171+
"truncated": true,
172+
"original_size": original_size,
173+
"preview_chars": TOOL_CONTENT_PREVIEW_CHARS
174+
}),
175+
);
176+
}
177+
178+
Ok(response)
179+
}
180+
181+
fn is_tool_message(msg_type: MessageType) -> bool {
182+
matches!(
183+
msg_type,
184+
MessageType::ToolCall | MessageType::ToolGroup | MessageType::AcpToolCall
185+
)
186+
}
187+
188+
fn truncate_large_strings(value: &mut serde_json::Value, max_chars: usize, truncated: &mut bool) {
189+
match value {
190+
serde_json::Value::String(text) if text.chars().count() > max_chars => {
191+
let preview: String = text.chars().take(max_chars).collect();
192+
*text = format!("{preview}\n...[truncated]");
193+
*truncated = true;
194+
}
195+
serde_json::Value::Array(items) => {
196+
for item in items {
197+
truncate_large_strings(item, max_chars, truncated);
198+
}
199+
}
200+
serde_json::Value::Object(map) => {
201+
for entry in map.values_mut() {
202+
truncate_large_strings(entry, max_chars, truncated);
203+
}
204+
}
205+
_ => {}
206+
}
207+
}
208+
154209
/// Convert an artifact database row into an API response DTO.
155210
pub fn row_to_artifact_response(row: ConversationArtifactRow) -> Result<ConversationArtifactResponse, AppError> {
156211
let kind = string_to_enum(&row.kind)?;

crates/aionui-conversation/src/routes.rs

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ use aionui_api_types::{
88
ActiveCountResponse, ApiResponse, ApprovalCheckQuery, ApprovalCheckResponse, CloneConversationRequest,
99
ConfirmRequest, ConfirmationListResponse, ConversationArtifactListResponse, ConversationArtifactResponse,
1010
ConversationListResponse, ConversationResponse, CreateConversationRequest, ListConversationsQuery,
11-
ListMessagesQuery, MessageListResponse, MessageSearchResponse, SearchMessagesQuery, SendMessageRequest,
12-
SendMessageResponse, UpdateConversationArtifactRequest, UpdateConversationRequest,
11+
ListMessagesQuery, MessageListResponse, MessageResponse, MessageSearchResponse, SearchMessagesQuery,
12+
SendMessageRequest, SendMessageResponse, UpdateConversationArtifactRequest, UpdateConversationRequest,
1313
};
1414
use aionui_auth::CurrentUser;
1515
use aionui_common::AppError;
@@ -26,6 +26,7 @@ pub fn conversation_routes(state: ConversationRouterState) -> Router {
2626
.route("/api/conversations/{id}/reset", post(reset))
2727
.route("/api/conversations/{id}/associated", get(associated))
2828
.route("/api/conversations/{id}/messages", get(list_msg).post(send_msg))
29+
.route("/api/conversations/{id}/messages/{messageId}", get(get_msg))
2930
.route("/api/conversations/{id}/artifacts", get(list_artifacts))
3031
.route("/api/conversations/{id}/artifacts/{artifactId}", patch(update_artifact))
3132
.route("/api/conversations/{id}/cancel", post(cancel))
@@ -128,6 +129,25 @@ async fn list_msg(
128129
Ok(Json(ApiResponse::ok(result)))
129130
}
130131

132+
#[derive(serde::Deserialize)]
133+
struct MessagePathParams {
134+
id: String,
135+
#[serde(rename = "messageId")]
136+
message_id: String,
137+
}
138+
139+
async fn get_msg(
140+
State(state): State<ConversationRouterState>,
141+
Extension(user): Extension<CurrentUser>,
142+
Path(params): Path<MessagePathParams>,
143+
) -> Result<Json<ApiResponse<MessageResponse>>, AppError> {
144+
let result = state
145+
.service
146+
.get_message(&user.id, &params.id, &params.message_id)
147+
.await?;
148+
Ok(Json(ApiResponse::ok(result)))
149+
}
150+
131151
async fn send_msg(
132152
State(state): State<ConversationRouterState>,
133153
Extension(user): Extension<CurrentUser>,

0 commit comments

Comments
 (0)