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
48 changes: 24 additions & 24 deletions shared/rust-bridge/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 7 additions & 5 deletions shared/rust-bridge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ codex-config = { path = "../third_party/codex/codex-rs/config" }
codex-utils-absolute-path = { path = "../third_party/codex/codex-rs/utils/absolute-path" }
codex-git-utils = { path = "../third_party/codex/codex-rs/git-utils" }
# Pinned to the same immutable commit as the kittylitter daemon so the phone
# and host share one immutable Alleycat release revision.
alleycat-bridge-core = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" }
alleycat-pi-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" }
alleycat-claude-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" }
alleycat-opencode-bridge = { git = "https://github.com/dnakov/alleycat.git", rev = "417f2a9fe38cbed63754f0af7df61f32ec3034e6" }
# and host share one immutable Alleycat release revision. All crates come from
# the same source so the `Bridge` trait identity is consistent; forked until
# the project-aware opencode session enumeration lands upstream (dnakov/alleycat).
alleycat-bridge-core = { git = "https://github.com/DatScreamer/alleycat.git", rev = "eead5e5a4664ed8713183afad10ba8c1389b15fc" }
alleycat-pi-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "eead5e5a4664ed8713183afad10ba8c1389b15fc" }
alleycat-claude-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "eead5e5a4664ed8713183afad10ba8c1389b15fc" }
alleycat-opencode-bridge = { git = "https://github.com/DatScreamer/alleycat.git", rev = "eead5e5a4664ed8713183afad10ba8c1389b15fc" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "net", "io-util"] }
Expand Down
118 changes: 113 additions & 5 deletions shared/rust-bridge/codex-mobile-client/src/store/reconcile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,15 @@ impl MobileClient {
wire_method,
response,
)?;
self.apply_thread_read_response(server_id, response)
let params = downcast_public_rpc_params::<upstream::ThreadReadParams>(
wire_method,
params.map(|value| value as &dyn Any),
)?;
self.apply_thread_read_response_for_request(
server_id,
response,
params.include_turns,
)
.map(|_| ())
.map_err(RpcError::Deserialization)
}
Expand Down Expand Up @@ -252,9 +260,27 @@ impl MobileClient {
server_id: &str,
response: &upstream::ThreadReadResponse,
) -> Result<ThreadKey, String> {
self.apply_thread_read_response_for_request(server_id, response, true)
}

fn apply_thread_read_response_for_request(
&self,
server_id: &str,
response: &upstream::ThreadReadResponse,
include_turns: bool,
) -> Result<ThreadKey, String> {
let mut upstream_thread = response.thread.clone();
if !include_turns {
// Treat the request contract as authoritative. Some compatibility
// bridges have returned a full archive even for metadata-only
// reads; accepting it bypasses bounded thread/turns/list hydration
// and can replace a five-turn page with hundreds of UI items.
upstream_thread.turns.clear();
}
let upstream_turns = upstream_thread.turns.clone();
let mut snapshot = crate::thread_snapshot_from_upstream_thread_with_overrides(
server_id,
response.thread.clone(),
upstream_thread,
None,
None,
response.approval_policy.map(Into::into),
Expand All @@ -269,15 +295,15 @@ impl MobileClient {
// `load_thread_turns_page` stored. A legacy (or authoritative)
// response with embedded turns clears the cursor because the
// embedded list is the full history.
apply_pagination_merge(existing.as_ref(), &mut snapshot, &response.thread.turns);
apply_pagination_merge(existing.as_ref(), &mut snapshot, &upstream_turns);
// thread/read is authoritative for `initial_turns_loaded`: if the
// server returned no turns AND no prior state exists, treat the
// thread as having no history rather than a pending page load, so
// the iOS spinner doesn't stick (task #10 invariant).
if existing.is_none() && response.thread.turns.is_empty() {
if include_turns && existing.is_none() && upstream_turns.is_empty() {
snapshot.initial_turns_loaded = true;
}
crate::reconcile_active_turn(existing.as_ref(), &mut snapshot, &response.thread.turns);
crate::reconcile_active_turn(existing.as_ref(), &mut snapshot, &upstream_turns);
self.app_store.upsert_thread_snapshot(snapshot);
Ok(key)
}
Expand Down Expand Up @@ -1499,4 +1525,86 @@ mod tests {
"existing paged items must be preserved when embedded turns are empty"
);
}

#[tokio::test]
async fn metadata_only_thread_read_ignores_bridge_embedded_history() {
let client = MobileClient::new();
client.app_store.upsert_server(
&ServerConfig {
server_id: "srv".to_string(),
display_name: "Server".to_string(),
host: "localhost".to_string(),
port: 8390,
websocket_url: None,
is_local: true,
tls: false,
},
ServerHealthSnapshot::Connected,
);

let info = crate::types::ThreadInfo {
id: "thread-1".to_string(),
title: None,
model: None,
status: crate::types::ThreadSummaryStatus::Idle,
preview: None,
cwd: None,
path: None,
model_provider: None,
agent_nickname: None,
agent_role: None,
parent_thread_id: None,
forked_from_id: None,
agent_status: None,
created_at: None,
updated_at: None,
};
let mut primed = ThreadSnapshot::from_info("srv", info);
primed.items = vec![item_with_turn("paged-turn", "paged-item")];
primed.older_turns_cursor = Some("older-cursor".to_string());
primed.initial_turns_loaded = true;
client.app_store.upsert_thread_snapshot(primed);

let mut violating_thread = test_upstream_thread("thread-1");
violating_thread.turns = vec![upstream::Turn {
id: "unbounded-turn".to_string(),
status: upstream::TurnStatus::Completed,
items: vec![upstream::ThreadItem::UserMessage {
id: "unbounded-item".to_string(),
content: vec![upstream::UserInput::Text {
text: "bridge returned history despite includeTurns=false".to_string(),
text_elements: Vec::new(),
}],
}],
items_view: upstream::TurnItemsView::Full,
error: None,
started_at: None,
completed_at: None,
duration_ms: None,
}];
let response = upstream::ThreadReadResponse {
thread: violating_thread,
approval_policy: None,
sandbox: None,
};
let params = upstream::ThreadReadParams {
thread_id: "thread-1".to_string(),
include_turns: false,
};

client
.reconcile_public_rpc("thread/read", "srv", Some(&params), &response)
.await
.expect("metadata-only thread/read reconciliation");

let key = ThreadKey {
server_id: "srv".to_string(),
thread_id: "thread-1".to_string(),
};
let snapshot = client.app_store.thread_snapshot(&key).expect("snapshot");
assert_eq!(snapshot.items.len(), 1);
assert_eq!(snapshot.items[0].id, "paged-item");
assert_eq!(snapshot.older_turns_cursor.as_deref(), Some("older-cursor"));
assert!(snapshot.initial_turns_loaded);
}
}
2 changes: 1 addition & 1 deletion tools/scripts/update-alleycat-main.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ resolve_alleycat_main() {
}

alleycat_is_pinned() {
grep -q 'dnakov/alleycat\.git.*rev = ' "$1"
grep -q '\.git.*rev = ' "$1"
}

update_shared() {
Expand Down
Loading