Skip to content
Open
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
134 changes: 133 additions & 1 deletion src/memory/sync/composio/providers/notion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ impl IncrementalSource for NotionSyncPipeline {
state,
)
.await?;
let content = [
let body = [
"/markdown",
"/data/markdown",
"/data/response_data/markdown",
Expand All @@ -151,6 +151,17 @@ impl IncrementalSource for NotionSyncPipeline {
.filter(|value| !value.trim().is_empty())
.map(str::to_owned)
.unwrap_or(serde_json::to_string_pretty(&item.raw)?);
// `NOTION_GET_PAGE_MARKDOWN` returns page *block* content only, so a
// database-row page's structured property values (select / status /
// multi_select / date / …) never appear in `body`. Render them from the
// fetched row and prepend, so the agent reads the real dropdown
// selections instead of inventing them (#5500).
let properties = render_properties(&item.raw);
let content = if properties.is_empty() {
body
} else {
format!("{properties}\n\n{body}")
};
Ok(document(
"notion",
connection_id,
Expand Down Expand Up @@ -191,3 +202,124 @@ fn notion_title(page: &Value) -> Option<String> {
})
.or_else(|| pick_str(page, &["title", "data.title", "name", "data.name"]))
}

/// Render a Notion row's structured database properties into readable
/// `Name: value` lines under a `Properties:` header.
///
/// The sync body comes from `NOTION_GET_PAGE_MARKDOWN`, which returns page
/// *block* content only — a database row's property values
/// (`select`/`status`/`multi_select`/`date`/`people`/`relation`/scalars) are
/// **not** in that markdown. Without this, a tracker page reaches the agent with
/// no dropdown text and the model invents the selections (#5500). Values are
/// read from the already-fetched row (`item.raw`, the same object
/// [`notion_title`] reads), so no extra Composio call is needed. The `title`
/// property is skipped here (it is already the document title); empty / null
/// values are skipped. Returns an empty string when nothing renders.
fn render_properties(page: &Value) -> String {
let Some(properties) = page
.get("properties")
.or_else(|| page.pointer("/data/properties"))
.and_then(Value::as_object)
else {
return String::new();
};
let mut lines: Vec<String> = properties
.iter()
.filter_map(|(name, property)| {
let kind = property.get("type").and_then(Value::as_str)?;
let value = match kind {
// Already surfaced as the document title.
"title" => return None,
"rich_text" => plain_text(property.get("rich_text")),
"select" | "status" => property
.get(kind)
.and_then(|inner| inner.get("name"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
"multi_select" => named_list(property.get("multi_select")),
"people" => named_list(property.get("people")),
"relation" => property
.get("relation")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.get("id").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default(),
"date" => property
.get("date")
.and_then(Value::as_object)
.map(|date| {
let start = date
.get("start")
.and_then(Value::as_str)
.unwrap_or_default();
match date.get("end").and_then(Value::as_str) {
Some(end) if !end.is_empty() => format!("{start} → {end}"),
_ => start.to_string(),
}
})
.unwrap_or_default(),
"checkbox" => match property.get("checkbox").and_then(Value::as_bool) {
Some(true) => "Yes".to_string(),
Some(false) => "No".to_string(),
None => String::new(),
},
"number" => property
.get("number")
.filter(|value| value.is_number())
.map(Value::to_string)
.unwrap_or_default(),
"url" | "email" | "phone_number" => property
.get(kind)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
_ => String::new(),
};
let value = value.trim();
(!value.is_empty()).then(|| format!("{name}: {value}"))
})
.collect();
if lines.is_empty() {
return String::new();
}
// Stable ordering so the synced document is deterministic across runs
// (serde_json preserves object insertion order only with the `preserve_order`
// feature, which is not enabled here).
lines.sort();
format!("Properties:\n{}", lines.join("\n"))
}

/// Join the `plain_text` runs of a Notion rich-text array into a single string.
fn plain_text(value: Option<&Value>) -> String {
value
.and_then(Value::as_array)
.map(|parts| {
parts
.iter()
.filter_map(|part| part.get("plain_text").and_then(Value::as_str))
.collect::<Vec<_>>()
.join("")
})
.unwrap_or_default()
}

/// Comma-join the `name` field of every object in a Notion array property
/// (`multi_select` options, `people` entries).
fn named_list(value: Option<&Value>) -> String {
value
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| item.get("name").and_then(Value::as_str))
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default()
}
81 changes: 81 additions & 0 deletions tests/composio_sync_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,87 @@ async fn notion_fetches_markdown_and_counts_both_requests() {
assert_eq!(state.daily_budget.requests_used, 2);
}

#[tokio::test]
async fn notion_renders_database_row_properties_into_document() {
// #5500: NOTION_GET_PAGE_MARKDOWN returns page *block* content only, so a
// database row's structured property values (status / select / multi_select
// / date) never appear in the markdown. Before the fix the synced document
// was just the markdown body, so the agent could not read the dropdown
// selections and invented them. The row's `properties` must now be rendered
// into the document text alongside the body.
let server = MockServer::start().await;
Mock::given(path("/tools/execute/NOTION_FETCH_DATA"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"successful": true,
"data": {"results": [{
"id": "page-1",
"last_edited_time": "2026-03-01T00:00:00Z",
"properties": {
"Name": {"type": "title", "title": [{"plain_text": "Roadmap"}]},
"Status": {"type": "status", "status": {"name": "In progress"}},
"Priority": {"type": "select", "select": {"name": "High"}},
"Tags": {"type": "multi_select", "multi_select": [
{"name": "infra"}, {"name": "urgent"}
]},
"Due": {"type": "date", "date": {"start": "2026-06-01"}},
// An empty select must be skipped, not rendered blank.
"Owner": {"type": "select", "select": null}
}
}]}
})))
.mount(&server)
.await;
Mock::given(path("/tools/execute/NOTION_GET_PAGE_MARKDOWN"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({"successful": true, "data": {"markdown": "# Roadmap\n\nBody"}}),
))
.mount(&server)
.await;
let (captures, context) = test_context();
let pipeline = NotionSyncPipeline::new(
ComposioClient::new(direct_config(server.uri(), "key")),
"notion-props-conn",
);
pipeline.tick(&test_config(), &context).await.unwrap();

let documents = captures.documents.lock().unwrap();
let content = &documents[0].content;
// Title still comes from the `title` property.
assert_eq!(documents[0].title, "Roadmap");
// Every structured selection reaches the document text …
assert!(
content.contains("Status: In progress"),
"status missing: {content}"
);
assert!(
content.contains("Priority: High"),
"select missing: {content}"
);
assert!(
content.contains("Tags: infra, urgent"),
"multi_select missing: {content}"
);
assert!(
content.contains("Due: 2026-06-01"),
"date missing: {content}"
);
// … the markdown body is preserved …
assert!(
content.contains("# Roadmap\n\nBody"),
"body missing: {content}"
);
// … the title property is not duplicated as a property line …
assert!(
!content.contains("Name: Roadmap"),
"title duplicated: {content}"
);
// … and an empty property is skipped rather than rendered blank.
assert!(
!content.contains("Owner:"),
"empty select rendered: {content}"
);
}

#[tokio::test]
async fn google_docs_fetches_plaintext_and_counts_both_requests() {
let server = MockServer::start().await;
Expand Down