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
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Native conversation materialization fidelity

## Source and root cause

Read-only inspection matched the reported SDE conversation and its Codex child in the local session database. The failed child was pending with no user_input, PID, accepted turn intent or usage. It retained one failed optimistic user event, one native-ID ledger row and 18 copied shell replays, but its native binding/file had been discarded after read-back verification failed. No private transcript content or identifiers are included here.

The source SDE events use built-in names such as `run_shell`; Rust native ingestion resolves them to storage names such as `run_command_line`. TypeScript previously wrote the unnormalized name and compared it strictly against the normalized read-back. The first send therefore failed despite identical arguments/output. The catch path then removed a published native artifact without removing its dependent ledger/replay records. Retry could no longer bracket the child's history with a stable revision. A native app run also reached Codex CLI → SDE and reproduced the reverse mismatch: Rust projected the retained SDE history using the raw executable name while canonical SessionEvents used the storage name.

The canonical TS materialization and Rust SDE history read-back projections now use the Rust-owned storage alias registry and lowercase fallback, matching native ingestion for both calls and results. The native executable history remains unchanged. Comparisons still validate call identity, arguments, output, error flags and ordering. Read-back failure still prevents dispatch but retains the published artifact/binding for subsequent inspection and prefix validation. A transient read failure can recover through existing synchronization; a real mismatch still fails closed.

After a restart, the native run exposed a second Retry blocker: its failed EventStore projection retained a `queueMessageId`, while the durable queue had no corresponding delivery. The Retry path now asks the existing orphan reconciler to inspect that exact session, reads the row back, and proceeds only if it is still failed and its ownership claim has been removed. This is bounded work on a user click, not a background scan. Claude Code Default is also resolved through the ambient native CLI login rather than an unrelated saved credential; an explicitly selected account still uses its own key.

## Historical remediation

No live database, transcript or dependent replay was manually modified. Previously discarded native files are not recreated by this PR. The reported already-stranded child therefore needs separate, explicitly authorized recovery after inventory; its failed prompt and audit records must be preserved. Future failed queue projections with no durable owner can be reconciled narrowly when their Retry button is clicked. Keeping future failed materializations avoids producing the missing-native-artifact state again. No schema, IPC or persistence-format change; rollback is a code revert, with retained artifacts remaining ordinary provider transcripts.

## Architecture review

| Layer | Verdict / scope |
| -------------------- | ------------------------------------------------------------------------------------------------ |
| 1 Compilation | Full tsgo check, changed-file ESLint, Rust tests and clippy passed |
| 2 Ownership | Canonical TS/Rust projections own aliasing; the queue reconciler owns orphan claims |
| 3 Naming | Storage canonical names replace raw built-in names only at comparison boundaries |
| 4 Semantics | Executable native history, storage canonical names and provider call identity remain distinct |
| 5 Defaults | Unknown tools follow Rust's lowercase fallback; Claude Default uses ambient native login |
| 6 Boundaries | Projection normalization precedes native comparison; Retry proves absent queue ownership |
| 7 Discoverability | Comments document alias, credential and orphan Retry mechanisms |
| 8 Wire | Materialize invocation and Rust prefix regressions assert canonical names; wire format unchanged |
| 9 Init parity | Existing bundled registry initialization reused; native CLI launch profile tested |
| 10 Resolver symmetry | Calls/results share normalization in both TS and Rust projections |

## Lifecycle review

| Area | Verdict | Evidence | Change or reason kept | Verification |
| ------------------ | ------- | ------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------- |
| Background work | keep | User-triggered materialization/read-back/Retry | No timers or subscriptions added; Retry reconciles once | Call-chain review and invocation assertions |
| Memory | keep | Existing bounded IR and registry | No retained transcript cache | Source inspection |
| Scope/isolation | fix | Session-bound native receipt and failed queue row | Retain exact artifact; reconcile only the failed projection session | Failure, synchronization and Retry regressions |
| Rendering/hot path | keep | No React/streaming changes | No runtime performance claim | Source inspection |

| Provider | Raw transition | App/UI state | Topology/boundary | Expected invariant | Observed evidence |
| ------------------- | ------------------------------------------------------- | ---------------- | ------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SDE → Codex | Native seed write/read-back | Native macOS app | Local synthetic conversation | Tool aliases preserve semantic identity | Send succeeded after TS fix |
| Shared native IR | Built-in/CLI/custom names, failed read, synchronization | Unit fixture | TS materializer and Rust history projection | Stable canonical names; no discard; strict changed-output rejection | Targeted TS/Rust regressions passed |
| Codex / Claude Code | GUI switch, retry, restart | Native macOS app | Same synthetic conversation | Successful send and stable reloaded history | Six sequential SDE → Codex CLI → Claude CLI → Codex CLI → SDE → Claude CLI sends passed; reverse SDE mismatch reproduced before the Rust fix, then fixed-build Retry and cold restart passed |
| All | Idle/hidden/close/delete | Runtime | CPU/RSS | No added background work | Source review only; runtime measurements not run |

Performance verdict: native round-trip, Retry and cold-restart acceptance passed. CPU/RSS and idle/hidden resource use were not measured; no performance improvement is claimed. No new retained runtime resource is introduced.

## Verification

- `pnpm test src/engines/SessionCore/conversations src/engines/SessionCore/__tests__/rustTsContract.test.ts`: 13 files, 541 tests passed
- `pnpm typecheck:fast`: passed
- `pnpm exec eslint src/engines/SessionCore/conversations/nativeConversationProjection.ts src/engines/SessionCore/conversations/nativeConversationMaterializer.ts src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts --max-warnings 0`: passed
- `pnpm check:circular`: passed, 8,166 modules
- `pnpm check:test-placement`: passed, 634 directories
- `pnpm test src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts src/engines/SessionCore/hooks/session/__tests__/messageQueuePersistence.test.ts`: 2 files, 56 tests passed
- `cargo test -p org2 --lib agent_sessions::cli::native_ir::tests`: 11 passed; `cargo test -p org2 --lib ambient_claude_launch_does_not_inherit_a_saved_account`: 1 passed
- Changed-file rustfmt and pre-commit `cargo clippy -p org2`: passed
- `git diff --check`: passed
- Native macOS app completed SDE → Codex CLI → Claude CLI → Codex CLI → SDE → Claude CLI in one synthetic conversation. Codex CLI → SDE reproduced the reverse mismatch before the Rust fix; fixed-build Retry passed, and cold restart preserved all six prompt/response pairs once
- Two isolated native app data homes and local clones exchanged Team Chat messages without starting an Agent run. A subsequent Agent prompt recovered both human marker strings from context; this is an integration check, not a change to Team Chat's writer
- Full `cargo fmt --all -- --check` reports pre-existing formatting differences outside this diff; changed Rust files passed rustfmt individually
- No visual layout changed; screenshots would not establish native round-trip correctness
41 changes: 39 additions & 2 deletions src-tauri/src/agent_sessions/cli/native_ir.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,15 +485,20 @@ pub(super) fn native_items_from_agent_history(history: &[Value]) -> Vec<NativeCo
.get("name")
.and_then(Value::as_str)
.unwrap_or("tool");
call_names.insert(raw_call_id.to_string(), name.to_string());
// Agent history keeps its executable tool name (for example
// `run_shell`), while SessionEvents use the storage alias.
// Normalize only this read-back projection so a later
// provider switch compares the same semantic vocabulary.
let name = resolve_function_name(name, "", None);
call_names.insert(raw_call_id.to_string(), name.clone());
let arguments = function
.get("arguments")
.and_then(Value::as_str)
.unwrap_or("{}");
items.push(NativeConversationItem::ToolCall {
id: format!("agent-history-{index}-tool-{tool_index}"),
call_id,
name: name.to_string(),
name,
arguments: arguments.to_string(),
created_at: created_at.clone(),
});
Expand Down Expand Up @@ -533,6 +538,7 @@ fn tool_result_item(
.map(str::to_string)
.or_else(|| call_names.get(raw_call_id).cloned())
.unwrap_or_else(|| "tool".to_string());
let name = resolve_function_name(&name, "", None);
NativeConversationItem::ToolResult {
id: format!("agent-history-{index}-result"),
call_id,
Expand Down Expand Up @@ -1083,6 +1089,37 @@ mod tests {
.is_empty());
}

#[test]
fn agent_shell_history_is_a_prefix_of_the_storage_canonical_conversation() {
let history = vec![
json!({"role":"assistant","tool_calls":[{
"id":"call_shell",
"function":{"name":"run_shell","arguments":"{\"command\":\"printf ok\"}"}
}]}),
json!({"role":"tool","tool_call_id":"call_shell","name":"run_shell","content":"ok"}),
];
let native = native_items_from_agent_history(&history);
let canonical = vec![
test_tool_call(
"call_shell",
"run_command_line",
r#"{"command":"printf ok"}"#,
),
NativeConversationItem::ToolResult {
id: "canonical-result".to_string(),
call_id: "call_shell".to_string(),
name: "run_command_line".to_string(),
output: "ok".to_string(),
is_error: false,
interrupted: false,
created_at: String::new(),
},
];
assert!(provider_portable_append_suffix(&native, &canonical)
.expect("SDE shell history must remain a semantic prefix after a provider switch")
.is_empty());
}

#[test]
fn interrupted_agent_batch_projects_only_durable_pairs_before_next_turn() {
let history = vec![
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ async fn live_luna_reserve_app_server() {
user_input: "Reply exactly ORG2_APP_SERVER_RESERVE_OK. Do not use tools, read files or run commands.".into(),
developer_instructions: None, working_dir: workspace.path().to_string_lossy().into_owned(),
project_id: None, resume_thread_id: None, model: wire,
turn_intent_id: None,
permission_mode: CliPermissionMode::Plan, config: None, image_paths: vec![], allow_native_context_recovery: false,
}, tx
)).await;
Expand Down
33 changes: 25 additions & 8 deletions src-tauri/src/agent_sessions/cli/session_runner/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use tokio::sync::Mutex;

use crate::api::websocket_handler;
use agent_core::session::{AgentExecMode, IdeContext};
use key_vault::key_store::{KeyService, ModelType, KEY_SERVICE};
use key_vault::key_store::{KeyService, ModelKey, ModelType, KEY_SERVICE};

use super::super::launch_profile_store::resolve_cli_launch_profile;
use super::super::persistence;
Expand Down Expand Up @@ -47,6 +47,24 @@ const OVERLOAD_RETRY_BASE_DELAY_SECS: u64 = 2;

const MAX_STDERR_LINES: usize = 20;

fn initial_cli_key(
key_service: &KeyService,
agent: &ModelType,
key_source: KeySource,
account_id: Option<&str>,
) -> Option<ModelKey> {
if let Some(account_id) = account_id {
return key_service.get_key_by_id(account_id);
}
// Claude Code's Default model means the native CLI login, not an
// arbitrary saved account. Picking the oldest credential here can select
// a disabled account and make every retry fail before the CLI starts.
if key_source == KeySource::OwnKey && !matches!(agent, ModelType::ClaudeCode) {
return key_service.get_key(agent, None);
}
None
}

/// Routing/auth variables owned by an explicit Claude account selection.
/// `tokio::process::Command` inherits the desktop process environment, so a
/// variable that is absent from the newly selected account must be explicitly
Expand Down Expand Up @@ -539,14 +557,13 @@ pub(crate) async fn run_session_with_ide_context(
// handles the routing.
// Keep dynamic-source ownership alive through spawn, retries and finalization.
let managed_execution = crate::cli_managed_proxy::prepare_execution_profile(&session).await?;
let mut selected_key = session
.account_id
.as_deref()
.and_then(|id| key_vault::key_store::KEY_SERVICE.get_key_by_id(id));
let mut selected_key = initial_cli_key(
&KEY_SERVICE,
&agent,
session.key_source,
session.account_id.as_deref(),
);
if session.key_source == KeySource::OwnKey {
if session.account_id.is_none() {
selected_key = KEY_SERVICE.get_key(&agent, None);
}
if let Some(account_id) = session.account_id.as_deref() {
selected_key = match agent {
ModelType::Codex => {
Expand Down
26 changes: 26 additions & 0 deletions src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,32 @@ fn codex_turns_always_use_the_desktop_visible_native_transport() {
}
}

#[test]
fn ambient_claude_launch_does_not_inherit_a_saved_account() {
let home = tempfile::tempdir().expect("temporary credential home");
let service = KeyService::new(Some(home.path().to_path_buf()));
let mut stale = ModelKey::new(ModelType::ClaudeCode);
stale.enabled = false;
stale.name = Some("old disabled Claude login".to_string());
let stale = service.save_key(stale).expect("save stale account");

assert!(
initial_cli_key(&service, &ModelType::ClaudeCode, KeySource::OwnKey, None).is_none(),
"the Default Claude CLI model must use ambient auth, even when saved accounts exist"
);
assert_eq!(
initial_cli_key(
&service,
&ModelType::ClaudeCode,
KeySource::OwnKey,
Some(&stale.id),
)
.map(|key| key.id),
Some(stale.id),
"an explicitly selected account must retain its identity for launch validation"
);
}

#[test]
fn command_logging_redacts_mcp_config_values() {
let raw = vec![
Expand Down
Loading
Loading