diff --git a/docs/org2-performance-guard-2026-09-26/NativeConversationMaterialization.md b/docs/org2-performance-guard-2026-09-26/NativeConversationMaterialization.md new file mode 100644 index 0000000000..c2ccd6f31d --- /dev/null +++ b/docs/org2-performance-guard-2026-09-26/NativeConversationMaterialization.md @@ -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 diff --git a/src-tauri/src/agent_sessions/cli/native_ir.rs b/src-tauri/src/agent_sessions/cli/native_ir.rs index ff1f34e1e0..41fdb2fd2f 100644 --- a/src-tauri/src/agent_sessions/cli/native_ir.rs +++ b/src-tauri/src/agent_sessions/cli/native_ir.rs @@ -485,7 +485,12 @@ pub(super) fn native_items_from_agent_history(history: &[Value]) -> Vec Vec, +) -> Option { + 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 @@ -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 => { diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs index f6b3465baf..7589d71d56 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/tests.rs @@ -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![ diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts index 6f9ab3ab13..9e96faf888 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts @@ -37,10 +37,12 @@ const { evictSessionSpy, invokeTauriSpy, flushMessageQueueSpy, + getEventsSpy, hydrateMessageQueueSpy, messageQueueHydrated, queuedDeliveries, realQueueStore, + reconcileOrphanSpy, removeByIdPrefixSpy, updateByIdSpy, upsertSpy, @@ -57,10 +59,12 @@ const { evictSessionSpy: vi.fn(async () => undefined), invokeTauriSpy: vi.fn(async () => 0), flushMessageQueueSpy: vi.fn(async () => undefined), + getEventsSpy: vi.fn(async () => [] as SessionEvent[]), hydrateMessageQueueSpy: vi.fn(async () => undefined), messageQueueHydrated: { current: true }, queuedDeliveries: { current: [] as Array> }, realQueueStore: { current: null as Store | null }, + reconcileOrphanSpy: vi.fn(async () => false), removeByIdPrefixSpy: vi.fn(async () => 1), updateByIdSpy: vi.fn(async () => true), upsertSpy: vi.fn(async (..._args: unknown[]) => undefined), @@ -140,6 +144,7 @@ vi.mock("@src/engines/SessionCore/core/atoms", () => ({ vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { + getEvents: getEventsSpy, removeByIdPrefix: removeByIdPrefixSpy, updateById: updateByIdSpy, upsert: upsertSpy, @@ -153,6 +158,7 @@ vi.mock( () => ({ flushMessageQueuePersistence: flushMessageQueueSpy, hydrateMessageQueue: hydrateMessageQueueSpy, + reconcileOrphanedOptimisticQueueProjections: reconcileOrphanSpy, refreshMessageDeliveries: refreshMessageDeliveriesSpy, }) ); @@ -260,7 +266,11 @@ describe("useEditUserMessage resend projection", () => { evictSessionSpy.mockClear(); invokeTauriSpy.mockClear(); flushMessageQueueSpy.mockClear(); + getEventsSpy.mockReset(); + getEventsSpy.mockResolvedValue([]); hydrateMessageQueueSpy.mockClear(); + reconcileOrphanSpy.mockReset(); + reconcileOrphanSpy.mockResolvedValue(false); hydrateMessageQueueSpy.mockImplementation(async () => { queuedDeliveries.current = [...durableHydrationRows.current]; messageQueueHydrated.current = true; @@ -491,6 +501,54 @@ describe("useEditUserMessage resend projection", () => { ); }); + it("repairs a missing durable owner on Retry before resubmitting the failed turn", async () => { + storeSessionId.current = "sdeagent-root"; + const failed = { + event: { + id: "queued-user:queue-orphan:", + sessionId: "cliagent-child", + source: "user", + displayText: "continue the conversation", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-orphan", + turnIntentId: "intent-orphan", + deliveryError: "native transcript mismatch", + }, + }, + chunk_id: "queued-user:queue-orphan:", + } as unknown as OptimizedChatItem; + reconcileOrphanSpy.mockResolvedValueOnce(true); + const failedEvent = failed.event as SessionEvent; + getEventsSpy.mockResolvedValueOnce([ + { + ...failedEvent, + result: { + ...failedEvent.result, + queueMessageId: undefined, + }, + } as SessionEvent, + ]); + + await act(async () => { + await editUserMessage?.(failed, "continue the conversation"); + }); + + expect(reconcileOrphanSpy).toHaveBeenCalledWith("cliagent-child"); + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sdeagent-root", + turnIntentId: "intent-orphan", + }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "queued-user:queue-orphan:", + "cliagent-child" + ); + }); + it.each(["delivery", "execution"] as const)( "retries a hydrated %s failure in place without losing attachments", async (kind) => { diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts b/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts index 066a242acc..9cd2fb8637 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts @@ -38,6 +38,7 @@ import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreP import { flushMessageQueuePersistence, hydrateMessageQueue, + reconcileOrphanedOptimisticQueueProjections, refreshMessageDeliveries, } from "@src/engines/SessionCore/hooks/session/messageQueuePersistence"; import { @@ -157,6 +158,8 @@ export function useEditUserMessage( // replaced. A retired accepted turn must remain in the history; never // truncate later turns or offer a file rewind for this case. if (failedSyntheticIntent && initiatedSessionId && chatItem.event) { + const failedProjectionSessionId = + chatItem.event.sessionId ?? initiatedSessionId; const originalText = chatItem.event.displayText ?? ""; const originalTurnIntentId = turnIntentIdOf(chatItem.event); const ownerRetired = @@ -202,12 +205,25 @@ export function useEditUserMessage( } if (queueMessageId && !durableFailedQueueRow) { if (chatItem.event.result?.deliveryOwnerRetired !== true) { - // queueMessageId is an ownership claim, not a hint. Falling - // back to a new submit here would delete the only visible root - // row and create a second delivery on whichever Session is - // currently mounted. Preserve the failed bubble until its owner - // is readable. - throw new Error("failed delivery owner is not available yet"); + // A restart can leave the failed EventStore projection after + // its durable queue owner has gone away. The passive orphan + // scan may have run before this session was mounted, so prove + // absence at Retry and remove only that stale ownership claim. + await reconcileOrphanedOptimisticQueueProjections( + failedProjectionSessionId + ); + const currentFailedRow = ( + await eventStoreProxy.getEvents(failedProjectionSessionId) + ).find((event) => event.id === eventId); + if ( + currentFailedRow?.displayStatus !== "failed" || + currentFailedRow.result?.deliveryStatus !== "failed" || + currentFailedRow.result?.queueMessageId != null + ) { + // A still-owned, accepted, or concurrently changed row must + // never become a second dispatch. + throw new Error("failed delivery owner is not available yet"); + } } // The dispatcher retired this owner after a terminal provider/ // Cloud verdict and stamped the row. The failed bubble is the only @@ -302,14 +318,17 @@ export function useEditUserMessage( }); } if (!preserveAcceptedHistory) { - await eventStoreProxy.removeByIdPrefix(eventId, initiatedSessionId); + await eventStoreProxy.removeByIdPrefix( + eventId, + failedProjectionSessionId + ); } } catch (error) { // A send-stage error already produced the replacement failed row. // A preparation/storage error did not, so retain the original row. if (!preserveAcceptedHistory && isUserIntentSendError(error)) { await eventStoreProxy - .removeByIdPrefix(eventId, initiatedSessionId) + .removeByIdPrefix(eventId, failedProjectionSessionId) .catch(() => 0); } log.error( diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts index 68f49e9f28..e8c643062c 100644 --- a/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts @@ -886,7 +886,7 @@ describe("native conversation materialization", () => { ).rejects.toThrow("round-trip verification failed"); }); - it("removes a failed CLI materialization without touching other native history", async () => { + it("retains a failed CLI materialization for authoritative retry inspection", async () => { mocks.invokeTauri.mockResolvedValueOnce({ nativeSessionId: "native-1", itemCount: 1, @@ -902,12 +902,82 @@ describe("native conversation materialization", () => { timeline: [message("u1", "user", "hello")], }) ).rejects.toThrow("round-trip verification failed"); - expect(mocks.invokeTauri).toHaveBeenNthCalledWith( - 2, - "discard_native_conversation_materialization", - { sessionId: "cliagent-target", nativeSessionId: "native-1" } - ); + expect(mocks.invokeTauri).toHaveBeenCalledTimes(1); + }); + + it("can synchronize a retained materialization after a transient read failure", async () => { + const timeline = [message("u1", "user", "hello")]; + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 1, + }); + mocks.loadEvents.mockRejectedValueOnce(new Error("read interrupted")); + await expect( + materializeNativeConversation({ sessionId: "cliagent-target", timeline }) + ).rejects.toThrow("read interrupted"); + mocks.loadEvents.mockResolvedValue({ + events: timeline, + source: "native_store", + }); + await expect( + synchronizeNativeConversation({ sessionId: "cliagent-target", timeline }) + ).resolves.toMatchObject({ receipt: { nativeSessionId: "native-1" } }); + expect(mocks.invokeTauri.mock.calls.map(([command]) => command)).toEqual([ + "materialize_native_conversation", + "synchronize_native_conversation", + ]); }); + + it.each([ + ["run_shell", "run_command_line"], + ["Bash", "run_command_line"], + ["Read", "read_file"], + ["Edit", "edit_file_by_replace"], + ["CustomTool", "customtool"], + ])( + "materializes %s using the native reader's %s vocabulary", + async (sourceName, storedName) => { + const source = { + ...tool(), + functionName: sourceName, + result: { output: "done" }, + }; + const replay = { ...source, functionName: storedName }; + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 2, + }); + mocks.loadEvents.mockResolvedValue({ + events: [replay], + source: "native_store", + }); + await expect( + materializeNativeConversation({ + sessionId: "cliagent-target", + timeline: [source], + }) + ).resolves.toMatchObject({ receipt: { itemCount: 2 } }); + const items = mocks.invokeTauri.mock.calls[0][1].items; + expect(items.map((item: { name: string }) => item.name)).toEqual([ + storedName, + storedName, + ]); + expect( + nativeConversationItemsAreProviderPortablePrefix( + projectNativeConversationItems([source]), + projectNativeConversationItems([replay]) + ) + ).toBe(true); + expect( + nativeConversationItemsEqual( + projectNativeConversationItems([source]), + projectNativeConversationItems([ + { ...replay, result: { output: "changed" } }, + ]) + ) + ).toBe(false); + } + ); }); describe("structured tool names and arguments", () => { diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts index d0d901f982..e091ca23ee 100644 --- a/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts @@ -9,7 +9,6 @@ import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { loadAuthoritativeSessionEvents } from "@src/engines/SessionCore/sync/authoritativeSessionEvents"; import { invokeTauri } from "@src/util/platform/tauri/init"; -import { isCliSession } from "@src/util/session/sessionDispatch"; import { type LocalConversationTarget, @@ -141,29 +140,24 @@ export async function materializeNativeConversation(params: { { sessionId: params.sessionId, items } ); const receipt: NativeMaterializationReceipt = { ...wireReceipt, fidelity }; - try { - if (receipt.itemCount !== items.length) { - throw new Error( - `native materializer wrote ${receipt.itemCount} of ${items.length} items` - ); - } - const { events } = await loadAuthoritativeSessionEvents(params.sessionId); - const roundTripped = projectNativeConversationItems(events); - if (!nativeConversationItemsEqual(items, roundTripped)) { - throw new Error( - `native transcript round-trip verification failed; the target session was not started (${nativeConversationMismatch(items, roundTripped)})` - ); - } - return { events, receipt }; - } catch (error) { - if (isCliSession(params.sessionId)) { - await invokeTauri("discard_native_conversation_materialization", { - sessionId: params.sessionId, - nativeSessionId: receipt.nativeSessionId, - }).catch(() => undefined); - } - throw error; + // Rust has already published this artifact and its native binding. Keep + // them on a failed read/verification so Retry can read a stable revision + // and revalidate the prefix. Deleting only the file and binding leaves + // ledger/replay witnesses behind and permanently poisons canonical reads. + // The caller still fails closed before dispatch on every mismatch. + if (receipt.itemCount !== items.length) { + throw new Error( + `native materializer wrote ${receipt.itemCount} of ${items.length} items` + ); } + const { events } = await loadAuthoritativeSessionEvents(params.sessionId); + const roundTripped = projectNativeConversationItems(events); + if (!nativeConversationItemsEqual(items, roundTripped)) { + throw new Error( + `native transcript round-trip verification failed; the target session was not started (${nativeConversationMismatch(items, roundTripped)})` + ); + } + return { events, receipt }; } /** diff --git a/src/engines/SessionCore/conversations/nativeConversationProjection.ts b/src/engines/SessionCore/conversations/nativeConversationProjection.ts index e24d5215ee..efa0a08212 100644 --- a/src/engines/SessionCore/conversations/nativeConversationProjection.ts +++ b/src/engines/SessionCore/conversations/nativeConversationProjection.ts @@ -5,6 +5,7 @@ */ import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { isInternalLifecycleEvent } from "@src/engines/SessionCore/ingestion/visibilityFilters"; +import { getCliStorageCanonical } from "@src/engines/SessionCore/rendering/registry/initToolRegistry"; import { conversationSenderStampOf } from "./conversationSenderMetadata"; import type { @@ -209,10 +210,14 @@ export function projectNativeConversationItems( continue; } const callId = portableToolCallId(event); - const name = event.functionName.trim(); - if (!name) { + const rawName = event.functionName.trim(); + if (!rawName) { throw new Error(`native transcript tool event ${event.id} has no name`); } + // SDE events retain built-in names (run_shell), while native replay + // resolves storage aliases (run_command_line). Project both through + // the same Rust-owned alias registry before writing or comparing IR. + const name = getCliStorageCanonical(rawName).toLowerCase(); items.push({ kind: "tool_call", id: `${nativeSourceEventId(event)}:call`,