Skip to content
Merged
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ User-facing release notes for RelayHistory. Every public package — the `ai-his

This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Before 1.0, a breaking change is a minor release.

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- Devin remote capture skips provider-classified `code_review` and `code_review_and_analysis` sessions only when their origin is `automation`; person-started reviews and other work remain discoverable.

## [0.45.2] - 2026-10-17

Expand Down
31 changes: 28 additions & 3 deletions plugins/provider-sources/rust/src/remote/devin_cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,31 @@ fn ensure_devin_status(what: &str, scope: &str, status: u16, body: &str) -> Resu
}
}

/// `category` values the v3 listing assigns to code-review work. Cognition's
/// review sessions — Devin Review and push-review automations — run inside the
/// org as `automation`-origin sessions, so a review category only skips a row
/// when automation spawned it: a person-directed session whose work Cognition
/// classifies as review still belongs in the catalog.
const REVIEW_CATEGORIES: [&str; 2] = ["code_review", "code_review_and_analysis"];

/// The absolute fetch bound on one session-list enumeration. Pages where
/// every item is filtered out — review automations — still count toward this
/// backstop, so the walk always terminates; only yielding pages count toward
/// `MAX_LIST_PAGES`, the shared bound that bounds catalogable results.
const MAX_LIST_FETCHES: usize = MAX_LIST_PAGES * 4;

/// Map one session object from `GET /v3/organizations/{org}/sessions` into a
/// catalog row. The listing's `title` is the only human-readable identifier
/// the endpoint offers; `None` for a malformed id.
/// the endpoint offers; `None` for a malformed id or a review session, which
/// is an automation artifact rather than work a person directed.
pub(super) fn map_devin_session(value: &Value) -> Option<(Candidate, ShallowSession)> {
if string_field(value, "origin").as_deref() == Some("automation")
&& REVIEW_CATEGORIES
.iter()
.any(|category| string_field(value, "category").as_deref() == Some(category))
{
return None;
}
Comment thread
cursor[bot] marked this conversation as resolved.
let id = string_field(value, "session_id")?;
let id = devin_session_hex(&id)?.to_string();
let created_ms = value
Expand Down Expand Up @@ -467,7 +488,8 @@ impl ShallowSessionProvider for DevinCloudProvider {
let mut candidates = Vec::new();
let mut rows = BTreeMap::new();
let mut cursor: Option<String> = None;
for _ in 0..MAX_LIST_PAGES {
let mut yielding_pages = 0usize;
for _ in 0..MAX_LIST_FETCHES {
let page_limit = match self.limit {
Some(limit) => limit
.saturating_sub(candidates.len())
Expand All @@ -483,15 +505,18 @@ impl ShallowSessionProvider for DevinCloudProvider {
&response.body,
)?;
let page = parse_devin_page(&response.body, "session list")?;
let before = candidates.len();
for item in &page.items {
if let Some((candidate, session)) = map_devin_session(item) {
rows.insert(candidate.locator.clone(), session);
candidates.push(candidate);
}
}
yielding_pages += usize::from(candidates.len() > before);
cursor = page.end_cursor.filter(|_| page.has_next_page);
let done = cursor.is_none()
|| page.items.is_empty()
|| yielding_pages >= MAX_LIST_PAGES
|| self.limit.is_some_and(|limit| candidates.len() >= limit);
if done {
break;
Expand All @@ -502,7 +527,7 @@ impl ShallowSessionProvider for DevinCloudProvider {
// caller-specified limit that was reached is not truncation.
anyhow::ensure!(
cursor.is_none() || self.limit.is_some_and(|limit| candidates.len() >= limit),
"EVIDENCE_PARTIAL: Devin session list exceeded the 100-page bound"
"EVIDENCE_PARTIAL: Devin session list exceeded the page bound"
);
*self.fetched.lock().expect("remote connector row cache") = rows;
Ok(candidates)
Expand Down
89 changes: 89 additions & 0 deletions plugins/provider-sources/rust/src/remote/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1019,6 +1019,57 @@ fn devin_mapping_stamp_tracks_the_mapped_listing_fields() {
assert_eq!(base.0.stamp, identical.0.stamp);
}

#[test]
fn devin_mapping_skips_review_sessions_and_keeps_uncategorized_work() {
// Devin Review and push-review automations are `automation`-origin
// sessions the classifier files under the review categories; they carry
// no conversational history worth keeping.
for category in ["code_review", "code_review_and_analysis"] {
let mut entry = devin_session(DEVIN_SESSION_ID, "Review this push");
entry["category"] = serde_json::json!(category);
assert!(
map_devin_session(&entry).is_none(),
"{category} must not map"
);
}
// Uncategorized and non-review sessions still map: `category` is only
// populated once Cognition's classifier has run.
for category in [
serde_json::Value::Null,
serde_json::json!("bug_fixing"),
serde_json::json!("other"),
] {
let mut entry = devin_session(DEVIN_SESSION_ID, "t");
entry["category"] = category.clone();
assert!(
map_devin_session(&entry).is_some(),
"{category} must still map"
);
}
// The review filter is scoped to automation-spawned sessions: a
// person-directed session whose work the classifier files as review
// still belongs in the catalog.
for category in ["code_review", "code_review_and_analysis"] {
for origin in [
None,
Some("webapp"),
Some("slack"),
Some("teams"),
Some("api"),
Some("desktop"),
Some("cli"),
] {
let mut entry = devin_session(DEVIN_SESSION_ID, "Review my PR");
entry["origin"] = serde_json::json!(origin);
entry["category"] = serde_json::json!(category);
assert!(
map_devin_session(&entry).is_some(),
"{origin:?} {category} work must still map"
);
}
}
}

#[test]
fn devin_mapping_skips_malformed_ids_and_normalizes_the_prefixed_form() {
for bad_id in [
Expand Down Expand Up @@ -1074,6 +1125,44 @@ fn devin_credentials_parse_the_flat_toml() {
assert!(devin_org_id(&config).is_none());
}

#[test]
fn devin_enumeration_keeps_walking_past_review_only_pages() {
// An org whose recent history is dense with Devin Review automations
// must not exhaust the page bound on filtered sessions: pages that yield
// nothing do not count toward it, while an absolute fetch bound still
// terminates the walk.
let _cleared = without_credentials_override();
let home = tempfile::tempdir().unwrap();
let mut review = devin_session(DEVIN_SESSION_ID, "Review this push");
review["category"] = serde_json::json!("code_review");
let mut script: Vec<(u16, String)> = (0..MAX_LIST_PAGES + 20)
.map(|page| {
(
200,
devin_page(&[review.clone()], Some(&format!("cursor+{page}")), true),
)
})
.collect();
script.push((
200,
devin_page(
&[devin_session("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "real")],
None,
false,
),
));
let transport = Arc::new(ScriptedDevinTransport::new(script));
let provider = devin_provider(home.path(), &transport, None);
let conn = catalog();
let env = env_at(&conn, home.path());
let candidates = provider.enumerate(&env, None).unwrap();
assert_eq!(candidates.len(), 1);
assert_eq!(
candidates[0].session_id.as_deref(),
Some("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
);
}

#[test]
fn devin_enumeration_pages_with_first_and_after() {
let _cleared = without_credentials_override();
Expand Down
Loading