diff --git a/docs/architecture/managed-cloud-collaboration.md b/docs/architecture/managed-cloud-collaboration.md index debb88e269..14246c5e9c 100644 --- a/docs/architecture/managed-cloud-collaboration.md +++ b/docs/architecture/managed-cloud-collaboration.md @@ -23,12 +23,13 @@ a link. The recipient sees that session in **Shared directly with me** without copying a URL. Link generation remains an explicit action and always exposes a Copy control. -An imported shared session or local Codex/Claude/Cursor history is immutable -at its source. The user may inspect and comment where cloud authorization -exists. On the first attempt to continue the conversation, ORGII asks for a -local repository/workspace with the same Git remote plus the local account and -model, then creates a writable ORGII-owned fork and sends the message there. -Cancelling the picker preserves the unsent message. +An imported shared session or local provider history remains immutable at its +source. Continuing it does not create a product-level fork or flatten history +into a prompt. ORGII rebuilds the canonical role/tool transcript in the chosen +Codex, Claude Code, or native Agent runtime, automatically reuses a valid local +workspace, and sends through the ordinary durable message queue. Compatible +runtime/account/workspace bindings retain their native UUID, so switching back +synchronizes the missing suffix instead of starting over. ## Ownership and authorization @@ -64,27 +65,30 @@ deletion may remain recoverable, so the sync worker uses a distinct purge path. Deleting a Project also deletes its child Work Items; children must never be silently converted into standalone items by an FK default. -### Comments and owner-local agent follow-up +### Conversation, comments, and local execution Session comments are durable cloud rows. Replies retain their thread root, -edits and deletes converge live, and status is a typed tri-state value. The -literal `@agent ` prefix is stored verbatim and rendered as a pill. It starts -work only when submitted on the original cloud session by that session's -owner; on another member's import, a read-only replay, or a writable fork it -is ordinary comment text with no suggestion, assignment, toast, or agent side -effect. - -There is no cloud task/lease/claim plane. An owner submission enters the same -local queue/send path as an ordinary message and therefore uses the owner's -locally authenticated account and selected model. The backend returns a -viewer-derived ownership capability, the UI and runner both fail closed on -it, and only the owner may stamp the resulting `agent_report`. The Address -Comments action operates on an explicit selection and links agent output back -to the originating comment using the exact dispatched turn generation. -Top-level comments have exactly one scope: no event anchor means a session -note applying to the session as a whole; an event anchor means a round comment. -Address Comments groups both scopes, selects both by default, permits -scope-level selection, and carries the scope into the agent briefing. +edits and deletes converge live, and delivery is pending/sent/failed on the +same visible message. Human Team Chat comments also project into the canonical +conversation as user-role events with structured sender identity. Mentions +select a notification audience; they do not create a separate transcript. + +Provider execution remains local and uses the sender's explicitly selected +local account/model. Cloud never receives a provider key and does not host the +runtime. When the backend advertises `conversationTurnCoordination`, admitting +an Agent-directed user event also creates one per-canonical-root FIFO turn. +The author's device claims the FIFO head with a renewable lease before local +provider dispatch, marks it `accepted` immediately before that dispatch, and +finishes it only after the provider tail is durably published. An expired +`claimed` turn may be reclaimed, but an `accepted` turn cannot be stolen by a +different device; renewal failure after acceptance therefore cannot start a +second provider run. The same device must recover and finish that accepted +turn. Backends without the capability retain the existing idempotent +conversation-event push/list path and do not use coordination RPCs. The +ordinary durable queue remains the only client dispatcher and owns local +ordering and restart recovery. Agent reports remain system cards. Top-level +comments have exactly one scope: no event anchor means a session note applying +to the session as a whole; an event anchor means a round comment. ### Background upload policy diff --git a/docs/conversation-events-plane-design-2026-08-21.md b/docs/conversation-events-plane-design-2026-08-21.md index 2d0dec58d1..c411c70366 100644 --- a/docs/conversation-events-plane-design-2026-08-21.md +++ b/docs/conversation-events-plane-design-2026-08-21.md @@ -1,102 +1,94 @@ -# Conversation Events Plane — the real fix for "it's just one session" +# Canonical conversation continuation -2026-08-21. User directive: chatting in a conversation must NOT be a fork — -forks exist only behind the explicit Fork button. This design removes the -fork machinery from implicit continuation entirely by giving conversations -their own **multi-writer event plane** on the cloud, mirroring the proven -session-comments wire. +This document records the current continuation contract. A conversation is a +single canonical event history that can be resumed by any supported native +runtime. Switching runtime is not a fork and does not flatten history into a +prompt. -## Model +## Authority and projection -- A **conversation** is keyed by `(org_id, root_session_id)` — the family - root's bare session id. It OUTLIVES the root session row (retention - expiry of the oldest segment must never mute the conversation — observed - live 2026-08-21 with ORG2_RETENTION_EXPIRED). -- The owner's own session transcript stays the base timeline (owner-only - push unchanged) — AND every owner turn is ALSO published to the plane - (user row at dispatch, agent tail at terminal, one turnId) under the - local event ids, so the plane carries every turn of the conversation and - its seq is the one total order. Clients fold plane rows onto their local - twins (owner transcript, imported replay copies) by turn-intent id for - user rows and by source event id for the rest; pre-plane history keeps - the timestamp merge. -- Any other member's turn runs on THEIR machine (sender-runs/sender-pays) - in a **local runner session** that is: created empty (external-history - fork pattern — context injected, never copied), per-session sync OFF - (never pushed as a session row), invisible in every session list. -- On turn completion the runner's new events are pushed to - `cloud_conversation_events` with the author's identity; every client - merges `owner transcript + conversation plane + discussion` into ONE - stream (the merge/attribution/rendering pipeline from the fork-stitching - work is reused verbatim — turn-plane events are normalized SessionEvents - with a `conversationSender` stamp). -- Context continuity: EVERY send (owner included) prefixes the agent - content with a rendered delta of conversation events the executing - session has not yet seen (per-runner cursor). Display text stays the - user's words; the delta rides agentContent (the projection contract from - the external-history fork path). +- `SessionEvent[]` is the provider-neutral authority for roles, completed tool + call/result pairs, images, compaction summaries, delivery state and sender + provenance. +- Codex and Claude Code histories are projections of that authority into each + provider's native role/tool transcript format. +- A target provider receives the complete verified canonical prefix as native + messages. The new user turn is delivered once through the provider's normal + send path. +- Provider-private reasoning and policy are not portable. Interrupted turns + retain the accepted user event, completed assistant output and closed tool + pairs; unresolved tool calls are not projected into another provider. +- Round-trip parsing must reproduce the same portable semantic items before a + materialization can be used. -## Cloud (migration 0024_conversation_events.sql) +## Identity and runtime switching -- Table `cloud_conversation_events(id, org_id, root_session_id, -author_user_id, turn_id, seq, event jsonb, created_at)`. - - `seq` server-assigned per conversation under - `pg_advisory_xact_lock(hash(org_id, root_session_id))` (0015 pattern). - - Event cap 64KB each, ≤200 events per push call; oversized payloads are - truncated client-side before push with a marker. - - No FK to cloud_sessions: the plane outlives the root row. -- Counters table `cloud_conversations(org_id, root_session_id, event_count, -prompt_count, last_event_at)` maintained under the same lock — feeds - listing badges without count(\*) scans. -- RPCs (definer, RPC-only posture, org-membership asserted; visibility - honors the root session's access ladder WHILE the row exists, falls back - to org-wide once it ages out; read-time retention on event created_at — - soft, Slack model): - - `cloud_push_conversation_events(p_org_id, p_root_session_id, p_turn_id, -p_events jsonb[])` → `{firstSeq, lastSeq}`; batch-append so live - streaming of a running turn is a client cadence choice, not a schema - change. - - `cloud_list_conversation_events(p_org_id, p_root_session_id, -p_after_seq, p_limit)` → ordered rows + authors. -- Signal: new kind `conversationEvents` via `nudge_org_signal` (dedicated - trigger fn, 0015 precedent) + client presence-channel broadcast - (comments-bus pattern) for sub-second delivery. -- `cloud_list_org_sessions`: additive per-row `conversationEventCount` / - `conversationPromptCount` (joined from the counters table by - root_session_id == sourceSessionId). -- `get_cloud_capabilities()` gains `conversationEvents: true` — the client - feature gate; pre-plane backends keep the fork-wire fallback. -- GDPR: export includes authored events; account deletion removes them - (cloud_session_comments precedent for personal content). Both functions - recreated from their LATEST bodies (delete: 0016, export: 0003) with - additive blocks. +- A canonical root identifies the conversation independently of any execution + episode. +- Each compatible runtime/account/workspace binding may keep its own native + UUID. Switching `Codex -> Claude Code -> Codex` synchronizes only the missing + canonical suffix and reuses the earlier Codex UUID when it is still valid. +- The normal New Session runtime/model selectors choose the next target. No + continuation-only workspace dialog or model registry exists. +- Native transcripts and the provider application catalog are published as one + lifecycle. A native-format JSONL file alone is not advertised as visible in + Codex or Claude Desktop. -## Client (ORGII) +## Delivery and concurrency -1. Protocol: `org2CloudConversationEventsClient` + per-conversation atom - (after_seq cursor, LWW merge), realtime bump on the `conversationEvents` - signal kind + broadcast bus. -2. Read: ConversationStreamProvider merges plane events (author-stamped) - after the base segments; dedup by turn against optimistic local copies. -3. Write: `conversation runner` — registry `rootSessionId → runner session` - (per device); created via the continuation setup flow (setup memory - applies, so no dialog after the first time anywhere in the org repo - scope); per-session sync forced OFF; hidden from session lists. - Turn watch = event-marker based (never bare terminal status — the - stale-reply race), then push the turn's events. -4. Send routing (capability-gated): implicit sends in any conversation - surface go to the runner+plane; the fork-before-send and tip-follow - paths remain ONLY as the fallback for pre-plane backends. The explicit - Fork button keeps real forking (a deliberate branch = a new - conversation). -5. Unread: family badge adds conversationPromptCount to the aggregate; - seen watermark unchanged (counts ride the same ratchet). +- `messageQueueAtom` is the only durable client dispatcher for ordinary sends, + imported histories, My Sessions and Team Sessions. +- A queued row owns one stable `turnIntentId` across optimistic display, + provider acceptance, restart recovery and Cloud publication. +- The existing queue FSM owns queued/preparing/accepted state, retry deadlines, + Stop/Send Now behavior and follow-up ordering. Continuation code does not add + a second wake counter, queue, footer FSM or scroll/follow implementation. +- A Web Lock only prevents two webviews on the same app instance from mutating + one canonical root concurrently. For a backend that advertises + `conversationTurnCoordination`, Cloud additionally admits Agent-directed + turns into a per-canonical-root FIFO and lets the author's device claim only + the head. The claim lease is renewed while the local provider run proceeds. +- A `claimed` turn whose lease expires before provider acceptance may be + reclaimed. Immediately before provider dispatch, the owner marks the turn + `accepted`; accepted ownership cannot move to another device. A renewal + failure after that point therefore fails closed instead of causing a second + provider run, and the accepting device must recover publication/finalization. +- Backends without the capability keep the existing idempotent event push/list + workflow. The client does not call coordination RPCs or add a fallback + dispatcher, poller, or watcher. +- Failed outgoing messages remain visible with their original body, images and + mentions and can be retried or edited. Pre-send validation failures leave the + composer unchanged. -## Explicitly deferred +## Team Sessions and Team Chat -- Live streaming of in-flight turns to OTHER clients (plane supports it; - client pushes at turn completion in v1). The sender's own surface overlays - the runner's live events and scopes the working indicator to the runner. -- Migrating Team chat (comments) onto the same plane. -- Backfilling legacy fork families into planes (they keep the stitched - read path indefinitely). +- Cloud stores the shared canonical event plane and assigns a monotonic + per-conversation sequence under the existing advisory lock. +- Push idempotency is `(org, root, turnIntentId, event.id)`. The Cloud never + receives a user's provider key and does not execute a native runtime. +- Human Team Chat comments are canonical user-role events with structured + sender provenance. `@member` and `@all` determine human notification audience; + they do not create a second transcript. +- Agent reports remain non-portable system cards. +- The event plane remains multi-writer for durable transcript publication. + Capability-gated turn coordination orders Agent execution per root without + turning Cloud into an execution host; Team Chat remains outside that claim + lifecycle. + +## Context exhaustion + +- Compaction is triggered only after the provider reports context exhaustion. +- If the accepted attempt has no replay-unsafe tool or assistant side effects, + the provider may use its native compact/rollover capability. +- Otherwise ORG2 creates a fresh native episode from the structured canonical + role/tool list and retries the accepted user turn once. It never works around + exhaustion by embedding the transcript in one user prompt. +- The new native UUID remains attached to the same canonical root. + +## Surface adapters + +- My Session, imported history and Team Session surfaces provide only root + identity, event loading/publication and target selection. +- Work Item comments may trigger this mechanism in the future, but Work Item + code must remain a thin adapter and cannot own continuation, queue or provider + materialization semantics. diff --git a/scripts/tauri/instance-profile.cjs b/scripts/tauri/instance-profile.cjs index dc005dfa83..d6c2a59e09 100644 --- a/scripts/tauri/instance-profile.cjs +++ b/scripts/tauri/instance-profile.cjs @@ -3,11 +3,14 @@ const os = require("os"); const PRIMARY_IDE_PORT = 13847; const PRIMARY_PROXY_PORT = 17888; +const MAX_INSTANCE_ID = 99; function parseInstanceId(value) { const id = Number(value); - if (!Number.isInteger(id) || id < 2 || id > 99) { - throw new Error("--instance must be an integer from 2 through 99"); + if (!Number.isInteger(id) || id < 2 || id > MAX_INSTANCE_ID) { + throw new Error( + `--instance must be an integer from 2 through ${MAX_INSTANCE_ID}` + ); } return id; } @@ -27,4 +30,24 @@ function createInstanceProfile(value) { }; } -module.exports = { createInstanceProfile, parseInstanceId }; +function createInstanceProfileFromIdeServerPort(value) { + const port = Number(value); + const instanceId = port - PRIMARY_IDE_PORT + 1; + if (!Number.isInteger(port) || !Number.isInteger(instanceId)) { + throw new Error(`IDE server port must be an integer, got ${value}`); + } + try { + return createInstanceProfile(instanceId); + } catch { + throw new Error( + `IDE server port ${port} must identify an instance from 2 through ${MAX_INSTANCE_ID} ` + + `(${PRIMARY_IDE_PORT + 1}-${PRIMARY_IDE_PORT + MAX_INSTANCE_ID - 1})` + ); + } +} + +module.exports = { + createInstanceProfile, + createInstanceProfileFromIdeServerPort, + parseInstanceId, +}; diff --git a/scripts/tauri/instance-profile.test.cjs b/scripts/tauri/instance-profile.test.cjs index 2f778c3d83..3793923f6a 100644 --- a/scripts/tauri/instance-profile.test.cjs +++ b/scripts/tauri/instance-profile.test.cjs @@ -3,6 +3,7 @@ const test = require("node:test"); const { createInstanceProfile, + createInstanceProfileFromIdeServerPort, parseInstanceId, } = require("./instance-profile.cjs"); @@ -20,3 +21,14 @@ test("primary and unbounded instance ids are rejected", () => { assert.throws(() => parseInstanceId(value)); } }); + +test("IDE server ports resolve through the canonical instance profile", () => { + const profile = createInstanceProfileFromIdeServerPort("13848"); + assert.equal(profile.id, 2); + assert.equal(profile.productName, "ORG2 Instance 2"); + assert.equal(profile.cliProxyPort, 17889); + + for (const value of [undefined, "13847", "13946", "not-a-port"]) { + assert.throws(() => createInstanceProfileFromIdeServerPort(value)); + } +}); diff --git a/scripts/tauri/open-instance.cjs b/scripts/tauri/open-instance.cjs index acfc544ba4..b8701c4410 100644 --- a/scripts/tauri/open-instance.cjs +++ b/scripts/tauri/open-instance.cjs @@ -2,6 +2,7 @@ const { spawn, spawnSync } = require("child_process"); const fs = require("fs"); +const os = require("os"); const path = require("path"); const { createInstanceProfile } = require("./instance-profile.cjs"); @@ -27,6 +28,38 @@ const appPath = path.resolve( ); const dataHome = path.resolve(optionValue("--data-home") ?? profile.dataHome); const externalHistoryHome = path.join(dataHome, "external-history-home"); +// Publishing into a real provider profile is an explicit opt-in. Independent +// dev/E2E instances otherwise keep both discovery and materialization inside +// their isolated data home and cannot mutate one another's native catalogs. +const nativeTranscriptHomeOption = + optionValue("--native-transcript-home") ?? + process.env.ORGII_NATIVE_TRANSCRIPT_HOME; +const nativeTranscriptHome = nativeTranscriptHomeOption + ? path.resolve(nativeTranscriptHomeOption) + : null; +const requiresOfficialNativeHome = + process.env.E2E_NATIVE_PROVIDER_SWITCH_LIVE === "1"; +const officialNativeHome = path.resolve( + process.env.E2E_NATIVE_PROVIDER_SWITCH_OFFICIAL_HOME ?? os.homedir() +); + +if (requiresOfficialNativeHome && !nativeTranscriptHome) { + console.error( + "E2E_NATIVE_PROVIDER_SWITCH_LIVE=1 requires --native-transcript-home " + + "(or ORGII_NATIVE_TRANSCRIPT_HOME) so provider App proof cannot publish " + + "into the disposable external-history home." + ); + process.exit(1); +} +if ( + requiresOfficialNativeHome && + nativeTranscriptHome !== officialNativeHome +) { + console.error( + `Native provider App proof requires ${officialNativeHome}, got ${nativeTranscriptHome}.` + ); + process.exit(1); +} if (!fs.existsSync(appPath)) { console.error(`Instance app not found: ${appPath}`); @@ -34,10 +67,16 @@ if (!fs.existsSync(appPath)) { } fs.mkdirSync(dataHome, { recursive: true }); fs.mkdirSync(externalHistoryHome, { recursive: true }); +if (nativeTranscriptHome) { + fs.mkdirSync(nativeTranscriptHome, { recursive: true }); +} const instanceEnv = { ORGII_HOME: dataHome, ORGII_EXTERNAL_HISTORY_HOME: externalHistoryHome, + ...(nativeTranscriptHome + ? { ORGII_NATIVE_TRANSCRIPT_HOME: nativeTranscriptHome } + : {}), ORGII_IDE_SERVER_PORT: String(profile.ideServerPort), ORGII_CLI_PROXY_PORT: String(profile.cliProxyPort), ORGII_DEEP_LINK_SCHEME: profile.authDeepLinkScheme, @@ -56,6 +95,7 @@ if (process.platform === "win32") { `[instance ${profile.id}] started ${appPath}\n` + ` ORGII_HOME=${dataHome}\n` + ` External history home=${externalHistoryHome}\n` + + ` Native transcript home=${nativeTranscriptHome ?? "isolated"}\n` + ` IDE server=${profile.ideServerPort}, CLI proxy=${profile.cliProxyPort}` ); process.exit(0); @@ -73,5 +113,6 @@ console.log( `[instance ${profile.id}] opened ${appPath}\n` + ` ORGII_HOME=${dataHome}\n` + ` External history home=${externalHistoryHome}\n` + + ` Native transcript home=${nativeTranscriptHome ?? "isolated"}\n` + ` IDE server=${profile.ideServerPort}, CLI proxy=${profile.cliProxyPort}` ); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f6a4afe91c..957f2b12c1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -7191,6 +7191,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -9292,6 +9298,7 @@ dependencies = [ "getrandom 0.4.2", "js-sys", "serde_core", + "sha1_smol", "wasm-bindgen", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6eaff33877..2c4889bdc4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -150,7 +150,7 @@ authors = ["you"] license = "AGPL-3.0-or-later" repository = "" edition = "2021" -rust-version = "1.85.0" +rust-version = "1.89.0" default-run = "org2" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -201,7 +201,7 @@ tauri-plugin-notification = "2" tauri-plugin-webdriver-automation = { version = "0.1", optional = true } portable-pty = "0.9" tokio = { workspace = true } -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "v5"] } flate2 = "1" tar = "0.4" plist = "1" @@ -525,6 +525,7 @@ tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] } windows = { version = "0.61", features = [ "Win32_Foundation", "Win32_Graphics_Dwm", + "Win32_Storage_FileSystem", ] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs index 7cad86184d..7fdb8298ff 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/messages.rs @@ -1,5 +1,7 @@ //! Message persistence — insertion, loading, truncation, history building. +use std::collections::{HashMap, HashSet}; + use chrono::Utc; use rusqlite::{params, OptionalExtension, Result as SqliteResult}; use uuid::Uuid; @@ -25,6 +27,48 @@ pub struct AgentOrgInboxTranscriptMaterialization { pub content: String, } +/// One provider-neutral history row used to seed or extend an Agent session. +/// +/// Materialization identity is carried beside the content instead of being +/// hidden inside provider-style JSON. This keeps LLM/tool payloads free of +/// ORG2-only fields while preserving deterministic, retry-safe row ids. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MaterializedHistorySeed { + pub id: String, + pub created_at: String, + pub content: MaterializedHistoryContent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MaterializedHistoryContent { + Message { + role: MaterializedHistoryRole, + text: String, + images: Vec, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, + ToolResult { + call_id: String, + name: String, + output: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaterializedHistoryRole { + User, + Assistant, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaterializedHistoryReceipt { + pub row_count: usize, +} + /// Load the transcript batches already materialized for the supplied unread /// Inbox rows in this exact Session. A row stays unread until a successful /// provider turn, but its durable receipt prevents it from being appended to @@ -545,6 +589,186 @@ fn compacted_history_rows( rows } +fn materialized_history_rows( + session_id: &str, + seeds: &[MaterializedHistorySeed], +) -> SqliteResult> { + seeds + .iter() + .map(|seed| { + if seed.id.trim().is_empty() || seed.created_at.trim().is_empty() { + return Err(history_append_constraint( + "materialized history requires a stable id and timestamp".to_string(), + )); + } + let mut row = match &seed.content { + MaterializedHistoryContent::Message { role, text, images } => { + let role = match role { + MaterializedHistoryRole::User => shared::message_role::USER, + MaterializedHistoryRole::Assistant => shared::message_role::ASSISTANT, + }; + let images = (!images.is_empty()).then(|| { + serde_json::to_string(images) + .expect("Vec serialization is infallible") + }); + message_row(session_id, role, text.clone(), images) + } + MaterializedHistoryContent::ToolCall { + call_id, + name, + arguments, + } => { + if call_id.trim().is_empty() || name.trim().is_empty() { + return Err(history_append_constraint( + "materialized tool call requires a call id and name".to_string(), + )); + } + serde_json::from_str::(arguments).map_err(|error| { + history_append_constraint(format!( + "materialized tool call {call_id} has invalid JSON arguments: {error}" + )) + })?; + let mut row = message_row( + session_id, + shared::message_role::TOOL_CALL, + format!("Tool call: {name}"), + None, + ); + row.tool_call_id = Some(call_id.clone()); + row.tool_name = Some(name.clone()); + row.tool_input = Some(arguments.clone()); + row + } + MaterializedHistoryContent::ToolResult { + call_id, + name, + output, + } => { + if call_id.trim().is_empty() || name.trim().is_empty() { + return Err(history_append_constraint( + "materialized tool result requires a call id and name".to_string(), + )); + } + let mut row = message_row( + session_id, + shared::message_role::TOOL_RESULT, + crate::utils::safe_truncate_chars_to_string(output, 2000), + None, + ); + row.tool_call_id = Some(call_id.clone()); + row.tool_name = Some(name.clone()); + row.tool_output = Some(output.clone()); + row + } + }; + row.id = seed.id.clone(); + row.created_at = seed.created_at.clone(); + Ok(row) + }) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HistoryRowsValidation { + None, + MaterializedToolGraph, +} + +fn apply_tool_graph_row( + role: &str, + call_id: Option<&str>, + tool_name: Option<&str>, + open_calls: &mut HashMap, + completed_calls: &mut HashSet, +) -> SqliteResult<()> { + match role { + shared::message_role::TOOL_CALL => { + let call_id = call_id.ok_or_else(|| { + history_append_constraint("materialized tool call is missing call id".to_string()) + })?; + let tool_name = tool_name.ok_or_else(|| { + history_append_constraint("materialized tool call is missing name".to_string()) + })?; + if open_calls.contains_key(call_id) || completed_calls.contains(call_id) { + return Err(history_append_constraint(format!( + "materialized history contains duplicate tool call id {call_id}" + ))); + } + open_calls.insert(call_id.to_string(), tool_name.to_string()); + } + shared::message_role::TOOL_RESULT => { + let call_id = call_id.ok_or_else(|| { + history_append_constraint("materialized tool result is missing call id".to_string()) + })?; + let tool_name = tool_name.ok_or_else(|| { + history_append_constraint("materialized tool result is missing name".to_string()) + })?; + let expected_name = open_calls.remove(call_id).ok_or_else(|| { + history_append_constraint(format!( + "materialized tool result {call_id} has no prior unresolved tool call" + )) + })?; + if expected_name != tool_name { + return Err(history_append_constraint(format!( + "materialized tool result {call_id} names {tool_name}, expected {expected_name}" + ))); + } + completed_calls.insert(call_id.to_string()); + } + _ => {} + } + Ok(()) +} + +/// Validate the complete persisted-prefix + candidate-suffix tool graph while +/// holding the same SQLite write transaction that appends the suffix. An open +/// call at the end is valid partial-turn state; a later synchronization may +/// close it with a result in its next suffix. +fn validate_materialized_tool_graph( + tx: &rusqlite::Transaction<'_>, + session_id: &str, + include_persisted_prefix: bool, + rows: &[shared::AgentMessageRow], +) -> SqliteResult<()> { + let mut open_calls = HashMap::new(); + let mut completed_calls = HashSet::new(); + if include_persisted_prefix { + let mut statement = tx.prepare( + "SELECT role, tool_call_id, tool_name + FROM agent_messages + WHERE session_id = ?1 + ORDER BY sequence ASC", + )?; + let persisted = statement.query_map([session_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + for row in persisted { + let (role, call_id, tool_name) = row?; + apply_tool_graph_row( + &role, + call_id.as_deref(), + tool_name.as_deref(), + &mut open_calls, + &mut completed_calls, + )?; + } + } + for row in rows { + apply_tool_graph_row( + &row.role, + row.tool_call_id.as_deref(), + row.tool_name.as_deref(), + &mut open_calls, + &mut completed_calls, + )?; + } + Ok(()) +} + fn message_row( session_id: &str, role: &str, @@ -570,52 +794,166 @@ fn message_row( } } -/// Replace a session's persisted transcript with a compacted LLM history view. -/// -/// **Seeding only.** This is the durable bootstrap used by compact-fork: -/// it writes an initial transcript into a *fresh* session id. It refuses -/// to run against a session that already has messages — in-place -/// compaction must use [`append_compact_boundary`] instead, which never -/// rewrites or deletes existing rows (immutable transcript invariant). -/// The destructive DELETE+INSERT variant of this function is what -/// destroyed session transcripts when `created_at`-based truncation met -/// rewritten timestamps (2026-06-11 incident). -pub fn seed_session_with_messages( +fn history_append_constraint(message: String) -> rusqlite::Error { + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT), + Some(message), + ) +} + +fn persisted_history_row_matches( + persisted: &shared::AgentMessageRow, + expected: &shared::AgentMessageRow, +) -> bool { + persisted.session_id == expected.session_id + && persisted.role == expected.role + && persisted.content == expected.content + && persisted.tool_name == expected.tool_name + && persisted.tool_call_id == expected.tool_call_id + && persisted.tool_input == expected.tool_input + && persisted.tool_output == expected.tool_output + && persisted.model == expected.model + && persisted.created_at == expected.created_at + && persisted.images == expected.images + && match expected.compact_from_sequence { + Some(_) => { + persisted.compact_from_sequence == Some(persisted.sequence.saturating_add(1)) + } + None => persisted.compact_from_sequence.is_none(), + } +} + +fn persisted_history_row( + tx: &rusqlite::Transaction<'_>, + id: &str, +) -> SqliteResult> { + tx.query_row( + "SELECT session_id, role, content, tool_name, tool_call_id, + tool_input, tool_output, model, sequence, created_at, + images, compact_from_sequence + FROM agent_messages WHERE id = ?1", + params![id], + |row| { + Ok(shared::AgentMessageRow { + id: id.to_string(), + session_id: row.get(0)?, + role: row.get(1)?, + content: row.get(2)?, + tool_name: row.get(3)?, + tool_call_id: row.get(4)?, + tool_input: row.get(5)?, + tool_output: row.get(6)?, + model: row.get(7)?, + sequence: row.get(8)?, + created_at: row.get(9)?, + images: row.get(10)?, + compact_from_sequence: row.get(11)?, + compact_tokens_before: None, + compact_tokens_after: None, + }) + }, + ) + .optional() +} + +fn persist_history_rows( session_id: &str, - compacted_messages: &[serde_json::Value], + rows: &[shared::AgentMessageRow], + require_empty: bool, + validation: HistoryRowsValidation, ) -> SqliteResult<()> { - let rows = compacted_history_rows(session_id, compacted_messages); with_sessions_writer(|| -> SqliteResult<()> { - let conn = get_connection()?; - let now = Utc::now().to_rfc3339(); - conn.execute_batch("BEGIN IMMEDIATE")?; - - let existing: i64 = match conn.query_row( - "SELECT COUNT(*) FROM agent_messages WHERE session_id = ?1", - [session_id], - |row| row.get(0), - ) { - Ok(count) => count, - Err(err) => { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); + let mut conn = get_connection()?; + let tx = conn.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + let next_sequence = if require_empty { + let existing: i64 = tx.query_row( + "SELECT COUNT(*) FROM agent_messages WHERE session_id = ?1", + [session_id], + |row| row.get(0), + )?; + if existing == 0 { + 0 + } else { + let mut exact_rows = 0usize; + for (offset, expected) in rows.iter().enumerate() { + let Some(persisted) = persisted_history_row(&tx, &expected.id)? else { + continue; + }; + exact_rows += 1; + if persisted.sequence != offset as i64 + || !persisted_history_row_matches(&persisted, expected) + { + return Err(history_append_constraint(format!( + "seed_session_with_messages conflict: native row {} already exists with different content, ownership, or sequence", + expected.id + ))); + } + } + if exact_rows == rows.len() && existing as usize == rows.len() { + // A previous seed committed the complete deterministic + // native transcript but lost its response. The exact rows + // are the durable receipt, so retry is a no-op. + return tx.commit(); + } + return Err(history_append_constraint(format!( + "seed_session_with_messages conflict: {exact_rows} of {} expected native rows exist among {existing} session row(s); transcripts are immutable, refusing a mixed or unrelated seed", + rows.len() + ))); + } + } else { + let next_sequence = tx.query_row( + "SELECT COALESCE(MAX(sequence), -1) + 1 FROM agent_messages WHERE session_id = ?1", + [session_id], + |row| row.get(0), + )?; + let mut existing_count = 0usize; + let mut first_existing_sequence = None; + for (offset, expected) in rows.iter().enumerate() { + let persisted = persisted_history_row(&tx, &expected.id)?; + let Some(persisted) = persisted else { + continue; + }; + existing_count += 1; + let first_sequence = *first_existing_sequence.get_or_insert(persisted.sequence); + let expected_sequence = first_sequence.saturating_add(offset as i64); + if persisted.sequence != expected_sequence + || !persisted_history_row_matches(&persisted, expected) + { + let message = format!( + "history append conflict: native row {} already exists with different content, ownership, or sequence", + expected.id + ); + return Err(history_append_constraint(message)); + } + } + if existing_count == rows.len() { + // A previous attempt committed the entire deterministic + // suffix but lost its response. Treat the exact durable rows + // as the authoritative receipt and do not append them again. + return tx.commit(); } + if existing_count > 0 { + return Err(history_append_constraint(format!( + "history append conflict: {existing_count} of {} native rows already exist; refusing a mixed suffix", + rows.len() + ))); + } + next_sequence }; - if existing > 0 { - let _ = conn.execute_batch("ROLLBACK"); - return Err(rusqlite::Error::SqliteFailure( - rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT), - Some(format!( - "seed_session_with_messages refused: session {session_id} already has {existing} message row(s); transcripts are immutable — use append_compact_boundary" - )), - )); + + if validation == HistoryRowsValidation::MaterializedToolGraph { + validate_materialized_tool_graph(&tx, session_id, !require_empty, rows)?; } - for (sequence, row) in rows.iter().enumerate() { - let result = conn.execute( + for (offset, row) in rows.iter().enumerate() { + let sequence = next_sequence + offset as i64; + let compact_from_sequence = row + .compact_from_sequence + .map(|_| sequence.saturating_add(1)); + tx.execute( "INSERT INTO agent_messages - (id, session_id, role, content, tool_name, tool_call_id, tool_input, tool_output, model, sequence, created_at, images) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)", + (id, session_id, role, content, tool_name, tool_call_id, tool_input, tool_output, model, sequence, created_at, images, compact_from_sequence) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", params![ row.id, row.session_id, @@ -626,27 +964,79 @@ pub fn seed_session_with_messages( row.tool_input, row.tool_output, row.model, - sequence as i64, + sequence, row.created_at, row.images, + compact_from_sequence, ], - ); - if let Err(err) = result { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); - } + )?; } - if let Err(err) = conn.execute( + let now = Utc::now().to_rfc3339(); + tx.execute( "UPDATE agent_sessions SET updated_at = ?2 WHERE session_id = ?1", params![session_id, now], - ) { - let _ = conn.execute_batch("ROLLBACK"); - return Err(err); - } + )?; + tx.commit() + }) +} - conn.execute_batch("COMMIT")?; - Ok(()) +/// Replace a session's persisted transcript with a compacted LLM history view. +/// +/// **Seeding only.** This is the durable bootstrap used by compact-fork: +/// it writes an initial transcript into a *fresh* session id. It refuses +/// to run against a session that already has messages — in-place +/// compaction must use [`append_compact_boundary`] instead, which never +/// rewrites or deletes existing rows (immutable transcript invariant). +/// The destructive DELETE+INSERT variant of this function is what +/// destroyed session transcripts when `created_at`-based truncation met +/// rewritten timestamps (2026-06-11 incident). +pub fn seed_session_with_messages( + session_id: &str, + compacted_messages: &[serde_json::Value], +) -> SqliteResult<()> { + let rows = compacted_history_rows(session_id, compacted_messages); + persist_history_rows(session_id, &rows, true, HistoryRowsValidation::None) +} + +/// Seed a fresh Agent session from typed canonical history. +/// +/// The returned row count is the durable materialization receipt. Exact +/// retries are accepted by [`persist_history_rows`]; mixed or conflicting +/// retries fail without appending a partial suffix. +pub fn seed_session_with_materialized_history( + session_id: &str, + seeds: &[MaterializedHistorySeed], +) -> SqliteResult { + let rows = materialized_history_rows(session_id, seeds)?; + persist_history_rows( + session_id, + &rows, + true, + HistoryRowsValidation::MaterializedToolGraph, + )?; + Ok(MaterializedHistoryReceipt { + row_count: rows.len(), + }) +} + +/// Append typed canonical history to an existing Agent session atomically. +pub fn append_session_with_materialized_history( + session_id: &str, + seeds: &[MaterializedHistorySeed], +) -> SqliteResult { + if seeds.is_empty() { + return Ok(MaterializedHistoryReceipt { row_count: 0 }); + } + let rows = materialized_history_rows(session_id, seeds)?; + persist_history_rows( + session_id, + &rows, + false, + HistoryRowsValidation::MaterializedToolGraph, + )?; + Ok(MaterializedHistoryReceipt { + row_count: rows.len(), }) } @@ -944,6 +1334,57 @@ mod tests { use database::db::get_connection; use test_helpers::test_env; + fn materialized_message( + id: &str, + created_at: &str, + role: MaterializedHistoryRole, + text: &str, + ) -> MaterializedHistorySeed { + MaterializedHistorySeed { + id: id.to_string(), + created_at: created_at.to_string(), + content: MaterializedHistoryContent::Message { + role, + text: text.to_string(), + images: Vec::new(), + }, + } + } + + fn materialized_tool_call( + id: &str, + created_at: &str, + call_id: &str, + name: &str, + ) -> MaterializedHistorySeed { + MaterializedHistorySeed { + id: id.to_string(), + created_at: created_at.to_string(), + content: MaterializedHistoryContent::ToolCall { + call_id: call_id.to_string(), + name: name.to_string(), + arguments: "{}".to_string(), + }, + } + } + + fn materialized_tool_result( + id: &str, + created_at: &str, + call_id: &str, + name: &str, + ) -> MaterializedHistorySeed { + MaterializedHistorySeed { + id: id.to_string(), + created_at: created_at.to_string(), + content: MaterializedHistoryContent::ToolResult { + call_id: call_id.to_string(), + name: name.to_string(), + output: "done".to_string(), + }, + } + } + fn seed_session_for_message_tests(session_id: &str) { let conn = get_connection().expect("get_connection in seed_session_for_message_tests"); crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); @@ -1163,6 +1604,282 @@ mod tests { assert_eq!(history[2]["content"], "recent assistant"); } + #[test] + fn native_materialization_preserves_portable_message_identity() { + let _sandbox = test_env::sandbox(); + let session_id = "seed-native-identity-test"; + seed_session_for_message_tests(session_id); + seed_session_with_materialized_history( + session_id, + &[materialized_message( + "org2-turn-v1.dHVybi0x.c291cmNlLTE.nonce", + "2026-08-29T00:00:00Z", + MaterializedHistoryRole::User, + "continue", + )], + ) + .expect("seed native identity"); + + let rows = load_messages(session_id).expect("load native identity"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "org2-turn-v1.dHVybi0x.c291cmNlLTE.nonce"); + assert_eq!(rows[0].created_at, "2026-08-29T00:00:00Z"); + } + + #[test] + fn native_materialization_accepts_an_exact_fully_seeded_retry() { + let _sandbox = test_env::sandbox(); + let session_id = "seed-native-idempotent-retry-test"; + seed_session_for_message_tests(session_id); + let transcript = [materialized_message( + "org2-native-v1.c291cmNlLTE.target", + "2026-08-29T00:00:00Z", + MaterializedHistoryRole::User, + "continue", + )]; + + seed_session_with_materialized_history(session_id, &transcript) + .expect("seed native transcript"); + seed_session_with_materialized_history(session_id, &transcript) + .expect("retry exact native seed"); + + let rows = load_messages(session_id).expect("load native transcript"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "org2-native-v1.c291cmNlLTE.target"); + assert_eq!(rows[0].sequence, 0); + } + + #[test] + fn typed_materialization_preserves_native_role_and_tool_order() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-history-test"; + seed_session_for_message_tests(session_id); + seed_session_with_materialized_history( + session_id, + &[materialized_message( + "user-1", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "first", + )], + ) + .expect("seed prefix"); + + append_session_with_materialized_history( + session_id, + &[ + materialized_message( + "assistant-1", + "2026-08-30T00:00:01Z", + MaterializedHistoryRole::Assistant, + "answer", + ), + MaterializedHistorySeed { + id: "tool-call-1".to_string(), + created_at: "2026-08-30T00:00:02Z".to_string(), + content: MaterializedHistoryContent::ToolCall { + call_id: "call-1".to_string(), + name: "read_file".to_string(), + arguments: "{\"path\":\"README.md\"}".to_string(), + }, + }, + MaterializedHistorySeed { + id: "tool-result-1".to_string(), + created_at: "2026-08-30T00:00:03Z".to_string(), + content: MaterializedHistoryContent::ToolResult { + call_id: "call-1".to_string(), + name: "read_file".to_string(), + output: "contents".to_string(), + }, + }, + ], + ) + .expect("append native suffix"); + + let history = load_llm_history(session_id).expect("load appended history"); + assert_eq!(history.len(), 4); + assert_eq!(history[0]["content"], "first"); + assert_eq!(history[1]["content"], "answer"); + assert_eq!(history[2]["tool_calls"][0]["id"], "call-1"); + assert_eq!(history[3]["tool_call_id"], "call-1"); + let rows = load_messages(session_id).expect("load raw rows"); + assert_eq!( + rows.iter().map(|row| row.sequence).collect::>(), + vec![0, 1, 2, 3] + ); + } + + #[test] + fn typed_materialization_preserves_partial_call_then_closes_it_in_a_suffix() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-partial-tool-call-test"; + seed_session_for_message_tests(session_id); + seed_session_with_materialized_history( + session_id, + &[materialized_tool_call( + "tool-call-1", + "2026-08-30T00:00:00Z", + "call-1", + "read_file", + )], + ) + .expect("an unresolved call is valid partial-turn history"); + + append_session_with_materialized_history( + session_id, + &[materialized_tool_result( + "tool-result-1", + "2026-08-30T00:00:01Z", + "call-1", + "read_file", + )], + ) + .expect("a later suffix may close the persisted unresolved call"); + + let rows = load_messages(session_id).expect("load partial call history"); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].role, shared::message_role::TOOL_CALL); + assert_eq!(rows[1].role, shared::message_role::TOOL_RESULT); + } + + #[test] + fn typed_materialization_rejects_orphan_and_mismatched_tool_results() { + let _sandbox = test_env::sandbox(); + let orphan_session = "append-native-orphan-tool-result-test"; + seed_session_for_message_tests(orphan_session); + let orphan = materialized_tool_result( + "tool-result-orphan", + "2026-08-30T00:00:00Z", + "missing-call", + "read_file", + ); + assert!(seed_session_with_materialized_history(orphan_session, &[orphan]).is_err()); + assert!(load_messages(orphan_session) + .expect("load rejected orphan history") + .is_empty()); + + let mismatch_session = "append-native-mismatched-tool-result-test"; + seed_session_for_message_tests(mismatch_session); + seed_session_with_materialized_history( + mismatch_session, + &[materialized_tool_call( + "tool-call-1", + "2026-08-30T00:00:00Z", + "call-1", + "read_file", + )], + ) + .expect("seed unresolved call"); + let mismatch = materialized_tool_result( + "tool-result-1", + "2026-08-30T00:00:01Z", + "call-1", + "write_file", + ); + assert!(append_session_with_materialized_history(mismatch_session, &[mismatch]).is_err()); + assert_eq!( + load_messages(mismatch_session) + .expect("load history after name mismatch") + .len(), + 1, + "the invalid suffix must be rejected atomically" + ); + } + + #[test] + fn typed_materialization_rejects_duplicate_tool_call_ids() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-duplicate-tool-call-test"; + seed_session_for_message_tests(session_id); + let duplicate_calls = [ + materialized_tool_call("tool-call-1", "2026-08-30T00:00:00Z", "call-1", "read_file"), + materialized_tool_call("tool-call-2", "2026-08-30T00:00:01Z", "call-1", "read_file"), + ]; + + assert!(seed_session_with_materialized_history(session_id, &duplicate_calls).is_err()); + assert!(load_messages(session_id) + .expect("load rejected duplicate-call history") + .is_empty()); + } + + #[test] + fn typed_materialization_accepts_a_fully_applied_suffix_once() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-idempotent-suffix-test"; + seed_session_for_message_tests(session_id); + let suffix = [materialized_message( + "org2-native-v1.c291cmNlLTE.target", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::Assistant, + "answer", + )]; + + append_session_with_materialized_history(session_id, &suffix) + .expect("append native suffix"); + append_session_with_materialized_history(session_id, &suffix) + .expect("retry committed suffix"); + + let rows = load_messages(session_id).expect("load idempotent suffix"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "org2-native-v1.c291cmNlLTE.target"); + assert_eq!(rows[0].sequence, 0); + } + + #[test] + fn typed_materialization_rejects_missing_identity_metadata() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-missing-identity-test"; + seed_session_for_message_tests(session_id); + let missing_id = materialized_message( + "", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "first", + ); + + assert!(append_session_with_materialized_history(session_id, &[missing_id]).is_err()); + assert!(load_messages(session_id) + .expect("load rows after rejected append") + .is_empty()); + } + + #[test] + fn typed_materialization_rejects_mixed_or_conflicting_suffixes() { + let _sandbox = test_env::sandbox(); + let session_id = "append-native-conflicting-suffix-test"; + seed_session_for_message_tests(session_id); + let first = materialized_message( + "org2-native-v1.Zmlyc3Q.target", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "first", + ); + append_session_with_materialized_history(session_id, std::slice::from_ref(&first)) + .expect("append first native row"); + + let mixed = [ + first.clone(), + materialized_message( + "org2-native-v1.c2Vjb25k.target", + "2026-08-30T00:00:01Z", + MaterializedHistoryRole::Assistant, + "second", + ), + ]; + assert!(append_session_with_materialized_history(session_id, &mixed).is_err()); + + let conflict = [materialized_message( + "org2-native-v1.Zmlyc3Q.target", + "2026-08-30T00:00:00Z", + MaterializedHistoryRole::User, + "different", + )]; + assert!(append_session_with_materialized_history(session_id, &conflict).is_err()); + let rows = load_messages(session_id).expect("load rows after rejected suffixes"); + assert_eq!(rows.len(), 1, "failed retries must not append partial rows"); + assert_eq!(rows[0].content, "first"); + } + #[test] fn truncate_anchor_resolution_fails_loud_for_missing_rows() { let _sandbox = test_env::sandbox(); diff --git a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs index ffaf217141..98a0277344 100644 --- a/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs +++ b/src-tauri/crates/agent-core/src/core/session/persistence/mod.rs @@ -43,17 +43,19 @@ pub use sidebar::{ }; pub use messages::{ - anchor_at_or_after_created_at, append_compact_boundary, clear_messages, - clear_session_memory_state, compact_cutoff_sequence, - load_agent_org_inbox_transcript_materializations, load_llm_history, + anchor_at_or_after_created_at, append_compact_boundary, + append_session_with_materialized_history, clear_messages, clear_session_memory_state, + compact_cutoff_sequence, load_agent_org_inbox_transcript_materializations, load_llm_history, load_llm_history_start_sequences, load_llm_history_text_only, load_llm_history_text_only_bounded, load_messages, load_session_memory_state, mark_turn_cancelled, materialize_agent_org_inbox_transcript, message_anchor, message_created_at, save_assistant_msg, save_compact_summary_msg, save_session_memory_state, save_snapshot, save_subagent_transcript, save_tool_call_msg, save_tool_result_msg, - save_user_msg, save_user_msg_with_id, seed_session_with_messages, take_turn_cancelled, - truncate_messages_from_sequence, update_compact_boundary_token_delta, - AgentOrgInboxTranscriptMaterialization, MessageAnchor, + save_user_msg, save_user_msg_with_id, seed_session_with_materialized_history, + seed_session_with_messages, take_turn_cancelled, truncate_messages_from_sequence, + update_compact_boundary_token_delta, AgentOrgInboxTranscriptMaterialization, + MaterializedHistoryContent, MaterializedHistoryReceipt, MaterializedHistoryRole, + MaterializedHistorySeed, MessageAnchor, }; use rusqlite::{Connection, Result as SqliteResult}; diff --git a/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs b/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs index 40d8236ed9..987dbe1de4 100644 --- a/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs +++ b/src-tauri/crates/agent-core/src/state/commands/session/compaction.rs @@ -82,6 +82,50 @@ impl ManualCompactCommandResult { } } +/// Return the canonical in-memory session whose scheduler owns maintenance +/// exclusion, initializing an old persisted session exactly like the normal +/// send path when necessary. Callers must enqueue their mutation on the +/// returned session's [`crate::session::DialogScheduler`]; merely obtaining +/// the handle is not an exclusion boundary. +pub async fn prepare_session_for_scheduler_maintenance( + state: &AgentAppState, + session_id: &str, +) -> Result, String> { + // CLI transcripts belong to their provider. They must never initialize + // an unrelated Agent runtime merely because the shared composer invoked + // this maintenance entry point with a CLI conversation root. + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + return Err("Native CLI compaction is owned by the provider runtime".to_string()); + } + let needs_init = match state.get_session(session_id).await { + Some(session) => session.get_runtime().await.is_none(), + None => true, + }; + if needs_init { + let identity = super::identity::resolve_session_identity( + state, + session_id, + super::identity::IdentityOverrides::default(), + ) + .await?; + let launch_spec = crate::init::launch_spec::AgentLaunchSpec::from_session_sources( + state, + session_id, + identity.workspace_root, + identity.account_id, + Some(identity.model), + identity.native_harness_type, + ) + .await?; + crate::init::init_session(state, launch_spec).await?; + } + + state + .get_session(session_id) + .await + .ok_or_else(|| format!("Session {session_id} missing after runtime initialization")) +} + /// Desktop-only manual compaction. Unlike gateway `/compact`, this rewrites the /// visible durable transcript in-place by appending a compact boundary and does /// not fork the session. @@ -119,56 +163,14 @@ pub async fn agent_session_manual_compact( // resolved config. Initialize on demand exactly like `agent_send_message` // does (idempotent fast-path when already live) instead of bouncing the // user with "send a message first". - let needs_init = match state.get_session(&session_id).await { - Some(session) => session.get_runtime().await.is_none(), - None => true, - }; - if needs_init { - let identity = match super::identity::resolve_session_identity( - state.inner(), - &session_id, - super::identity::IdentityOverrides::default(), - ) - .await - { - Ok(identity) => identity, - Err(err) => { - return Ok(ManualCompactCommandResult::failed(format!( - "session runtime init failed: {}", - err - ))); - } - }; - let launch_spec = match crate::init::launch_spec::AgentLaunchSpec::from_session_sources( - state.inner(), - &session_id, - identity.workspace_root, - identity.account_id, - Some(identity.model), - identity.native_harness_type, - ) - .await - { - Ok(spec) => spec, - Err(err) => { - return Ok(ManualCompactCommandResult::failed(format!( - "session runtime init failed: {}", - err - ))); - } - }; - if let Err(err) = crate::init::init_session(state.inner(), launch_spec).await { + let session = match prepare_session_for_scheduler_maintenance(state.inner(), &session_id).await + { + Ok(session) => session, + Err(err) => { return Ok(ManualCompactCommandResult::failed(format!( - "session runtime init failed: {}", - err - ))); + "session runtime init failed: {err}" + ))) } - } - - let Some(session) = state.get_session(&session_id).await else { - return Ok(ManualCompactCommandResult::status( - ManualCompactStatus::NoRuntime, - )); }; // Always enqueue maintenance, even while a turn is running. The scheduler @@ -583,3 +585,26 @@ fn already_compact_result( boundary: None, } } + +#[cfg(test)] +mod runtime_ownership_tests { + use super::*; + + #[tokio::test] + async fn cli_compaction_cannot_initialize_an_agent_runtime() { + let _sandbox = test_helpers::test_env::sandbox(); + let conn = database::db::get_connection().expect("sandbox database"); + crate::persistence::test_schema::ensure_agent_sessions_schema(&conn); + unified_persistence::init(&conn).expect("session schema"); + let state = AgentAppState::new(); + let session_id = "cliagent-manual-compact-owner"; + let result = prepare_session_for_scheduler_maintenance(&state, session_id).await; + assert!( + matches!(result, Err(ref error) if error.contains("owned by the provider runtime")) + ); + assert!(state.get_session(session_id).await.is_none()); + assert!(unified_persistence::get_session(session_id) + .unwrap() + .is_none()); + } +} diff --git a/src-tauri/crates/app-paths/src/home.rs b/src-tauri/crates/app-paths/src/home.rs index f1cad7660a..d1de864686 100644 --- a/src-tauri/crates/app-paths/src/home.rs +++ b/src-tauri/crates/app-paths/src/home.rs @@ -20,6 +20,21 @@ pub fn external_history_home_dir() -> PathBuf { external_history_home_override().unwrap_or_else(home_dir) } +/// User-home root where newly materialized provider-native transcripts live. +/// +/// Production shares the ordinary external-history home so continuations are +/// visible in the provider's native app. Tests may separate bounded discovery +/// from publication with `ORGII_NATIVE_TRANSCRIPT_HOME`. +pub fn native_transcript_home_dir() -> PathBuf { + native_transcript_home_override().unwrap_or_else(external_history_home_dir) +} + +fn native_transcript_home_override() -> Option { + std::env::var_os("ORGII_NATIVE_TRANSCRIPT_HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + fn external_history_home_override() -> Option { std::env::var_os("ORGII_EXTERNAL_HISTORY_HOME") .filter(|value| !value.is_empty()) @@ -188,6 +203,31 @@ mod tests { ); } + #[test] + fn native_transcript_home_defaults_to_external_history_home() { + let _lock = env_lock(); + let _native = EnvVarGuard::unset("ORGII_NATIVE_TRANSCRIPT_HOME"); + let _external = EnvVarGuard::set("ORGII_EXTERNAL_HISTORY_HOME", "/tmp/orgii-discovery"); + + assert_eq!( + native_transcript_home_dir(), + PathBuf::from("/tmp/orgii-discovery") + ); + } + + #[test] + fn native_transcript_home_can_be_separate_from_discovery() { + let _lock = env_lock(); + let _external = EnvVarGuard::set("ORGII_EXTERNAL_HISTORY_HOME", "/tmp/orgii-discovery"); + let _native = EnvVarGuard::set("ORGII_NATIVE_TRANSCRIPT_HOME", "/Users/tester"); + + assert_eq!(native_transcript_home_dir(), PathBuf::from("/Users/tester")); + assert_eq!( + external_history_home_dir(), + PathBuf::from("/tmp/orgii-discovery") + ); + } + #[test] fn xdg_config_dir_is_none_under_isolation_override() { let _lock = env_lock(); diff --git a/src-tauri/crates/integrations/src/cli_binary_resolver.rs b/src-tauri/crates/integrations/src/cli_binary_resolver.rs index 8fedf1cb0a..5d2398b9dd 100644 --- a/src-tauri/crates/integrations/src/cli_binary_resolver.rs +++ b/src-tauri/crates/integrations/src/cli_binary_resolver.rs @@ -406,6 +406,23 @@ pub fn resolve_cli_binary_command(id: CliBinaryId) -> String { resolve_cli_binary(id).command } +/// Resolve a CLI command after checking caller-owned, higher-priority paths. +/// +/// Product-specific callers own which paths are preferred (for example an +/// executable bundled inside a native desktop App). This shared resolver +/// remains the single owner of executable validation and of the ordinary +/// PATH/login-shell/known-location fallback chain. +pub fn resolve_cli_binary_command_preferring( + id: CliBinaryId, + preferred_paths: impl IntoIterator, +) -> String { + preferred_paths + .into_iter() + .find(|path| is_executable_file(path)) + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| resolve_cli_binary_command(id)) +} + /// Best-effort ` --version` probe. /// /// Callers own the cache policy. This function resolves no credentials and @@ -804,6 +821,27 @@ mod tests { assert!(resolution.installed()); } + #[test] + fn preferred_paths_reuse_executable_validation_before_normal_resolution() { + let temp_dir = tempfile::tempdir().unwrap(); + let non_executable = temp_dir.path().join("old-codex"); + let executable = temp_dir.path().join("app-bundled-codex"); + fs::write(&non_executable, "not executable").unwrap(); + make_executable(&executable); + + assert_eq!( + resolve_cli_binary_command_preferring( + CliBinaryId::Codex, + [ + temp_dir.path().join("missing"), + non_executable, + executable.clone(), + ], + ), + executable.to_string_lossy() + ); + } + #[test] fn cursor_known_location_fallback_is_preserved() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs b/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs index d8db5cc0c6..baf07afd48 100644 --- a/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs +++ b/src-tauri/crates/key-vault/src/key_store/agent_env_builder.rs @@ -11,6 +11,14 @@ const ZENMUX_ANTHROPIC_BASE_URL: &str = "https://zenmux.ai/api/anthropic"; const LONGCAT_OPENAI_BASE_URL: &str = "https://api.longcat.chat/openai"; const LONGCAT_ANTHROPIC_BASE_URL: &str = "https://api.longcat.chat/anthropic"; const ATLASCLOUD_ANTHROPIC_BASE_URL: &str = "https://api.atlascloud.ai"; +const CLAUDE_CROSS_TYPE_MODEL_ENV_KEYS: &[&str] = &[ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", + "DISABLE_INTERLEAVED_THINKING", +]; impl KeyService { /// Get environment variables for running an agent @@ -73,6 +81,14 @@ impl KeyService { } }, ModelType::ClaudeCode => { + // Rebuild Claude routing from the selected account instead of + // trusting an old env mirror. Auth methods are exclusive, the + // endpoint comes from the account's canonical base_url, and + // compatible-provider model overrides never belong to a + // native Claude account. + let stale_env_base_url = env.remove("ANTHROPIC_BASE_URL"); + env.remove("ANTHROPIC_API_KEY"); + env.remove("ANTHROPIC_AUTH_TOKEN"); if entry.auth_method == AuthMethod::Oauth { if let Some(token) = entry .session_token @@ -81,8 +97,15 @@ impl KeyService { { env.insert("ANTHROPIC_AUTH_TOKEN".to_string(), token.to_string()); } - } else if let Some(ref key) = entry.api_key { - env.insert("ANTHROPIC_API_KEY".to_string(), key.clone()); + } else { + if let Some(ref key) = entry.api_key { + env.insert("ANTHROPIC_API_KEY".to_string(), key.clone()); + } + } + if !is_cross_type { + for key in CLAUDE_CROSS_TYPE_MODEL_ENV_KEYS { + env.remove(*key); + } } // Official Claude OAuth tokens (sk-ant-oat…) only authenticate // at api.anthropic.com. A non-official base_url on such a row @@ -97,16 +120,14 @@ impl KeyService { .as_deref() .is_some_and(is_claude_official_oauth_token); if official_oauth - && !is_official_anthropic_endpoint( - env.get("ANTHROPIC_BASE_URL").map(String::as_str), - ) + && !is_official_anthropic_endpoint(stale_env_base_url.as_deref()) + && stale_env_base_url.is_some() { tracing::warn!( "[agent_env_builder] Claude OAuth key {} has a non-official ANTHROPIC_BASE_URL env var; \ official OAuth tokens only authenticate at api.anthropic.com — dropping it", entry.id ); - env.remove("ANTHROPIC_BASE_URL"); } let official_oauth_with_stale_base_url = official_oauth && !is_official_anthropic_endpoint(entry.base_url.as_deref()); diff --git a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs index e9d4c84d16..55329832bf 100644 --- a/src-tauri/crates/key-vault/src/key_store/tests/tests.rs +++ b/src-tauri/crates/key-vault/src/key_store/tests/tests.rs @@ -862,6 +862,21 @@ fn test_claude_code_official_oauth_env_drops_stale_relay_base_url() { "ANTHROPIC_BASE_URL".to_string(), "https://relay.example.com/v1".to_string(), ); + claude_key.env_vars.insert( + "ANTHROPIC_API_KEY".to_string(), + "stale-atlas-key".to_string(), + ); + claude_key + .env_vars + .insert("ANTHROPIC_MODEL".to_string(), "zai-org/glm-5.2".to_string()); + claude_key.env_vars.insert( + "ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), + "zai-org/glm-5.2".to_string(), + ); + claude_key.env_vars.insert( + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS".to_string(), + "1".to_string(), + ); let key_id = claude_key.id.clone(); service.save_key(claude_key).unwrap(); @@ -871,6 +886,10 @@ fn test_claude_code_official_oauth_env_drops_stale_relay_base_url() { Some("sk-ant-oat01-abc"), ); assert!(!env.contains_key("ANTHROPIC_BASE_URL")); + assert!(!env.contains_key("ANTHROPIC_API_KEY")); + assert!(!env.contains_key("ANTHROPIC_MODEL")); + assert!(!env.contains_key("ANTHROPIC_DEFAULT_OPUS_MODEL")); + assert!(!env.contains_key("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS")); } #[test] @@ -1507,6 +1526,13 @@ fn test_cross_type_exact_match_takes_priority() { let mut claude_key = ModelKey::new(ModelType::ClaudeCode); claude_key.api_key = Some("sk-ant-native".to_string()); + claude_key.env_vars.insert( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "stale-oauth".to_string(), + ); + claude_key + .env_vars + .insert("ANTHROPIC_MODEL".to_string(), "zai-org/glm-5.2".to_string()); let claude_id = claude_key.id.clone(); service.save_key(claude_key).unwrap(); @@ -1515,6 +1541,8 @@ fn test_cross_type_exact_match_takes_priority() { env.get("ANTHROPIC_API_KEY").map(|v| v.as_str()), Some("sk-ant-native"), ); + assert!(!env.contains_key("ANTHROPIC_AUTH_TOKEN")); + assert!(!env.contains_key("ANTHROPIC_MODEL")); } #[test] diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs index 8bb862a40b..984347e716 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs @@ -28,7 +28,9 @@ const CLAUDE_CODE_PROVIDER_SLUG: &str = "claudecode"; // survive Claude Code rewriting the first user message during compaction. // v12: name subagent rows from their small `.meta.json` sidecar instead of // the shared beginning of each child prompt. -const CLAUDE_CODE_METADATA_PARSER_VERSION: i64 = 14; +// v15: compact summaries are provider context metadata, not human turns or +// first-prompt title candidates. +const CLAUDE_CODE_METADATA_PARSER_VERSION: i64 = 15; const MAX_COMPACT_BOUNDARY_MARKERS: usize = crate::sources::imported_history::cache::MAX_CONTINUATION_MARKERS - 1; @@ -38,7 +40,7 @@ pub type ClaudeCodeHistorySessionPage = pub type ClaudeCodeRecentPath = crate::sources::imported_history::ImportedHistoryRecentPath; pub use cache_sync::{list_claude_code_history_sessions_paginated, list_claude_code_recent_paths}; -pub use replay::load_claude_code_history_for_session; +pub use replay::{load_claude_code_history_for_session, load_claude_code_history_from_path}; pub use windows::{ load_claude_code_cloud_turn_windows_for_session, load_claude_code_initial_window_for_session, load_claude_code_turn_ids_for_session, load_claude_code_turn_index_for_session, @@ -74,8 +76,6 @@ use metadata::{ parse_claude_session_meta_with_title, session_meta_to_cache_input, }; #[cfg(test)] -use replay::load_claude_code_history_from_path; -#[cfg(test)] use windows::{ claude_window_turn_id, index_claude_user_turns, load_claude_code_cloud_turn_windows_from_path, load_claude_code_initial_window_from_path, load_claude_turn_range, overlay_indexed_body_counts, diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs index c27970c53b..02ee07cd44 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/cache_sync.rs @@ -53,7 +53,7 @@ fn sync_claude_code_history_cache(conn: &mut Connection) -> Result<(), String> { SOURCE_CLAUDE_CODE, )?; for record in &mut discovered { - managed_mirror::append_managed_fingerprint( + managed_mirror::append_managed_origin_fingerprint( &mut record.source_fingerprint, managed_ids.contains(&record.source_session_id), ); @@ -95,11 +95,10 @@ fn sync_claude_code_history_cache(conn: &mut Connection) -> Result<(), String> { &parse.watermark, )?; if let Some(mut meta) = parse.meta { - let is_managed_history_mirror = managed_ids.contains(&meta.source_session_id); reparsed_ids.push(meta.session_id.clone()); rounds.append(&mut meta.rounds); let mut input = session_meta_to_cache_input(meta); - input.listable = input.listable && !is_managed_history_mirror; + managed_mirror::apply_managed_history_mirror(&mut input, &managed_ids); inputs.push(input); } } @@ -109,6 +108,10 @@ fn sync_claude_code_history_cache(conn: &mut Connection) -> Result<(), String> { imported_cache::live_ids_from_signatures(&signatures), inputs, )?; + // Provenance is stored in the transcript and therefore outlives the + // local binding ledger. Repair older cached mirrors even when their files + // are unchanged and the incremental parser correctly skipped them. + managed_mirror::demote_org2_origin_mirrors_from_conn(conn, SOURCE_CLAUDE_CODE)?; imported_cache::write_session_rounds_from_conn(conn, &reparsed_ids, &rounds)?; // Context-window continuations rewrite the conversation into a new // session file with the same first-user-message uuid; keep only the diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/discovery.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/discovery.rs index 2a7f14c7ce..b0177badda 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/discovery.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/discovery.rs @@ -28,6 +28,7 @@ pub(super) fn discover_claude_code_history_records( ) -> Result { let mut records = Vec::new(); let mut external_titles = HashMap::new(); + let mut discovered_files = HashSet::new(); for projects_dir in projects_dirs { if !projects_dir.is_dir() { continue; @@ -47,7 +48,28 @@ pub(super) fn discover_claude_code_history_records( continue; }; let (source_mtime_ms, source_size_bytes) = - imported_paths::file_metadata_signature(&path, "Claude")?; + match imported_paths::file_metadata_signature(&path, "Claude") { + Ok(signature) => signature, + // Files can disappear between directory enumeration and + // metadata lookup, and old native-materialization runs + // may leave a broken transcript symlink behind. Neither + // makes the other Claude sessions unreadable. + Err(_) if !path.exists() => continue, + Err(error) => return Err(error), + }; + // Native handoff exposes the same transcript through an account + // profile symlink. A cache row is keyed by session ID, so emitting + // both paths makes unchanged files alternate writers on every scan. + // Normalize the path as well as deduplicating it, making discovery + // stable when the native/profile root enumeration order changes. + let source_path = match fs::canonicalize(&path) { + Ok(path) => path, + Err(_) if !path.exists() => continue, + Err(error) => return Err(format!("Failed to resolve Claude transcript: {error}")), + }; + if !discovered_files.insert((file_stem.clone(), source_path.clone())) { + continue; + } let subagent_title = claude_subagent_metadata_title(&path); if let Some(title) = subagent_title.as_ref() { external_titles.insert(file_stem.clone(), title.clone()); @@ -59,7 +81,7 @@ pub(super) fn discover_claude_code_history_records( } records.push(ImportedHistoryDiscoveredRecord { source_session_id: file_stem.clone(), - source_path: path, + source_path, source_record_key: file_stem.clone(), source_mtime_ms, source_size_bytes, diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/metadata.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/metadata.rs index e136ec9648..91d33c38ce 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/metadata.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/metadata.rs @@ -17,7 +17,10 @@ use crate::sources::imported_history::{ use super::discovery::claude_session_title_for_record; use super::replay::{claude_content_items, claude_content_text}; use super::tools::{collect_claude_impact_from_item, collect_claude_impact_from_tool_result}; -use super::types::{is_harness_injected_user_line, ClaudeCodeHistoryMeta, ClaudeJsonlLine}; +use super::types::{ + is_claude_compact_summary, is_harness_injected_user_line, ClaudeCodeHistoryMeta, + ClaudeJsonlLine, +}; use super::{ CLAUDE_CODE_METADATA_PARSER_VERSION, CLAUDE_CODE_SESSION_PREFIX, MAX_COMPACT_BOUNDARY_MARKERS, }; @@ -141,8 +144,10 @@ impl ClaudeSessionMetaState { &mut self.touched_files, ); } + let compact_summary = is_claude_compact_summary(&parsed); if self.first_user_uuid.is_none() && parsed.r#type == "user" + && !compact_summary && !parsed.uuid.trim().is_empty() { self.first_user_uuid = Some(parsed.uuid.trim().to_string()); @@ -165,7 +170,11 @@ impl ClaudeSessionMetaState { } let harness_injected = is_harness_injected_user_line(&parsed); if let Some(message) = parsed.message { - if self.first_prompt.is_empty() && parsed.r#type == "user" && !harness_injected { + if self.first_prompt.is_empty() + && parsed.r#type == "user" + && !compact_summary + && !harness_injected + { if let Some(text) = claude_content_text(&message.content) { // GUI-launched runs prefix the first prompt with the // exec-mode briefing; bridge-only text is no title diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs index 390ab34164..60e12e9672 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs @@ -10,7 +10,7 @@ use crate::sources::imported_history::{self, ImportedToolCall}; use super::discovery::{claude_file_stem_from_session_id, resolve_claude_session_path}; use super::tools::{apply_claude_edit_diff, claude_tool_call_from_item}; -use super::types::{is_harness_injected_user_line, ClaudeJsonlLine}; +use super::types::{is_claude_compact_summary, is_harness_injected_user_line, ClaudeJsonlLine}; use super::CLAUDE_CODE_PROVIDER_SLUG; pub fn load_claude_code_history_for_session( @@ -22,7 +22,7 @@ pub fn load_claude_code_history_for_session( load_claude_code_history_from_path(session_id, &path) } -pub(super) fn load_claude_code_history_from_path( +pub fn load_claude_code_history_from_path( session_id: &str, path: &Path, ) -> Result, String> { @@ -42,6 +42,7 @@ pub(super) fn load_claude_code_history_from_reader( imported_history::PendingCallMap::new(); let mut sequence = start_sequence; let mut forced_first_user_id = forced_first_user_id; + let mut pending_compact_boundary: Option<(String, String)> = None; for line in reader.lines() { let line = line.map_err(|err| format!("Failed to read Claude history line: {err}"))?; @@ -58,6 +59,61 @@ pub(super) fn load_claude_code_history_from_reader( .as_deref() .map(imported_history::normalize_created_at) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + if parsed.r#type == "system" && parsed.subtype == "compact_boundary" { + if let Some((boundary_id, boundary_created_at)) = pending_compact_boundary.take() { + chunks.push(claude_context_compacted_chunk( + session_id, + sequence, + &boundary_id, + &boundary_created_at, + None, + )); + sequence += 1; + } + let boundary_id = if parsed.uuid.trim().is_empty() { + format!("boundary-{sequence}") + } else { + parsed.uuid.clone() + }; + pending_compact_boundary = Some((boundary_id, created_at)); + continue; + } + if is_claude_compact_summary(&parsed) { + let summary = parsed + .message + .as_ref() + .and_then(|message| claude_content_text(&message.content)); + let (boundary_id, boundary_created_at) = + pending_compact_boundary.take().unwrap_or_else(|| { + let id = if parsed.uuid.trim().is_empty() { + format!("summary-{sequence}") + } else { + parsed.uuid.clone() + }; + (id, created_at.clone()) + }); + chunks.push(claude_context_compacted_chunk( + session_id, + sequence, + &boundary_id, + &boundary_created_at, + summary.as_deref(), + )); + sequence += 1; + continue; + } + if parsed.message.is_some() { + if let Some((boundary_id, boundary_created_at)) = pending_compact_boundary.take() { + chunks.push(claude_context_compacted_chunk( + session_id, + sequence, + &boundary_id, + &boundary_created_at, + None, + )); + sequence += 1; + } + } let harness_injected = is_harness_injected_user_line(&parsed); let Some(message) = parsed.message else { continue; @@ -66,7 +122,7 @@ pub(super) fn load_claude_code_history_from_reader( match parsed.r#type.as_str() { "user" => { if let Some(tool_result_output) = claude_tool_result_text(&message.content) { - if let Some((call_id, output)) = tool_result_output { + if let Some((call_id, output, is_error)) = tool_result_output { if let Some(call) = pending_tool_calls.remove(&call_id) { let mut chunk = imported_history::tool_call_chunk( session_id, @@ -75,6 +131,11 @@ pub(super) fn load_claude_code_history_from_reader( &call, &output, ); + if is_error { + chunk.result["success"] = Value::Bool(false); + chunk.result["status"] = Value::String("failed".to_string()); + chunk.result["is_error"] = Value::Bool(true); + } // Edit/MultiEdit/Write results carry a // `structuredPatch`; attach it as the exact diff so // the edit card renders the real change. @@ -152,13 +213,22 @@ pub(super) fn load_claude_code_history_from_reader( } } + if let Some((boundary_id, boundary_created_at)) = pending_compact_boundary.take() { + chunks.push(claude_context_compacted_chunk( + session_id, + sequence, + &boundary_id, + &boundary_created_at, + None, + )); + } + for call in pending_tool_calls.drain_in_file_order() { - chunks.push(imported_history::tool_call_chunk( + chunks.push(imported_history::unresolved_tool_call_chunk( session_id, CLAUDE_CODE_PROVIDER_SLUG, sequence, &call, - "", )); sequence += 1; } @@ -166,6 +236,26 @@ pub(super) fn load_claude_code_history_from_reader( Ok(chunks) } +fn claude_context_compacted_chunk( + session_id: &str, + sequence: usize, + boundary_id: &str, + created_at: &str, + summary: Option<&str>, +) -> ActivityChunk { + let mut chunk = ActivityChunk::new(session_id, "context_compacted", "context_compacted"); + chunk.chunk_id = format!("claude-context-compacted-{boundary_id}-{sequence}"); + chunk.created_at = created_at.to_string(); + chunk.result = json!({ + "success": true, + "native": true, + "provider": "claude_code", + "header": "Context compacted", + "observation": summary.unwrap_or(""), + }); + chunk +} + pub(super) fn claude_content_items(content: &Value) -> Vec<&Value> { match content { Value::Array(items) => items.iter().collect(), @@ -218,7 +308,7 @@ fn claude_content_image_data_urls(content: &Value) -> Vec { .collect() } -pub(super) fn claude_tool_result_text(content: &Value) -> Option> { +pub(super) fn claude_tool_result_text(content: &Value) -> Option> { let Value::Array(items) = content else { return None; }; @@ -236,5 +326,9 @@ pub(super) fn claude_tool_result_text(content: &Value) -> Option other.to_string(), None => String::new(), }; - Some(Some((call_id, output))) + let is_error = result_item + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + Some(Some((call_id, output, is_error))) } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/tools.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/tools.rs index 87d37ecdaa..4d6371e60d 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/tools.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/tools.rs @@ -134,7 +134,12 @@ fn normalize_claude_tool_call(raw_name: &str, args: Value) -> (String, Value) { imported_history::FUNCTION_EDIT_FILE.to_string(), normalize_edit_args(raw_name, args), ), - _ => (raw_name.to_string(), args), + _ => ( + core_types::cli_alias::resolve_cli_alias(raw_name) + .map(|(storage_name, _)| storage_name.to_string()) + .unwrap_or_else(|| raw_name.to_lowercase()), + args, + ), } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/types.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/types.rs index 2ee3ae19d9..555f25b9de 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/types.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/types.rs @@ -94,6 +94,12 @@ pub(super) struct ClaudeJsonlLine { /// loop ticks) that Claude Code's own UI hides from the conversation. #[serde(default)] pub(super) is_meta: bool, + /// Claude Code writes the model-facing summary immediately after a + /// `system/compact_boundary` row as a `user` record. It is provider + /// context metadata, not a human-authored turn and must never render as + /// "Shared user" or enter ORGII's portable role transcript. + #[serde(default)] + pub(super) is_compact_summary: bool, /// Provenance of a user line. Observed kinds: `human` (typed prompt) and /// `task-notification` (background-task completion wake). #[serde(default)] @@ -113,6 +119,10 @@ pub(super) fn is_harness_injected_user_line(parsed: &ClaudeJsonlLine) -> bool { ) } +pub(super) fn is_claude_compact_summary(parsed: &ClaudeJsonlLine) -> bool { + parsed.r#type == "user" && parsed.is_compact_summary +} + #[derive(Debug, Deserialize)] pub(super) struct ClaudeMessage { /// Assistant API-response id (`msg_…`). One response is written across diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs index cc05f14dfb..2e4b8ddaaa 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs @@ -8,7 +8,9 @@ use crate::projectors::turn_metadata::ProjectedTurnMetadata; use crate::sources::imported_history; use super::super::replay::{claude_content_text, claude_tool_result_text}; -use super::super::types::{is_harness_injected_user_line, ClaudeJsonlLine}; +use super::super::types::{ + is_claude_compact_summary, is_harness_injected_user_line, ClaudeJsonlLine, +}; use super::super::CLAUDE_CODE_PROVIDER_SLUG; pub(in crate::sources::claude_code::history) const CLAUDE_WINDOW_TURN_ID_PREFIX: &str = @@ -149,7 +151,10 @@ pub(in crate::sources::claude_code::history) fn index_claude_user_turns( count_toward_previous_turn(&mut turns); continue; }; - if parsed.r#type != "user" || is_harness_injected_user_line(&parsed) { + if parsed.r#type != "user" + || is_claude_compact_summary(&parsed) + || is_harness_injected_user_line(&parsed) + { count_toward_previous_turn(&mut turns); continue; } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs index 8848195574..bb5bf1eca9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs @@ -79,6 +79,161 @@ fn parses_claude_jsonl_into_replay_chunks() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } +#[test] +fn normalizes_claude_read_tool_to_the_shared_storage_identity() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-read-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("claude-read.jsonl"); + let content = r#"{"type":"assistant","sessionId":"abc","timestamp":"2026-09-04T19:14:56Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_read","name":"Read","input":{"file_path":"/tmp/CLAUDE.md","limit":10}}]}} +{"type":"user","sessionId":"abc","timestamp":"2026-09-04T19:14:57Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_read","content":"contents"}]}}"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_claude_code_history_from_path("claudecodeapp-read", &path) + .expect("parse read tool transcript"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == imported_history::ACTION_TYPE_TOOL_CALL) + .expect("read tool call"); + + assert_eq!(tool.function, imported_history::FUNCTION_READ_FILE); + assert_eq!(tool.result["raw_tool_name"], "Read"); + assert_eq!(tool.result["call_id"], "toolu_read"); + assert_eq!(tool.args["file_path"], "/tmp/CLAUDE.md"); + assert_eq!(tool.args["limit"], 10); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn marks_an_unresolved_claude_tool_as_interrupted_not_completed() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-interrupted-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("claude-interrupted.jsonl"); + let content = r#"{"type":"user","sessionId":"abc","timestamp":"2026-08-30T01:00:00Z","message":{"role":"user","content":"inspect"}} +{"type":"assistant","sessionId":"abc","timestamp":"2026-08-30T01:00:01Z","message":{"role":"assistant","content":[{"type":"text","text":"I found one thing."}]}} +{"type":"assistant","sessionId":"abc","timestamp":"2026-08-30T01:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_interrupted","name":"Bash","input":{"command":"sleep 30"}}]}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_claude_code_history_from_path("claudecodeapp-interrupted", &path) + .expect("parse interrupted transcript"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("interrupted tool is diagnostic history"); + assert_eq!(tool.result["status"], "pending"); + assert_eq!(tool.result["interrupted"], true); + assert!(chunks.iter().any(|chunk| { + chunk.function == "assistant" + && chunk.result["content"].as_str() == Some("I found one thing.") + })); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn preserves_claude_native_tool_error_status() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-tool-error-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("claude-tool-error.jsonl"); + let content = r#"{"type":"assistant","sessionId":"abc","timestamp":"2026-08-30T01:00:02Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_failed","name":"Bash","input":{"command":"false"}}]}} +{"type":"user","sessionId":"abc","timestamp":"2026-08-30T01:00:03Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_failed","content":"exit code 1","is_error":true}]}}"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_claude_code_history_from_path("claudecodeapp-tool-error", &path) + .expect("parse failed tool result"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("failed tool is preserved"); + assert_eq!(tool.result["success"], false); + assert_eq!(tool.result["status"], "failed"); + assert_eq!(tool.result["is_error"], true); + assert_eq!(tool.result["output"], "exit code 1"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn compact_summary_is_system_metadata_not_a_shared_user_turn() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-compact-history-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("claude-compact-replay.jsonl"); + let content = r#"{"type":"user","uuid":"u-before","timestamp":"2026-08-29T07:00:00Z","message":{"role":"user","content":"inspect the repo"}} +{"type":"assistant","uuid":"a-tool","timestamp":"2026-08-29T07:00:01Z","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_before_compact","name":"Bash","input":{"command":"pwd"}}]}} +{"type":"user","uuid":"tool-result","timestamp":"2026-08-29T07:00:02Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_before_compact","content":"/repo"}]}} +{"type":"system","subtype":"compact_boundary","uuid":"compact-boundary-1","parentUuid":null,"timestamp":"2026-08-29T07:00:03Z","compactMetadata":{"trigger":"auto"}} +{"type":"queue-operation","operation":"dequeue","timestamp":"2026-08-29T07:00:03Z"} +{"type":"user","uuid":"compact-summary-1","parentUuid":"compact-boundary-1","isCompactSummary":true,"timestamp":"2026-08-29T07:00:03Z","message":{"role":"user","content":"Native compact summary; this is not a human prompt."}} +{"type":"user","uuid":"u-after","timestamp":"2026-08-29T07:00:04Z","message":{"role":"user","content":"continue after compact"}} +{"type":"assistant","uuid":"a-after","timestamp":"2026-08-29T07:00:05Z","message":{"role":"assistant","content":[{"type":"text","text":"continued"}]}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_claude_code_history_from_path("claudecodeapp-compact", &path) + .expect("parse compact transcript"); + let human_messages = chunks + .iter() + .filter(|chunk| chunk.function == imported_history::FUNCTION_USER_MESSAGE) + .map(|chunk| { + chunk.result["message"]["content"] + .as_str() + .unwrap_or_default() + }) + .collect::>(); + assert_eq!( + human_messages, + vec!["inspect the repo", "continue after compact"] + ); + assert!(!human_messages + .iter() + .any(|message| message.contains("Native compact summary"))); + let boundary = chunks + .iter() + .find(|chunk| chunk.function == "context_compacted") + .expect("compact boundary marker"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .count(), + 1 + ); + assert_eq!(boundary.action_type, "context_compacted"); + assert_eq!( + boundary.result["observation"].as_str(), + Some("Native compact summary; this is not a human prompt.") + ); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == imported_history::ACTION_TYPE_TOOL_CALL) + .expect("tool pair before compact"); + assert_eq!(tool.args["command"], "pwd"); + assert_eq!(tool.result["output"], "/repo"); + + let indexed = + index_claude_user_turns("claudecodeapp-compact", &path).expect("index compact transcript"); + assert_eq!(indexed.len(), 2, "compact summary is not a turn header"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn byte_index_discovers_rounds_without_parsing_tool_result_bodies() { let temp_dir = std::env::temp_dir().join(format!( @@ -320,8 +475,10 @@ fn harness_injected_first_line_does_not_title_session() { )); std::fs::create_dir_all(&temp_dir).expect("create temp dir"); let path = temp_dir.join("claude-synthetic-title.jsonl"); - let content = r#"{"type":"user","timestamp":"2026-04-01T07:00:00Z","isMeta":true,"message":{"role":"user","content":"Caveat: the following was run"}} -{"type":"user","timestamp":"2026-04-01T07:00:01Z","origin":{"kind":"human"},"message":{"role":"user","content":"actual request"}} + let content = r#"{"type":"system","subtype":"compact_boundary","uuid":"title-boundary","timestamp":"2026-04-01T06:59:59Z"} +{"type":"user","uuid":"title-compact-summary","isCompactSummary":true,"timestamp":"2026-04-01T06:59:59Z","message":{"role":"user","content":"provider compact summary"}} +{"type":"user","timestamp":"2026-04-01T07:00:00Z","isMeta":true,"message":{"role":"user","content":"Caveat: the following was run"}} +{"type":"user","uuid":"actual-user-uuid","timestamp":"2026-04-01T07:00:01Z","origin":{"kind":"human"},"message":{"role":"user","content":"actual request"}} {"type":"assistant","timestamp":"2026-04-01T07:00:02Z","message":{"role":"assistant","model":"claude-sonnet-4","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":1,"output_tokens":1}}} "#; std::fs::write(&path, content).expect("write fixture"); @@ -342,6 +499,7 @@ fn harness_injected_first_line_does_not_title_session() { .expect("session meta"); assert_eq!(meta.name, "actual request"); + assert_eq!(meta.first_user_uuid.as_deref(), Some("actual-user-uuid")); std::fs::remove_file(&path).expect("remove fixture"); std::fs::remove_dir(&temp_dir).expect("remove temp dir"); @@ -366,9 +524,8 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() { } std::fs::write(&path, content).expect("write fixture"); - let window = - load_claude_code_initial_window_from_path("claudecodeapp-counts", &path, 1) - .expect("load initial window"); + let window = load_claude_code_initial_window_from_path("claudecodeapp-counts", &path, 1) + .expect("load initial window"); assert_eq!(window.total_turn_count, 3); assert_eq!(window.loaded_turn_count, 1); @@ -391,7 +548,10 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() { Some(&Value::Bool(true)) ); assert_eq!( - placeholder.result.get("observation").and_then(Value::as_str), + placeholder + .result + .get("observation") + .and_then(Value::as_str), Some(format!("round {round} done").as_str()) ); // …and a real end timestamp so the collapse bar shows the round's @@ -402,7 +562,10 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() { let ended_at = placeholder.result["unloadedTurn"]["endedAt"] .as_str() .expect("endedAt"); - assert!(ended_at > started_at, "{ended_at} must be after {started_at}"); + assert!( + ended_at > started_at, + "{ended_at} must be after {started_at}" + ); } // The loaded newest round keeps its exact projected counts (no overlay). assert_eq!(window.turns[2].body_event_count, 2); @@ -450,7 +613,10 @@ fn claude_initial_window_previews_skip_tool_use_only_assistant_lines() { .find(|chunk| chunk.chunk_id.starts_with("imported-unloaded-turn-")) .expect("round 1 placeholder"); assert_eq!( - placeholder.result.get("observation").and_then(Value::as_str), + placeholder + .result + .get("observation") + .and_then(Value::as_str), Some("first reply") ); // No stray body chunks may survive next to an unloaded round: its user @@ -459,8 +625,10 @@ fn claude_initial_window_previews_skip_tool_use_only_assistant_lines() { window .chunks .iter() - .filter(|chunk| chunk.function != imported_history::FUNCTION_USER_MESSAGE - && !chunk.chunk_id.starts_with("imported-unloaded-turn-")) + .filter( + |chunk| chunk.function != imported_history::FUNCTION_USER_MESSAGE + && !chunk.chunk_id.starts_with("imported-unloaded-turn-") + ) .count(), 1 // the loaded newest round's single assistant reply ); @@ -823,6 +991,91 @@ fn prefers_claude_subagent_metadata_description_over_prompt() { std::fs::remove_dir_all(&temp_dir).expect("remove temp dir"); } +#[cfg(unix)] +#[test] +fn claude_discovery_skips_broken_transcript_symlink() { + use std::os::unix::fs::symlink; + + let temp_dir = std::env::temp_dir().join(format!( + "orgii-claude-broken-symlink-test-{}", + std::process::id() + )); + std::fs::remove_dir_all(&temp_dir).ok(); + let projects_dir = temp_dir.join("projects/project"); + std::fs::create_dir_all(&projects_dir).expect("create projects dir"); + let live_id = "11111111-1111-1111-1111-111111111111"; + std::fs::write( + projects_dir.join(format!("{live_id}.jsonl")), + format!( + r#"{{"type":"user","sessionId":"{live_id}","timestamp":"2026-08-28T00:00:00Z","message":{{"role":"user","content":"live"}}}} +"# + ), + ) + .expect("write live transcript"); + symlink( + temp_dir.join("missing-native-transcript.jsonl"), + projects_dir.join("22222222-2222-2222-2222-222222222222.jsonl"), + ) + .expect("create broken transcript symlink"); + + let previous = HashMap::new(); + let mut walker = + imported_history::scan_snapshot::SnapshotDirWalker::new(&previous, "jsonl", "Claude"); + let discovery = discover_claude_code_history_records(&[temp_dir.join("projects")], &mut walker) + .expect("broken symlink must not abort Claude discovery"); + + assert_eq!(discovery.records.len(), 1); + assert_eq!(discovery.records[0].source_session_id, live_id); + std::fs::remove_dir_all(&temp_dir).expect("remove temp dir"); +} + +#[cfg(unix)] +#[test] +fn claude_native_profile_alias_has_one_stable_discovery_signature() { + use std::os::unix::fs::symlink; + let temp = std::env::temp_dir().join(format!("orgii-claude-alias-{}", std::process::id())); + std::fs::remove_dir_all(&temp).ok(); + let native = temp.join("native/projects/project"); + let profile = temp.join("profile/projects/project"); + std::fs::create_dir_all(&native).unwrap(); + std::fs::create_dir_all(&profile).unwrap(); + let path = native.join("session.jsonl"); + std::fs::write(&path, "{\"type\":\"user\"}\n").unwrap(); + symlink(&path, profile.join("session.jsonl")).unwrap(); + let discover = |roots: &[std::path::PathBuf]| { + let snapshots = HashMap::new(); + let mut walker = + imported_history::scan_snapshot::SnapshotDirWalker::new(&snapshots, "jsonl", "Claude"); + discover_claude_code_history_records(roots, &mut walker) + .unwrap() + .records + }; + let first = discover(&[profile.clone(), native.clone()]); + assert_eq!( + first.len(), + 1, + "native and profile alias must not alternate cache writers" + ); + let second = discover(&[native.clone(), profile.clone()]); + assert_eq!(second.len(), 1); + assert!(imported_history::cache::record_matches_cached_signature( + &first[0].signature(), + &second[0].signature() + )); + std::fs::OpenOptions::new() + .append(true) + .open(&path) + .and_then(|mut f| std::io::Write::write_all(&mut f, b"{\"type\":\"assistant\"}\n")) + .unwrap(); + let appended = discover(&[profile, native]); + assert_eq!(appended.len(), 1); + assert!(!imported_history::cache::record_matches_cached_signature( + &second[0].signature(), + &appended[0].signature() + )); + std::fs::remove_dir_all(temp).unwrap(); +} + #[test] fn claude_subagent_metadata_change_invalidates_fingerprint() { let temp_dir = std::env::temp_dir().join(format!( @@ -1050,6 +1303,7 @@ fn captures_first_user_uuid_as_continuation_group_key() { let content = r#"{"type":"custom-title","customTitle":"My convo","sessionId":"d0641111-1111-1111-1111-111111111111"} {"type":"user","uuid":"b7b5ae5f-0000-0000-0000-000000000001","sessionId":"d0641111-1111-1111-1111-111111111111","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-07-17T10:00:00.000Z","message":{"role":"user","content":"first message"}} {"type":"system","subtype":"compact_boundary","uuid":"eeb66522-0000-0000-0000-000000000001","sessionId":"d0641111-1111-1111-1111-111111111111","timestamp":"2026-07-17T10:00:30.000Z"} +{"type":"user","uuid":"compact-summary-not-a-family-key","isCompactSummary":true,"sessionId":"d0641111-1111-1111-1111-111111111111","timestamp":"2026-07-17T10:00:30.000Z","message":{"role":"user","content":"provider compact summary"}} {"type":"user","uuid":"b7b5ae5f-0000-0000-0000-000000000002","sessionId":"d0641111-1111-1111-1111-111111111111","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-07-17T10:01:00.000Z","message":{"role":"user","content":"second message"}} "#; std::fs::write(&path, content).expect("write fixture"); @@ -1105,7 +1359,7 @@ fn captures_first_user_uuid_as_continuation_group_key() { } #[test] -fn strips_ide_context_from_claude_replay() { +fn strips_all_orgii_context_wrappers_from_claude_replay() { let temp_dir = std::env::temp_dir().join(format!( "orgii-claude-history-ide-context-test-{}", std::process::id() @@ -1113,9 +1367,10 @@ fn strips_ide_context_from_claude_replay() { std::fs::create_dir_all(&temp_dir).expect("create temp dir"); let path = temp_dir.join("claude-ide-context.jsonl"); // Line 1: ide_context-only user message (no user-authored text at all). - // Line 2: bridge + ide_context prefixed user message with real text. + // Line 2 matches a real continuation prompt: provider context + execution + // bridge + IDE context followed by the user-authored text. let content = r#"{"type":"user","sessionId":"abc","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-04-01T07:06:46.543Z","message":{"role":"user","content":"\nopen file: src/app.ts\n"}} -{"type":"user","sessionId":"abc","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-04-01T07:06:47.000Z","message":{"role":"user","content":"\ninternal briefing\n\n\n\nopen file: src/app.ts\n\n\nfix the login bug"}} +{"type":"user","sessionId":"abc","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-04-01T07:06:47.000Z","message":{"role":"user","content":"\nrepository rules\n\n\n\ninternal briefing\n\n\n\nopen file: src/app.ts\n\n\nfix the login bug"}} {"type":"assistant","sessionId":"abc","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-04-01T07:06:49.000Z","message":{"role":"assistant","model":"claude-sonnet-4","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":3,"output_tokens":5}}} "#; std::fs::write(&path, content).expect("write fixture"); diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs index a4c5b27d2f..adce4803fe 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/index.rs @@ -318,7 +318,7 @@ fn sync_codex_app_cache(conn: &mut Connection) -> Result<(), String> { SOURCE_CODEX_APP, )?; for record in &mut discovered { - crate::sources::imported_history::managed_mirror::append_managed_fingerprint( + crate::sources::imported_history::managed_mirror::append_managed_origin_fingerprint( &mut record.source_fingerprint, // Suffix match: the imported key is the rollout stem while the // runner binds the bare thread uuid. @@ -368,15 +368,13 @@ fn sync_codex_app_cache(conn: &mut Connection) -> Result<(), String> { &parse.watermark, )?; if let Some(mut meta) = parse.meta { - let is_managed_history_mirror = - crate::sources::imported_history::managed_mirror::is_managed_source_session_id( - &managed_ids, - &meta.source_session_id, - ); reparsed_ids.push(meta.session_id.clone()); rounds.append(&mut meta.rounds); let mut input = session_meta_to_cache_input(meta); - input.listable = input.listable && !is_managed_history_mirror; + crate::sources::imported_history::managed_mirror::apply_managed_history_mirror( + &mut input, + &managed_ids, + ); inputs.push(input); } } @@ -386,6 +384,10 @@ fn sync_codex_app_cache(conn: &mut Connection) -> Result<(), String> { imported_cache::live_ids_from_signatures(&signatures), inputs, )?; + crate::sources::imported_history::managed_mirror::demote_org2_origin_mirrors_from_conn( + conn, + SOURCE_CODEX_APP, + )?; imported_cache::write_session_rounds_from_conn(conn, &reparsed_ids, &rounds) } diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/normalize.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/normalize.rs index 52a2194840..834cb5d0ea 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/normalize.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/normalize.rs @@ -9,5 +9,7 @@ mod shell_tokenizer; mod tool_args; pub(crate) use dispatch::normalize_codex_tool_calls; -pub(in crate::sources::codex::app) use dispatch::{is_codex_shell_tool_key, normalize_tool_name_key}; +pub(in crate::sources::codex::app) use dispatch::{ + is_codex_shell_tool_key, normalize_tool_name_key, +}; pub(in crate::sources::codex::app) use tool_args::normalize_web_search_args; diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs index 1b66259cfa..daa3ce967d 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript.rs @@ -9,6 +9,8 @@ mod reader; mod tool_calls; const CODEX_PROVIDER_SLUG: &str = "codex"; +const NATIVE_SOURCE_EVENT_ID_ARG: &str = "__orgiiSourceEventId"; +const NATIVE_SOURCE_EVENT_ID_PREFIX: &str = "orgii_evt_"; pub use reader::{ load_codex_app_from_path, load_codex_app_initial_window_from_path, diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs index 19f482950b..1af36b2ceb 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/messages.rs @@ -1,10 +1,11 @@ use core_types::activity::ActivityChunk; +use serde::Deserialize; use serde_json::{json, Value}; use crate::sources::imported_history::{self, strip_orgii_exec_mode_bridge}; use super::super::CodexJsonlLine; -use super::CODEX_PROVIDER_SLUG; +use super::{CODEX_PROVIDER_SLUG, NATIVE_SOURCE_EVENT_ID_PREFIX}; const CODEX_EMBEDDED_IMAGE_MARKER: &str = "\"image_url\":\"data:image/"; const CODEX_OMITTED_IMAGE_VALUE: &str = "[embedded image omitted]"; @@ -15,6 +16,14 @@ const CODEX_OMITTED_IMAGE_VALUE: &str = "[embedded image omitted]"; /// churn. Remove the ignored payload in-place before JSON parsing while /// preserving the surrounding output array and text parts. pub(crate) fn strip_ignored_embedded_images(line: &mut String) { + // User-authored image blocks are part of the portable conversation and + // must survive a Codex -> canonical -> target-native round trip. Only + // provider/tool output images are projection-irrelevant. Inspect the + // compact JSON envelope before the first image rather than deserializing + // every repeated screenshot payload just to classify the line. + if preserves_user_embedded_images(line) { + return; + } let mut search_from = 0usize; while let Some(relative_marker) = line[search_from..].find(CODEX_EMBEDDED_IMAGE_MARKER) { let marker_start = search_from + relative_marker; @@ -28,6 +37,74 @@ pub(crate) fn strip_ignored_embedded_images(line: &mut String) { } } +fn preserves_user_embedded_images(line: &str) -> bool { + if !line.contains(CODEX_EMBEDDED_IMAGE_MARKER) { + return false; + } + + #[derive(Deserialize)] + struct Envelope<'a> { + #[serde(borrow)] + payload: Option>, + } + + #[derive(Deserialize)] + struct Payload<'a> { + #[serde(rename = "type", borrow)] + kind: Option<&'a str>, + #[serde(borrow)] + role: Option<&'a str>, + #[serde(borrow)] + item: Option>, + } + + #[derive(Deserialize)] + struct Item<'a> { + #[serde(rename = "type", borrow)] + kind: Option<&'a str>, + } + + let Ok(envelope) = serde_json::from_str::>(line) else { + return false; + }; + let Some(payload) = envelope.payload else { + return false; + }; + match payload.kind { + Some("message") => payload.role == Some("user"), + Some("user_message") => true, + Some("item_completed") => payload.item.and_then(|item| item.kind) == Some("UserMessage"), + _ => false, + } +} + +#[cfg(test)] +mod embedded_image_tests { + use super::*; + + #[test] + fn preserves_user_image_data_for_native_transfer() { + let mut line = r#"{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"inspect"},{"type":"input_image","image_url":"data:image/png;base64,USER"}]}}"#.to_string(); + strip_ignored_embedded_images(&mut line); + assert!(line.contains("data:image/png;base64,USER")); + } + + #[test] + fn preserves_reordered_user_image_envelope_for_native_transfer() { + let mut line = r#"{"payload":{"content":[{"image_url":"data:image/png;base64,USER","type":"input_image"}],"role":"user","type":"message"},"type":"response_item"}"#.to_string(); + strip_ignored_embedded_images(&mut line); + assert!(line.contains("data:image/png;base64,USER")); + } + + #[test] + fn strips_projection_irrelevant_tool_output_images() { + let mut line = r#"{"type":"response_item","payload":{"type":"custom_tool_call_output","output":[{"type":"input_image","image_url":"data:image/png;base64,TOOL"}]}}"#.to_string(); + strip_ignored_embedded_images(&mut line); + assert!(!line.contains("base64,TOOL")); + assert!(line.contains(CODEX_OMITTED_IMAGE_VALUE)); + } +} + pub(crate) fn legacy_user_message_text_from_payload(payload: &Value) -> Option { let raw = payload.get("message").and_then(Value::as_str)?; let stripped = strip_orgii_exec_mode_bridge(raw); @@ -60,6 +137,96 @@ pub(super) fn user_message_from_line(parsed: &CodexJsonlLine) -> Option Vec { + if payload.get("type").and_then(Value::as_str) != Some("message") + || payload.get("role").and_then(Value::as_str) != Some("user") + { + return Vec::new(); + } + + let mut refs = Vec::new(); + let Some(content) = payload.get("content").and_then(Value::as_array) else { + return refs; + }; + for part in content { + if part.get("type").and_then(Value::as_str) != Some("input_image") { + continue; + } + let Some(image_url) = part + .get("image_url") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| value.starts_with("data:image/")) + else { + continue; + }; + if !refs.iter().any(|existing| existing == image_url) { + refs.push(image_url.to_string()); + } + } + refs +} + +/// User rows injected through Codex app-server's supported +/// `thread/inject_items` API have no later `event_msg/UserMessage` mirror. +/// Current Codex also assigns IDs to provider context and mirrored user rows. +/// Its structured content kinds distinguish those from injected history; +/// ordinary user text is owned by the later UI message, with images staged by +/// the caller. Older injected rows without these kinds remain supported. +pub(super) fn injected_user_message_chunk_from_response_message( + session_id: &str, + sequence: usize, + created_at: &str, + payload: &Value, +) -> Option { + let has_native_item_id = payload + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| !id.trim().is_empty()); + if !has_native_item_id + || payload.get("type").and_then(Value::as_str) != Some("message") + || payload.get("role").and_then(Value::as_str) != Some("user") + { + return None; + } + + let id = payload.get("id").and_then(Value::as_str).unwrap_or_default(); + let is_orgii_injected = id.starts_with(NATIVE_SOURCE_EVENT_ID_PREFIX) + || id.strip_prefix("msg_").is_some_and(|id| id.starts_with(NATIVE_SOURCE_EVENT_ID_PREFIX)); + if !is_orgii_injected { + if let Some(kinds) = payload.pointer("/internal_chat_message_metadata_passthrough/content_item_kinds").and_then(Value::as_array) { + let has_user_mirror = kinds.iter().any(|kind| kind.as_str().is_some_and(|kind| kind.starts_with("user."))); + let only_provider_context = !kinds.is_empty() && kinds.iter().all(|kind| matches!(kind.as_str(), Some("plugins.recommendations" | "agents_md.instructions" | "environments.environment_context"))); + if has_user_mirror || only_provider_context { + return None; + } + } + } + + let raw_text = content_text_from_payload(payload).unwrap_or_default(); + let text = strip_orgii_exec_mode_bridge(&raw_text).to_string(); + let images = user_image_data_urls_from_response_message(payload); + if text.trim().is_empty() && images.is_empty() { + return None; + } + let mut chunk = imported_history::user_message_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + created_at, + &text, + ); + if !images.is_empty() { + chunk.result["images"] = json!(images); + } + Some(chunk) +} + pub(in crate::sources::codex::app) fn user_message_text_from_line( parsed: &CodexJsonlLine, ) -> Option { diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs index b2f4af087a..af17fa5e82 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/parser.rs @@ -13,8 +13,9 @@ use super::super::CodexJsonlLine; use super::cache::CodexTurnOffset; use super::collector::{CodexTranscriptCollectionMode, CodexTranscriptCollector}; use super::messages::{ - content_text_from_payload, reasoning_text_from_payload, strip_ignored_embedded_images, - user_message_chunk_from_line, + content_text_from_payload, injected_user_message_chunk_from_response_message, + reasoning_text_from_payload, strip_ignored_embedded_images, + user_image_data_urls_from_response_message, user_message_chunk_from_line, }; use super::tool_calls::{ attach_subagent_activity_to_pending_call, background_cell_id, background_cell_key, @@ -23,7 +24,7 @@ use super::tool_calls::{ pending_tool_calls_from_payload, resolve_codex_tool_outputs, wait_cell_id, web_search_call_from_payload, PendingBackgroundToolCall, }; -use super::CODEX_PROVIDER_SLUG; +use super::{CODEX_PROVIDER_SLUG, NATIVE_SOURCE_EVENT_ID_ARG, NATIVE_SOURCE_EVENT_ID_PREFIX}; type CodexTranscriptLoad = ( Vec, @@ -31,6 +32,84 @@ type CodexTranscriptLoad = ( Vec, ); +#[derive(Debug)] +struct PendingCompactionMirror { + window_id: Option, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum AssistantMirrorKind { + EventMessage, + ResponseItem, +} + +struct PendingAssistantMirror { + kind: AssistantMirrorKind, + message: String, +} + +fn attach_native_source_event_id(chunk: &mut ActivityChunk, payload: &Value) { + let Some(source_event_id) = payload + .get("id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return; + }; + let Some(args) = chunk.args.as_object_mut() else { + return; + }; + args.insert( + NATIVE_SOURCE_EVENT_ID_ARG.to_string(), + Value::String( + source_event_id + .strip_prefix("msg_") + .filter(|id| id.starts_with(NATIVE_SOURCE_EVENT_ID_PREFIX)) + .unwrap_or(source_event_id) + .to_string(), + ), + ); +} + +fn compacted_window_id(payload: &Value) -> Option { + payload + .get("window_id") + .or_else(|| payload.get("first_window_id")) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn compacted_extends_pending_window(pending: &PendingCompactionMirror, payload: &Value) -> bool { + let Some(previous_window_id) = payload + .get("previous_window_id") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + else { + return false; + }; + pending.window_id.as_deref() == Some(previous_window_id) +} + +fn should_emit_assistant_mirror( + pending: &mut Option, + kind: AssistantMirrorKind, + message: &str, +) -> bool { + let is_mirror = pending + .as_ref() + .is_some_and(|previous| previous.kind != kind && previous.message == message); + if is_mirror { + *pending = None; + return false; + } + *pending = Some(PendingAssistantMirror { + kind, + message: message.to_string(), + }); + true +} + pub(super) fn parse_codex_app_from_path_with_mode<'a>( session_id: &'a str, path: &Path, @@ -59,6 +138,30 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( let mut pending_task_turn_offset: Option = None; let mut active_task_turn_id: Option = None; let mut sequence = initial_sequence; + // Current Codex rollouts write one or more chained top-level `compacted` + // window checkpoints followed immediately by an `event_msg/context_compacted` + // UI mirror. Track that provider-owned window chain instead of guessing + // identity from timestamps: two real compactions may legitimately happen + // within a few seconds of each other. + let mut pending_compacted_mirror: Option = None; + // Codex writes one assistant message twice: once as model-context + // `response_item/message` and once as visible `event_msg/agent_message`. + // Either representation can be first and their timestamps can differ by + // a millisecond, so pair only consecutive cross-representation records. + let mut pending_assistant_mirror: Option = None; + // The model-context response item carries portable image data, while the + // following UI projection may carry only a source-machine local path. + // Pair them without emitting the response item as a duplicate user turn. + let mut pending_user_image_data_urls: Vec = Vec::new(); + // `thread/start` may persist user-role provider bootstrap (for example + // plugin/runtime context) before the first native `turn_context`. It is + // model setup, not a conversational user turn. ORG2 app-server injection + // is the one exception: its globally scoped item id is canonical history + // even when an older provider writes it before `turn_context`. + // A non-zero window starts after an already-catalogued native boundary; + // never classify its first row as thread bootstrap merely because the + // preceding `turn_context` lives outside this bounded read. + let mut before_first_turn_context = start_offset == 0; let mut line = String::new(); let mut next_byte_offset = start_offset; @@ -79,17 +182,124 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( } let parsed: CodexJsonlLine = match serde_json::from_str(trimmed) { Ok(parsed) => parsed, - Err(_) => continue, + Err(_) => { + pending_compacted_mirror = None; + pending_assistant_mirror = None; + continue; + } }; let created_at = parsed .timestamp .as_deref() .map(imported_history::normalize_created_at) .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + if parsed.line_type == "turn_context" { + before_first_turn_context = false; + } + if parsed.line_type == "compacted" { + pending_assistant_mirror = None; + let marker_id = parsed + .payload + .get("window_id") + .or_else(|| parsed.payload.get("first_window_id")) + .and_then(Value::as_str) + .unwrap_or("checkpoint"); + let summary = parsed + .payload + .get("message") + .and_then(Value::as_str) + .filter(|summary| !summary.trim().is_empty()); + let belongs_to_open_window_batch = pending_compacted_mirror + .as_ref() + .is_some_and(|pending| compacted_extends_pending_window(pending, &parsed.payload)); + if belongs_to_open_window_batch { + if let Some(existing) = collector + .current + .last_mut() + .filter(|chunk| chunk.function == "context_compacted") + { + // A single Codex compaction can persist several adjacent + // window checkpoints before its event_msg UI mirror. They + // are one logical boundary, not repeated compactions. + *existing = codex_context_compacted_chunk( + session_id, + sequence.saturating_sub(1), + marker_id, + &created_at, + summary, + ); + } + } else { + collector.current.push(codex_context_compacted_chunk( + session_id, + sequence, + marker_id, + &created_at, + summary, + )); + sequence += 1; + } + pending_compacted_mirror = Some(PendingCompactionMirror { + window_id: compacted_window_id(&parsed.payload), + }); + continue; + } let Some(payload_type) = parsed.payload.get("type").and_then(Value::as_str) else { + pending_compacted_mirror = None; + pending_assistant_mirror = None; continue; }; + let is_assistant_mirror_record = payload_type == "agent_message" + || (payload_type == "message" + && parsed.payload.get("role").and_then(Value::as_str) == Some("assistant")); + if !is_assistant_mirror_record { + pending_assistant_mirror = None; + } + + if payload_type == "context_compacted" { + if pending_compacted_mirror.take().is_some() { + continue; + } + collector.current.push(codex_context_compacted_chunk( + session_id, + sequence, + "event", + &created_at, + parsed + .payload + .get("message") + .and_then(Value::as_str) + .filter(|summary| !summary.trim().is_empty()), + )); + sequence += 1; + continue; + } + + // Usage and applied-settings notifications can separate the checkpoint + // from its UI mirror. Conversational/lifecycle records close the batch, + // so a later independent compaction remains a distinct boundary. + if !matches!(payload_type, "token_count" | "thread_settings_applied") { + pending_compacted_mirror = None; + } + + if payload_type == "context_compaction" { + let marker_id = parsed + .payload + .get("id") + .and_then(Value::as_str) + .unwrap_or("context-compaction"); + collector.current.push(codex_context_compacted_chunk( + session_id, + sequence, + marker_id, + &created_at, + None, + )); + sequence += 1; + continue; + } + match payload_type { // Codex writes task_started immediately before its user_message. // Hold it until the user chunk exists so the projector can attach @@ -103,9 +313,13 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( pending_task_turn_offset = Some(line_start_offset); } "user_message" | "item_completed" => { - if let Some(user_chunk) = + if let Some(mut user_chunk) = user_message_chunk_from_line(session_id, sequence, &created_at, &parsed) { + if !pending_user_image_data_urls.is_empty() { + user_chunk.result["images"] = + json!(std::mem::take(&mut pending_user_image_data_urls)); + } let user_sequence = sequence; sequence += 1; if collector.start_turn(user_chunk) { @@ -134,21 +348,17 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( } "agent_message" => { if let Some(message) = parsed.payload.get("message").and_then(Value::as_str) { - collector - .current - .push(imported_history::assistant_message_chunk( - session_id, - CODEX_PROVIDER_SLUG, - sequence, - &created_at, - message, - )); - sequence += 1; - } - } - "message" => { - if parsed.payload.get("role").and_then(Value::as_str) == Some("assistant") { - if let Some(text) = content_text_from_payload(&parsed.payload) { + // Synthesized/native Codex rollouts carry both the + // response_item (model context) and event_msg (visible + // thread mirror). They describe one assistant message, + // not two conversation turns. Either representation can + // be written first, so both branches use the same mirror + // predicate. + if should_emit_assistant_mirror( + &mut pending_assistant_mirror, + AssistantMirrorKind::EventMessage, + message, + ) { collector .current .push(imported_history::assistant_message_chunk( @@ -156,12 +366,73 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( CODEX_PROVIDER_SLUG, sequence, &created_at, - &text, + message, )); sequence += 1; } } } + "message" => { + let role = parsed.payload.get("role").and_then(Value::as_str); + if role == Some("user") { + let is_orgii_injected = parsed + .payload + .get("id") + .and_then(Value::as_str) + .is_some_and(|id| { + id.starts_with(NATIVE_SOURCE_EVENT_ID_PREFIX) + || id.strip_prefix("msg_").is_some_and(|id| { + id.starts_with(NATIVE_SOURCE_EVENT_ID_PREFIX) + }) + }); + let has_portable_user_images = + !user_image_data_urls_from_response_message(&parsed.payload).is_empty(); + if before_first_turn_context && !is_orgii_injected && !has_portable_user_images + { + continue; + } + if let Some(mut user_chunk) = injected_user_message_chunk_from_response_message( + session_id, + sequence, + &created_at, + &parsed.payload, + ) { + attach_native_source_event_id(&mut user_chunk, &parsed.payload); + let user_sequence = sequence; + sequence += 1; + if collector.start_turn(user_chunk) { + break; + } + collector.record_turn_offset( + format!("codex-user-{user_sequence}"), + line_start_offset, + user_sequence, + ); + } else { + pending_user_image_data_urls = + user_image_data_urls_from_response_message(&parsed.payload); + } + } else if role == Some("assistant") { + if let Some(text) = content_text_from_payload(&parsed.payload) { + if should_emit_assistant_mirror( + &mut pending_assistant_mirror, + AssistantMirrorKind::ResponseItem, + &text, + ) { + let mut chunk = imported_history::assistant_message_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + &created_at, + &text, + ); + attach_native_source_event_id(&mut chunk, &parsed.payload); + collector.current.push(chunk); + sequence += 1; + } + } + } + } "reasoning" | "agent_reasoning" => { if let Some(text) = reasoning_text_from_payload(&parsed.payload) { collector.current.push(imported_history::thinking_chunk( @@ -323,7 +594,12 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( for call in calls { collector .current - .push(codex_tool_call_chunk(session_id, sequence, &call, "", None)); + .push(imported_history::unresolved_tool_call_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + &call, + )); sequence += 1; } } @@ -337,12 +613,45 @@ pub(super) fn parse_codex_app_from_path_with_mode<'a>( } let outputs = output_parts_for_tool_calls(&background.calls, &background.latest_output); for (call, output) in background.calls.iter().zip(outputs.iter()) { - collector.current.push(codex_tool_call_chunk( - session_id, sequence, call, output, None, - )); + let mut interrupted = imported_history::unresolved_tool_call_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + call, + ); + interrupted.result["output"] = Value::String(output.clone()); + interrupted.result["observation"] = Value::String(output.clone()); + if !output.is_empty() { + // This is no longer a grammar-dangling call: Codex exposed + // durable stdout before the process was interrupted. Pair it + // with an explicit interrupted result during cross-runtime + // materialization instead of dropping already-visible work. + interrupted.result["status"] = Value::String("interrupted".to_string()); + } + collector.current.push(interrupted); sequence += 1; } } Ok(collector.finish()) } + +fn codex_context_compacted_chunk( + session_id: &str, + sequence: usize, + marker_id: &str, + created_at: &str, + summary: Option<&str>, +) -> ActivityChunk { + let mut chunk = ActivityChunk::new(session_id, "context_compacted", "context_compacted"); + chunk.chunk_id = format!("codex-context-compacted-{marker_id}-{sequence}"); + chunk.created_at = created_at.to_string(); + chunk.result = json!({ + "success": true, + "native": true, + "provider": "codex", + "header": "Context compacted", + "observation": summary.unwrap_or(""), + }); + chunk +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tests.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tests.rs index 1749b1c30c..262590b2ca 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tests.rs @@ -10,6 +10,323 @@ use super::{ load_codex_app_turn_ids_from_path, }; +#[test] +fn current_codex_model_switch_context_and_user_mirrors_are_not_conversation_turns() { + let dir = std::env::temp_dir().join(format!("orgii-codex-current-user-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rollout.jsonl"); + let rows = serde_json::json!([ + {"type":"turn_context","payload":{}}, + {"type":"response_item","payload":{"type":"message","id":"msg_orgii_evt_seed","role":"user","content":[{"type":"input_text","text":"Copy title"}]}}, + {"type":"response_item","payload":{"type":"message","id":"msg_orgii_evt_reply","role":"assistant","content":[{"type":"output_text","text":"Copy plain text"}]}}, + {"type":"compacted","payload":{"message":""}}, + {"type":"event_msg","payload":{"type":"thread_settings_applied"}}, + {"type":"event_msg","payload":{"type":"token_count"}}, + {"type":"event_msg","payload":{"type":"context_compacted"}}, + {"type":"response_item","payload":{"type":"message","id":"msg_context","role":"user","content":[{"type":"input_text","text":"Provider environment refresh"}],"internal_chat_message_metadata_passthrough":{"content_item_kinds":["plugins.recommendations","agents_md.instructions","environments.environment_context"]}}}, + {"type":"turn_context","payload":{}}, + {"type":"response_item","payload":{"type":"message","id":"msg_user","role":"user","content":[{"type":"input_text","text":"Disable placeholder titles"},{"type":"input_image","image_url":"data:image/png;base64,QUJD"}],"internal_chat_message_metadata_passthrough":{"content_item_kinds":["user.text","user.image"]}}}, + {"type":"event_msg","payload":{"type":"user_message","message":"Disable placeholder titles","images":[],"local_images":[]}}, + {"type":"event_msg","payload":{"type":"agent_message","message":"Keep disabled"}}, + {"type":"response_item","payload":{"type":"message","id":"msg_reply","role":"assistant","content":[{"type":"output_text","text":"Keep disabled"}]}} + ]); + let lines = rows.as_array().unwrap().iter().map(|row| serde_json::to_string(row).unwrap()).collect::>().join("\n"); + std::fs::write(&path, lines + "\n").unwrap(); + let chunks = load_codex_app_from_path("codexapp-current-user", &path).unwrap(); + let users = chunks.iter().filter(|c| c.function == "user_message").collect::>(); + assert_eq!(users.len(), 2, "provider context and UI mirrors must not create user turns"); + assert_eq!(users[0].result["message"]["content"], "Copy title"); + assert_eq!(users[1].result["message"]["content"], "Disable placeholder titles"); + assert_eq!(users[1].result["images"][0], "data:image/png;base64,QUJD"); + assert_eq!(chunks.iter().filter(|c| c.function == "context_compacted").count(), 1); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn preserves_codex_user_image_data_url_for_native_transfer() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-user-image-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-user-image.jsonl"); + let content = r#"{"timestamp":"2026-08-30T01:00:00Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"inspect"},{"type":"input_image","image_url":"data:image/png;base64,QUJD"}]}} +{"timestamp":"2026-08-30T01:00:00Z","type":"event_msg","payload":{"type":"item_completed","item":{"type":"UserMessage","id":"user-1","content":[{"type":"text","text":"inspect","text_elements":[]},{"type":"local_image","path":"/source-machine/image.png"}]}}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-user-image", &path) + .expect("parse user image transcript"); + let user = chunks + .iter() + .find(|chunk| chunk.function == "user_message") + .expect("user message"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .count(), + 1 + ); + assert_eq!(user.result["message"]["content"], "inspect"); + assert_eq!(user.result["images"][0], "data:image/png;base64,QUJD"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn preserves_app_server_injected_user_rows_without_ui_mirrors() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-injected-user-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-injected-user.jsonl"); + // `thread/start` establishes the native turn-context boundary before its + // supported `thread/inject_items` history. Provider bootstrap may precede + // it, while these injected rows deliberately follow it. + let content = r#"{"timestamp":"2026-08-30T00:59:59Z","type":"turn_context","payload":{"turn_id":"materialization"}} +{"timestamp":"2026-08-30T01:00:00Z","type":"response_item","payload":{"type":"message","id":"user-1","role":"user","content":[{"type":"input_text","text":"first"},{"type":"input_image","image_url":"data:image/png;base64,QUJD"}]}} +{"timestamp":"2026-08-30T01:00:01Z","type":"response_item","payload":{"type":"message","id":"assistant-1","role":"assistant","content":[{"type":"output_text","text":"answer"}]}} +{"timestamp":"2026-08-30T01:00:02Z","type":"response_item","payload":{"type":"message","id":"user-2","role":"user","content":[{"type":"input_text","text":"second"}]}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-injected-user", &path) + .expect("parse app-server injected transcript"); + let users = chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .collect::>(); + assert_eq!(users.len(), 2); + assert_eq!(users[0].result["message"]["content"], "first"); + assert_eq!(users[0].result["images"][0], "data:image/png;base64,QUJD"); + assert_eq!(users[0].args["__orgiiSourceEventId"], "user-1"); + assert_eq!(users[1].result["message"]["content"], "second"); + assert_eq!(users[1].args["__orgiiSourceEventId"], "user-2"); + let assistants = chunks + .iter() + .filter(|chunk| chunk.function == "assistant") + .collect::>(); + assert_eq!(assistants.len(), 1); + assert_eq!(assistants[0].args["__orgiiSourceEventId"], "assistant-1"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn drops_pre_turn_app_server_bootstrap_without_trimming_later_native_turns() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-app-server-bootstrap-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-app-server-bootstrap.jsonl"); + let content = r#"{"timestamp":"2026-08-30T01:00:00Z","type":"session_meta","payload":{"id":"native-1"}} +{"timestamp":"2026-08-30T01:00:01Z","type":"response_item","payload":{"type":"message","id":"msg-bootstrap","role":"user","content":[{"type":"input_text","text":"provider bootstrap"}]}} +{"timestamp":"2026-08-30T01:00:02Z","type":"response_item","payload":{"type":"message","id":"msg_orgii_evt_fedcba9876543210fedcba9876543210","role":"user","content":[{"type":"input_text","text":"early canonical team message"}]}} +{"timestamp":"2026-08-30T01:00:03Z","type":"turn_context","payload":{"turn_id":"turn-native"}} +{"timestamp":"2026-08-30T01:00:04Z","type":"event_msg","payload":{"type":"user_message","message":"real native question","images":[],"local_images":[]}} +{"timestamp":"2026-08-30T01:00:05Z","type":"event_msg","payload":{"type":"agent_message","message":"real native answer"}} +{"timestamp":"2026-08-30T01:00:06Z","type":"response_item","payload":{"type":"message","id":"orgii_evt_0123456789abcdef0123456789abcdef","role":"user","content":[{"type":"input_text","text":"later canonical team message"}]}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-bootstrap-boundary", &path) + .expect("parse app-server bootstrap transcript"); + let messages = chunks + .iter() + .filter(|chunk| chunk.function == "user_message" || chunk.function == "assistant") + .collect::>(); + + assert_eq!(messages.len(), 4); + assert_eq!( + messages[0].result["message"]["content"], + "early canonical team message" + ); + assert_eq!( + messages[0].args["__orgiiSourceEventId"], + "orgii_evt_fedcba9876543210fedcba9876543210" + ); + assert_eq!( + messages[1].result["message"]["content"], + "real native question" + ); + assert_eq!(messages[2].result["content"], "real native answer"); + assert_eq!( + messages[3].result["message"]["content"], + "later canonical team message" + ); + assert_eq!( + messages[3].args["__orgiiSourceEventId"], + "orgii_evt_0123456789abcdef0123456789abcdef" + ); + assert!(chunks.iter().all(|chunk| { + chunk.result["message"]["content"].as_str() + != Some("provider bootstrap") + })); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn marks_an_unresolved_codex_tool_as_interrupted_not_completed() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-interrupted-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-interrupted.jsonl"); + let content = r#"{"timestamp":"2026-08-30T01:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"inspect","images":[],"local_images":[]}} +{"timestamp":"2026-08-30T01:00:01Z","type":"event_msg","payload":{"type":"agent_message","message":"I found one thing."}} +{"timestamp":"2026-08-30T01:00:02Z","type":"response_item","payload":{"type":"function_call","name":"read_file","arguments":"{\"path\":\"/repo/README.md\"}","call_id":"call_interrupted"}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-interrupted", &path) + .expect("parse interrupted transcript"); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("interrupted tool is diagnostic history"); + assert_eq!(tool.result["status"], "pending"); + assert_eq!(tool.result["interrupted"], true); + assert!(chunks.iter().any(|chunk| { + chunk.function == "assistant" + && chunk.result["content"].as_str() == Some("I found one thing.") + })); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn native_compaction_is_one_system_marker_not_replacement_user_history() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-native-compact-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-compact.jsonl"); + let content = r#"{"timestamp":"2026-08-29T07:00:00Z","type":"event_msg","payload":{"type":"user_message","message":"inspect the repo","images":[],"local_images":[]}} +{"timestamp":"2026-08-29T07:00:01Z","type":"response_item","payload":{"type":"function_call","name":"read_file","arguments":"{\"path\":\"/repo/README.md\"}","call_id":"call_before_compact"}} +{"timestamp":"2026-08-29T07:00:02Z","type":"response_item","payload":{"type":"function_call_output","call_id":"call_before_compact","output":"contents"}} +{"timestamp":"2026-08-29T07:00:03Z","type":"event_msg","payload":{"type":"agent_message","message":"done"}} +{"timestamp":"2026-08-29T07:00:04Z","type":"compacted","payload":{"message":"Native Codex summary","replacement_history":[{"item":{"type":"message","role":"user","content":[{"type":"input_text","text":"replacement history copy"}]}},{"item":{"type":"compaction","encrypted_content":"opaque-provider-state"}}],"window_number":2,"first_window_id":"window-1","previous_window_id":"window-1","window_id":"window-2"}} +{"timestamp":"2026-08-29T07:00:04Z","type":"event_msg","payload":{"type":"token_count","info":null}} +{"timestamp":"2026-08-29T07:00:04Z","type":"event_msg","payload":{"type":"context_compacted"}} +{"timestamp":"2026-08-29T07:00:05Z","type":"event_msg","payload":{"type":"user_message","message":"continue after compact","images":[],"local_images":[]}} +{"timestamp":"2026-08-29T07:00:06Z","type":"event_msg","payload":{"type":"agent_message","message":"continued"}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-native-compact", &path) + .expect("parse native compact transcript"); + let human_messages = chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .map(|chunk| { + chunk.result["message"]["content"] + .as_str() + .unwrap_or_default() + }) + .collect::>(); + assert_eq!( + human_messages, + vec!["inspect the repo", "continue after compact"] + ); + assert!(!serde_json::to_string(&chunks) + .expect("serialize chunks") + .contains("replacement history copy")); + let compact_markers = chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .collect::>(); + assert_eq!(compact_markers.len(), 1); + assert_eq!( + compact_markers[0].result["observation"].as_str(), + Some("Native Codex summary") + ); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("paired tool call"); + assert_eq!(tool.args["path"], "/repo/README.md"); + assert_eq!(tool.result["output"], "contents"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn adjacent_native_compaction_windows_form_one_logical_boundary() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-native-compact-windows-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-compact-windows.jsonl"); + let content = r#"{"timestamp":"2026-08-29T07:00:00.000Z","type":"event_msg","payload":{"type":"user_message","message":"inspect","images":[],"local_images":[]}} +{"timestamp":"2026-08-29T07:00:04.000Z","type":"compacted","payload":{"message":"","window_number":152,"window_id":"window-152","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:04.020Z","type":"compacted","payload":{"message":"","window_number":153,"previous_window_id":"window-152","window_id":"window-153","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:04.040Z","type":"compacted","payload":{"message":"final summary","window_number":154,"previous_window_id":"window-153","window_id":"window-154","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:04.050Z","type":"event_msg","payload":{"type":"context_compacted"}} +{"timestamp":"2026-08-29T07:00:05.000Z","type":"event_msg","payload":{"type":"user_message","message":"continue","images":[],"local_images":[]}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-native-compact-windows", &path) + .expect("parse native compact windows"); + let compact_markers = chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .collect::>(); + assert_eq!(compact_markers.len(), 1); + assert_eq!( + compact_markers[0].result["observation"].as_str(), + Some("final summary") + ); + assert!(compact_markers[0].chunk_id.contains("window-154")); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + +#[test] +fn nearby_distinct_native_compactions_are_not_merged() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-distinct-native-compacts-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-distinct-compacts.jsonl"); + let content = r#"{"timestamp":"2026-08-29T07:00:00.000Z","type":"event_msg","payload":{"type":"user_message","message":"inspect","images":[],"local_images":[]}} +{"timestamp":"2026-08-29T07:00:01.000Z","type":"compacted","payload":{"message":"first summary","window_number":2,"previous_window_id":"window-1","window_id":"window-2","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:01.010Z","type":"event_msg","payload":{"type":"context_compacted"}} +{"timestamp":"2026-08-29T07:00:02.000Z","type":"compacted","payload":{"message":"second summary","window_number":3,"previous_window_id":"window-2","window_id":"window-3","replacement_history":[]}} +{"timestamp":"2026-08-29T07:00:02.010Z","type":"event_msg","payload":{"type":"context_compacted"}}"#; + std::fs::write(&path, format!("{content}\n")).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-distinct-native-compacts", &path) + .expect("parse distinct nearby native compactions"); + let compact_markers = chunks + .iter() + .filter(|chunk| chunk.function == "context_compacted") + .collect::>(); + assert_eq!(compact_markers.len(), 2); + assert_eq!( + compact_markers[0].result["observation"].as_str(), + Some("first summary") + ); + assert_eq!( + compact_markers[1].result["observation"].as_str(), + Some("second summary") + ); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn cloud_turn_ids_are_source_offsets_in_transcript_order() { let temp_dir = std::env::temp_dir().join(format!( diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/normalization.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/normalization.rs index 00ab1699de..d54c5eb7d9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/normalization.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/tool_calls/normalization.rs @@ -6,6 +6,7 @@ use super::super::super::desktop_exec::normalize_codex_exec_tool_calls; use super::super::super::normalize::{ normalize_codex_tool_calls, normalize_tool_name_key, normalize_web_search_args, }; +use super::super::NATIVE_SOURCE_EVENT_ID_ARG; pub(in crate::sources::codex::app::transcript) fn pending_tool_calls_from_payload( payload: &Value, @@ -13,11 +14,42 @@ pub(in crate::sources::codex::app::transcript) fn pending_tool_calls_from_payloa ) -> Option<(String, Vec)> { let call_id = payload.get("call_id")?.as_str()?.to_string(); let raw_name = payload.get("name")?.as_str()?.to_string(); - let arguments = payload + let mut arguments = payload .get("arguments") .and_then(Value::as_str) .map(imported_history::parse_inner_json) .unwrap_or_else(|| json!({})); + // `thread/inject_items` preserves the native response-item id supplied by + // the materializer. Canonical tool calls injected through that supported + // API must not be normalized a second time; ordinary Codex rollout tool + // calls have only `call_id` in the currently supported transcript schema. + if let Some(source_item_id) = payload + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.trim().is_empty()) + { + if let Some(args) = arguments.as_object_mut() { + args.insert( + NATIVE_SOURCE_EVENT_ID_ARG.to_string(), + Value::String( + source_item_id + .strip_suffix(":call") + .unwrap_or(source_item_id) + .to_string(), + ), + ); + } + return Some(( + call_id.clone(), + vec![ImportedToolCall { + call_id, + raw_name: raw_name.clone(), + canonical_name: raw_name, + args: arguments, + created_at: created_at.to_string(), + }], + )); + } let normalized_calls = normalize_codex_tool_calls(&raw_name, arguments); let call_count = normalized_calls.len(); if call_count == 0 { diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs index 08a3494f62..2c5cc2002d 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app_tests.rs @@ -201,6 +201,50 @@ fn parses_codex_jsonl_into_replay_chunks() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } +#[test] +fn deduplicates_native_assistant_context_and_visible_event_mirror() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-native-mirror-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-native-mirror.jsonl"); + let content = r#"{"timestamp":"2026-08-26T06:00:00.000Z","type":"event_msg","payload":{"type":"user_message","message":"hello","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-08-26T06:00:01.000Z","type":"response_item","payload":{"type":"message","id":"a1","role":"assistant","content":[{"type":"output_text","text":"one answer"}]}} +{"timestamp":"2026-08-26T06:00:01.001Z","type":"event_msg","payload":{"type":"agent_message","message":"one answer","phase":"final_answer","memory_citation":null}} +{"timestamp":"2026-08-26T06:00:02.000Z","type":"event_msg","payload":{"type":"user_message","message":"continue","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-08-26T06:00:03.000Z","type":"event_msg","payload":{"type":"agent_message","message":"two answer","phase":"final_answer","memory_citation":null}} +{"timestamp":"2026-08-26T06:00:03.001Z","type":"response_item","payload":{"type":"message","id":"a2","role":"assistant","content":[{"type":"output_text","text":"two answer"}]}} +"#; + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-native-mirror", &path).expect("parse"); + let assistant = chunks + .iter() + .filter(|chunk| chunk.function == imported_history::FUNCTION_ASSISTANT) + .collect::>(); + assert_eq!(assistant.len(), 2); + assert_eq!( + assistant[0] + .result + .get("observation") + .or_else(|| assistant[0].result.get("content")) + .and_then(Value::as_str), + Some("one answer") + ); + assert_eq!( + assistant[1] + .result + .get("observation") + .or_else(|| assistant[1].result.get("content")) + .and_then(Value::as_str), + Some("two answer") + ); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn parses_paginated_codex_user_items_without_model_context_duplicates() { let temp_dir = std::env::temp_dir().join(format!( @@ -1320,6 +1364,58 @@ fn codex_write_stdin_polls_merge_into_originating_exec_command() { std::fs::remove_dir(&temp_dir).expect("remove temp dir"); } +#[test] +fn codex_background_command_partial_output_is_an_interrupted_result() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-background-partial-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-background-partial.jsonl"); + let content = [ + json!({ + "timestamp": "2026-07-18T01:00:00Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call", + "name": "exec", + "call_id": "call_shell", + "input": r#"const r = await tools.exec_command({cmd:"cargo test",workdir:"/tmp/project",yield_time_ms:10000,max_output_tokens:3000}); text(r)"#, + } + }), + json!({ + "timestamp": "2026-07-18T01:00:10Z", + "type": "response_item", + "payload": { + "type": "custom_tool_call_output", + "call_id": "call_shell", + "output": [ + { "type": "input_text", "text": "Script running with session ID 82118\n" }, + { "type": "input_text", "text": r#"{"session_id":82118,"output":"Compiling\n"}"# }, + ], + } + }), + ] + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + std::fs::write(&path, content).expect("write fixture"); + + let chunks = load_codex_app_from_path("codexapp-background-partial", &path).expect("parse"); + assert_eq!(chunks.len(), 1); + assert_eq!( + chunks[0].function, + imported_history::FUNCTION_RUN_COMMAND_LINE + ); + assert_eq!(chunks[0].result["status"], "interrupted"); + assert_eq!(chunks[0].result["interrupted"], true); + assert_eq!(chunks[0].result["output"], "Compiling\n"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn codex_write_stdin_cell_wait_still_merges_into_originating_command() { let temp_dir = std::env::temp_dir().join(format!( @@ -1604,6 +1700,58 @@ fn codex_desktop_exec_unwraps_web_search_query() { assert_eq!(calls[0].args["query"], "Codex app event format"); } +#[test] +fn codex_native_canonical_tool_args_are_not_normalized_twice() { + let temp_dir = std::env::temp_dir().join(format!( + "orgii-codex-materialized-tool-test-{}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("create temp dir"); + let path = temp_dir.join("rollout-materialized-tool.jsonl"); + let canonical_args = json!({ + "action": "search", + "query": "Codex app event format", + "queries": [], + "url": "", + "pattern": "", + "payload": {"search_query": [{"q": "Codex app event format"}]} + }); + let payload = json!({ + "type": "function_call", + "id": "tool-item-1", + "name": "web_search", + "arguments": canonical_args.to_string(), + "call_id": "call_materialized_web", + }); + let output = json!({ + "type": "function_call_output", + "call_id": "call_materialized_web", + "output": "search result", + }); + std::fs::write( + &path, + format!( + "{}\n{}\n", + json!({"timestamp": "2026-08-26T00:00:01Z", "type": "response_item", "payload": payload}), + json!({"timestamp": "2026-08-26T00:00:02Z", "type": "response_item", "payload": output}) + ), + ) + .expect("write materialized canonical tool fixture"); + + let chunks = load_codex_app_from_path("codexapp-materialized-tool", &path) + .expect("parse materialized canonical tool call"); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].function, "web_search"); + assert_eq!(chunks[0].args["action"], canonical_args["action"]); + assert_eq!(chunks[0].args["query"], canonical_args["query"]); + assert_eq!(chunks[0].args["payload"], canonical_args["payload"]); + assert_eq!(chunks[0].args["__orgiiSourceEventId"], "tool-item-1"); + assert_eq!(chunks[0].result["output"], "search result"); + + std::fs::remove_file(&path).expect("remove fixture"); + std::fs::remove_dir(&temp_dir).expect("remove temp dir"); +} + #[test] fn codex_first_class_web_search_calls_render_as_web_activity() { let temp_dir = std::env::temp_dir().join(format!( @@ -2436,6 +2584,19 @@ fn strips_orgii_exec_mode_bridge_from_codex_user_text() { ); } +#[test] +fn strips_orgii_provider_context_from_codex_user_text() { + let wrapped = "\nworkspace instructions\n\n\n\nbuild mode\n\n\n\nopen file: src/app.ts\n\n\ncontinue the shared session"; + assert_eq!( + strip_orgii_exec_mode_bridge(wrapped), + "continue the shared session" + ); + + let provider_only = + "\nworkspace instructions\n"; + assert_eq!(strip_orgii_exec_mode_bridge(provider_only), ""); +} + #[test] fn strips_ide_context_from_codex_user_text() { // Bridge + ide_context prefixes followed by the real user text → only diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs index 912052058a..1d2387c752 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs @@ -1101,3 +1101,35 @@ fn a_source_wide_prune_does_not_erase_pins() { "a prune of the rebuildable projection must not take user pin state with it" ); } + +#[test] +fn managed_native_origin_survives_exact_id_cache_hydration() { + use crate::sources::imported_history::client_origin::ImportedClientOrigin; + use crate::sources::imported_history::managed_mirror::apply_managed_history_mirror; + use std::collections::HashSet; + + let mut conn = fixture_conn(); + let mut managed = input(SOURCE_CODEX_APP, "rollout-date-native-uuid", 300); + managed.client_origin = Some(ImportedClientOrigin::OfficialApp); + managed.client_origin_raw = Some("Codex Desktop".to_string()); + let mut ordinary = input(SOURCE_CODEX_APP, "ordinary-native-app", 200); + ordinary.client_origin = Some(ImportedClientOrigin::OfficialApp); + let ids = HashSet::from(["native-uuid".to_string()]); + apply_managed_history_mirror(&mut managed, &ids); + apply_managed_history_mirror(&mut ordinary, &ids); + let managed_id = managed.session_id.clone(); + let ordinary_id = ordinary.session_id.clone(); + upsert_imported_session_cache_from_conn(&mut conn, &[managed, ordinary]).expect("persist"); + let (_, hydrated) = query_cached_session_by_session_id_from_conn(&conn, &managed_id) + .expect("exact id read").expect("mirror remains readable"); + assert_eq!(hydrated.client_origin, Some(ImportedClientOrigin::Org2)); + let raw: String = conn.query_row( + "SELECT client_origin_raw FROM imported_history_session_cache WHERE session_id = ?1", + [&managed_id], |row| row.get(0), + ).expect("retain native header provenance"); + assert_eq!(raw, "Codex Desktop"); + let page = query_imported_session_page_from_conn(&conn, SOURCE_CODEX_APP, 10, 0).expect("list"); + assert_eq!(page.sessions.len(), 1); + assert_eq!(page.sessions[0].session_id, ordinary_id); + assert_eq!(page.sessions[0].client_origin, Some(ImportedClientOrigin::OfficialApp)); +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs index a9ffdfbec5..2e2be344fb 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/managed_mirror.rs @@ -17,6 +17,9 @@ use std::collections::HashSet; use rusqlite::Connection; +use super::client_origin::ImportedClientOrigin; +use super::metadata::ImportedHistoryCacheInput; + fn table_exists(conn: &Connection, name: &str) -> bool { conn.query_row( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -103,6 +106,70 @@ pub fn is_managed_source_session_id( }) } +/// Whether an imported transcript is only the native-provider mirror of a +/// session ORGII already owns. +/// +/// The binding ledger is the strongest signal, but it cannot be the only +/// signal: an isolated test home, a moved profile, or a rebuilt local DB can +/// leave an ORGII-authored transcript in the provider's real native store +/// after the ledger row is gone. Both Codex (`originator`) and Claude +/// (`entrypoint` / managed profile path) persist ORGII provenance in the +/// transcript itself, so that provenance is the durable fallback. +pub fn is_managed_history_mirror( + managed_ids: &HashSet, + source_session_id: &str, + client_origin: Option, +) -> bool { + is_managed_source_session_id(managed_ids, source_session_id) + || client_origin == Some(ImportedClientOrigin::Org2) +} + +/// Apply durable managed ownership before persisting an imported projection. +pub fn apply_managed_history_mirror( + input: &mut ImportedHistoryCacheInput, + managed_ids: &HashSet, +) { + let managed = is_managed_history_mirror( + managed_ids, + &input.source_session_id, + input.client_origin, + ); + if managed { + input.listable = false; + // Provider apps can rewrite their header after a managed launch. The + // durable binding still owns the conversation. Preserve the raw header + // for diagnostics, but exact-ID hydration must carry the same ownership + // as the primary-list decision or it becomes independently publishable. + input.client_origin = Some(ImportedClientOrigin::Org2); + } +} + +/// Invalidate only ledger-managed cached rows from before origin propagation. +/// Ordinary native histories keep their existing fingerprint and parse budget. +pub fn append_managed_origin_fingerprint(fingerprint: &mut String, is_managed: bool) { + append_managed_fingerprint(fingerprint, is_managed); + if is_managed { + fingerprint.push_str("|managed-origin=org2-v1"); + } +} + +/// Repair already-cached ORGII mirrors without requiring their native file to +/// change and trigger a reparse. New parses are hidden by +/// [`is_managed_history_mirror`]; this closes the same invariant for cache rows +/// written by an older build or by a process whose binding ledger disappeared. +pub fn demote_org2_origin_mirrors_from_conn( + conn: &Connection, + source: &str, +) -> Result { + conn.execute( + "UPDATE imported_history_session_cache + SET listable = 0 + WHERE source = ?1 AND client_origin = 'org2' AND listable != 0", + [source], + ) + .map_err(|err| format!("Failed to demote ORGII history mirrors: {err}")) +} + /// Fold the managed verdict into a discovery fingerprint so a session that /// becomes managed (or stops being) re-parses on the next scan and its /// `listable` flag flips. @@ -141,6 +208,86 @@ mod tests { assert!(!is_managed_source_session_id(&HashSet::new(), "anything")); } + #[test] + fn org2_provenance_survives_a_missing_binding_ledger() { + let no_ids = HashSet::new(); + assert!(is_managed_history_mirror( + &no_ids, + "native-id-from-an-isolated-run", + Some(ImportedClientOrigin::Org2), + )); + assert!(!is_managed_history_mirror( + &no_ids, + "ordinary-cli-session", + Some(ImportedClientOrigin::Cli), + )); + assert!(!is_managed_history_mirror( + &no_ids, + "unknown-origin-session", + None, + )); + } + + #[test] + fn repairs_cached_org2_mirror_without_hiding_other_clients() { + let conn = Connection::open_in_memory().expect("open"); + conn.execute_batch( + "CREATE TABLE imported_history_session_cache ( + source TEXT NOT NULL, + source_session_id TEXT NOT NULL, + client_origin TEXT NOT NULL, + listable INTEGER NOT NULL + ); + INSERT INTO imported_history_session_cache VALUES + ('claude_code', 'org2-copy', 'org2', 1), + ('claude_code', 'terminal-session', 'cli', 1), + ('codex_app', 'other-source', 'org2', 1);", + ) + .expect("seed"); + + assert_eq!( + demote_org2_origin_mirrors_from_conn(&conn, "claude_code").expect("demote"), + 1 + ); + let rows = conn + .prepare( + "SELECT source_session_id, listable + FROM imported_history_session_cache ORDER BY source_session_id", + ) + .expect("prepare") + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + }) + .expect("query") + .collect::, _>>() + .expect("rows"); + assert_eq!( + rows, + vec![ + ("org2-copy".to_string(), 0), + ("other-source".to_string(), 1), + ("terminal-session".to_string(), 1), + ] + ); + } + + #[test] + fn origin_upgrade_invalidates_only_managed_cache_fingerprints() { + let mut old_managed = "native-file-unchanged".to_string(); + append_managed_fingerprint(&mut old_managed, true); + let mut upgraded = "native-file-unchanged".to_string(); + append_managed_origin_fingerprint(&mut upgraded, true); + assert_ne!(old_managed, upgraded); + let mut next_scan = "native-file-unchanged".to_string(); + append_managed_origin_fingerprint(&mut next_scan, true); + assert_eq!(upgraded, next_scan); + let mut ordinary_old = "native-file-unchanged".to_string(); + append_managed_fingerprint(&mut ordinary_old, false); + let mut ordinary_new = "native-file-unchanged".to_string(); + append_managed_origin_fingerprint(&mut ordinary_new, false); + assert_eq!(ordinary_old, ordinary_new); + } + #[test] fn unions_current_binding_and_ledger() { let conn = Connection::open_in_memory().expect("open"); diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs index 673b259484..6a0b8d9375 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs @@ -648,12 +648,11 @@ pub fn recent_paths_from_paths( recent_paths } -/// Internal wrapper blocks ORGII prepends to the prompt it hands the CLI: -/// the GUI exec-mode briefing and the IDE-context injection -/// (`inject_ide_context_into_prompt`). The CLI's native transcript stores -/// the full prompt verbatim, so replay readers must strip these to recover -/// what the user actually typed. +/// Internal wrapper blocks ORGII prepends to the prompt it hands the CLI. +/// The CLI's native transcript stores the full prompt verbatim, so replay +/// readers must strip these to recover what the user actually typed. const INTERNAL_CONTEXT_BLOCKS: &[(&str, &str)] = &[ + ("", ""), ( "", "", @@ -693,8 +692,8 @@ pub fn strip_internal_context_blocks(text: &str) -> &str { } } -/// GUI-launched runs prefix the task with an internal exec-mode briefing; -/// strip it so titles/replay show only what the user typed. +/// GUI-launched runs prefix the task with internal provider, exec-mode, and +/// IDE context; strip them so titles/replay show only what the user typed. /// /// Back-compat name: now also strips the `` injection via /// [`strip_internal_context_blocks`]. @@ -811,6 +810,28 @@ pub fn tool_call_chunk( chunk } +/// A provider-native transcript ended with a tool call but no matching result. +/// Keep it visible as interrupted diagnostics, while making the missing result +/// machine-readable so cross-provider projection can exclude the invalid tail. +pub fn unresolved_tool_call_chunk( + session_id: &str, + provider_slug: &str, + sequence: usize, + call: &ImportedToolCall, +) -> ActivityChunk { + let mut chunk = tool_call_chunk(session_id, provider_slug, sequence, call, ""); + chunk.result = json!({ + "success": false, + "status": "pending", + "call_id": call.call_id, + "output": "", + "observation": "", + "raw_tool_name": call.raw_name, + "interrupted": true, + }); + chunk +} + /// Derive conservative file-impact metadata from normalized edit tool calls. /// /// Source loaders remain responsible for recognizing their native tool names and diff --git a/src-tauri/crates/session-persistence/src/editing.rs b/src-tauri/crates/session-persistence/src/editing.rs index eb8d1b3225..b7a3be6bb0 100644 --- a/src-tauri/crates/session-persistence/src/editing.rs +++ b/src-tauri/crates/session-persistence/src/editing.rs @@ -203,6 +203,39 @@ pub fn delete_event(session_id: &str, event_id: &str) -> SqliteResult { }) } +/// Delete an exact event set in one transaction. +/// +/// Callers that mirror a prefix removal in memory first resolve that prefix to +/// stable IDs, then use this operation so a mid-batch SQLite failure cannot +/// leave only part of the durable set deleted. +pub fn delete_events_by_ids(session_id: &str, event_ids: &[String]) -> SqliteResult { + if event_ids.is_empty() { + return Ok(0); + } + let deleted = with_sessions_writer(|| { + let conn = get_connection()?; + let tx = begin_immediate(&conn)?; + let deleted = { + let mut statement = + tx.prepare_cached("DELETE FROM events WHERE session_id = ?1 AND id = ?2")?; + let mut deleted = 0usize; + for event_id in event_ids { + deleted += statement.execute(params![session_id, event_id])?; + } + deleted + }; + if deleted > 0 { + update_session_metadata(&conn, session_id)?; + } + tx.commit()?; + Ok::(deleted) + })?; + if deleted > 0 { + super::turn_index_debounce::schedule(session_id); + } + Ok(deleted) +} + /// Update an existing event by ID pub fn update_event(session_id: &str, event: &CachedEvent) -> SqliteResult { with_sessions_writer(|| { @@ -277,3 +310,96 @@ pub fn clear_session_history(session_id: &str) -> SqliteResult { deleted_sequences, }) } + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + use crate::{get_session_metadata, init_session_tables, load_events, save_events, CachedEvent}; + + fn cached_event(session_id: &str, id: &str) -> CachedEvent { + CachedEvent { + id: id.to_string(), + session_id: session_id.to_string(), + event_type: "raw".to_string(), + function_name: Some("user_message".to_string()), + thread_id: None, + args_json: "{}".to_string(), + result_json: "{}".to_string(), + content: id.to_string(), + created_at: "2026-09-05T00:00:00Z".to_string(), + meta_json: None, + history_sequence: None, + } + } + + #[test] + fn batch_delete_rolls_back_every_id_when_one_delete_fails() { + let _guard = crate::ORGII_HOME_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous_home = std::env::var_os("ORGII_HOME"); + let root = std::env::temp_dir().join(format!( + "orgii-session-delete-batch-test-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&root); + fs::create_dir_all(&root).expect("create test home"); + std::env::set_var("ORGII_HOME", &root); + + let session_id = "atomic-prefix-delete"; + let ids = vec!["prefix-a".to_string(), "prefix-b".to_string()]; + let conn = get_connection().expect("open session database"); + init_session_tables(&conn).expect("initialize session schema"); + drop(conn); + save_events( + session_id, + &[ + cached_event(session_id, &ids[0]), + cached_event(session_id, &ids[1]), + cached_event(session_id, "keep"), + ], + ) + .expect("seed events"); + let conn = get_connection().expect("reopen session database"); + conn.execute_batch( + "CREATE TRIGGER abort_second_batch_delete + BEFORE DELETE ON events WHEN OLD.id = 'prefix-b' + BEGIN SELECT RAISE(ABORT, 'blocked delete'); END;", + ) + .expect("install aborting delete trigger"); + drop(conn); + + assert!(delete_events_by_ids(session_id, &ids).is_err()); + let remaining = load_events(session_id).expect("reload rolled-back events"); + assert!(remaining.iter().any(|event| event.id == "prefix-a")); + assert!(remaining.iter().any(|event| event.id == "prefix-b")); + assert!(remaining.iter().any(|event| event.id == "keep")); + + let conn = get_connection().expect("reopen session database after rollback"); + conn.execute_batch("DROP TRIGGER abort_second_batch_delete") + .expect("remove aborting delete trigger"); + drop(conn); + assert_eq!( + delete_events_by_ids(session_id, &ids).expect("retry atomic batch delete"), + 2 + ); + let remaining = load_events(session_id).expect("reload successfully deleted events"); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].id, "keep"); + assert_eq!( + get_session_metadata(session_id) + .expect("load session metadata") + .expect("session metadata exists") + .event_count, + 1 + ); + + match previous_home { + Some(value) => std::env::set_var("ORGII_HOME", value), + None => std::env::remove_var("ORGII_HOME"), + } + let _ = fs::remove_dir_all(root); + } +} diff --git a/src-tauri/crates/session-persistence/src/lib.rs b/src-tauri/crates/session-persistence/src/lib.rs index 13ab5b9941..e4ad436920 100644 --- a/src-tauri/crates/session-persistence/src/lib.rs +++ b/src-tauri/crates/session-persistence/src/lib.rs @@ -62,7 +62,9 @@ pub use crud::{ get_cache_stats, get_event, get_session_metadata, load_events, load_session, save_events, save_events_deferred, save_session, search_all_sessions, search_events, update_session_specs, }; -pub use editing::{clear_session_history, delete_event, truncate_after_event, update_event}; +pub use editing::{ + clear_session_history, delete_event, delete_events_by_ids, truncate_after_event, update_event, +}; // Tauri commands — registered in `app::commands::handler_list.inc` as // `session_persistence::cache_*` (formerly `session::cache::cache_*`). diff --git a/src-tauri/crates/session-persistence/src/schema.rs b/src-tauri/crates/session-persistence/src/schema.rs index 67822b04d3..802e0bd7b2 100644 --- a/src-tauri/crates/session-persistence/src/schema.rs +++ b/src-tauri/crates/session-persistence/src/schema.rs @@ -468,8 +468,8 @@ pub fn init_session_tables(conn: &Connection) -> SqliteResult<()> { /// /// Triggers must go in the same batch: an insert into `events` with a /// surviving trigger referencing the dropped vtable would fail. `DROP TABLE` -/// on an FTS5 vtable removes all of its shadow tables. Marker-gated so the -/// batch runs once (a failed attempt retries next startup); best-effort — +/// on an FTS5 vtable removes all of its shadow tables. Skip cleanup only when +/// the marker and actual schema agree (failed attempts retry); best-effort — /// schema init must never fail over cleanup. fn drop_events_fts(conn: &Connection) { const MARKER: &str = "events_fts_dropped_2026_07"; @@ -483,7 +483,17 @@ fn drop_events_fts(conn: &Connection) { .and_then(|mut stmt| stmt.query_row([MARKER], |row| row.get::<_, i64>(0))) .unwrap_or(0) > 0; - if already_dropped { + // An older executable can recreate these objects after the marker was + // recorded. The schema, not the historical marker, owns this invariant. + let legacy_objects_remain = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name IN + ('events_fts', 'events_ai', 'events_ad', 'events_au'))", + [], + |row| row.get::<_, bool>(0), + ) + .unwrap_or(true); + if already_dropped && !legacy_objects_remain { return; } @@ -697,6 +707,43 @@ mod tests { .expect("query trigger existence") } + #[test] + fn drop_events_fts_rechecks_schema_after_recorded_migration() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE events (id TEXT PRIMARY KEY, content TEXT); + INSERT INTO events VALUES ('retained', 'keep this conversation'); + CREATE TABLE _migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL); + INSERT INTO _migrations VALUES ('events_fts_dropped_2026_07', 'old'); + CREATE VIRTUAL TABLE events_fts USING fts5( + content, content='events', content_rowid='rowid' + ); + CREATE TRIGGER events_ad AFTER DELETE ON events BEGIN + INSERT INTO events_fts(events_fts, rowid, content) + VALUES ('delete', OLD.rowid, OLD.content); + END;", + ) + .unwrap(); + + // Recreated external-content FTS has no entry for the existing row. + // Its delete trigger breaks ordinary cache eviction before recovery. + assert!(conn.execute("DELETE FROM events", []).is_err()); + drop_events_fts(&conn); + + assert!(!table_exists(&conn, "events_fts")); + assert!(!trigger_exists(&conn, "events_ad")); + let content: String = conn + .query_row( + "SELECT content FROM events WHERE id = 'retained'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(content, "keep this conversation"); + drop_events_fts(&conn); + assert_eq!(conn.execute("DELETE FROM events", []).unwrap(), 1); + } + #[test] fn init_session_tables_drops_legacy_events_fts_and_records_marker() { let conn = Connection::open_in_memory().expect("open in-memory sqlite"); diff --git a/src-tauri/crates/session-persistence/src/turn_index.rs b/src-tauri/crates/session-persistence/src/turn_index.rs index c950037b55..f4c8fdc241 100644 --- a/src-tauri/crates/session-persistence/src/turn_index.rs +++ b/src-tauri/crates/session-persistence/src/turn_index.rs @@ -13,6 +13,7 @@ use super::crud::normalize_session_sequences; const USER_MESSAGE_FUNCTION: &str = "user_message"; const IMPORTED_USER_MESSAGE_FUNCTION: &str = "user"; +const CANONICAL_USER_INPUT_FUNCTION: &str = "user_input"; const TURN_STATUS_PENDING: &str = "pending"; const TURN_STATUS_COMPLETED: &str = "completed"; const TURN_STATUS_FAILED: &str = "failed"; @@ -30,7 +31,10 @@ const TURN_STATUS_FAILED: &str = "failed"; /// v11: treat the normalized imported-history `user` function as the same /// turn boundary as the native `user_message` function. /// v12: materialize the canonical `turn_intent_id` carried by the user row. -const TURN_INDEX_VERSION: i64 = 12; +/// v13: treat provider-native canonical `user_input` events as the same turn +/// boundary. These are emitted by the shared role/tool transcript adapter and +/// can arrive through Team Session, personal Cloud sync, or runtime migration. +const TURN_INDEX_VERSION: i64 = 13; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -159,7 +163,9 @@ fn turn_intent_id_for_row(row: &IndexEventRow) -> Option { fn is_user_message(row: &IndexEventRow) -> bool { matches!( row.function_name.as_deref(), - Some(USER_MESSAGE_FUNCTION | IMPORTED_USER_MESSAGE_FUNCTION) + Some( + USER_MESSAGE_FUNCTION | IMPORTED_USER_MESSAGE_FUNCTION | CANONICAL_USER_INPUT_FUNCTION + ) ) && !is_synthetic_user_input(row) } @@ -253,7 +259,7 @@ fn load_existing_user_event_keys( let mut stmt = conn.prepare_cached( "SELECT id, content, result_json FROM events - WHERE session_id = ?1 AND function_name IN ('user_message', 'user') + WHERE session_id = ?1 AND function_name IN ('user_message', 'user', 'user_input') ORDER BY COALESCE(history_sequence, rowid) ASC, created_at ASC, id ASC", )?; let mut ids = std::collections::HashSet::new(); @@ -283,6 +289,7 @@ fn load_existing_user_event_keys( let preview = content .strip_prefix("user_message ") .or_else(|| content.strip_prefix("user ")) + .or_else(|| content.strip_prefix("user_input ")) .unwrap_or(&content) .to_string(); *content_counts @@ -1015,6 +1022,25 @@ mod tests { assert_eq!(drafts[0].body_event_count, 1); } + #[test] + fn provider_native_user_input_starts_turn() { + let rows = vec![ + row( + "canonical-user-input", + Some(CANONICAL_USER_INPUT_FUNCTION), + "{}", + 1, + ), + row("assistant-event", Some("assistant_message"), "{}", 2), + ]; + + let drafts = build_turn_drafts(&rows, &StaleIntentIds::new()); + + assert_eq!(drafts.len(), 1); + assert_eq!(drafts[0].turn_id, "canonical-user-input"); + assert_eq!(drafts[0].body_event_count, 1); + } + #[test] fn consecutive_user_messages_do_not_materialize_ghost_pending_turns() { let rows = vec![ diff --git a/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs index acac8f10cf..030a0bfb5b 100644 --- a/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs +++ b/src-tauri/src/agent_sessions/cli/commands/resume_delete.rs @@ -12,6 +12,12 @@ use git::worktree; /// the CLI agent with the resume flag, continuing the previous conversation. #[tauri::command] pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { + // Resume owns the same short lifecycle boundary as create/follow-up. It + // checks for a live runner before waiting for provider identity, preserving + // the global invariant that no control holder waits on an active + // finalizer's identity guard. + let control_lock = session_runner::session_control_lock(&session_id).await; + let _control_guard = control_lock.lock_owned().await; // Load session to get the original user_input, current stage, and CLI session ID let session = tokio::task::spawn_blocking({ let sid = session_id.clone(); @@ -81,18 +87,26 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { // Stop any stale per-session proxy from a previous run integrations::proxy::server::stop_session_proxy(&session_id).await; + // Resume participates in the same provider-identity boundary as a normal + // turn so runtime/account patches cannot retarget the active UUID. + let identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; // Accept the resumed turn exactly like the create path: session + intent go // Running together and the frontend gets a `running` event carrying the // intent, so the terminal event below can be attributed to this turn. let turn_intent_id = super::run::new_turn_intent_id(); let accept_session_id = session_id.clone(); let accept_turn_intent_id = turn_intent_id.clone(); - tokio::task::spawn_blocking(move || { + let accept_result = tokio::task::spawn_blocking(move || { persistence::accept_cli_resume_turn(&accept_session_id, &accept_turn_intent_id) .map_err(|err| format!("failed to accept CLI resume turn lifecycle: {err}")) }) .await - .map_err(|err| format!("Task error: {err}"))??; + .map_err(|err| format!("Task error: {err}")) + .and_then(|result| result); + accept_result?; let mut running_msg = serde_json::json!({ "type": "code_session.status_changed", "session_id": session_id, @@ -106,6 +120,7 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { let runner_turn_intent_id = turn_intent_id.clone(); let handle = tokio::spawn(async move { + let _identity_guard = identity_guard; if let Err(e) = session_runner::run_session( sid.clone(), input, @@ -113,6 +128,7 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { None, None, Some(&runner_turn_intent_id), + false, ) .await { @@ -184,8 +200,14 @@ pub async fn cli_agent_resume(session_id: String) -> Result<(), String> { /// cleans up the persistent Cursor config directory, and removes any worktree. #[tauri::command] pub async fn cli_agent_delete(session_id: String) -> Result { + let control_lock = session_runner::session_control_lock(&session_id).await; + let _control_guard = control_lock.lock_owned().await; // Kill the agent process, Tokio task, and per-session proxy session_runner::kill_running_agent(&session_id).await; + let _identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; // Release proxy token BEFORE deleting the DB row — after deletion, // release_proxy_token_for_session can't find the session to read the token. diff --git a/src-tauri/src/agent_sessions/cli/commands/run.rs b/src-tauri/src/agent_sessions/cli/commands/run.rs index c33b6f6042..4197391b48 100644 --- a/src-tauri/src/agent_sessions/cli/commands/run.rs +++ b/src-tauri/src/agent_sessions/cli/commands/run.rs @@ -1,6 +1,6 @@ //! `cli_agent_run` / `cli_agent_message` / `cli_agent_approval_response` — -//! spawning and driving the background CLI agent runner, plus IDE-context -//! injection and TUI-pane release. +//! spawning and driving the background CLI agent runner, plus typed IDE +//! context forwarding and TUI-pane release. use super::super::persistence; use super::super::session_runner; @@ -37,6 +37,8 @@ pub struct CliRunRequest { /// entry points such as Mobile Remote must request the authoritative row. #[serde(default)] pub materialize_user_message_event: bool, + #[serde(default)] + pub allow_native_context_recovery: bool, } /// Send a follow-up message on an existing session, optionally switching the @@ -58,6 +60,8 @@ pub struct CliMessageRequest { /// True when the caller has no desktop-side optimistic EventStore row. #[serde(default)] pub materialize_user_message_event: bool, + #[serde(default)] + pub allow_native_context_recovery: bool, } /// Identity of a single turn. `turn_intent_id` keys the `turn_intents` row and @@ -89,24 +93,6 @@ pub(super) fn new_turn_intent_id() -> String { new_id() } -/// Prepend IDE context (open files, git status, etc.) to the user prompt -/// so external CLI agents are aware of the user's IDE state. -fn inject_ide_context_into_prompt(user_input: &str, ide_context: Option<&IdeContext>) -> String { - let Some(ctx) = ide_context else { - return user_input.to_string(); - }; - - let section = agent_core::core::session::prompt::ide_context::format_ide_context(ctx); - if section.is_empty() { - return user_input.to_string(); - } - - format!( - "\n{}\n\n\n{}", - section, user_input - ) -} - /// Park a TUI-hosted session when its terminal pane goes away (PTY exit or /// tab close). Non-TUI sessions and already-terminal rows are left alone. #[tauri::command] @@ -260,6 +246,7 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri turn_intent_id: _, client_message_id: _, materialize_user_message_event, + allow_native_context_recovery, } = request; let TurnIdentity { turn_intent_id, @@ -311,11 +298,35 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri return Ok(()); } - // Hold the registry lock across acceptance persistence + spawn so two - // concurrent calls cannot both create a running intent for one session. - let mut sessions = session_runner::RUNNING_SESSIONS.lock().await; + // Reject an active runner before waiting for provider identity. The + // current finalizer owns identity and then needs the caller-held control + // lock, so reversing that order would deadlock a duplicate start. Do not + // retain the global registry lock while a background finalizer may + // still own identity for this one session. + { + let sessions = session_runner::RUNNING_SESSIONS.lock().await; + if let Some(handle) = sessions.get(&session_id) { + if !handle.is_finished() { + return Err(format!( + "Session {} already has a running agent. Cancel it first.", + session_id + )); + } + } + } - // Guard: prevent duplicate parallel agents for the same session + // Freeze runtime/account/native binding through the complete background + // turn. `session_patch` waits on this guard and therefore applies picker + // changes to the next turn instead of retargeting the active runner. + let identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; + + // Hold the registry lock across acceptance persistence + spawn so an old + // resume entry point that does not share the caller's control guard cannot + // race this turn between the optimistic check above and registration. + let mut sessions = session_runner::RUNNING_SESSIONS.lock().await; if let Some(handle) = sessions.get(&session_id) { if !handle.is_finished() { return Err(format!( @@ -332,11 +343,10 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri } else { None }; - let persist_session_id = session_id.clone(); let persist_turn_intent_id = turn_intent_id.clone(); let persist_client_message_id = client_message_id.clone(); - tokio::task::spawn_blocking(move || { + let accept_result = tokio::task::spawn_blocking(move || { persistence::accept_cli_turn( &persist_session_id, &persist_turn_intent_id, @@ -345,7 +355,9 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri .map_err(|err| format!("failed to accept CLI turn lifecycle: {err}")) }) .await - .map_err(|err| format!("Task error: {err}"))??; + .map_err(|err| format!("Task error: {err}")) + .and_then(|result| result); + accept_result?; // The desktop composer appends a synthetic user event before dispatch and // native-transcript sessions intentionally avoid echoing another chunk. @@ -402,7 +414,6 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri crate::api::websocket_handler::broadcast(running_msg.to_string()); let sid = session_id.clone(); - let cli_input = inject_ide_context_into_prompt(&user_input, ide_context.as_ref()); let resume_id = cli_resume_id.clone(); let agent_mode = mode.clone(); let runner_turn_intent_id = turn_intent_id.clone(); @@ -411,13 +422,16 @@ async fn run_turn(request: CliRunRequest, turn: TurnIdentity) -> Result<(), Stri // Spawn as background task let handle = tokio::spawn(async move { - if let Err(e) = session_runner::run_session( + let _identity_guard = identity_guard; + if let Err(e) = session_runner::run_session_with_ide_context( sid.clone(), - cli_input, + user_input, + ide_context, resume_id, agent_mode.as_deref(), images, Some(&runner_turn_intent_id), + allow_native_context_recovery, ) .await { @@ -524,6 +538,7 @@ pub async fn cli_agent_message(request: CliMessageRequest) -> Result Result Result Result Result, + managed_session_id: &str, +) -> Vec { + for chunk in &mut chunks { + chunk.session_id = managed_session_id.to_string(); + } + chunks +} + +/// Read the exact provider path first, then its imported-history discovery +/// path. A readable exact transcript is authoritative and never consults the +/// eventually-consistent discovery cache. If either candidate exists but its +/// reader fails, propagate that error unless the other candidate succeeds; +/// silently falling back to DB chunks would certify a shorter history against +/// the still-stable native file revision. +fn load_native_transcript_candidate( + managed_session_id: &str, + imported_id: &str, + exact: Exact, + discovery: Discovery, +) -> Result>, String> +where + Exact: FnOnce() -> Result>, String>, + Discovery: FnOnce() -> Result>, String>, +{ + let exact_error = match exact() { + Ok(Some(chunks)) if !chunks.is_empty() => { + return Ok(Some(stamp_managed_session_id(chunks, managed_session_id))); + } + Ok(_) => None, + Err(error) => Some(error), + }; + + let discovery_error = match discovery() { + Ok(Some(chunks)) if !chunks.is_empty() => { + return Ok(Some(stamp_managed_session_id(chunks, managed_session_id))); + } + Ok(_) => None, + Err(error) => Some(error), + }; + + match (exact_error, discovery_error) { + (Some(exact), Some(discovery)) => Err(format!( + "Native transcript load failed for {imported_id}: exact={exact}; discovery={discovery}" + )), + (Some(exact), None) => Err(format!( + "Exact native transcript load failed for {imported_id}: {exact}" + )), + (None, Some(discovery)) => Err(format!( + "Native transcript discovery failed for {imported_id}: {discovery}" + )), + (None, None) => Ok(None), + } +} + /// Resolve and parse a native-mode session's transcript from the CLI's own -/// store through the imported-history loaders. `None` falls back to legacy -/// chunks — covering pre-migration sessions, crash-before-native-write, and -/// a store the reader can't currently open. -fn load_native_transcript_chunks(session: &CodeSession) -> Option> { +/// store through the imported-history loaders. `Ok(None)` falls back to legacy +/// chunks only when no native candidate exists (pre-migration or a first turn +/// before its native file is created). Existing-but-unreadable native state is +/// an error and must never degrade to a shorter DB replay. +fn load_native_transcript_chunks( + session: &CodeSession, +) -> Result>, String> { use super::super::native_transcript; if session.transcript_source != native_transcript::TRANSCRIPT_SOURCE_NATIVE { - return None; + return Ok(None); } - let agent = session - .cli_agent_type - .as_deref() - .and_then(key_vault::key_store::ModelType::from_str)?; - let binding = native_transcript::native_transcript_binding(&agent)?; - // Walk the binding ledger newest→oldest instead of trusting only the - // newest id: an aborted follow-up can bind a fork whose file the killed - // CLI never flushed, and replaying "nothing" would blank turns that a - // superseded fork still holds. - let mut candidate_ids = - persistence::native_transcript_ids_newest_first(&session.session_id, binding.source) - .unwrap_or_default(); - if let Some(cli_session_id) = session.cli_session_id.clone() { - if !candidate_ids.contains(&cli_session_id) { - candidate_ids.push(cli_session_id); - } + // UI replay and provider resume must use the same account-scoped native + // UUID. The historical ledger is source-wide and may contain another + // account's newest UUID after A→B→A; consulting it here would render B's + // transcript while the next send resumes A. Until the ledger itself is + // profile-scoped, fail closed to the exact current account mapping. + let Some((binding, cli_session_id)) = + native_transcript::current_native_store_key_for_session(session)? + else { + return Ok(None); + }; + let imported_id = binding.imported_session_id(&cli_session_id); + load_native_transcript_candidate( + &session.session_id, + &imported_id, + || { + // A managed native session already has an exact provider UUID and + // execution workspace. Read that authoritative file first: it is + // available synchronously after materialization and does not + // require the eventually-consistent imported-history cache. + super::super::native_materializer::load_materialized_cli_transcript( + session, + &cli_session_id, + ) + }, + || { + // Discovery covers legacy/provider files that moved away from the + // bound workspace. The exact success path above stays independent + // of this cache and its database connection. + let conn = database::db::get_connection() + .map_err(|error| format!("Failed to open imported history DB: {error}"))?; + orgtrack_core::sources::imported_history::load_activity_chunks_for_session( + &conn, + &imported_id, + ) + }, + ) +} + +#[cfg(test)] +mod native_transcript_resolution_tests { + use super::*; + + fn one_chunk(session_id: &str) -> Vec { + vec![ActivityChunk::new(session_id, "raw", "assistant_message")] } - let conn = database::db::get_connection().ok()?; - for cli_session_id in candidate_ids { - let imported_id = binding.imported_session_id(&cli_session_id); - match orgtrack_core::sources::imported_history::load_activity_chunks_for_session( - &conn, - &imported_id, - ) { - Ok(Some(mut chunks)) if !chunks.is_empty() => { - // Loaders stamp the imported id; the frontend event store, - // WS merge, and snapshot keys all key on the managed id. - for chunk in &mut chunks { - chunk.session_id = session.session_id.clone(); - } - return Some(chunks); - } - Ok(_) => continue, - Err(err) => { - tracing::warn!( - "[cli_agent_chunks] Native transcript load failed for {imported_id}: {err}" - ); - continue; - } - } + + #[test] + fn exact_success_is_authoritative_and_skips_discovery() { + let chunks = load_native_transcript_candidate( + "managed", + "codex:native", + || Ok(Some(one_chunk("native"))), + || -> Result>, String> { + panic!("discovery must not run after an exact transcript succeeds") + }, + ) + .expect("exact transcript should load") + .expect("exact transcript should be present"); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].session_id, "managed"); + } + + #[test] + fn healthy_discovery_recovers_an_unreadable_exact_candidate() { + let chunks = load_native_transcript_candidate( + "managed", + "claude-code:native", + || Err("exact parse failed".to_string()), + || Ok(Some(one_chunk("imported"))), + ) + .expect("discovery transcript should recover exact failure") + .expect("discovery transcript should be present"); + + assert_eq!(chunks[0].session_id, "managed"); + } + + #[test] + fn unreadable_native_candidate_fails_closed_instead_of_falling_back() { + let error = load_native_transcript_candidate( + "managed", + "codex:native", + || Err("invalid jsonl".to_string()), + || Ok(None), + ) + .expect_err("an unreadable native file must not fall back to DB chunks"); + + assert!(error.contains("invalid jsonl")); + } + + #[test] + fn absent_native_candidates_allow_the_legacy_fallback() { + let chunks = + load_native_transcript_candidate("managed", "codex:native", || Ok(None), || Ok(None)) + .expect("absence is not a read failure"); + + assert!(chunks.is_none()); } - None } /// Where a managed session's transcript of record lives, for display @@ -73,6 +190,119 @@ pub struct CliTranscriptLocation { pub path: Option, } +#[derive(Debug, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliTranscriptRevision { + /// False for legacy DB-chunk sessions, which have no provider file. + native: bool, + /// Opaque provider-file-set token. `None` for an unbound or unavailable + /// native transcript; callers must not treat that snapshot as stable. + revision: Option, +} + +fn cached_native_transcript_path( + conn: &rusqlite::Connection, + source: &str, + native_id: &str, +) -> Result, String> { + orgtrack_core::sources::imported_history::cache::get_cached_source_path_from_conn( + conn, source, native_id, + ) + .and_then(|path| { + if path.is_some() { + Ok(path) + } else { + orgtrack_core::sources::imported_history::cache:: + get_cached_source_path_by_suffix_from_conn(conn, source, native_id) + } + }) +} + +fn load_cli_transcript_revision(session_id: &str) -> Result { + use super::super::native_transcript; + + let legacy = || CliTranscriptRevision { + native: false, + revision: None, + }; + let unavailable = || CliTranscriptRevision { + native: true, + revision: None, + }; + + let Some(session) = + persistence::get_session(session_id).map_err(|error| format!("DB error: {error}"))? + else { + return Ok(legacy()); + }; + if session.transcript_source != native_transcript::TRANSCRIPT_SOURCE_NATIVE { + return Ok(legacy()); + } + let Some(binding) = session + .cli_agent_type + .as_deref() + .and_then(key_vault::key_store::ModelType::from_str) + .and_then(|agent| native_transcript::native_transcript_binding(&agent)) + else { + return Ok(unavailable()); + }; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let Some(native_id) = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|error| format!("read native binding for {session_id}: {error}"))? + else { + return Ok(unavailable()); + }; + + let exact_revision = super::super::native_materializer::materialized_cli_transcript_revision( + &session, &native_id, + ) + .ok() + .flatten(); + + let discovery_revision = database::db::get_connection() + .ok() + .and_then(|conn| { + cached_native_transcript_path(&conn, binding.source, &native_id) + .ok() + .flatten() + }) + .map(std::path::PathBuf::from) + .filter(|path| path.is_file()) + .and_then(|path| native_transcript_revision(&path).ok()); + // The transcript reader tries the exact materialized path and then its + // imported-history discovery path. Track both candidates: if the exact + // file is unreadable and replay falls back to discovery, an external App + // append to either possible source still invalidates the snapshot. + let revision = if exact_revision.is_none() && discovery_revision.is_none() { + None + } else { + Some( + serde_json::to_string(&("native-file-set-v1", exact_revision, discovery_revision)) + .map_err(|error| format!("serialize native transcript revision: {error}"))?, + ) + }; + Ok(CliTranscriptRevision { + native: true, + revision, + }) +} + +/// Return the provider-file-set revision through the same native binding and +/// path resolution used by transcript replay. The token is opaque to +/// TypeScript; callers may only compare it for equality around a canonical +/// read. +#[tauri::command] +pub async fn cli_agent_transcript_revision( + session_id: String, +) -> Result { + tokio::task::spawn_blocking(move || load_cli_transcript_revision(&session_id)) + .await + .map_err(|error| format!("Task error: {error}"))? +} + /// Resolve the storage location of a session's transcript of record. /// Chunks-mode (legacy) sessions report `native: false` — the caller keeps /// showing `sessions.db`. Native sessions report the CLI store file path when @@ -108,16 +338,7 @@ pub async fn cli_agent_transcript_path( .map_err(|err| format!("Failed to open orgtrack source cache DB: {err}"))?; // Exact match first; Codex caches key on the rollout file stem, which // only the `-`-bounded suffix variant matches. - let mut path = - orgtrack_core::sources::imported_history::cache::get_cached_source_path_from_conn( - &conn, - binding.source, - &cli_session_id, - )?; - if path.is_none() { - path = orgtrack_core::sources::imported_history::cache:: - get_cached_source_path_by_suffix_from_conn(&conn, binding.source, &cli_session_id)?; - } + let path = cached_native_transcript_path(&conn, binding.source, &cli_session_id)?; Ok(CliTranscriptLocation { native: true, path }) }) .await @@ -154,7 +375,7 @@ pub async fn cli_agent_chunks(session_id: String) -> Result, let session = persistence::get_session(&session_id).map_err(|e| format!("DB error: {}", e))?; if let Some(session) = session.as_ref() { - if let Some(chunks) = load_native_transcript_chunks(session) { + if let Some(chunks) = load_native_transcript_chunks(session)? { return Ok(chunks); } } @@ -195,9 +416,14 @@ pub async fn cli_agent_truncate_after_chunk( created_at: String, revert_files: Option, ) -> Result { + let control_lock = session_runner::session_control_lock(&session_id).await; + let _control_guard = control_lock.lock_owned().await; // Kill any running agent first to prevent it from writing new chunks session_runner::kill_running_agent(&session_id).await; - + let _identity_guard = session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await; // Wipe the Cursor config dir so the agent starts fresh — legacy chunk mode // ONLY. Under `transcript_source = 'native'` that directory IS the // transcript of record (hosted-key Cursor stores its chats under the diff --git a/src-tauri/src/agent_sessions/cli/mod.rs b/src-tauri/src/agent_sessions/cli/mod.rs index e975925c99..adffe4a486 100644 --- a/src-tauri/src/agent_sessions/cli/mod.rs +++ b/src-tauri/src/agent_sessions/cli/mod.rs @@ -15,6 +15,9 @@ pub mod agent_core_bridge; pub mod commands; pub mod hook_approvals; pub mod launch_profile_store; +mod native_ir; +pub mod native_materializer; +mod native_store; pub mod native_transcript; pub mod parsers; pub mod persistence; @@ -85,6 +88,8 @@ pub fn init_cli_agent_tables(conn: &Connection) -> SqliteResult<()> { session_id TEXT NOT NULL REFERENCES code_sessions(session_id) ON DELETE CASCADE, profile_key TEXT NOT NULL, cli_session_id TEXT NOT NULL, + native_catalog_requested_revision INTEGER NOT NULL DEFAULT 0, + native_catalog_applied_revision INTEGER NOT NULL DEFAULT 0, updated_at TEXT NOT NULL, PRIMARY KEY (session_id, profile_key) ); @@ -111,6 +116,14 @@ pub fn init_cli_agent_tables(conn: &Connection) -> SqliteResult<()> { ", )?; + // Existing installations predate durable native-App catalog receipts. + // These additive columns deliberately live on the resume binding owner: + // they describe whether that exact provider UUID still needs its native + // application's discovery metadata refreshed. Do not suppress arbitrary + // ALTER failures here; only the standard duplicate-column race is safe to + // treat as an already-applied migration. + ensure_native_catalog_revision_columns(conn)?; + conn.execute("ALTER TABLE code_session_chunks DROP COLUMN stage_name", []) .ok(); @@ -334,6 +347,40 @@ pub fn init_cli_agent_tables(conn: &Connection) -> SqliteResult<()> { Ok(()) } +fn ensure_native_catalog_revision_columns(conn: &Connection) -> SqliteResult<()> { + for (column, statement) in [ + ( + "native_catalog_requested_revision", + "ALTER TABLE code_session_cli_resume_state + ADD COLUMN native_catalog_requested_revision INTEGER NOT NULL DEFAULT 0", + ), + ( + "native_catalog_applied_revision", + "ALTER TABLE code_session_cli_resume_state + ADD COLUMN native_catalog_applied_revision INTEGER NOT NULL DEFAULT 0", + ), + ] { + let present = conn + .prepare("PRAGMA table_info(code_session_cli_resume_state)")? + .query_map([], |row| row.get::<_, String>(1))? + .collect::, _>>()? + .iter() + .any(|candidate| candidate == column); + if present { + continue; + } + match conn.execute(statement, []) { + Ok(_) => {} + Err(rusqlite::Error::SqliteFailure(_, Some(message))) + if message + .to_ascii_lowercase() + .contains("duplicate column name") => {} + Err(error) => return Err(error), + } + } + Ok(()) +} + /// One-time migration: remove `...` blocks from stored /// user input and user_message chunks so they don't appear in chat history UI. fn migrate_strip_ide_context(conn: &Connection) { @@ -401,3 +448,43 @@ fn migrate_strip_ide_context(conn: &Connection) { ) .ok(); } + +#[cfg(test)] +mod native_catalog_revision_migration_tests { + use super::*; + + #[test] + fn upgrades_legacy_resume_state_idempotently() { + let conn = Connection::open_in_memory().expect("open legacy database"); + init_cli_agent_tables(&conn).expect("prime surrounding CLI schema"); + conn.execute_batch( + "DROP TABLE code_session_cli_resume_state; + CREATE TABLE code_session_cli_resume_state ( + session_id TEXT NOT NULL, + profile_key TEXT NOT NULL, + cli_session_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (session_id, profile_key) + ); + INSERT INTO code_session_cli_resume_state + (session_id, profile_key, cli_session_id, updated_at) + VALUES ('session-1', 'account-1', 'native-1', '2026-09-04T00:00:00Z');", + ) + .expect("create legacy resume-state schema"); + + init_cli_agent_tables(&conn).expect("upgrade legacy schema"); + init_cli_agent_tables(&conn).expect("repeat upgrade"); + + let revisions = conn + .query_row( + "SELECT native_catalog_requested_revision, + native_catalog_applied_revision + FROM code_session_cli_resume_state + WHERE session_id = 'session-1' AND profile_key = 'account-1'", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .expect("read migrated binding"); + assert_eq!(revisions, (0, 0)); + } +} diff --git a/src-tauri/src/agent_sessions/cli/native_ir.rs b/src-tauri/src/agent_sessions/cli/native_ir.rs new file mode 100644 index 0000000000..915b893f24 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/native_ir.rs @@ -0,0 +1,1158 @@ +//! Canonical provider-neutral role/tool conversation IR. +//! +//! This module owns validation and projections from provider/Agent history. +//! Native store mutation and provider serialization remain in the materializer. + +use std::collections::{HashMap, HashSet}; +use std::io::Write; + +use core_types::activity::ActivityChunk; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +pub(super) const MAX_ITEMS: usize = 100_000; +const MAX_SERIALIZED_BYTES: usize = 64 * 1024 * 1024; +const MAX_PORTABLE_TOOL_CALL_ID_LENGTH: usize = 64; +const PORTABLE_TOOL_CALL_NAMESPACE: Uuid = Uuid::from_u128(0x9e7db8a394bf5c589416a244ba6e30d3); + +fn is_portable_tool_call_id(value: &str) -> bool { + !value.is_empty() + && value.chars().count() <= MAX_PORTABLE_TOOL_CALL_ID_LENGTH + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +fn portable_tool_call_id(value: &str) -> String { + let value = value.trim(); + if is_portable_tool_call_id(value) { + return value.to_string(); + } + + format!( + "call_{}", + Uuid::new_v5(&PORTABLE_TOOL_CALL_NAMESPACE, value.as_bytes()).simple() + ) +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)] +#[serde( + tag = "kind", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum NativeConversationItem { + Message { + id: String, + role: String, + text: String, + #[serde(default)] + images: Vec, + created_at: String, + #[serde(default)] + turn_id: Option, + }, + ToolCall { + id: String, + call_id: String, + name: String, + arguments: String, + created_at: String, + }, + ToolResult { + id: String, + call_id: String, + name: String, + output: String, + #[serde(default)] + is_error: bool, + #[serde(default)] + interrupted: bool, + created_at: String, + }, + /// Provider-owned effective-context boundary. The full SessionEvent log + /// remains available for UI/history; materialization uses this typed + /// summary plus the structured suffix instead of replaying the superseded + /// pre-compaction model context. + ContextSummary { + id: String, + summary: String, + created_at: String, + }, +} + +impl NativeConversationItem { + pub(super) fn id(&self) -> &str { + match self { + Self::Message { id, .. } + | Self::ToolCall { id, .. } + | Self::ToolResult { id, .. } + | Self::ContextSummary { id, .. } => id, + } + } + + pub(super) fn created_at(&self) -> &str { + match self { + Self::Message { created_at, .. } + | Self::ToolCall { created_at, .. } + | Self::ToolResult { created_at, .. } + | Self::ContextSummary { created_at, .. } => created_at, + } + } +} + +pub(super) fn validate_items(items: &[NativeConversationItem]) -> Result<(), String> { + if items.len() > MAX_ITEMS { + return Err(format!( + "native transcript has {} items; limit is {MAX_ITEMS}", + items.len() + )); + } + struct SerializedSize(usize); + + impl Write for SerializedSize { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 = self + .0 + .checked_add(bytes.len()) + .ok_or_else(|| std::io::Error::other("native transcript size overflow"))?; + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + // Measure the wire representation without allocating a second copy of a + // potentially 64 MiB transcript on every materialize/synchronize call. + let mut encoded_size = SerializedSize(0); + serde_json::to_writer(&mut encoded_size, items) + .map_err(|err| format!("serialize native transcript input: {err}"))?; + if encoded_size.0 > MAX_SERIALIZED_BYTES { + return Err(format!( + "native transcript is {} bytes; limit is {MAX_SERIALIZED_BYTES}", + encoded_size.0 + )); + } + let mut item_ids = HashSet::with_capacity(items.len()); + for item in items { + if item.id().trim().is_empty() { + return Err("native transcript item id is required".to_string()); + } + if !item_ids.insert(item.id()) { + return Err(format!( + "native transcript contains duplicate canonical item id {:?}", + item.id() + )); + } + match item { + NativeConversationItem::Message { + id, role, images, .. + } => { + if !matches!(role.as_str(), "user" | "assistant") { + return Err(format!("unsupported native message role {role:?}")); + } + if role == "assistant" && !images.is_empty() { + return Err(format!( + "assistant historical images cannot be transferred losslessly to this native target: item={id:?}, images={}", + images.len() + )); + } + for image in images { + if !image.starts_with("data:image/") { + return Err(format!( + "historical images must be embedded data URLs for exact native transfer: item={id:?}" + )); + } + } + } + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => { + if call_id.trim().is_empty() || name.trim().is_empty() { + return Err("native tool call requires callId and name".to_string()); + } + if !is_portable_tool_call_id(call_id) { + return Err(format!( + "native tool call id must match [A-Za-z0-9_-] and be at most {MAX_PORTABLE_TOOL_CALL_ID_LENGTH} characters" + )); + } + serde_json::from_str::(arguments).map_err(|err| { + format!("native tool call {call_id} has invalid JSON arguments: {err}") + })?; + } + NativeConversationItem::ToolResult { call_id, name, .. } => { + if call_id.trim().is_empty() || name.trim().is_empty() { + return Err("native tool result requires callId and name".to_string()); + } + if !is_portable_tool_call_id(call_id) { + return Err(format!( + "native tool result id must match [A-Za-z0-9_-] and be at most {MAX_PORTABLE_TOOL_CALL_ID_LENGTH} characters" + )); + } + } + NativeConversationItem::ContextSummary { summary, .. } => { + if summary.trim().is_empty() { + return Err("native context summary cannot be empty".to_string()); + } + } + } + } + Ok(()) +} + +fn json_text(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Array(parts) => parts + .iter() + .filter_map(|part| { + part.get("text") + .and_then(Value::as_str) + .or_else(|| part.get("content").and_then(Value::as_str)) + }) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +fn chunk_text(chunk: &ActivityChunk) -> String { + chunk + .result + .get("message") + .and_then(|message| message.get("content")) + .map(json_text) + .filter(|text| !text.is_empty()) + .or_else(|| { + ["content", "observation", "output"] + .into_iter() + .find_map(|field| chunk.result.get(field).and_then(Value::as_str)) + .map(str::to_string) + }) + .unwrap_or_default() +} + +fn transferable_tool_args(chunk: &ActivityChunk) -> Value { + let mut args = chunk.args.clone(); + if let Some(object) = args.as_object_mut() { + object.retain(|key, _| { + key != "conversationTurnId" + && key != "conversationSender" + && !key.starts_with("__orgii") + }); + } + args +} + +fn agent_message_images(message: &Value) -> Vec { + message + .get("content") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|part| { + let image = part.get("image_url")?; + image + .as_str() + .or_else(|| image.get("url").and_then(Value::as_str)) + .filter(|url| url.starts_with("data:image/")) + .map(str::to_string) + }) + .collect() +} + +/// Project the provider reader's authoritative transcript back into the same +/// portable role/tool IR accepted by the materializer. Native lifecycle, +/// usage, reasoning, and compact markers deliberately stay outside this +/// projection; compaction remains owned by the live target provider. +pub(super) fn native_items_from_chunks(chunks: &[ActivityChunk]) -> Vec { + let mut items = Vec::new(); + for chunk in chunks { + match chunk.function.as_str() { + "context_compacted" => { + let summary = chunk_text(chunk); + if !summary.trim().is_empty() { + // Only the latest compact boundary is effective model + // context. Superseded rows remain in SessionEvents for UI + // history but must not be fed to the next provider. + items.clear(); + items.push(NativeConversationItem::ContextSummary { + id: chunk.chunk_id.clone(), + summary, + created_at: chunk.created_at.clone(), + }); + } + } + orgtrack_core::sources::imported_history::FUNCTION_USER_MESSAGE => { + let images = chunk + .result + .get("images") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string) + .collect::>(); + items.push(NativeConversationItem::Message { + id: chunk.chunk_id.clone(), + role: "user".to_string(), + text: chunk_text(chunk), + images, + created_at: chunk.created_at.clone(), + turn_id: None, + }); + } + orgtrack_core::sources::imported_history::FUNCTION_ASSISTANT => { + let text = chunk_text(chunk); + if !text.is_empty() { + items.push(NativeConversationItem::Message { + id: chunk.chunk_id.clone(), + role: "assistant".to_string(), + text, + images: Vec::new(), + created_at: chunk.created_at.clone(), + turn_id: None, + }); + } + } + _ if chunk.action_type == "tool_call" => { + // A provider-native interrupt is recorded as a tool call with + // no result. That is not a portable conversation boundary: + // the canonical projection drops it, so reading the provider + // store back must drop it too instead of inventing a result + // the provider never wrote. + let status_is_pending = chunk + .result + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| matches!(status, "pending" | "running")); + let interrupted = + chunk.result.get("interrupted").and_then(Value::as_bool) == Some(true); + let output = chunk_text(chunk); + // A call with no provider result cannot cross a runtime + // boundary. If Stop already observed durable output, however, + // carry an honest interrupted result: both native writers can + // encode it as failure (Claude is_error / Codex exit 130). + if (status_is_pending || interrupted) && (!interrupted || output.is_empty()) { + continue; + } + let is_error = chunk.result.get("is_error").and_then(Value::as_bool) == Some(true) + || chunk.result.get("success").and_then(Value::as_bool) == Some(false) + || interrupted + || chunk + .result + .get("status") + .and_then(Value::as_str) + .is_some_and(|status| matches!(status, "failed" | "error" | "cancelled")); + let raw_call_id = chunk + .result + .get("call_id") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .unwrap_or(&chunk.chunk_id); + let call_id = portable_tool_call_id(raw_call_id); + let name = chunk.function.clone(); + items.push(NativeConversationItem::ToolCall { + id: format!("{}:call", chunk.chunk_id), + call_id: call_id.clone(), + name: name.clone(), + arguments: transferable_tool_args(chunk).to_string(), + created_at: chunk.created_at.clone(), + }); + items.push(NativeConversationItem::ToolResult { + id: format!("{}:result", chunk.chunk_id), + call_id, + name, + output, + is_error, + interrupted, + created_at: chunk.created_at.clone(), + }); + } + _ => {} + } + } + items +} + +pub(super) fn native_items_from_agent_history(history: &[Value]) -> Vec { + let mut items = Vec::new(); + // The persisted LLM history serializes a tool result as + // `{"role":"tool","tool_call_id","content"}`; the tool name lives only on + // the assistant `tool_calls` entry that opened the pair. + let mut call_names: std::collections::HashMap = + std::collections::HashMap::new(); + let mut index = 0; + while index < history.len() { + let message = &history[index]; + let role = message + .get("role") + .and_then(Value::as_str) + .unwrap_or_default(); + let created_at = message + .get("created_at") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + match role { + "user" | "assistant" => { + let text = message.get("content").map(json_text).unwrap_or_default(); + let images = agent_message_images(message); + if !text.is_empty() || !images.is_empty() { + items.push(NativeConversationItem::Message { + id: format!("agent-history-{index}"), + role: role.to_string(), + text, + images, + created_at: created_at.clone(), + turn_id: None, + }); + } + if role != "assistant" { + index += 1; + continue; + } + let tool_calls: Vec<&Value> = message + .get("tool_calls") + .and_then(Value::as_array) + .into_iter() + .flatten() + .collect(); + if tool_calls.is_empty() { + index += 1; + continue; + } + // The OpenAI-style history batches every call of one assistant + // step ahead of all of its results. The canonical conversation + // (and the rows this history was reconstructed from) interleave + // each call with its own result, so re-pair the batch here: + // call, result, call, result. Only the immediately following + // `tool` messages belong to this batch. + let mut results: Vec<(usize, &Value)> = Vec::new(); + let mut next = index + 1; + while next < history.len() + && history[next].get("role").and_then(Value::as_str) == Some("tool") + { + results.push((next, &history[next])); + next += 1; + } + for (tool_index, tool) in tool_calls.iter().enumerate() { + let raw_call_id = tool.get("id").and_then(Value::as_str).unwrap_or_default(); + let call_id = portable_tool_call_id(raw_call_id); + let function = tool.get("function").unwrap_or(tool); + let name = function + .get("name") + .and_then(Value::as_str) + .unwrap_or("tool"); + call_names.insert(raw_call_id.to_string(), name.to_string()); + 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(), + arguments: arguments.to_string(), + created_at: created_at.clone(), + }); + if let Some(position) = results.iter().position(|(_, result)| { + result.get("tool_call_id").and_then(Value::as_str) == Some(raw_call_id) + }) { + let (result_index, result) = results.remove(position); + items.push(tool_result_item(result, result_index, &call_names)); + } + } + // Results whose call is not in this batch keep their history order. + for (result_index, result) in results { + items.push(tool_result_item(result, result_index, &call_names)); + } + index = next; + continue; + } + "tool" => { + items.push(tool_result_item(message, index, &call_names)); + } + _ => {} + } + index += 1; + } + items +} + +fn tool_result_item( + message: &Value, + index: usize, + call_names: &std::collections::HashMap, +) -> NativeConversationItem { + let raw_call_id = message + .get("tool_call_id") + .and_then(Value::as_str) + .unwrap_or_default(); + let call_id = portable_tool_call_id(raw_call_id); + let name = message + .get("name") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| call_names.get(raw_call_id).cloned()) + .unwrap_or_else(|| "tool".to_string()); + NativeConversationItem::ToolResult { + id: format!("agent-history-{index}-result"), + call_id, + name, + output: message.get("content").map(json_text).unwrap_or_default(), + is_error: message + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false), + interrupted: message + .get("interrupted") + .and_then(Value::as_bool) + .unwrap_or(false), + created_at: message + .get("created_at") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + } +} + +pub(super) fn native_item_semantically_equal( + left: &NativeConversationItem, + right: &NativeConversationItem, +) -> bool { + match (left, right) { + ( + NativeConversationItem::Message { + role: left_role, + text: left_text, + images: left_images, + .. + }, + NativeConversationItem::Message { + role: right_role, + text: right_text, + images: right_images, + .. + }, + ) => left_role == right_role && left_text == right_text && left_images == right_images, + ( + NativeConversationItem::ToolCall { + call_id: left_id, + name: left_name, + arguments: left_arguments, + .. + }, + NativeConversationItem::ToolCall { + call_id: right_id, + name: right_name, + arguments: right_arguments, + .. + }, + ) => { + left_id == right_id + && left_name == right_name + && tool_arguments_semantically_equal(left_arguments, right_arguments) + } + ( + NativeConversationItem::ToolResult { + call_id: left_id, + name: left_name, + output: left_output, + is_error: left_is_error, + .. + }, + NativeConversationItem::ToolResult { + call_id: right_id, + name: right_name, + output: right_output, + is_error: right_is_error, + .. + }, + ) => { + // `interrupted` refines `is_error` for ORG2 diagnostics only. No + // supported provider transcript carries it: an Anthropic + // `tool_result` block is content plus `is_error`, and a Codex + // `function_call_output` is text. Comparing it here would make + // every transcript ORG2 wrote diverge from itself on read-back. + left_id == right_id + && left_name == right_name + && left_output == right_output + && left_is_error == right_is_error + } + ( + NativeConversationItem::ContextSummary { + summary: left_summary, + .. + }, + NativeConversationItem::ContextSummary { + summary: right_summary, + .. + }, + ) => left_summary == right_summary, + _ => false, + } +} + +/// Structural description used in diagnostics. Content is summarized by +/// length only so provider transcripts never leak into error strings. +fn native_item_shape(item: &NativeConversationItem) -> String { + match item { + NativeConversationItem::Message { + role, text, images, .. + } => format!( + "message:{role}:text={}:images={}", + text.chars().count(), + images.len() + ), + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => format!( + "tool_call:{name}:call={call_id}:arguments={}", + arguments.chars().count() + ), + NativeConversationItem::ToolResult { + call_id, + name, + output, + is_error, + .. + } => format!( + "tool_result:{name}:call={call_id}:output={}:is_error={is_error}", + output.chars().count() + ), + NativeConversationItem::ContextSummary { summary, .. } => { + format!("context_summary:text={}", summary.chars().count()) + } + } +} + +fn tool_arguments_semantically_equal(left: &str, right: &str) -> bool { + match ( + serde_json::from_str::(left), + serde_json::from_str::(right), + ) { + (Ok(left), Ok(right)) => left == right, + // Provider readers should not normally produce invalid JSON, but a + // corrupt native row must not compare equal to a different corrupt + // row merely because both failed to parse. + (Err(_), Err(_)) => left == right, + _ => false, + } +} + +#[derive(Debug)] +struct PortableToolCallBinding { + native_call_id: String, + name: String, + has_result: bool, +} + +#[derive(Default)] +struct PortableToolCallBindings { + canonical_to_native: HashMap, + native_to_canonical: HashMap, +} + +impl PortableToolCallBindings { + fn bind_call( + &mut self, + native_call_id: &str, + canonical_call_id: &str, + name: &str, + ) -> Result<(), String> { + if self.canonical_to_native.contains_key(canonical_call_id) { + return Err("canonical tool call id is reused".to_string()); + } + if self.native_to_canonical.contains_key(native_call_id) { + return Err("provider tool call id is reused".to_string()); + } + self.native_to_canonical + .insert(native_call_id.to_string(), canonical_call_id.to_string()); + self.canonical_to_native.insert( + canonical_call_id.to_string(), + PortableToolCallBinding { + native_call_id: native_call_id.to_string(), + name: name.to_string(), + has_result: false, + }, + ); + Ok(()) + } + + fn match_result( + &mut self, + native_call_id: &str, + canonical_call_id: &str, + name: &str, + ) -> Result<(), String> { + let binding = self + .canonical_to_native + .get_mut(canonical_call_id) + .ok_or_else(|| "tool result has no preceding canonical call".to_string())?; + if binding.native_call_id != native_call_id { + return Err("tool result does not match the provider call alias".to_string()); + } + if binding.name != name { + return Err("tool result name does not match its call".to_string()); + } + if binding.has_result { + return Err("tool call has more than one result".to_string()); + } + binding.has_result = true; + Ok(()) + } + + fn native_call_id_for_result( + &mut self, + canonical_call_id: &str, + name: &str, + ) -> Result { + let native_call_id = self + .canonical_to_native + .get(canonical_call_id) + .ok_or_else(|| "tool result has no preceding canonical call".to_string())? + .native_call_id + .clone(); + self.match_result(&native_call_id, canonical_call_id, name)?; + Ok(native_call_id) + } +} + +/// Compare an authoritative provider transcript with a canonical prefix while +/// preserving provider-local tool-call aliases. If the prefix is valid, return +/// the canonical suffix rewritten so results that cross the prefix boundary +/// still target the provider's accepted call id. +pub(super) fn provider_portable_append_suffix( + authoritative: &[NativeConversationItem], + complete: &[NativeConversationItem], +) -> Result, String> { + if authoritative.len() > complete.len() { + let first_divergence = authoritative + .iter() + .zip(complete) + .position(|(native, canonical)| !native_item_semantically_equal(native, canonical)) + .map(|index| { + format!( + "; first divergence at item {index}: native={} canonical={}", + native_item_shape(&authoritative[index]), + native_item_shape(&complete[index]) + ) + }) + .unwrap_or_default(); + let extra = authoritative[complete.len()..] + .iter() + .take(4) + .map(native_item_shape) + .collect::>() + .join(", "); + return Err(format!( + "provider transcript is longer than the canonical conversation{first_divergence}; native items beyond the canonical end: [{extra}]" + )); + } + + let mut bindings = PortableToolCallBindings::default(); + for (index, (native, canonical)) in authoritative.iter().zip(complete).enumerate() { + let comparison = match (native, canonical) { + ( + NativeConversationItem::ToolCall { + call_id: native_call_id, + name: native_name, + arguments: native_arguments, + .. + }, + NativeConversationItem::ToolCall { + call_id: canonical_call_id, + name: canonical_name, + arguments: canonical_arguments, + .. + }, + ) if native_name == canonical_name + && tool_arguments_semantically_equal(native_arguments, canonical_arguments) => + { + bindings.bind_call(native_call_id, canonical_call_id, canonical_name) + } + ( + NativeConversationItem::ToolResult { + call_id: native_call_id, + name: native_name, + output: native_output, + is_error: native_is_error, + .. + }, + NativeConversationItem::ToolResult { + call_id: canonical_call_id, + name: canonical_name, + output: canonical_output, + is_error: canonical_is_error, + .. + }, + ) if native_name == canonical_name + && native_output == canonical_output + && native_is_error == canonical_is_error => + { + bindings.match_result(native_call_id, canonical_call_id, canonical_name) + } + _ if native_item_semantically_equal(native, canonical) => Ok(()), + _ => Err(format!( + "item semantics differ: native={} canonical={}", + native_item_shape(native), + native_item_shape(canonical) + )), + }; + comparison.map_err(|reason| format!("item {index}: {reason}"))?; + } + + let mut append = Vec::with_capacity(complete.len() - authoritative.len()); + for (index, item) in complete.iter().enumerate().skip(authoritative.len()) { + let mut item = item.clone(); + match &mut item { + NativeConversationItem::ToolCall { call_id, name, .. } => bindings + .bind_call(call_id, call_id, name) + .map_err(|reason| format!("item {index}: {reason}"))?, + NativeConversationItem::ToolResult { call_id, name, .. } => { + *call_id = bindings + .native_call_id_for_result(call_id, name) + .map_err(|reason| format!("item {index}: {reason}"))?; + } + NativeConversationItem::Message { .. } + | NativeConversationItem::ContextSummary { .. } => {} + } + append.push(item); + } + Ok(append) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn test_tool_call(call_id: &str, name: &str, arguments: &str) -> NativeConversationItem { + NativeConversationItem::ToolCall { + id: format!("call-item-{call_id}"), + call_id: call_id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + created_at: "2026-09-07T00:00:00Z".to_string(), + } + } + + fn test_tool_result(call_id: &str, name: &str) -> NativeConversationItem { + NativeConversationItem::ToolResult { + id: format!("result-item-{call_id}"), + call_id: call_id.to_string(), + name: name.to_string(), + output: "result".to_string(), + is_error: false, + interrupted: false, + created_at: "2026-09-07T00:00:01Z".to_string(), + } + } + + #[test] + fn provider_portable_prefix_rewrites_a_cross_boundary_result() { + let authoritative = vec![test_tool_call( + "provider_call_a", + "read_file", + r#"{"path":"README.md"}"#, + )]; + let complete = vec![ + test_tool_call("canonical_call_a", "read_file", r#"{"path":"README.md"}"#), + test_tool_result("canonical_call_a", "read_file"), + ]; + + let suffix = provider_portable_append_suffix(&authoritative, &complete) + .expect("provider aliases preserve a semantic prefix"); + assert!(matches!( + suffix.as_slice(), + [NativeConversationItem::ToolResult { call_id, .. }] + if call_id == "provider_call_a" + )); + } + + #[test] + fn provider_portable_prefix_rejects_swapped_or_reused_tool_aliases() { + let complete = vec![ + test_tool_call("canonical_a", "read_file", r#"{"path":"a"}"#), + test_tool_call("canonical_b", "read_file", r#"{"path":"b"}"#), + test_tool_result("canonical_a", "read_file"), + test_tool_result("canonical_b", "read_file"), + ]; + let swapped = vec![ + test_tool_call("provider_a", "read_file", r#"{"path":"a"}"#), + test_tool_call("provider_b", "read_file", r#"{"path":"b"}"#), + test_tool_result("provider_b", "read_file"), + test_tool_result("provider_a", "read_file"), + ]; + assert!(provider_portable_append_suffix(&swapped, &complete).is_err()); + + let reused = vec![ + test_tool_call("provider_a", "read_file", r#"{"path":"a"}"#), + test_tool_call("provider_a", "read_file", r#"{"path":"b"}"#), + ]; + assert!(provider_portable_append_suffix(&reused, &complete).is_err()); + + let collision_prefix = vec![test_tool_call( + "canonical_b", + "read_file", + r#"{"path":"a"}"#, + )]; + let colliding_complete = vec![ + test_tool_call("canonical_a", "read_file", r#"{"path":"a"}"#), + test_tool_call("canonical_b", "read_file", r#"{"path":"b"}"#), + ]; + assert!(provider_portable_append_suffix(&collision_prefix, &colliding_complete).is_err()); + } + + #[test] + fn provider_portable_prefix_ignores_provider_unrepresentable_interrupted_refinement() { + let authoritative = vec![ + test_tool_call("provider_a", "read_file", r#"{"path":"a"}"#), + test_tool_result("provider_a", "read_file"), + ]; + let mut complete = vec![ + test_tool_call("canonical_a", "read_file", r#"{"path":"a"}"#), + test_tool_result("canonical_a", "read_file"), + ]; + if let NativeConversationItem::ToolResult { interrupted, .. } = &mut complete[1] { + *interrupted = true; + } + + assert_eq!( + provider_portable_append_suffix(&authoritative, &complete) + .expect("interrupted is not provider-portable"), + Vec::::new() + ); + } + + #[test] + fn invalid_tool_arguments_compare_by_exact_raw_text() { + let left = test_tool_call("call_a", "read_file", "{invalid-left"); + let same = test_tool_call("call_a", "read_file", "{invalid-left"); + let different = test_tool_call("call_a", "read_file", "{invalid-right"); + + assert!(native_item_semantically_equal(&left, &same)); + assert!(!native_item_semantically_equal(&left, &different)); + assert!(provider_portable_append_suffix(&[left], &[different]).is_err()); + } + + #[test] + fn provider_tool_ids_use_the_portable_conversation_identity() { + let raw_call_id = "call_ZQuKyGuKN6l4aFX6Kg6trDeR:part-0"; + let expected = "call_0b3a8cc5654a5208989d80ed5659c267"; + let mut chunk = ActivityChunk::new("source", "tool_call", "read_file"); + chunk.chunk_id = format!("codex-tool-7-{raw_call_id}"); + chunk.args = json!({"path": "CLAUDE.md"}); + chunk.result = json!({ + "call_id": raw_call_id, + "output": "contents", + "status": "completed", + "success": true + }); + + let chunk_items = native_items_from_chunks(&[chunk]); + assert!(matches!( + chunk_items.as_slice(), + [ + NativeConversationItem::ToolCall { call_id, .. }, + NativeConversationItem::ToolResult { + call_id: result_call_id, + .. + } + ] if call_id == expected && result_call_id == expected + )); + + let history_items = native_items_from_agent_history(&[ + json!({ + "role": "assistant", + "tool_calls": [{ + "id": raw_call_id, + "function": {"name": "read_file", "arguments": "{\"path\":\"CLAUDE.md\"}"} + }] + }), + json!({ + "role": "tool", + "tool_call_id": raw_call_id, + "name": "read_file", + "content": "contents" + }), + ]); + assert!(matches!( + history_items.as_slice(), + [ + NativeConversationItem::ToolCall { call_id, .. }, + NativeConversationItem::ToolResult { + call_id: result_call_id, + .. + } + ] if call_id == expected && result_call_id == expected + )); + + assert_eq!( + portable_tool_call_id("call_already_portable"), + "call_already_portable" + ); + } + + #[test] + fn persisted_tool_result_without_a_name_inherits_the_paired_call_name() { + let items = native_items_from_agent_history(&[ + json!({ + "role": "assistant", + "tool_calls": [{ + "id": "call_0b3a8cc5654a5208989d80ed5659c267", + "type": "function", + "function": {"name": "read_file", "arguments": "{\"path\":\"CLAUDE.md\"}"} + }] + }), + json!({ + "role": "tool", + "tool_call_id": "call_0b3a8cc5654a5208989d80ed5659c267", + "content": "Script completed" + }), + ]); + let canonical = vec![ + NativeConversationItem::ToolCall { + id: "canonical-call".to_string(), + call_id: "call_0b3a8cc5654a5208989d80ed5659c267".to_string(), + name: "read_file".to_string(), + arguments: "{\"path\":\"CLAUDE.md\"}".to_string(), + created_at: String::new(), + }, + NativeConversationItem::ToolResult { + id: "canonical-result".to_string(), + call_id: "call_0b3a8cc5654a5208989d80ed5659c267".to_string(), + name: "read_file".to_string(), + output: "Script completed".to_string(), + is_error: false, + interrupted: false, + created_at: String::new(), + }, + ]; + assert!(matches!( + items.as_slice(), + [_, NativeConversationItem::ToolResult { name, .. }] if name == "read_file" + )); + assert!(items + .iter() + .zip(&canonical) + .all(|(left, right)| native_item_semantically_equal(left, right))); + assert!(provider_portable_append_suffix(&items, &canonical) + .expect("a persisted history must be a prefix of the conversation it was built from") + .is_empty()); + } + + #[test] + fn batched_agent_history_tool_calls_are_re_paired_with_their_results() { + // load_llm_history batches every call of one assistant step before all + // of its results; the canonical conversation interleaves them. + let items = native_items_from_agent_history(&[ + json!({ + "role": "assistant", + "content": "I'm using read-only inspection only.", + "tool_calls": [ + {"id": "call_a", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"CLAUDE.md\"}"}}, + {"id": "call_b", "type": "function", "function": {"name": "read_file", "arguments": "{\"path\":\"package.json\"}"}} + ] + }), + json!({"role": "tool", "tool_call_id": "call_a", "content": "claude"}), + json!({"role": "tool", "tool_call_id": "call_b", "content": "package"}), + json!({"role": "user", "content": "next question"}), + ]); + let shapes: Vec = items + .iter() + .map(|item| match item { + NativeConversationItem::Message { role, .. } => format!("message:{role}"), + NativeConversationItem::ToolCall { call_id, .. } => format!("call:{call_id}"), + NativeConversationItem::ToolResult { call_id, name, .. } => { + format!("result:{call_id}:{name}") + } + NativeConversationItem::ContextSummary { .. } => "summary".to_string(), + }) + .collect(); + assert_eq!( + shapes, + vec![ + "message:assistant", + "call:call_a", + "result:call_a:read_file", + "call:call_b", + "result:call_b:read_file", + "message:user", + ] + ); + } + + #[test] + fn interrupted_tool_output_is_portable_but_an_empty_dangling_call_is_not() { + let interrupted = |output: &str| { + let mut chunk = ActivityChunk::new("source", "tool_call", "run_command_line"); + chunk.chunk_id = "interrupted-command".to_string(); + chunk.args = json!({"command": "pnpm test"}); + chunk.result = json!({ + "call_id": "call_interrupted", + "status": "pending", + "success": false, + "interrupted": true, + "output": output, + "observation": output + }); + chunk + }; + + let partial = native_items_from_chunks(&[interrupted("Tests 12 passed\n")]); + assert!(matches!( + partial.as_slice(), + [ + NativeConversationItem::ToolCall { call_id, .. }, + NativeConversationItem::ToolResult { + call_id: result_call_id, + output, + is_error: true, + interrupted: true, + .. + } + ] if call_id == "call_interrupted" + && result_call_id == "call_interrupted" + && output == "Tests 12 passed\n" + )); + + assert!(native_items_from_chunks(&[interrupted("")]).is_empty()); + } + + #[test] + fn transport_metadata_is_not_part_of_portable_tool_arguments() { + let mut chunk = ActivityChunk::new("source", "tool_call", "read_file"); + chunk.chunk_id = "materialized-tool".to_string(); + chunk.args = json!({ + "path": "README.md", + "conversationTurnId": "turn-1", + "conversationSender": {"memberId": "member-1"}, + "__orgiiSourceEventId": "orgii_evt_source" + }); + chunk.result = json!({ + "call_id": "call_read", + "output": "contents", + "status": "completed", + "success": true + }); + + let items = native_items_from_chunks(&[chunk]); + assert!(matches!( + items.first(), + Some(NativeConversationItem::ToolCall { arguments, .. }) + if serde_json::from_str::(arguments).ok() + == Some(json!({"path": "README.md"})) + )); + } +} diff --git a/src-tauri/src/agent_sessions/cli/native_materializer.rs b/src-tauri/src/agent_sessions/cli/native_materializer.rs new file mode 100644 index 0000000000..c307ab63c6 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/native_materializer.rs @@ -0,0 +1,4733 @@ +//! Structured conversation -> provider-native transcript materialization. +//! +//! This is deliberately not a prompt bridge. Every supported target gets the +//! role/tool records its own resume protocol reads. Unsupported targets fail +//! closed before a process is launched. + +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex}; + +use agent_core::session::persistence::{ + MaterializedHistoryContent, MaterializedHistoryRole, MaterializedHistorySeed, +}; +use agent_core::session::{ScheduledKind, ScheduledMessage}; +use agent_core::state::AgentAppState; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use core_types::activity::ActivityChunk; +use serde::Serialize; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tokio::sync::oneshot; +use uuid::Uuid; + +#[cfg(test)] +use super::native_ir::native_item_semantically_equal; +pub use super::native_ir::NativeConversationItem; +use super::native_ir::{ + native_items_from_agent_history, native_items_from_chunks, provider_portable_append_suffix, + validate_items, MAX_ITEMS, +}; +use super::native_store::{ + append_suffix_atomically, copy_file_atomically, lock_claude_transcript, + native_transcript_revision, replace_file_link_atomically, write_file_atomically, +}; +use super::native_transcript::TRANSCRIPT_SOURCE_NATIVE; +use super::parsers::codex_app_server as codex_native_catalog; +use super::persistence; + +const CODEX_NATIVE_PATH_CACHE_MAX_ENTRIES: usize = 512; +const CLAUDE_PROJECT_INDEX_VERSION: u64 = 1; +const CLAUDE_DESKTOP_ACCOUNT_SCAN_LIMIT: usize = 64; +const CLAUDE_DESKTOP_PROJECT_SCAN_LIMIT: usize = 2_048; +const CLAUDE_DESKTOP_METADATA_SCAN_LIMIT: usize = 10_000; +// Codex stores rollouts in a date-sharded directory tree. Resolving the same +// native UUID by walking that tree on every turn makes a long-running session +// progressively more expensive even though its path is immutable. Cache only +// successful resolutions and validate the provider file still exists before +// reusing one; deletion or profile cleanup naturally falls back to discovery. +static CODEX_NATIVE_PATH_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Claude's project index is shared by every ORG2 instance that points at the +/// same native history root. The adjacent advisory lock keeps the complete +/// read-modify-write transaction ordered across independently launched ORG2 +/// processes. Locking +/// the index file itself would be incorrect because `atomic_json` replaces its +/// inode. +struct ClaudeProjectIndexGuard { + lock_file: fs::File, +} + +impl Drop for ClaudeProjectIndexGuard { + fn drop(&mut self) { + // Releasing an advisory lock during Drop is best effort. Closing the + // descriptor releases it as well, including after an unlock error. + let _ = self.lock_file.unlock(); + } +} + +fn lock_claude_project_index(index_path: &Path) -> Result { + let parent = index_path.parent().ok_or_else(|| { + format!( + "Claude project index has no parent directory: {}", + index_path.display() + ) + })?; + + let lock_path = parent.join(".orgii-sessions-index.lock"); + let lock_file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|error| { + format!( + "open Claude project index lock {}: {error}", + lock_path.display() + ) + })?; + lock_file + .lock() + .map_err(|error| format!("lock Claude project index {}: {error}", lock_path.display()))?; + Ok(ClaudeProjectIndexGuard { lock_file }) +} + +#[derive(Debug, Clone)] +struct NativeTranscriptPaths { + /// Durable transcript discovered by the provider's real native App. + native_path: PathBuf, + /// Account-profile alias used by ORG2's isolated provider runner. + runner_path: PathBuf, +} + +/// Filesystem/native-binding mutations need both short lifecycle exclusion and +/// provider-identity exclusion. Never wait for identity while a runner is +/// alive: its finalizer already owns identity and briefly takes control for +/// terminal persistence, so doing so would invert the lock order. +struct NativeMutationGuards { + _control: tokio::sync::OwnedMutexGuard<()>, + _identity: tokio::sync::OwnedMutexGuard<()>, +} + +async fn lock_idle_native_mutation(session_id: &str) -> Result { + let control = super::session_runner::session_control_lock(session_id) + .await + .lock_owned() + .await; + let has_live_runner = { + let sessions = super::session_runner::RUNNING_SESSIONS.lock().await; + sessions + .get(session_id) + .is_some_and(|handle| !handle.is_finished()) + }; + if has_live_runner { + return Err(format!( + "Session {session_id} still has a running provider turn" + )); + } + let identity = super::session_runner::session_identity_lock(session_id) + .await + .lock_owned() + .await; + Ok(NativeMutationGuards { + _control: control, + _identity: identity, + }) +} + +/// Run an Agent transcript mutation through the same FIFO owner as ordinary +/// Agent turns. This is deliberately separate from the CLI lock path: an +/// Agent session has no CLI runner entry, so taking CLI locks provides no +/// exclusion from its live `DialogScheduler` turn. +async fn run_agent_native_maintenance( + state: &AgentAppState, + session_id: String, + operation: F, +) -> Result +where + F: FnOnce() -> Result + Send + 'static, +{ + let session = + agent_core::state::commands::prepare_session_for_scheduler_maintenance(state, &session_id) + .await?; + enqueue_agent_native_maintenance(session, session_id, operation).await +} + +async fn enqueue_agent_native_maintenance( + session: Arc, + session_id: String, + operation: F, +) -> Result +where + F: FnOnce() -> Result + Send + 'static, +{ + let (result_tx, result_rx) = oneshot::channel(); + let maintenance_id = format!("native-materialization-{}", Uuid::new_v4()); + session + .scheduler + .enqueue(ScheduledMessage { + kind: ScheduledKind::Maintenance, + message_id: maintenance_id, + generation: 0, + client_message_id: None, + turn_intent_id: String::new(), + org_run_id: None, + content: "[native transcript materialization]".to_string(), + execute: Box::new(move || { + Box::pin(async move { + let result = tokio::task::spawn_blocking(operation) + .await + .map_err(|error| format!("native materialization task failed: {error}")) + .and_then(|result| result); + let _ = result_tx.send(result); + // Maintenance failures travel through the command reply; + // returning Ok prevents the scheduler from manufacturing + // a user-visible Agent error for a non-turn operation. + Ok(String::new()) + }) + }), + }) + .await?; + result_rx.await.map_err(|_| { + format!("native materialization scheduler stopped before completing {session_id}") + })? +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeMaterializationReceipt { + native_session_id: String, + item_count: usize, +} + +fn authoritative_native_items(session_id: &str) -> Result, String> { + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + let session = persistence::get_session(session_id) + .map_err(|error| format!("load CLI session {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let native_id = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|error| format!("read native binding for {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} has no native resume binding"))?; + let chunks = load_materialized_cli_transcript(&session, &native_id)? + .ok_or_else(|| format!("provider-native transcript {native_id} was not found"))?; + Ok(native_items_from_chunks(&chunks)) + } else { + let history = agent_core::session::persistence::load_llm_history(session_id) + .map_err(|error| format!("load native Agent transcript {session_id}: {error}"))?; + Ok(native_items_from_agent_history(&history)) + } +} + +fn authoritative_append_suffix( + session_id: &str, + complete: &[NativeConversationItem], +) -> Result, String> { + let authoritative = authoritative_native_items(session_id)?; + provider_portable_append_suffix(&authoritative, complete).map_err(|reason| { + format!( + "provider-native transcript is not a semantic prefix of the canonical conversation: native={} canonical={} ({reason})", + authoritative.len(), + complete.len() + ) + }) +} + +fn atomic_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { + write_file_atomically(path, "jsonl.tmp", "native transcript", |file| { + for record in records { + serde_json::to_writer(&mut *file, record) + .map_err(|error| format!("serialize native transcript: {error}"))?; + std::io::Write::write_all(file, b"\n") + .map_err(|error| format!("serialize native transcript: {error}"))?; + } + Ok(()) + }) +} + +fn serialize_jsonl(records: &[Value]) -> Result, String> { + let mut payload = Vec::new(); + for record in records { + serde_json::to_writer(&mut payload, record) + .map_err(|err| format!("serialize native transcript suffix: {err}"))?; + payload.push(b'\n'); + } + Ok(payload) +} + +fn remove_file_if_present(path: &Path) -> Result { + match fs::remove_file(path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(format!( + "remove native transcript {}: {error}", + path.display() + )), + } +} + +fn atomic_json(path: &Path, value: &Value) -> Result<(), String> { + write_file_atomically(path, "json.tmp", "native metadata", |file| { + serde_json::to_writer_pretty(&mut *file, value) + .map_err(|error| format!("serialize native metadata: {error}"))?; + std::io::Write::write_all(file, b"\n") + .map_err(|error| format!("serialize native metadata: {error}")) + }) +} + +fn replace_runner_link(native_path: &Path, runner_path: &Path) -> Result<(), String> { + replace_file_link_atomically(native_path, runner_path, "native runner transcript link") +} + +fn validate_provider_jsonl(path: &Path, expected_native_id: &str) -> Result<(), String> { + let file = fs::File::open(path) + .map_err(|error| format!("open provider transcript {}: {error}", path.display()))?; + let mut records = 0usize; + let mut identity_seen = false; + for (index, line) in BufReader::new(file).lines().enumerate() { + if index >= MAX_ITEMS { + return Err(format!( + "provider transcript {} exceeds {MAX_ITEMS} records", + path.display() + )); + } + let line = + line.map_err(|error| format!("read provider transcript {}: {error}", path.display()))?; + if line.trim().is_empty() { + continue; + } + let record: Value = serde_json::from_str(&line).map_err(|error| { + format!( + "provider transcript {} has invalid JSON at line {}: {error}", + path.display(), + index + 1 + ) + })?; + records += 1; + identity_seen |= record["sessionId"].as_str() == Some(expected_native_id) + || record["session_id"].as_str() == Some(expected_native_id) + || record["payload"]["session_id"].as_str() == Some(expected_native_id) + || record["payload"]["id"].as_str() == Some(expected_native_id); + } + if records == 0 { + return Err(format!("provider transcript {} is empty", path.display())); + } + if !identity_seen { + return Err(format!( + "provider transcript {} does not contain expected native id {expected_native_id}", + path.display() + )); + } + Ok(()) +} + +fn file_is_byte_prefix(prefix: &Path, complete: &Path) -> Result { + let mut prefix_file = fs::File::open(prefix) + .map_err(|error| format!("open transcript {}: {error}", prefix.display()))?; + let mut complete_file = fs::File::open(complete) + .map_err(|error| format!("open transcript {}: {error}", complete.display()))?; + let mut left = [0u8; 64 * 1024]; + let mut right = [0u8; 64 * 1024]; + loop { + let left_len = prefix_file + .read(&mut left) + .map_err(|error| format!("read transcript {}: {error}", prefix.display()))?; + if left_len == 0 { + return Ok(true); + } + let mut right_len = 0usize; + while right_len < left_len { + let read = complete_file + .read(&mut right[right_len..left_len]) + .map_err(|error| format!("read transcript {}: {error}", complete.display()))?; + if read == 0 { + return Ok(false); + } + right_len += read; + } + if left[..left_len] != right[..left_len] { + return Ok(false); + } + } +} + +fn copy_transcript_atomically(source: &Path, destination: &Path) -> Result<(), String> { + copy_file_atomically(source, destination, "native transcript") +} + +/// Converge legacy profile-only and dual-root layouts on one durable native +/// App transcript plus one account-profile runner alias. Returns true only +/// when runner bytes were promoted into the native App store. +fn ensure_durable_runner_alias( + paths: &NativeTranscriptPaths, + expected_native_id: &str, +) -> Result { + if paths.native_path == paths.runner_path { + validate_provider_jsonl(&paths.native_path, expected_native_id)?; + return Ok(false); + } + + let native_exists = paths.native_path.is_file(); + let runner_exists = paths.runner_path.is_file(); + if !native_exists && !runner_exists { + return Err(format!( + "provider-native transcript {expected_native_id} was not found" + )); + } + if !native_exists { + validate_provider_jsonl(&paths.runner_path, expected_native_id)?; + copy_transcript_atomically(&paths.runner_path, &paths.native_path)?; + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(true); + } + if !runner_exists { + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(false); + } + if paths_match(&paths.native_path, &paths.runner_path) { + return Ok(false); + } + + validate_provider_jsonl(&paths.runner_path, expected_native_id)?; + if file_is_byte_prefix(&paths.native_path, &paths.runner_path)? { + copy_transcript_atomically(&paths.runner_path, &paths.native_path)?; + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(true); + } + if file_is_byte_prefix(&paths.runner_path, &paths.native_path)? { + replace_runner_link(&paths.native_path, &paths.runner_path)?; + return Ok(false); + } + Err(format!( + "provider-native transcript conflict for {expected_native_id}: native App and isolated runner both advanced" + )) +} + +fn write_native_store_jsonl( + paths: &NativeTranscriptPaths, + records: &[Value], +) -> Result<(), String> { + atomic_jsonl(&paths.native_path, records)?; + replace_runner_link(&paths.native_path, &paths.runner_path) +} + +fn stable_uuid(namespace: &str, native_id: &str, item_id: &str) -> String { + let mut digest = Sha256::new(); + digest.update(namespace.as_bytes()); + digest.update([0]); + digest.update(native_id.as_bytes()); + digest.update([0]); + digest.update(item_id.as_bytes()); + let hash = digest.finalize(); + let mut bytes = [0u8; 16]; + bytes.copy_from_slice(&hash[..16]); + bytes[6] = (bytes[6] & 0x0f) | 0x50; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Uuid::from_bytes(bytes).to_string() +} + +fn image_block(data_url: &str) -> Result { + let Some((header, data)) = data_url.split_once(',') else { + return Err("historical image data URL is malformed".to_string()); + }; + let media_type = header + .strip_prefix("data:") + .and_then(|value| value.strip_suffix(";base64")) + .filter(|value| value.starts_with("image/")) + .ok_or_else(|| "historical image must be a base64 image data URL".to_string())?; + Ok(json!({ + "type": "image", + "source": {"type": "base64", "media_type": media_type, "data": data} + })) +} + +fn native_agent_seeds( + target_session_id: &str, + items: &[NativeConversationItem], +) -> Vec { + items + .iter() + .map(|item| match item { + NativeConversationItem::Message { + id, + role, + text, + images, + created_at, + turn_id, + } => MaterializedHistorySeed { + id: native_agent_row_id(target_session_id, id, turn_id.as_deref()), + created_at: created_at.clone(), + content: MaterializedHistoryContent::Message { + role: if role == "user" { + MaterializedHistoryRole::User + } else { + MaterializedHistoryRole::Assistant + }, + text: text.clone(), + images: images.clone(), + }, + }, + NativeConversationItem::ToolCall { + id, + call_id, + name, + arguments, + created_at, + } => MaterializedHistorySeed { + id: native_agent_row_id(target_session_id, id, None), + created_at: created_at.clone(), + content: MaterializedHistoryContent::ToolCall { + call_id: call_id.clone(), + name: name.clone(), + arguments: arguments.clone(), + }, + }, + NativeConversationItem::ToolResult { + id, + call_id, + name, + output, + created_at, + .. + } => MaterializedHistorySeed { + id: native_agent_row_id(target_session_id, id, None), + created_at: created_at.clone(), + content: MaterializedHistoryContent::ToolResult { + call_id: call_id.clone(), + name: name.clone(), + output: output.clone(), + }, + }, + NativeConversationItem::ContextSummary { + id, + summary, + created_at, + } => MaterializedHistorySeed { + id: native_agent_row_id(target_session_id, id, None), + created_at: created_at.clone(), + content: MaterializedHistoryContent::Message { + role: MaterializedHistoryRole::User, + text: summary.clone(), + images: Vec::new(), + }, + }, + }) + .collect() +} + +fn native_agent_row_id(target_session_id: &str, source_id: &str, turn_id: Option<&str>) -> String { + let source = URL_SAFE_NO_PAD.encode(source_id.as_bytes()); + // The target is part of the stable suffix because agent_messages.id is a + // database-wide primary key: importing the same canonical source into two + // different execution Sessions must not collide, while retrying the same + // target append must resolve to the exact same durable rows. + let target_tag = stable_uuid("orgii-agent-native-row", target_session_id, source_id); + match turn_id.filter(|value| !value.is_empty()) { + Some(turn_id) => format!( + "org2-turn-v1.{}.{}.{}", + URL_SAFE_NO_PAD.encode(turn_id.as_bytes()), + source, + target_tag + ), + None => format!("org2-native-v1.{source}.{target_tag}"), + } +} + +fn sanitize_claude_project_name(path: &Path) -> String { + path.to_string_lossy() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } + }) + .collect() +} + +fn claude_native_paths( + account_id: Option<&str>, + cwd: &Path, + native_id: &str, +) -> NativeTranscriptPaths { + let relative = PathBuf::from("projects") + .join(sanitize_claude_project_name(cwd)) + .join(format!("{native_id}.jsonl")); + let native_path = app_paths::native_transcript_home_dir() + .join(".claude") + .join(&relative); + NativeTranscriptPaths { + runner_path: account_id + .map(|account_id| app_paths::claude_code_cli_profile_dir(account_id).join(relative)) + .unwrap_or_else(|| native_path.clone()), + native_path, + } +} + +/// Resolve an already-bound Claude transcript without assuming the current +/// account-profile layout is the only layout that has ever been published. +/// +/// The returned pair is canonical even when only the profile-only path created +/// by an intermediate release exists. Mutation code can then promote that file +/// without teaching every caller a second storage layout. +fn existing_claude_native_paths( + account_id: Option<&str>, + cwd: &Path, + native_id: &str, +) -> Option { + let paths = claude_native_paths(account_id, cwd, native_id); + (paths.native_path.is_file() || paths.runner_path.is_file()).then_some(paths) +} + +fn codex_profile_sessions_root(account_id: &str) -> PathBuf { + app_paths::codex_cli_profile_dir(account_id).join("sessions") +} + +fn codex_native_app_home() -> PathBuf { + app_paths::native_transcript_home_dir().join(".codex") +} + +fn codex_native_app_sessions_root() -> PathBuf { + codex_native_app_home().join("sessions") +} + +fn codex_native_paths_for_relative(account_id: &str, relative: &Path) -> NativeTranscriptPaths { + NativeTranscriptPaths { + native_path: codex_native_app_sessions_root().join(relative), + runner_path: codex_profile_sessions_root(account_id).join(relative), + } +} + +fn cache_codex_native_paths(account_id: &str, native_id: &str, paths: &NativeTranscriptPaths) { + let Ok(mut cache) = CODEX_NATIVE_PATH_CACHE.lock() else { + return; + }; + let key = (account_id.to_string(), native_id.to_string()); + if cache.len() >= CODEX_NATIVE_PATH_CACHE_MAX_ENTRIES && !cache.contains_key(&key) { + if let Some(evicted) = cache.keys().next().cloned() { + cache.remove(&evicted); + } + } + cache.insert(key, paths.clone()); +} + +fn existing_codex_native_paths( + account_id: &str, + native_id: &str, +) -> Result, String> { + let cache_key = (account_id.to_string(), native_id.to_string()); + if let Some(paths) = CODEX_NATIVE_PATH_CACHE + .lock() + .ok() + .and_then(|cache| cache.get(&cache_key).cloned()) + { + if paths.native_path.is_file() || paths.runner_path.is_file() { + return Ok(Some(paths)); + } + if let Ok(mut cache) = CODEX_NATIVE_PATH_CACHE.lock() { + cache.remove(&cache_key); + } + } + + let profile_root = codex_profile_sessions_root(account_id); + let native_app_root = codex_native_app_sessions_root(); + let found = find_codex_materialization(&native_app_root, native_id)? + .map(|path| (path, native_app_root)); + let (found, root) = match found { + Some(found) => found, + None => match find_codex_materialization(&profile_root, native_id)? { + Some(path) => (path, profile_root), + None => return Ok(None), + }, + }; + let relative = found.strip_prefix(&root).map_err(|error| { + format!( + "resolved Codex rollout {} outside scanned root {}: {error}", + found.display(), + root.display() + ) + })?; + let paths = codex_native_paths_for_relative(account_id, relative); + cache_codex_native_paths(account_id, native_id, &paths); + Ok(Some(paths)) +} + +fn registered_codex_native_paths( + account_id: &str, + native_path: &Path, +) -> Result { + let root = codex_native_app_sessions_root(); + let relative = match native_path.strip_prefix(&root) { + Ok(relative) => relative.to_path_buf(), + Err(_) => { + let canonical_root = fs::canonicalize(&root).map_err(|error| { + format!( + "canonicalize Codex sessions root {}: {error}", + root.display() + ) + })?; + let canonical_path = fs::canonicalize(native_path).map_err(|error| { + format!( + "canonicalize Codex rollout {}: {error}", + native_path.display() + ) + })?; + canonical_path + .strip_prefix(&canonical_root) + .map(Path::to_path_buf) + .map_err(|error| { + format!( + "Codex app-server registered rollout outside the native App store: path={} root={} ({error})", + native_path.display(), + root.display() + ) + })? + } + }; + Ok(codex_native_paths_for_relative(account_id, &relative)) +} + +/// Resolve one freshly bound provider transcript directly by its exact UUID. +/// +/// The imported-history cache is eventually refreshed and remains the normal +/// reader. Materialization, however, must prove its write synchronously before +/// the provider process starts. Requiring a global history scan here makes a +/// single continuation depend on every unrelated native transcript on disk. +fn materialized_cli_transcript_paths( + session: &persistence::CodeSession, + native_id: &str, +) -> Result, String> { + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let cwd = execution_cwd(session)?; + let paths = match agent { + "claude_code" => { + let Some(paths) = existing_claude_native_paths(account_id, &cwd, native_id) else { + return Ok(None); + }; + paths + } + "codex" => { + let account_id = account_id.ok_or_else(|| { + "native Codex transcript read requires an explicit local account".to_string() + })?; + let Some(paths) = existing_codex_native_paths(account_id, native_id)? else { + return Ok(None); + }; + paths + } + _ => return Ok(None), + }; + Ok(Some((agent.to_string(), paths))) +} + +fn materialized_cli_transcript_path( + session: &persistence::CodeSession, + native_id: &str, +) -> Result, String> { + let Some((agent, paths)) = materialized_cli_transcript_paths(session, native_id)? else { + return Ok(None); + }; + let Some(path) = preferred_materialized_transcript_path(&paths)? else { + return Ok(None); + }; + Ok(Some((agent, path.to_path_buf()))) +} + +/// Resolve only the transcript path the vendor's native App owns. +/// +/// The isolated runner alias is valid for ORG2 resume/replay, but cannot make +/// an official App deep link open successfully. App-open availability must +/// therefore require this exact native-store copy. +pub(crate) fn native_app_transcript_path( + session: &persistence::CodeSession, + native_id: &str, +) -> Result, String> { + let Some((_agent, paths)) = materialized_cli_transcript_paths(session, native_id)? else { + return Ok(None); + }; + Ok(paths.native_path.is_file().then_some(paths.native_path)) +} + +pub(super) fn load_materialized_cli_transcript( + session: &persistence::CodeSession, + native_id: &str, +) -> Result>, String> { + let Some((agent, path)) = materialized_cli_transcript_path(session, native_id)? else { + return Ok(None); + }; + let chunks = match agent.as_str() { + "claude_code" => { + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + &session.session_id, + &path, + )? + } + "codex" => orgtrack_core::sources::codex::app::load_codex_app_from_path( + &session.session_id, + &path, + )?, + _ => unreachable!("unsupported targets returned above"), + }; + Ok(Some(chunks)) +} + +/// Current revision of the exact provider transcript selected by the same +/// resolver as [`load_materialized_cli_transcript`]. +pub(super) fn materialized_cli_transcript_revision( + session: &persistence::CodeSession, + native_id: &str, +) -> Result, String> { + let Some((_agent, path)) = materialized_cli_transcript_path(session, native_id)? else { + return Ok(None); + }; + native_transcript_revision(&path).map(Some) +} + +/// Resolve the authoritative copy without guessing from timestamps. Two +/// independent regular files are safe only when one is the exact byte-prefix +/// of the other; otherwise both sides advanced and the caller must fail closed. +fn preferred_materialized_transcript_path( + paths: &NativeTranscriptPaths, +) -> Result, String> { + let native_metadata = fs::metadata(&paths.native_path).ok(); + let runner_metadata = fs::metadata(&paths.runner_path).ok(); + match (native_metadata, runner_metadata) { + (None, None) => Ok(None), + (Some(_), None) => Ok(Some(&paths.native_path)), + (None, Some(_)) => Ok(Some(&paths.runner_path)), + (Some(_), Some(_)) => { + if paths_match(&paths.native_path, &paths.runner_path) { + return Ok(Some(&paths.native_path)); + } + if file_is_byte_prefix(&paths.native_path, &paths.runner_path)? { + return Ok(Some(&paths.runner_path)); + } + if file_is_byte_prefix(&paths.runner_path, &paths.native_path)? { + return Ok(Some(&paths.native_path)); + } + Err(format!( + "provider-native transcript conflict: native App {} and runner {} both advanced", + paths.native_path.display(), + paths.runner_path.display() + )) + } + } +} + +fn paths_match(left: &Path, right: &Path) -> bool { + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +fn first_user_title(items: &[NativeConversationItem]) -> String { + let title = items.iter().find_map(|item| match item { + NativeConversationItem::Message { role, text, .. } if role == "user" => Some(text.trim()), + _ => None, + }); + let title = title + .filter(|value| !value.is_empty()) + .unwrap_or("Imported conversation"); + title.chars().take(120).collect() +} + +fn claude_session_title( + session: &persistence::CodeSession, + items: &[NativeConversationItem], +) -> String { + if session.name.trim().is_empty() { + first_user_title(items) + } else { + session.name.trim().chars().take(120).collect() + } +} + +fn validate_claude_project_index(index_path: &Path, index: &Value) -> Result<(), String> { + let object = index.as_object().ok_or_else(|| { + format!( + "Claude project index is not an object: {}", + index_path.display() + ) + })?; + let version = object + .get("version") + .and_then(Value::as_u64) + .ok_or_else(|| { + format!( + "Claude project index has no numeric schema version: {}", + index_path.display() + ) + })?; + if version != CLAUDE_PROJECT_INDEX_VERSION { + return Err(format!( + "unsupported Claude project index schema version {version} in {}; expected {CLAUDE_PROJECT_INDEX_VERSION}", + index_path.display() + )); + } + if !object.get("entries").is_some_and(Value::is_array) { + return Err(format!( + "Claude project index entries are not an array: {}", + index_path.display() + )); + } + Ok(()) +} + +fn read_claude_project_index(index_path: &Path) -> Result, String> { + match fs::read_to_string(index_path) { + Ok(raw) => { + let index = serde_json::from_str::(&raw).map_err(|error| { + format!( + "decode existing Claude project index {}: {error}", + index_path.display() + ) + })?; + validate_claude_project_index(index_path, &index)?; + Ok(Some(index)) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(format!( + "read Claude project index {}: {error}", + index_path.display() + )), + } +} + +fn transcript_modified_metadata(path: &Path) -> Result<(i64, String), String> { + let modified = fs::metadata(path) + .and_then(|metadata| metadata.modified()) + .map_err(|error| format!("read transcript metadata {}: {error}", path.display()))?; + let file_mtime = modified + .duration_since(std::time::UNIX_EPOCH) + .map_err(|error| format!("invalid transcript mtime {}: {error}", path.display()))? + .as_millis() + .try_into() + .map_err(|_| format!("transcript mtime overflows i64: {}", path.display()))?; + let modified = chrono::DateTime::::from(modified) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + Ok((file_mtime, modified)) +} + +/// Maintain Claude Code's native project catalog next to the durable JSONL. +/// The transcript remains the source of truth; this is only the provider-owned +/// discovery projection required by the native App. +fn publish_claude_project_index( + cwd: &Path, + native_id: &str, + items: &[NativeConversationItem], + git_branch: Option<&str>, +) -> Result<(), String> { + let transcript_path = claude_native_paths(None, cwd, native_id).native_path; + let project_dir = transcript_path.parent().ok_or_else(|| { + format!( + "Claude native transcript has no project directory: {}", + transcript_path.display() + ) + })?; + let index_path = project_dir.join("sessions-index.json"); + fs::create_dir_all(project_dir).map_err(|error| { + format!( + "create Claude project index directory {}: {error}", + project_dir.display() + ) + })?; + let _guard = lock_claude_project_index(&index_path)?; + let mut index = read_claude_project_index(&index_path)? + .unwrap_or_else(|| json!({"version": CLAUDE_PROJECT_INDEX_VERSION, "entries": []})); + let entries = index + .get_mut("entries") + .and_then(Value::as_array_mut) + .expect("validated/new Claude project index has an entries array"); + let previous = entries + .iter() + .find(|entry| entry["sessionId"].as_str() == Some(native_id)) + .cloned(); + entries.retain(|entry| entry["sessionId"].as_str() != Some(native_id)); + + let now = Utc::now(); + let now_iso = now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let created = previous + .as_ref() + .and_then(|entry| entry["created"].as_str()) + .unwrap_or(&now_iso) + .to_string(); + let first_prompt = items + .iter() + .find_map(|item| match item { + NativeConversationItem::Message { role, text, .. } if role == "user" => { + Some(text.trim()) + } + _ => None, + }) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + previous + .as_ref() + .and_then(|entry| entry["firstPrompt"].as_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| "Imported conversation".to_string()); + let projected_message_count = items + .iter() + .filter(|item| matches!(item, NativeConversationItem::Message { .. })) + .count(); + let previous_message_count = previous + .as_ref() + .and_then(|entry| entry["messageCount"].as_u64()) + .unwrap_or_default() as usize; + let mut entry = previous.unwrap_or_else(|| json!({})); + let entry = entry.as_object_mut().ok_or_else(|| { + format!( + "Claude project index entry {native_id} is not an object: {}", + index_path.display() + ) + })?; + entry.insert("sessionId".to_string(), json!(native_id)); + entry.insert("fullPath".to_string(), json!(transcript_path)); + entry.insert("fileMtime".to_string(), json!(now.timestamp_millis())); + entry.insert("firstPrompt".to_string(), json!(first_prompt)); + entry.insert( + "messageCount".to_string(), + json!(projected_message_count.max(previous_message_count)), + ); + entry.insert("created".to_string(), json!(created)); + entry.insert("modified".to_string(), json!(now_iso)); + entry.insert( + "gitBranch".to_string(), + json!(git_branch.unwrap_or_default()), + ); + entry.insert("workspacePath".to_string(), json!(cwd)); + entries.push(Value::Object(entry.clone())); + atomic_json(&index_path, &index) +} + +fn remove_claude_project_index_entry(cwd: &Path, native_id: &str) -> Result<(), String> { + let index_path = claude_native_paths(None, cwd, native_id) + .native_path + .parent() + .map(|project| project.join("sessions-index.json")) + .ok_or_else(|| "Claude native transcript has no project directory".to_string())?; + if !index_path.parent().is_some_and(Path::is_dir) { + return Ok(()); + } + let _guard = lock_claude_project_index(&index_path)?; + let Some(mut index) = read_claude_project_index(&index_path)? else { + return Ok(()); + }; + let entries = index["entries"] + .as_array_mut() + .expect("validated Claude project index has an entries array"); + let previous_len = entries.len(); + entries.retain(|entry| entry["sessionId"].as_str() != Some(native_id)); + if entries.len() != previous_len { + atomic_json(&index_path, &index)?; + } + Ok(()) +} + +/// Claude Desktop keeps a small discovery row for every Claude Code session +/// it exposes in the Code tab. The row points at the real CLI UUID; it is not +/// a transcript copy. Keep this projection beside the provider-owned JSONL +/// catalog so Desktop can discover materialized sessions without fabricating +/// another conversation history. +fn claude_desktop_sessions_root() -> PathBuf { + let home = app_paths::native_transcript_home_dir(); + #[cfg(target_os = "windows")] + let data_dir = home.join("AppData").join("Roaming"); + #[cfg(target_os = "macos")] + let data_dir = home.join("Library").join("Application Support"); + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + let data_dir = home.join(".config"); + data_dir.join("Claude").join("claude-code-sessions") +} + +fn claude_desktop_active_account_id(sessions_root: &Path) -> Option { + let config_path = sessions_root.parent()?.join("config.json"); + let config = serde_json::from_slice::(&fs::read(config_path).ok()?).ok()?; + let account_id = config["lastKnownAccountUuid"].as_str()?; + Uuid::parse_str(account_id).ok()?; + Some(account_id.to_string()) +} + +/// Read at most `budget` directory entries. The budget counts failed and +/// non-directory entries too, so a noisy provider-owned catalog cannot turn +/// discovery into an unbounded scan. Sort the accepted prefix so its later +/// processing is deterministic. +fn bounded_directory_paths(root: &Path, budget: &mut usize) -> Vec { + if *budget == 0 { + return Vec::new(); + } + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + let mut paths = Vec::new(); + for entry in entries { + if *budget == 0 { + break; + } + *budget -= 1; + if let Ok(entry) = entry { + paths.push(entry.path()); + } + } + paths.sort(); + paths +} + +fn claude_desktop_session_path( + sessions_root: &Path, + cwd: &Path, + native_id: &str, +) -> Option { + let mut account_budget = CLAUDE_DESKTOP_ACCOUNT_SCAN_LIMIT; + let account_dirs = bounded_directory_paths(sessions_root, &mut account_budget) + .into_iter() + .filter(|path| path.is_dir()) + .collect::>(); + let expected_filename = format!("local_{native_id}.json"); + + // An existing UUID remains owned by the account that first registered it, + // even after the user switches Claude Desktop accounts. Search every + // account before deciding where a new discovery row should be placed. + let mut project_budget = CLAUDE_DESKTOP_PROJECT_SCAN_LIMIT; + let mut metadata_budget = CLAUDE_DESKTOP_METADATA_SCAN_LIMIT; + 'account_scan: for account_dir in &account_dirs { + for project_dir in bounded_directory_paths(account_dir, &mut project_budget) + .into_iter() + .filter(|path| path.is_dir()) + { + let exact_path = project_dir.join(&expected_filename); + if exact_path.is_file() { + let is_same_session = fs::read(&exact_path) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()) + .and_then(|value| value["cliSessionId"].as_str().map(str::to_string)) + .as_deref() + == Some(native_id); + if is_same_session { + return Some(exact_path); + } + } + for path in bounded_directory_paths(&project_dir, &mut metadata_budget) { + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(value) = fs::read(&path) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()) + else { + continue; + }; + if value["cliSessionId"].as_str() == Some(native_id) { + return Some(path); + } + } + if metadata_budget == 0 { + break 'account_scan; + } + } + if project_budget == 0 { + break; + } + } + + // A brand-new row must be registered under the currently active Desktop + // account. Never silently place it into an arbitrary inactive account. + let active_account_dir = sessions_root.join(claude_desktop_active_account_id(sessions_root)?); + if !active_account_dir.is_dir() { + return None; + } + let mut matching_project: Option<(i64, PathBuf)> = None; + let mut project_budget = CLAUDE_DESKTOP_PROJECT_SCAN_LIMIT; + let mut metadata_budget = CLAUDE_DESKTOP_METADATA_SCAN_LIMIT; + for project_dir in bounded_directory_paths(&active_account_dir, &mut project_budget) + .into_iter() + .filter(|path| path.is_dir()) + { + for path in bounded_directory_paths(&project_dir, &mut metadata_budget) { + if path.extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let Some(value) = fs::read(&path) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()) + else { + continue; + }; + let matches_cwd = ["cwd", "originCwd"].into_iter().any(|field| { + value[field] + .as_str() + .is_some_and(|value| paths_match(Path::new(value), cwd)) + }); + if !matches_cwd { + continue; + } + let activity = value["lastActivityAt"] + .as_i64() + .or_else(|| value["createdAt"].as_i64()) + .unwrap_or_default(); + if matching_project + .as_ref() + .is_none_or(|(best_activity, _)| activity > *best_activity) + { + matching_project = Some((activity, project_dir.clone())); + } + } + if metadata_budget == 0 { + break; + } + } + matching_project.map(|(_, path)| path.join(expected_filename)) +} + +fn assistant_turn_count(items: &[NativeConversationItem]) -> usize { + items + .iter() + .filter(|item| { + matches!(item, NativeConversationItem::Message { role, .. } if role == "assistant") + }) + .count() +} + +#[cfg(test)] +fn publish_claude_desktop_session( + session: &persistence::CodeSession, + cwd: &Path, + native_id: &str, + native_path: &Path, + items: &[NativeConversationItem], +) -> Result, String> { + if !native_path.is_file() { + return Err(format!( + "refusing to publish Claude Desktop metadata without transcript {}", + native_path.display() + )); + } + let sessions_root = claude_desktop_sessions_root(); + if !sessions_root.is_dir() { + return Ok(None); + } + let Some(path) = claude_desktop_session_path(&sessions_root, cwd, native_id) else { + // Account/project UUIDs belong to Desktop. A machine with no existing + // matching project must use Claude's own import flow instead of ORG2 + // inventing identifiers that the App has never registered. + return Ok(None); + }; + publish_claude_desktop_session_at_path(session, cwd, native_id, native_path, items, path) +} + +fn publish_claude_desktop_session_at_path( + session: &persistence::CodeSession, + cwd: &Path, + native_id: &str, + native_path: &Path, + items: &[NativeConversationItem], + path: PathBuf, +) -> Result, String> { + let _guard = lock_claude_project_index(&path)?; + let previous = match fs::read(&path) { + Ok(raw) => Some(serde_json::from_slice::(&raw).map_err(|error| { + format!( + "decode existing Claude Desktop metadata {}: {error}", + path.display() + ) + })?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => { + return Err(format!( + "read Claude Desktop metadata {}: {error}", + path.display() + )) + } + }; + let mut metadata = previous.clone().unwrap_or_else(|| json!({})); + let object = metadata.as_object_mut().ok_or_else(|| { + format!( + "Claude Desktop metadata is not an object: {}", + path.display() + ) + })?; + let (file_mtime, _) = transcript_modified_metadata(native_path)?; + let title = claude_session_title(session, items); + let is_new = previous.is_none(); + object + .entry("sessionId".to_string()) + .or_insert_with(|| json!(format!("local_{native_id}"))); + object.insert("cliSessionId".to_string(), json!(native_id)); + object.insert("cwd".to_string(), json!(cwd)); + object + .entry("originCwd".to_string()) + .or_insert_with(|| json!(cwd)); + object + .entry("createdAt".to_string()) + .or_insert_with(|| json!(file_mtime)); + object + .entry("lastFocusedAt".to_string()) + .or_insert_with(|| json!(file_mtime)); + object.insert("lastActivityAt".to_string(), json!(file_mtime)); + object.insert("title".to_string(), json!(title)); + object + .entry("titleSource".to_string()) + .or_insert_with(|| json!("orgii")); + object + .entry("permissionMode".to_string()) + .or_insert_with(|| json!("default")); + object + .entry("isArchived".to_string()) + .or_insert_with(|| json!(false)); + object + .entry("remoteMcpServersConfig".to_string()) + .or_insert_with(|| json!([])); + object.insert( + "completedTurns".to_string(), + json!(assistant_turn_count(items)), + ); + object + .entry("alwaysAllowedReasons".to_string()) + .or_insert_with(|| json!([])); + object + .entry("sessionPermissionUpdates".to_string()) + .or_insert_with(|| json!([])); + object + .entry("classifierSummaryEnabled".to_string()) + .or_insert_with(|| json!(true)); + if let Some(model) = session + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + object.insert("model".to_string(), json!(model)); + } + if is_new { + object.insert("orgiiMaterialization".to_string(), json!(true)); + } + atomic_json(&path, &metadata)?; + let published: Value = serde_json::from_slice( + &fs::read(&path).map_err(|error| format!("read back Claude Desktop metadata: {error}"))?, + ) + .map_err(|error| format!("decode published Claude Desktop metadata: {error}"))?; + if published["cliSessionId"].as_str() != Some(native_id) + || published["sessionId"].as_str().is_none() + || !published["completedTurns"].is_number() + || !["cwd", "originCwd"].into_iter().any(|field| { + published[field] + .as_str() + .is_some_and(|value| paths_match(Path::new(value), cwd)) + }) + { + return Err(format!( + "Claude Desktop metadata read-back rejected {}", + path.display() + )); + } + Ok(Some(path)) +} + +fn remove_orgii_claude_desktop_session(cwd: &Path, native_id: &str) -> Result<(), String> { + let sessions_root = claude_desktop_sessions_root(); + let Some(path) = claude_desktop_session_path(&sessions_root, cwd, native_id) else { + return Ok(()); + }; + let _guard = lock_claude_project_index(&path)?; + let metadata = fs::read(&path) + .ok() + .and_then(|raw| serde_json::from_slice::(&raw).ok()); + if metadata.as_ref().is_some_and(|value| { + value["cliSessionId"].as_str() == Some(native_id) + && value["orgiiMaterialization"].as_bool() == Some(true) + }) { + remove_file_if_present(&path)?; + } + Ok(()) +} + +/// Refresh the native Claude App catalog from metadata written by the actual +/// Claude process in its isolated account profile. This path reads two small +/// index files and transcript stat metadata only; it never reparses a large +/// JSONL. Unknown provider fields are retained so ORG2 does not downgrade a +/// newer-but-still-v1 entry shape. +fn refresh_claude_project_index_from_provider( + cwd: &Path, + native_id: &str, + native_path: &Path, + runner_path: &Path, + git_branch: Option<&str>, +) -> Result { + let native_index_path = native_path + .parent() + .ok_or_else(|| { + format!( + "Claude native transcript has no project directory: {}", + native_path.display() + ) + })? + .join("sessions-index.json"); + let runner_index_path = runner_path + .parent() + .ok_or_else(|| { + format!( + "Claude runner transcript has no project directory: {}", + runner_path.display() + ) + })? + .join("sessions-index.json"); + + let Some(provider_index) = read_claude_project_index(&runner_index_path)? else { + return Ok(false); + }; + let Some(provider_entry) = provider_index["entries"] + .as_array() + .expect("validated Claude provider index has an entries array") + .iter() + .find(|entry| entry["sessionId"].as_str() == Some(native_id)) + .cloned() + else { + return Ok(false); + }; + let provider_entry = provider_entry.as_object().ok_or_else(|| { + format!( + "Claude provider index entry {native_id} is not an object: {}", + runner_index_path.display() + ) + })?; + let (file_mtime, modified) = transcript_modified_metadata(native_path)?; + let provider_mtime = provider_entry + .get("fileMtime") + .and_then(Value::as_i64) + .ok_or_else(|| { + format!( + "Claude provider index entry {native_id} has no numeric fileMtime: {}", + runner_index_path.display() + ) + })?; + if provider_mtime < file_mtime { + // The provider index snapshot predates the durable transcript. Its + // messageCount may therefore be stale; let the deferred fallback parse + // derive a correct projection instead of publishing a false count. + return Ok(false); + } + if !provider_entry + .get("messageCount") + .is_some_and(Value::is_u64) + { + return Err(format!( + "Claude provider index entry {native_id} has no numeric messageCount: {}", + runner_index_path.display() + )); + } + + fs::create_dir_all( + native_index_path + .parent() + .expect("Claude native project index has a parent"), + ) + .map_err(|error| { + format!( + "create Claude native project index directory {}: {error}", + native_index_path.display() + ) + })?; + let _guard = lock_claude_project_index(&native_index_path)?; + let mut native_index = read_claude_project_index(&native_index_path)? + .unwrap_or_else(|| json!({"version": CLAUDE_PROJECT_INDEX_VERSION, "entries": []})); + let entries = native_index["entries"] + .as_array_mut() + .expect("validated/new Claude native index has an entries array"); + let previous = entries + .iter() + .find(|entry| entry["sessionId"].as_str() == Some(native_id)) + .cloned(); + entries.retain(|entry| entry["sessionId"].as_str() != Some(native_id)); + + let mut merged = previous + .and_then(|entry| entry.as_object().cloned()) + .unwrap_or_default(); + merged.extend(provider_entry.clone()); + merged.insert("sessionId".to_string(), json!(native_id)); + merged.insert("fullPath".to_string(), json!(native_path)); + merged.insert("fileMtime".to_string(), json!(file_mtime)); + merged.insert("modified".to_string(), json!(modified)); + merged.insert("workspacePath".to_string(), json!(cwd)); + if merged + .get("gitBranch") + .and_then(Value::as_str) + .is_none_or(str::is_empty) + { + merged.insert( + "gitBranch".to_string(), + json!(git_branch.unwrap_or_default()), + ); + } + entries.push(Value::Object(merged)); + atomic_json(&native_index_path, &native_index)?; + Ok(true) +} + +fn claude_records( + native_id: &str, + cwd: &Path, + items: &[NativeConversationItem], +) -> Result, String> { + let mut records = Vec::with_capacity(items.len().saturating_mul(2)); + let mut parent_uuid: Option = None; + for item in items { + if let NativeConversationItem::ContextSummary { + id, + summary, + created_at, + } = item + { + let boundary_uuid = stable_uuid("orgii-claude-compact-boundary", native_id, id); + records.push(json!({ + "type": "system", + "subtype": "compact_boundary", + "uuid": boundary_uuid, + "parentUuid": parent_uuid, + "sessionId": native_id, + "cwd": cwd, + "timestamp": created_at, + "compactMetadata": {"trigger": "import"} + })); + let summary_uuid = stable_uuid("orgii-claude-compact-summary", native_id, id); + records.push(json!({ + "type": "user", + "uuid": summary_uuid, + "parentUuid": boundary_uuid, + "isCompactSummary": true, + "isSidechain": false, + "userType": "external", + "sessionId": native_id, + "cwd": cwd, + "timestamp": created_at, + "message": {"role": "user", "content": summary}, + "entrypoint": "orgii" + })); + parent_uuid = Some(summary_uuid); + continue; + } + let record_uuid = stable_uuid("orgii-claude-native", native_id, item.id()); + let (record_type, message, extra) = match item { + NativeConversationItem::Message { + role, text, images, .. + } => { + let content = if role == "assistant" { + Value::Array(vec![json!({"type": "text", "text": text})]) + } else if images.is_empty() { + Value::String(text.clone()) + } else { + let mut blocks = vec![json!({"type": "text", "text": text})]; + for image in images { + blocks.push(image_block(image)?); + } + Value::Array(blocks) + }; + ( + role.clone(), + json!({"role": role, "content": content}), + None, + ) + } + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => ( + "assistant".to_string(), + json!({ + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": call_id, + "name": name, + "input": serde_json::from_str::(arguments) + .map_err(|err| format!("parse tool arguments: {err}"))? + }] + }), + None, + ), + NativeConversationItem::ToolResult { + call_id, + output, + is_error, + interrupted, + .. + } => ( + "user".to_string(), + json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": call_id, + "content": output, + "is_error": *is_error || *interrupted + }] + }), + Some(json!({"toolUseResult": output})), + ), + NativeConversationItem::ContextSummary { .. } => { + unreachable!("context summaries are emitted before ordinary Claude records") + } + }; + let mut record = json!({ + "type": record_type, + "uuid": record_uuid, + "parentUuid": parent_uuid, + "isSidechain": false, + "userType": "external", + "sessionId": native_id, + "cwd": cwd, + "timestamp": item.created_at(), + "message": message, + "entrypoint": "orgii" + }); + if let Some(Value::Object(extra)) = extra { + record.as_object_mut().expect("record object").extend(extra); + } + parent_uuid = Some(record_uuid); + records.push(record); + } + Ok(records) +} + +fn claude_resume_checkpoint( + native_id: &str, + leaf_uuid: &str, + items: &[NativeConversationItem], +) -> Value { + let last_prompt = items + .iter() + .rev() + .find_map(|item| match item { + NativeConversationItem::Message { role, text, .. } + if role == "user" && !text.trim().is_empty() => + { + Some(text.as_str()) + } + _ => None, + }) + .unwrap_or_default(); + json!({ + "type": "last-prompt", + "lastPrompt": last_prompt, + "leafUuid": leaf_uuid, + "sessionId": native_id, + }) +} + +fn claude_custom_title(native_id: &str, title: &str) -> Value { + json!({ + "type": "custom-title", + "customTitle": title, + "sessionId": native_id, + }) +} + +fn claude_materialization_records( + native_id: &str, + cwd: &Path, + items: &[NativeConversationItem], + title: &str, +) -> Result, String> { + let mut records = claude_records_with_resume_checkpoint(native_id, cwd, items)?; + records.insert(0, claude_custom_title(native_id, title)); + Ok(records) +} + +fn claude_records_with_resume_checkpoint( + native_id: &str, + cwd: &Path, + items: &[NativeConversationItem], +) -> Result, String> { + let mut records = claude_records(native_id, cwd, items)?; + if let Some(leaf_uuid) = records + .last() + .and_then(|record| record["uuid"].as_str()) + .map(str::to_string) + { + records.push(claude_resume_checkpoint(native_id, &leaf_uuid, items)); + } + Ok(records) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NativeSuffixApplication { + Missing, + AlreadyApplied, +} + +fn inspect_claude_suffix_application( + path: &Path, + expected_records: &[Value], +) -> Result<(NativeSuffixApplication, Option), String> { + let mut expected_records_by_id = HashMap::with_capacity(expected_records.len()); + for record in expected_records { + let id = record["uuid"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "projected Claude native suffix record has no stable uuid".to_string() + })?; + let mut normalized = record.clone(); + normalized + .as_object_mut() + .ok_or_else(|| "projected Claude native suffix record is not an object".to_string())? + .remove("parentUuid"); + if expected_records_by_id + .insert(id.to_string(), normalized) + .is_some() + { + return Err(format!( + "projected Claude native suffix contains duplicate uuid {id}" + )); + } + } + if expected_records_by_id.is_empty() { + return Err("projected Claude native suffix is empty".to_string()); + } + let file = fs::File::open(path) + .map_err(|error| format!("open Claude native transcript {}: {error}", path.display()))?; + let mut found_ids = HashSet::with_capacity(expected_records_by_id.len()); + let mut active_leaf_uuid = None; + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + format!( + "read Claude native transcript {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line).map_err(|error| { + format!( + "decode Claude native transcript {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if record["type"] == "last-prompt" { + if let Some(leaf_uuid) = record["leafUuid"] + .as_str() + .filter(|value| !value.trim().is_empty()) + { + active_leaf_uuid = Some(leaf_uuid.to_string()); + } + } else if let Some(uuid) = record["uuid"] + .as_str() + .filter(|value| !value.trim().is_empty()) + { + active_leaf_uuid = Some(uuid.to_string()); + if let Some(expected) = expected_records_by_id.get(uuid) { + let mut normalized = record.clone(); + normalized + .as_object_mut() + .ok_or_else(|| { + format!( + "Claude native transcript {} contains non-object stable suffix record {uuid}", + path.display() + ) + })? + .remove("parentUuid"); + if &normalized != expected { + return Err(format!( + "Claude native transcript {} contains stable suffix uuid {uuid} with conflicting content", + path.display() + )); + } + if !found_ids.insert(uuid.to_string()) { + return Err(format!( + "Claude native transcript {} contains duplicate stable suffix uuid {uuid}", + path.display() + )); + } + } + } + } + + if found_ids.is_empty() { + Ok((NativeSuffixApplication::Missing, active_leaf_uuid)) + } else if found_ids.len() == expected_records_by_id.len() { + Ok((NativeSuffixApplication::AlreadyApplied, active_leaf_uuid)) + } else { + Err(format!( + "Claude native transcript {} contains {} of {} stable suffix records; refusing a mixed retry", + path.display(), + found_ids.len(), + expected_records_by_id.len() + )) + } +} + +fn ensure_claude_native_metadata( + path: &Path, + native_id: &str, + complete_items: &[NativeConversationItem], + title: &str, +) -> Result<(), String> { + let file = fs::File::open(path) + .map_err(|error| format!("open Claude native transcript {}: {error}", path.display()))?; + let mut last_message_uuid: Option = None; + let mut last_message_is_orgii = false; + let mut last_checkpoint_leaf: Option = None; + let mut has_custom_title = false; + let mut has_orgii_record = false; + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + format!( + "read Claude native transcript {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line).map_err(|error| { + format!( + "decode Claude native transcript {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + has_custom_title |= record["type"] == "custom-title" + && record["customTitle"] + .as_str() + .is_some_and(|value| !value.trim().is_empty()); + has_orgii_record |= record["entrypoint"].as_str() == Some("orgii"); + if record["type"] == "last-prompt" { + last_checkpoint_leaf = record["leafUuid"].as_str().map(str::to_string); + } else if let Some(uuid) = record["uuid"] + .as_str() + .filter(|value| !value.trim().is_empty()) + { + last_message_uuid = Some(uuid.to_string()); + last_message_is_orgii = record["entrypoint"].as_str() == Some("orgii"); + } + } + let mut metadata = Vec::with_capacity(2); + if has_orgii_record && !has_custom_title && !title.trim().is_empty() { + metadata.push(claude_custom_title(native_id, title)); + } + if let Some(leaf_uuid) = last_message_uuid.filter(|_| last_message_is_orgii) { + if last_checkpoint_leaf.as_deref() != Some(leaf_uuid.as_str()) { + metadata.push(claude_resume_checkpoint( + native_id, + &leaf_uuid, + complete_items, + )); + } + } + if metadata.is_empty() { + return Ok(()); + } + append_suffix_atomically(path, &serialize_jsonl(&metadata)?) +} + +/// Codex exit codes for a tool output ORG2 injects. A `function_call_output` +/// carries text, so the only failure channel the Codex rollout has is the +/// exec envelope its own shell tools emit. Writing the bare output instead +/// tells the resumed model a killed or failed command succeeded. +const CODEX_TOOL_FAILURE_EXIT_CODE: i64 = 1; +const CODEX_TOOL_INTERRUPT_EXIT_CODE: i64 = 130; + +fn codex_function_call_output(output: &str, is_error: bool, interrupted: bool) -> Value { + if !is_error && !interrupted { + return Value::String(output.to_string()); + } + let exit_code = if interrupted { + CODEX_TOOL_INTERRUPT_EXIT_CODE + } else { + CODEX_TOOL_FAILURE_EXIT_CODE + }; + Value::String(json!({"exit_code": exit_code, "output": output}).to_string()) +} + +fn codex_response_items(items: &[NativeConversationItem]) -> Vec { + let mut projected = Vec::with_capacity(items.len()); + for item in items { + match item { + NativeConversationItem::Message { + id, + role, + text, + images, + .. + } => { + let text_type = if role == "user" { + "input_text" + } else { + "output_text" + }; + let mut content = vec![json!({"type": text_type, "text": text})]; + if role == "user" { + content.extend( + images + .iter() + .map(|image| json!({"type": "input_image", "image_url": image})), + ); + } + // Responses API item IDs require a type prefix. The canonical + // message ID remains recoverable after the `msg_` prefix. + // `id` is part of Codex's native response-item schema and is + // preserved by `thread/inject_items`. Unlike Codex's + // user-role system/context prefix rows, an injected canonical + // user message therefore has a stable native item id without + // needing ORG2-only metadata inside the provider transcript. + projected + .push(json!({"type": "message", "id": format!("msg_{id}"), "role": role, "content": content})); + } + NativeConversationItem::ToolCall { + id, + call_id, + name, + arguments, + .. + } => projected.push(json!({ + "type": "function_call", + "id": format!("fc_{}", stable_uuid("orgii-codex-function-item", "", id).replace('-', "")), + "name": name, + "arguments": arguments, + "call_id": call_id + })), + NativeConversationItem::ToolResult { + call_id, + output, + is_error, + interrupted, + .. + } => projected.push(json!({ + "type": "function_call_output", + "call_id": call_id, + "output": codex_function_call_output(output, *is_error, *interrupted) + })), + NativeConversationItem::ContextSummary { id, summary, .. } => projected.push(json!({ + "type": "message", + "id": format!("msg_{id}"), + "role": "user", + "content": [{"type": "input_text", "text": summary}] + })), + } + } + projected +} + +fn provider_canonical_cwd(cwd: PathBuf) -> PathBuf { + fs::canonicalize(&cwd).unwrap_or(cwd) +} + +fn execution_cwd(session: &persistence::CodeSession) -> Result { + // Keep this selection identical to the CLI runner. A removed session + // worktree is no longer an executable workspace: the runner falls back + // to repo_path, and Claude keys its native store by that effective cwd. + // Materializing under the stale worktree key would therefore publish a + // valid UUID that `claude --resume` cannot find from the runner's cwd. + let value = session + .worktree_path + .as_deref() + .filter(|value| !value.trim().is_empty() && Path::new(value).is_dir()) + .or_else(|| { + session + .repo_path + .as_deref() + .filter(|value| !value.trim().is_empty()) + }); + let cwd = match value { + Some(value) => PathBuf::from(value), + None => std::env::current_dir().map_err(|err| format!("resolve execution cwd: {err}"))?, + }; + + // Provider CLIs identify projects by the canonical working directory. + // This matters on macOS where `/tmp` is a symlink to `/private/tmp`: + // writing a Claude transcript below `projects/-tmp-...` looks correct to + // our reader, but `claude --resume` searches `projects/-private-tmp-...` + // and rejects the freshly materialized UUID. Use the same identity the + // child process observes, while retaining the configured path for a + // not-yet-created repository so materialization still fails/rolls back at + // the normal launch boundary. + Ok(provider_canonical_cwd(cwd)) +} + +fn find_codex_materialization(root: &Path, native_id: &str) -> Result, String> { + let suffix = format!("-{native_id}.jsonl"); + let mut pending = vec![root.to_path_buf()]; + let mut visited = 0usize; + while let Some(directory) = pending.pop() { + let entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound && directory == root => { + return Ok(None) + } + Err(error) => { + return Err(format!( + "scan Codex transcript directory {}: {error}", + directory.display() + )) + } + }; + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "read Codex transcript directory entry {}: {error}", + directory.display() + ) + })?; + visited += 1; + if visited > MAX_ITEMS { + return Err(format!( + "Codex transcript scan under {} exceeded {MAX_ITEMS} entries", + root.display() + )); + } + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| { + format!("inspect Codex transcript path {}: {error}", path.display()) + })?; + if file_type.is_dir() { + pending.push(path); + } else if file_type.is_file() + && path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Ok(Some(path)); + } + } + } + Ok(None) +} + +fn discard_cli_materialization(session_id: &str, native_id: &str) -> Result { + let session = persistence::get_session(session_id) + .map_err(|err| format!("load CLI session {session_id}: {err}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let bound = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|err| format!("read native binding for {session_id}: {err}"))?; + if bound.as_deref() != Some(native_id) { + return Err( + "refusing to remove a native transcript that is not the episode's current binding" + .to_string(), + ); + } + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let cwd = execution_cwd(&session)?; + let paths = match agent { + "claude_code" => existing_claude_native_paths(account_id, &cwd, native_id) + .unwrap_or_else(|| claude_native_paths(account_id, &cwd, native_id)), + "codex" => { + let account_id = account_id + .ok_or_else(|| "native Codex materialization has no account binding".to_string())?; + let Some(paths) = existing_codex_native_paths(account_id, native_id)? else { + // A previous rollback may have removed the rollout and then + // failed while clearing the DB binding. Treat the missing + // marked artifact as already removed so retry can finish the + // durable state transition instead of wedging the episode. + persistence::clear_cli_resume_state(session_id, "native_materialization_rollback") + .map_err(|err| format!("clear native materialization binding: {err}"))?; + return Ok(false); + }; + paths + } + _ => return Ok(false), + }; + let removed = match agent { + "codex" => { + if paths.native_path.is_file() { + codex_native_catalog::archive_thread( + &codex_native_app_home(), + &paths.native_path, + native_id, + &cwd, + )?; + } + let runner_removed = remove_file_if_present(&paths.runner_path)?; + let native_removed = remove_file_if_present(&paths.native_path)?; + runner_removed || native_removed + } + "claude_code" => { + let _transcript_guard = lock_claude_transcript(&paths.native_path)?; + // Remove ORG2's discovery projection before the transcript so a + // partial rollback cannot leave a visible Desktop row whose CLI + // UUID no longer exists on disk. + remove_orgii_claude_desktop_session(&cwd, native_id)?; + let runner_removed = remove_file_if_present(&paths.runner_path)?; + let native_removed = remove_file_if_present(&paths.native_path)?; + remove_claude_project_index_entry(&cwd, native_id)?; + runner_removed || native_removed + } + _ => false, + }; + persistence::clear_staged_cli_session_id_for_account(session_id, account_id, native_id) + .map_err(|err| format!("clear native materialization binding: {err}"))?; + Ok(removed) +} + +fn materialize_cli( + session_id: &str, + items: &[NativeConversationItem], +) -> Result { + let session = persistence::get_session(session_id) + .map_err(|err| format!("load CLI session {session_id}: {err}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + if session.transcript_source != TRANSCRIPT_SOURCE_NATIVE { + return Err(format!( + "CLI target {:?} has no native transcript reader/writer contract", + session.cli_agent_type + )); + } + if session.cli_session_id.is_some() { + return Err("native materialization requires a fresh empty execution episode".to_string()); + } + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let cwd = execution_cwd(&session)?; + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let (native_id, paths) = match agent { + "claude_code" => { + let native_id = Uuid::new_v4().to_string(); + let paths = claude_native_paths(account_id, &cwd, &native_id); + let title = claude_session_title(&session, items); + let bound = + persistence::stage_cli_session_id_for_account(session_id, account_id, &native_id) + .map_err(|err| format!("record pending Claude materialization: {err}"))?; + if !bound { + return Err(format!( + "record pending Claude materialization: target session {session_id} disappeared" + )); + } + let _transcript_guard = lock_claude_transcript(&paths.native_path)?; + if let Err(error) = write_native_store_jsonl( + &paths, + &claude_materialization_records(&native_id, &cwd, items, &title)?, + ) { + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + let _ = persistence::clear_staged_cli_session_id_for_account( + session_id, account_id, &native_id, + ); + return Err(error); + } + (native_id, paths) + } + "codex" => { + let account_id = account_id.ok_or_else(|| { + "native Codex materialization requires an explicit local account".to_string() + })?; + let title = if session.name.trim().is_empty() { + first_user_title(items) + } else { + session.name.clone() + }; + let codex_home = codex_native_app_home(); + let registered = codex_native_catalog::register_thread( + &codex_home, + &cwd, + &title, + &codex_response_items(items), + )?; + let staged = persistence::stage_cli_session_id_for_account( + session_id, + Some(account_id), + ®istered.id, + ) + .map_err(|err| format!("record pending Codex materialization: {err}"))?; + if !staged { + let _ = codex_native_catalog::archive_thread( + &codex_home, + ®istered.path, + ®istered.id, + &cwd, + ); + let _ = remove_file_if_present(®istered.path); + return Err(format!( + "record pending Codex materialization: target session {session_id} disappeared" + )); + } + let paths = match registered_codex_native_paths(account_id, ®istered.path) { + Ok(paths) => paths, + Err(error) => { + let _ = codex_native_catalog::archive_thread( + &codex_home, + ®istered.path, + ®istered.id, + &cwd, + ); + let _ = remove_file_if_present(®istered.path); + let _ = persistence::clear_staged_cli_session_id_for_account( + session_id, + Some(account_id), + ®istered.id, + ); + return Err(error); + } + }; + cache_codex_native_paths(account_id, ®istered.id, &paths); + if let Err(error) = replace_runner_link(&paths.native_path, &paths.runner_path) { + let _ = codex_native_catalog::archive_thread( + &codex_home, + &paths.native_path, + ®istered.id, + &cwd, + ); + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + let _ = persistence::clear_staged_cli_session_id_for_account( + session_id, + Some(account_id), + ®istered.id, + ); + return Err(error); + } + (registered.id, paths) + } + other => { + return Err(format!( + "CLI target {other:?} cannot write a provider-native role/tool transcript" + )) + } + }; + if agent == "claude_code" { + if let Err(error) = + publish_claude_project_index(&cwd, &native_id, items, session.branch.as_deref()) + { + let _ = remove_file_if_present(&paths.runner_path); + let _ = remove_file_if_present(&paths.native_path); + let _ = remove_claude_project_index_entry(&cwd, &native_id); + let _ = persistence::clear_staged_cli_session_id_for_account( + session_id, account_id, &native_id, + ); + return Err(error); + } + } + let published = + persistence::update_cli_session_id_for_account(session_id, account_id, &native_id) + .map_err(|error| format!("publish native materialization binding: {error}"))?; + if !published { + return Err(format!( + "publish native materialization binding: target session {session_id} disappeared" + )); + } + tracing::info!( + session_id, + native_session_id = native_id, + target = agent, + native_path = %paths.native_path.display(), + runner_path = %paths.runner_path.display(), + item_count = items.len(), + "materialized provider-native conversation transcript" + ); + Ok(NativeMaterializationReceipt { + native_session_id: native_id, + item_count: items.len(), + }) +} + +fn materialize_native_agent( + session_id: &str, + items: &[NativeConversationItem], +) -> Result { + agent_core::session::persistence::get_session(session_id) + .map_err(|err| format!("load native Agent session {session_id}: {err}"))? + .ok_or_else(|| format!("native Agent session {session_id} does not exist"))?; + let receipt = agent_core::session::persistence::seed_session_with_materialized_history( + session_id, + &native_agent_seeds(session_id, items), + ) + .map_err(|err| format!("seed native Agent transcript {session_id}: {err}"))?; + if receipt.row_count != items.len() { + return Err(format!( + "native Agent seed persisted {} of {} canonical items", + receipt.row_count, + items.len() + )); + } + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: items.len(), + }) +} + +fn synchronize_cli( + session_id: &str, + complete_items: &[NativeConversationItem], + append_items: &[NativeConversationItem], +) -> Result { + let session = persistence::get_session(session_id) + .map_err(|err| format!("load CLI session {session_id}: {err}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + if session.transcript_source != TRANSCRIPT_SOURCE_NATIVE { + return Err(format!( + "CLI target {:?} has no native transcript reader/writer contract", + session.cli_agent_type + )); + } + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let native_id = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|err| format!("read native binding for {session_id}: {err}"))? + .ok_or_else(|| format!("CLI session {session_id} has no native resume binding"))?; + let cwd = execution_cwd(&session)?; + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let paths = match agent { + "claude_code" => existing_claude_native_paths(account_id, &cwd, &native_id) + .unwrap_or_else(|| claude_native_paths(account_id, &cwd, &native_id)), + "codex" => { + let account_id = account_id + .ok_or_else(|| "native Codex synchronization has no account binding".to_string())?; + existing_codex_native_paths(account_id, &native_id)? + .ok_or_else(|| format!("materialized Codex transcript {native_id} was not found"))? + } + other => { + return Err(format!( + "CLI target {other:?} cannot write a provider-native role/tool transcript" + )) + } + }; + // A provider UUID is append-only after its first materialization. Claude + // Rust has already proved the exact provider transcript is a semantic + // prefix. Append only the verified suffix so provider-private state such + // as usage and native compact checkpoints remains untouched. + match agent { + "claude_code" => { + // Inspection and suffix commit are one cross-process transaction. + // Lock the stable adjacent lock file, not the replaceable JSONL + // inode, so another ORG2 process cannot race this mutation. + let _transcript_guard = lock_claude_transcript(&paths.native_path)?; + ensure_durable_runner_alias(&paths, &native_id)?; + if !append_items.is_empty() { + let mut records = claude_records(&native_id, &cwd, append_items)?; + let (suffix_application, parent_uuid) = + inspect_claude_suffix_application(&paths.native_path, &records)?; + let appended = suffix_application == NativeSuffixApplication::Missing; + if appended { + if let Some(first) = records.first_mut() { + first["parentUuid"] = parent_uuid.map(Value::String).unwrap_or(Value::Null); + } + if let Some(leaf_uuid) = records + .last() + .and_then(|record| record["uuid"].as_str()) + .map(str::to_string) + { + records.push(claude_resume_checkpoint( + &native_id, + &leaf_uuid, + complete_items, + )); + } + let payload = serialize_jsonl(&records)?; + append_suffix_atomically(&paths.native_path, &payload)?; + } + } + let title = claude_session_title(&session, complete_items); + ensure_claude_native_metadata(&paths.native_path, &native_id, complete_items, &title)?; + publish_claude_project_index( + &cwd, + &native_id, + complete_items, + session.branch.as_deref(), + )?; + } + "codex" => { + let promoted_to_native_app = ensure_durable_runner_alias(&paths, &native_id)?; + let title = if session.name.trim().is_empty() { + first_user_title(complete_items) + } else { + session.name.clone() + }; + if promoted_to_native_app || !append_items.is_empty() { + codex_native_catalog::synchronize_thread( + &codex_native_app_home(), + &paths.native_path, + &native_id, + &cwd, + &title, + &codex_response_items(append_items), + )?; + } + } + _ => unreachable!("unsupported targets returned above"), + } + let published = + persistence::update_cli_session_id_for_account(session_id, account_id, &native_id) + .map_err(|error| format!("publish synchronized native binding: {error}"))?; + if !published { + return Err(format!( + "publish synchronized native binding: target session {session_id} disappeared" + )); + } + Ok(NativeMaterializationReceipt { + native_session_id: native_id, + item_count: complete_items.len(), + }) +} + +#[derive(Debug)] +enum BoundNativeCatalogRefresh { + Claude { + receipt: persistence::NativeCatalogRefreshReceipt, + session_id: String, + cwd: PathBuf, + native_id: String, + native_path: PathBuf, + runner_path: PathBuf, + branch: Option, + }, + Codex { + receipt: persistence::NativeCatalogRefreshReceipt, + cwd: PathBuf, + native_id: String, + native_path: PathBuf, + title: String, + }, +} + +/// Converge the provider-written transcript back to the native application's +/// durable file after a CLI turn exits. The provider may replace the isolated +/// profile symlink with a regular file, so this copy/relink step remains inside +/// the provider-identity boundary. Catalog/index refresh is deliberately +/// returned as deferred work: it must not hold that boundary or block the next +/// turn. +fn converge_bound_native_transcript( + session_id: &str, +) -> Result, String> { + let session = persistence::get_session(session_id) + .map_err(|error| format!("load CLI session {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + if session.transcript_source != TRANSCRIPT_SOURCE_NATIVE { + return Ok(None); + } + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let Some(native_id) = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|error| format!("read native binding for {session_id}: {error}"))? + else { + return Ok(None); + }; + let agent = session.cli_agent_type.clone().unwrap_or_default(); + if !matches!(agent.as_str(), "claude_code" | "codex") { + return Ok(None); + } + + let cwd = execution_cwd(&session)?; + // Convergence runs for every native-transcript session that carries a + // provider binding, not only the ones ORG2 materialized. A binding whose + // provider store this host cannot address at all — an ambient-auth Codex + // session with no local account, or a rollout that lives outside the + // scanned roots — is missing evidence, not proof of divergence, so it + // must not fail the turn closed. + let paths = match agent.as_str() { + "claude_code" => match existing_claude_native_paths(account_id, &cwd, &native_id) { + Some(paths) => paths, + None => { + tracing::warn!( + session_id, + native_id, + "skipping native transcript convergence: no Claude transcript on this host" + ); + return Ok(None); + } + }, + "codex" => { + let Some(account_id) = account_id else { + tracing::warn!( + session_id, + native_id, + "skipping native transcript convergence: Codex session has no local account" + ); + return Ok(None); + }; + match existing_codex_native_paths(account_id, &native_id)? { + Some(paths) => paths, + None => { + tracing::warn!( + session_id, + native_id, + "skipping native transcript convergence: no Codex rollout on this host" + ); + return Ok(None); + } + } + } + _ => unreachable!("provider checked before acquiring publication owner"), + }; + // Claude may replace the isolated alias with a regular file while it + // exits. Converging that file is a provider-store mutation and therefore + // shares the same cross-process UUID lock as fresh/suffix/discard. + let _claude_transcript_guard = if agent == "claude_code" { + Some(lock_claude_transcript(&paths.native_path)?) + } else { + None + }; + // Persist intent before the transcript alias mutation. A crash after this + // point can therefore leave, at worst, a dirty receipt that startup will + // retry; it cannot silently lose a required native-App catalog refresh. + let receipt = persistence::request_native_catalog_refresh(session_id, account_id, &native_id) + .map_err(|error| format!("request native App catalog refresh: {error}"))? + .ok_or_else(|| { + format!( + "request native App catalog refresh: binding {native_id} for {session_id} changed" + ) + })?; + let promoted = ensure_durable_runner_alias(&paths, &native_id)?; + if !promoted && agent == "codex" { + // The provider wrote through the existing native-App alias. Initial + // Codex's supported app-server registration already owns its catalog. + persistence::acknowledge_native_catalog_refresh(&receipt) + .map_err(|error| format!("acknowledge native App catalog refresh: {error}"))?; + return Ok(None); + } + Ok(Some(match agent.as_str() { + "claude_code" => BoundNativeCatalogRefresh::Claude { + receipt, + session_id: session_id.to_string(), + cwd, + native_id, + native_path: paths.native_path, + runner_path: paths.runner_path, + branch: session.branch, + }, + "codex" => BoundNativeCatalogRefresh::Codex { + receipt, + cwd, + native_id, + native_path: paths.native_path, + title: if session.name.trim().is_empty() { + "Imported conversation".to_string() + } else { + session.name + }, + }, + _ => unreachable!("provider checked before acquiring publication owner"), + })) +} + +fn refresh_bound_native_catalog(refresh: BoundNativeCatalogRefresh) -> Result<(), String> { + let receipt = match refresh { + BoundNativeCatalogRefresh::Claude { + receipt, + session_id, + cwd, + native_id, + native_path, + runner_path, + branch, + } => { + let mut parsed_items = None; + if !refresh_claude_project_index_from_provider( + &cwd, + &native_id, + &native_path, + &runner_path, + branch.as_deref(), + )? { + // Old Claude versions and profile repairs may not publish an + // index entry. This fallback stays outside the turn/identity + // boundary so a large JSONL cannot delay the footer. + let chunks = orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + &session_id, + &native_path, + )?; + let items = native_items_from_chunks(&chunks); + publish_claude_project_index(&cwd, &native_id, &items, branch.as_deref())?; + parsed_items = Some(items); + } + + // Claude Desktop does not watch Claude Code's sessions-index.json. + // Its Code tab discovers the provider JSONL through this separate + // metadata row. Publish it from the same deferred owner and only + // into an existing provider-registered account/project directory. + let desktop_root = claude_desktop_sessions_root(); + let desktop_path = desktop_root + .is_dir() + .then(|| claude_desktop_session_path(&desktop_root, &cwd, &native_id)) + .flatten(); + if let Some(desktop_path) = desktop_path { + let items = match parsed_items { + Some(items) => items, + None => { + let chunks = orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + &session_id, + &native_path, + )?; + native_items_from_chunks(&chunks) + } + }; + let session = persistence::get_session(&session_id) + .map_err(|error| { + format!("load CLI session {session_id} for Claude Desktop catalog: {error}") + })? + .ok_or_else(|| { + format!("CLI session {session_id} disappeared before Claude Desktop catalog refresh") + })?; + publish_claude_desktop_session_at_path( + &session, + &cwd, + &native_id, + &native_path, + &items, + desktop_path, + )?; + } + receipt + } + BoundNativeCatalogRefresh::Codex { + receipt, + cwd, + native_id, + native_path, + title, + } => { + codex_native_catalog::synchronize_thread( + &codex_native_app_home(), + &native_path, + &native_id, + &cwd, + &title, + &[], + )?; + receipt + } + }; + // Generation-CAS: an older successful task never clears a newer terminal + // request for the same native binding. + persistence::acknowledge_native_catalog_refresh(&receipt) + .map_err(|error| format!("acknowledge native App catalog refresh: {error}"))?; + Ok(()) +} + +fn prepare_pending_native_catalog_refresh( + pending: persistence::PendingNativeCatalogRefresh, +) -> Result { + let session_id = pending.receipt.session_id.clone(); + let native_id = pending.receipt.cli_session_id.clone(); + let account_id = pending.receipt.account_id().map(str::to_string); + let session = persistence::get_session(&session_id) + .map_err(|error| format!("load CLI session {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + let cwd = execution_cwd(&session)?; + + match pending.source.as_str() { + "claude_code" => { + let paths = existing_claude_native_paths(account_id.as_deref(), &cwd, &native_id) + .ok_or_else(|| { + format!("no Claude transcript for pending native binding {native_id}") + })?; + let _transcript_guard = lock_claude_transcript(&paths.native_path)?; + ensure_durable_runner_alias(&paths, &native_id)?; + Ok(BoundNativeCatalogRefresh::Claude { + receipt: pending.receipt, + session_id, + cwd, + native_id, + native_path: paths.native_path, + runner_path: paths.runner_path, + branch: session.branch, + }) + } + "codex_app" => { + let account_id = account_id.as_deref().ok_or_else(|| { + format!("pending Codex native binding {native_id} has no local account") + })?; + let paths = existing_codex_native_paths(account_id, &native_id)?.ok_or_else(|| { + format!("no Codex rollout for pending native binding {native_id}") + })?; + ensure_durable_runner_alias(&paths, &native_id)?; + Ok(BoundNativeCatalogRefresh::Codex { + receipt: pending.receipt, + cwd, + native_id, + native_path: paths.native_path, + title: if session.name.trim().is_empty() { + "Imported conversation".to_string() + } else { + session.name + }, + }) + } + source => Err(format!( + "unsupported pending native App catalog source {source}" + )), + } +} + +const STARTUP_NATIVE_CATALOG_REPAIR_LIMIT: usize = 64; + +/// One bounded, pending-only reconciliation pass on app startup. This is a +/// durable retry point for terminal fire-and-forget catalog work, not a poller: +/// no clean session or provider transcript is scanned. +pub(crate) async fn reconcile_pending_native_catalog_refreshes_on_startup() -> (usize, usize) { + let pending = match tokio::task::spawn_blocking(|| { + persistence::pending_native_catalog_refreshes(STARTUP_NATIVE_CATALOG_REPAIR_LIMIT) + }) + .await + { + Ok(Ok(pending)) => pending, + Ok(Err(error)) => { + tracing::warn!(error = %error, "failed to load pending native App catalog refreshes"); + return (0, 1); + } + Err(error) => { + tracing::warn!(error = %error, "pending native App catalog query task failed"); + return (0, 1); + } + }; + + let mut succeeded = 0usize; + let mut failed = 0usize; + for pending in pending { + let session_id = pending.receipt.session_id.clone(); + let mutation_guards = match lock_idle_native_mutation(&session_id).await { + Ok(guards) => guards, + Err(error) => { + failed += 1; + tracing::warn!(session_id, error = %error, "deferred pending native App catalog repair"); + continue; + } + }; + let prepared = tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + prepare_pending_native_catalog_refresh(pending) + }) + .await; + let refresh = match prepared { + Ok(Ok(refresh)) => refresh, + Ok(Err(error)) => { + failed += 1; + tracing::warn!(session_id, error = %error, "failed to prepare pending native App catalog repair"); + continue; + } + Err(error) => { + failed += 1; + tracing::warn!(session_id, error = %error, "pending native App catalog preparation task failed"); + continue; + } + }; + match tokio::task::spawn_blocking(move || refresh_bound_native_catalog(refresh)).await { + Ok(Ok(())) => succeeded += 1, + Ok(Err(error)) => { + failed += 1; + tracing::warn!(session_id, error = %error, "failed to repair pending native App catalog"); + } + Err(error) => { + failed += 1; + tracing::warn!(session_id, error = %error, "pending native App catalog repair task failed"); + } + } + } + (succeeded, failed) +} + +/// Finalize the durable transcript now and refresh discovery metadata after the +/// current runner releases its identity guard. The background refresh is +/// idempotent and never delays the footer or the next provider turn. +pub(super) async fn converge_bound_native_transcript_and_schedule_catalog( + session_id: &str, +) -> Result { + let converge_session_id = session_id.to_string(); + let refresh = + tokio::task::spawn_blocking(move || converge_bound_native_transcript(&converge_session_id)) + .await + .map_err(|error| format!("native transcript convergence task failed: {error}"))??; + let Some(refresh) = refresh else { + return Ok(false); + }; + + let boundary_session_id = session_id.to_string(); + tokio::spawn(async move { + // The caller's runner owns this lock. Waiting for and immediately + // dropping it establishes an after-finalization boundary without + // holding the lock during catalog I/O. If the next turn wins the race, + // its provider work remains authoritative and refresh waits harmlessly. + let identity = super::session_runner::session_identity_lock(&boundary_session_id) + .await + .lock_owned() + .await; + drop(identity); + match tokio::task::spawn_blocking(move || refresh_bound_native_catalog(refresh)).await { + Ok(Ok(())) => {} + Ok(Err(error)) => tracing::warn!( + session_id = %boundary_session_id, + error = %error, + "failed to refresh provider-native App catalog" + ), + Err(error) => tracing::warn!( + session_id = %boundary_session_id, + error = %error, + "native App catalog refresh task failed" + ), + } + }); + Ok(true) +} + +#[cfg(test)] +fn publish_bound_native_transcript(session_id: &str) -> Result { + let Some(refresh) = converge_bound_native_transcript(session_id)? else { + return Ok(false); + }; + refresh_bound_native_catalog(refresh)?; + Ok(true) +} + +fn synchronize_native_agent( + session_id: &str, + complete_items: &[NativeConversationItem], + append_items: &[NativeConversationItem], +) -> Result { + agent_core::session::persistence::get_session(session_id) + .map_err(|err| format!("load native Agent session {session_id}: {err}"))? + .ok_or_else(|| format!("native Agent session {session_id} does not exist"))?; + let receipt = agent_core::session::persistence::append_session_with_materialized_history( + session_id, + &native_agent_seeds(session_id, append_items), + ) + .map_err(|err| format!("append native Agent transcript {session_id}: {err}"))?; + if receipt.row_count != append_items.len() { + return Err(format!( + "native Agent append persisted {} of {} canonical suffix items", + receipt.row_count, + append_items.len() + )); + } + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: complete_items.len(), + }) +} + +async fn materialize_native_conversation_with_owner( + state: Option<&AgentAppState>, + session_id: String, + items: Vec, +) -> Result { + validate_items(&items)?; + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + // Move both guards into the blocking mutation. If the IPC future is + // cancelled after spawning, filesystem work stays serialized until + // it actually finishes instead of racing a follow-up. + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + return tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + materialize_cli(&session_id, &items) + }) + .await + .map_err(|err| format!("native materialization task failed: {err}"))?; + } + + let state = state + .ok_or_else(|| format!("Agent materialization for {session_id} requires AgentAppState"))?; + let operation_session_id = session_id.clone(); + run_agent_native_maintenance(state, session_id, move || { + materialize_native_agent(&operation_session_id, &items) + }) + .await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn materialize_native_conversation( + state: tauri::State<'_, AgentAppState>, + session_id: String, + items: Vec, +) -> Result { + materialize_native_conversation_with_owner(Some(state.inner()), session_id, items).await +} + +fn synchronize_native_conversation_blocking( + session_id: &str, + complete_items: &[NativeConversationItem], +) -> Result { + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + let session = persistence::get_session(session_id) + .map_err(|error| format!("load CLI session {session_id}: {error}"))? + .ok_or_else(|| format!("CLI session {session_id} does not exist"))?; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let native_id = persistence::get_cli_session_id_for_account(session_id, account_id) + .map_err(|error| format!("read native binding for {session_id}: {error}"))?; + if native_id.is_none() { + if complete_items.is_empty() { + // An empty canonical prefix has nothing to materialize. + // Keep the fresh episode unbound so its first real user + // turn lets the provider create a valid native UUID. + return Ok(NativeMaterializationReceipt { + native_session_id: String::new(), + item_count: 0, + }); + } + // A freshly created execution episode has no provider UUID yet, + // so there is no authoritative native prefix to compare. + return materialize_cli(session_id, complete_items); + } + if let Some(native_id) = native_id.as_deref() { + if load_materialized_cli_transcript(&session, native_id)?.is_none() { + // The resume row doubles as the materialization intent. A + // missing artifact means the process died before publication; + // clear that incomplete intent and replay through the ordinary + // materializer instead of leaving the episode permanently + // bound to a UUID that no provider can open. + persistence::clear_staged_cli_session_id_for_account( + session_id, account_id, native_id, + ) + .map_err(|error| { + format!("clear incomplete native materialization intent: {error}") + })?; + return materialize_cli(session_id, complete_items); + } + } + } + let append_items = authoritative_append_suffix(session_id, complete_items)?; + if append_items.is_empty() { + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + return synchronize_cli(session_id, complete_items, &[]); + } + return Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: complete_items.len(), + }); + } + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + synchronize_cli(session_id, complete_items, &append_items) + } else { + synchronize_native_agent(session_id, complete_items, &append_items) + } +} + +async fn synchronize_native_conversation_with_owner( + state: Option<&AgentAppState>, + session_id: String, + complete_items: Vec, +) -> Result { + validate_items(&complete_items)?; + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + return tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + synchronize_native_conversation_blocking(&session_id, &complete_items) + }) + .await + .map_err(|err| format!("native synchronization task failed: {err}"))?; + } + + let state = state + .ok_or_else(|| format!("Agent synchronization for {session_id} requires AgentAppState"))?; + let operation_session_id = session_id.clone(); + run_agent_native_maintenance(state, session_id, move || { + synchronize_native_conversation_blocking(&operation_session_id, &complete_items) + }) + .await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn synchronize_native_conversation( + state: tauri::State<'_, AgentAppState>, + session_id: String, + complete_items: Vec, +) -> Result { + synchronize_native_conversation_with_owner(Some(state.inner()), session_id, complete_items) + .await +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn discard_native_conversation_materialization( + session_id: String, + native_session_id: String, +) -> Result { + let mutation_guards = lock_idle_native_mutation(&session_id).await?; + let result = tokio::task::spawn_blocking(move || { + let _mutation_guards = mutation_guards; + discard_cli_materialization(&session_id, &native_session_id) + }) + .await + .map_err(|err| format!("native materialization rollback task failed: {err}"))?; + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::test_env; + use std::ffi::OsString; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[cfg(unix)] + const CLAUDE_INDEX_LOCK_CHILD_CWD: &str = "ORGII_TEST_CLAUDE_INDEX_LOCK_CHILD_CWD"; + #[cfg(unix)] + const CLAUDE_INDEX_LOCK_CHILD_READY: &str = "ORGII_TEST_CLAUDE_INDEX_LOCK_CHILD_READY"; + #[cfg(unix)] + const CLAUDE_INDEX_LOCK_CHILD_ACTION: &str = "ORGII_TEST_CLAUDE_INDEX_LOCK_CHILD_ACTION"; + + struct EnvVarGuard { + key: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &Path) -> Self { + let previous = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } + } + + fn message(id: &str, role: &str, text: &str) -> NativeConversationItem { + NativeConversationItem::Message { + id: id.to_string(), + role: role.to_string(), + text: text.to_string(), + images: Vec::new(), + created_at: "2026-09-02T00:00:00Z".to_string(), + turn_id: None, + } + } + + fn tool_call(call_id: &str, name: &str, arguments: &str) -> NativeConversationItem { + NativeConversationItem::ToolCall { + id: format!("{call_id}:call"), + call_id: call_id.to_string(), + name: name.to_string(), + arguments: arguments.to_string(), + created_at: "2026-09-02T00:00:01Z".to_string(), + } + } + + fn tool_result( + call_id: &str, + name: &str, + output: &str, + is_error: bool, + interrupted: bool, + ) -> NativeConversationItem { + NativeConversationItem::ToolResult { + id: format!("{call_id}:result"), + call_id: call_id.to_string(), + name: name.to_string(), + output: output.to_string(), + is_error, + interrupted, + created_at: "2026-09-02T00:00:02Z".to_string(), + } + } + + fn create_native_claude_session(session_id: &str, account_id: &str, repo_path: &Path) { + create_native_session(session_id, "claude_code", Some(account_id), repo_path); + } + + fn create_native_session( + session_id: &str, + cli_agent_type: &str, + account_id: Option<&str>, + repo_path: &Path, + ) { + persistence::create_session( + session_id, + &persistence::CreateCodeSessionParams { + name: Some("Native synchronization fixture".to_string()), + flow: None, + runner: None, + cli_agent_type: cli_agent_type.to_string(), + model: Some("claude-sonnet-4-6".to_string()), + tier: None, + account_id: account_id.map(str::to_string), + repo_path: Some(repo_path.to_string_lossy().into_owned()), + branch: None, + worktree_path: None, + worktree_base_ref: None, + proxy_token: None, + proxy_url: None, + hosted_token: None, + proxy_session_id: None, + isolate: None, + background: Some(false), + key_source: Some("own_key".to_string()), + additional_directories: None, + parent_session_id: None, + org_member_id: None, + agent_definition_id: None, + org_id: None, + project_id: None, + project_name: None, + project_slug: None, + work_item_id: None, + agent_role: None, + product_mode: None, + }, + ) + .expect("create fresh native CLI episode"); + } + + #[test] + fn unresolved_provider_tool_call_is_not_a_portable_item() { + let mut chunk = ActivityChunk::new("source", "tool_call", "read_file"); + chunk.chunk_id = "partial-tool".to_string(); + chunk.args = json!({"path": "README.md"}); + chunk.result = json!({ + "status": "pending", + "interrupted": true, + "success": false, + "call_id": "call_partial" + }); + + assert!(native_items_from_chunks(&[chunk]).is_empty()); + } + + #[test] + fn dangling_claude_tool_use_does_not_forge_a_result_item() { + let sandbox = test_env::sandbox(); + let native_id = "77777777-1111-4222-8333-999999999999"; + let path = sandbox.path().join("dangling-tool-use.jsonl"); + let items = vec![ + message("dangling-user", "user", "run the suite"), + tool_call("call_resolved", "list_files", "{\"path\":\".\"}"), + tool_result("call_resolved", "list_files", "README.md", false, false), + ]; + let mut records = claude_records(native_id, Path::new("/repo"), &items) + .expect("render resolved Claude pair"); + // Claude Code records a user interrupt as a `tool_use` its transcript + // never answers, so reading one back must not invent a result. + records.push(json!({ + "type": "assistant", + "uuid": "aaaaaaaa-2222-4333-8444-bbbbbbbbbbbb", + "parentUuid": Value::Null, + "isSidechain": false, + "userType": "external", + "sessionId": native_id, + "cwd": "/repo", + "timestamp": "2026-09-02T00:00:05Z", + "message": { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "call_interrupted", + "name": "list_files", + "input": {"path": "src"} + }] + } + })); + atomic_jsonl(&path, &records).expect("write Claude transcript with a dangling tool_use"); + + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + native_id, &path, + ) + .expect("read Claude transcript back"); + let round_tripped = native_items_from_chunks(&chunks); + + assert_eq!(round_tripped.len(), items.len()); + assert!(items + .iter() + .zip(&round_tripped) + .all(|(left, right)| native_item_semantically_equal(left, right))); + } + + #[test] + fn interrupted_tool_result_round_trips_through_the_claude_transcript() { + let sandbox = test_env::sandbox(); + let native_id = "88888888-1111-4222-8333-cccccccccccc"; + let path = sandbox.path().join("interrupted-tool.jsonl"); + let items = vec![ + message("interrupt-user", "user", "run the suite"), + tool_call( + "call_killed", + "run_command_line", + "{\"command\":\"cargo test\"}", + ), + tool_result( + "call_killed", + "run_command_line", + "compiling org2\n", + true, + true, + ), + message("interrupt-follow-up", "user", "stop and summarize"), + ]; + let records = + claude_records(native_id, Path::new("/repo"), &items).expect("render Claude records"); + atomic_jsonl(&path, &records).expect("write Claude transcript"); + + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + native_id, &path, + ) + .expect("read Claude transcript back"); + let round_tripped = native_items_from_chunks(&chunks); + + assert_eq!(round_tripped.len(), items.len()); + assert!(matches!( + &round_tripped[2], + NativeConversationItem::ToolResult { + output, + is_error: true, + .. + } if output == "compiling org2\n" + )); + assert!(items + .iter() + .zip(&round_tripped) + .all(|(left, right)| native_item_semantically_equal(left, right))); + } + + #[test] + fn codex_injected_items_use_provider_typed_ids() { + let source_id = "orgii_evt_fedcba9876543210fedcba9876543210"; + let items = vec![ + message(source_id, "user", "Search titles"), + message("orgii_evt_assistant", "assistant", "Keep the scope visible"), + tool_call("call_read", "read_file", "{}"), + NativeConversationItem::ContextSummary { + id: "orgii_evt_summary".to_string(), + summary: "Search only loaded titles".to_string(), + created_at: "2026-09-08T00:00:00Z".to_string(), + }, + ]; + let projected = codex_response_items(&items); + assert_eq!(projected[0]["id"], format!("msg_{source_id}")); + assert!(projected[1]["id"].as_str().unwrap().starts_with("msg_")); + let call_id = projected[2]["id"].as_str().unwrap(); + assert!(call_id.starts_with("fc_")); + assert!(call_id.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_')); + assert_eq!(projected[2]["call_id"], "call_read"); + assert!(projected[3]["id"].as_str().unwrap().starts_with("msg_")); + } + + #[test] + fn failed_codex_tool_output_round_trips_as_a_failed_tool() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("rollout-failed-tool.jsonl"); + let items = vec![ + tool_call( + "call_failed", + "run_command_line", + "{\"command\":\"cargo test\"}", + ), + tool_result("call_failed", "run_command_line", "boom\n", true, false), + tool_call("call_ok", "list_files", "{\"path\":\".\"}"), + tool_result("call_ok", "list_files", "README.md", false, false), + ]; + let projected = codex_response_items(&items); + assert_eq!(projected[3]["output"], "README.md"); + + let rollout = projected + .iter() + .map(|payload| { + json!({ + "timestamp": "2026-09-02T00:00:03Z", + "type": "response_item", + "payload": payload + }) + .to_string() + }) + .collect::>() + .join("\n"); + fs::write(&path, format!("{rollout}\n")).expect("write Codex rollout"); + + let chunks = orgtrack_core::sources::codex::app::load_codex_app_from_path( + "codexapp-failed-tool", + &path, + ) + .expect("read Codex rollout back"); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].result["is_error"], true); + assert_eq!(chunks[0].result["output"], "boom\n"); + assert_eq!(chunks[1].result["success"], true); + + let round_tripped = native_items_from_chunks(&chunks); + assert_eq!(round_tripped.len(), items.len()); + assert!(items + .iter() + .zip(&round_tripped) + .all(|(left, right)| native_item_semantically_equal(left, right))); + } + + #[test] + fn interrupted_codex_tool_output_is_not_injected_as_a_success() { + let items = vec![tool_result( + "call_killed", + "run_command_line", + "compiling org2\n", + true, + true, + )]; + let projected = codex_response_items(&items); + assert_eq!( + projected[0]["output"], + json!({"exit_code": 130, "output": "compiling org2\n"}).to_string() + ); + } + + #[test] + fn latest_compact_boundary_replaces_superseded_effective_context() { + let mut before = ActivityChunk::new("source", "message", "user_message"); + before.chunk_id = "before".to_string(); + before.result = json!({"content": "superseded prompt"}); + let mut compact = ActivityChunk::new("source", "context_compacted", "context_compacted"); + compact.chunk_id = "compact-1".to_string(); + compact.result = json!({"observation": "repository summary"}); + let mut after = ActivityChunk::new("source", "message", "user_message"); + after.chunk_id = "after".to_string(); + after.result = json!({"content": "continue"}); + + let items = native_items_from_chunks(&[before, compact, after]); + assert_eq!(items.len(), 2); + assert!(matches!( + &items[0], + NativeConversationItem::ContextSummary { summary, .. } + if summary == "repository summary" + )); + let records = claude_records("native-compact", Path::new("/repo"), &items) + .expect("serialize effective Claude context"); + assert_eq!(records[0]["subtype"], "compact_boundary"); + assert_eq!(records[1]["isCompactSummary"], true); + assert_eq!(records[1]["message"]["content"], "repository summary"); + } + + #[test] + fn missing_claude_resume_checkpoint_is_repaired_idempotently() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("missing-checkpoint.jsonl"); + let native_id = "99999999-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![message("checkpoint-user", "user", "continue")]; + let records = + claude_records(native_id, Path::new("/repo"), &items).expect("serialize Claude rows"); + atomic_jsonl(&path, &records).expect("write rows without checkpoint"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + ensure_claude_native_metadata(&path, native_id, &items, "") + .expect("repair missing checkpoint"); + let once = fs::read_to_string(&path).expect("read repaired transcript"); + ensure_claude_native_metadata(&path, native_id, &items, "") + .expect("repeat checkpoint repair"); + let twice = fs::read_to_string(&path).expect("read idempotent transcript"); + + assert_eq!(once, twice); + assert_eq!(once.lines().count(), 2); + let checkpoint: Value = + serde_json::from_str(once.lines().last().unwrap()).expect("decode repaired checkpoint"); + assert_eq!(checkpoint["type"], "last-prompt"); + assert_eq!( + checkpoint["leafUuid"], + records.last().expect("message record")["uuid"] + ); + } + + #[test] + fn claude_materialization_title_is_native_metadata_not_a_conversation_item() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("materialized-title.jsonl"); + let native_id = "12345678-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![ + message("title-user", "user", "inspect the repository"), + message("title-assistant", "assistant", "I will inspect it."), + ]; + let records = claude_materialization_records( + native_id, + Path::new("/repo"), + &items, + "Canonical conversation title", + ) + .expect("render titled Claude materialization"); + + assert_eq!(records[0]["type"], "custom-title"); + assert_eq!(records[0]["customTitle"], "Canonical conversation title"); + assert_eq!(records[0]["sessionId"], native_id); + assert_eq!( + records + .iter() + .filter(|record| record["type"] == "custom-title") + .count(), + 1 + ); + + atomic_jsonl(&path, &records).expect("write titled Claude transcript"); + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + native_id, &path, + ) + .expect("read titled Claude transcript"); + let round_tripped = native_items_from_chunks(&chunks); + assert_eq!(round_tripped.len(), items.len()); + assert!(items + .iter() + .zip(&round_tripped) + .all(|(left, right)| native_item_semantically_equal(left, right))); + } + + #[test] + fn missing_claude_custom_title_is_repaired_once_for_orgii_materialization() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("missing-title.jsonl"); + let native_id = "22345678-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![message("title-user", "user", "continue")]; + let records = claude_records_with_resume_checkpoint(native_id, Path::new("/repo"), &items) + .expect("serialize untitled Claude materialization"); + atomic_jsonl(&path, &records).expect("write untitled Claude materialization"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + ensure_claude_native_metadata(&path, native_id, &items, "Canonical title") + .expect("repair missing title"); + let once = fs::read_to_string(&path).expect("read repaired transcript"); + ensure_claude_native_metadata(&path, native_id, &items, "Canonical title") + .expect("repeat title repair"); + let twice = fs::read_to_string(&path).expect("read idempotent transcript"); + + assert_eq!(once, twice); + let custom_titles = once + .lines() + .map(|line| serde_json::from_str::(line).expect("decode Claude record")) + .filter(|record| record["type"] == "custom-title") + .collect::>(); + assert_eq!(custom_titles.len(), 1); + assert_eq!(custom_titles[0]["customTitle"], "Canonical title"); + assert_eq!(custom_titles[0]["sessionId"], native_id); + } + + #[test] + fn claude_metadata_repair_does_not_title_unmanaged_native_transcript() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("provider-owned.jsonl"); + let native_id = "32345678-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![message("provider-user", "user", "provider-owned")]; + let mut records = claude_records(native_id, Path::new("/repo"), &items) + .expect("serialize provider transcript fixture"); + for record in &mut records { + record + .as_object_mut() + .expect("Claude record object") + .remove("entrypoint"); + } + atomic_jsonl(&path, &records).expect("write provider-owned transcript"); + let before = fs::read(&path).expect("read provider-owned transcript"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + ensure_claude_native_metadata(&path, native_id, &items, "Must not be applied") + .expect("inspect provider-owned transcript"); + + assert_eq!( + fs::read(&path).expect("read untouched provider transcript"), + before + ); + } + + #[test] + fn claude_metadata_repair_preserves_existing_custom_title() { + let sandbox = test_env::sandbox(); + let path = sandbox.path().join("existing-title.jsonl"); + let native_id = "42345678-1111-4222-8333-aaaaaaaaaaaa"; + let items = vec![message("existing-title-user", "user", "continue")]; + let mut records = + claude_records_with_resume_checkpoint(native_id, Path::new("/repo"), &items) + .expect("serialize titled Claude materialization"); + records.insert(0, claude_custom_title(native_id, "Existing title")); + atomic_jsonl(&path, &records).expect("write titled Claude materialization"); + let before = fs::read(&path).expect("read titled transcript"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + ensure_claude_native_metadata(&path, native_id, &items, "Replacement title") + .expect("inspect existing title"); + + assert_eq!( + fs::read(&path).expect("read preserved titled transcript"), + before + ); + } + + #[cfg(unix)] + #[test] + #[ignore = "launched by claude_project_index_rmw_is_locked_across_processes"] + fn claude_project_index_lock_child() { + let Some(cwd) = std::env::var_os(CLAUDE_INDEX_LOCK_CHILD_CWD).map(PathBuf::from) else { + return; + }; + let ready = PathBuf::from( + std::env::var_os(CLAUDE_INDEX_LOCK_CHILD_READY) + .expect("cross-process lock child ready marker"), + ); + fs::write(&ready, b"ready").expect("write cross-process lock child ready marker"); + let native_id = "44444444-5555-4666-8777-888888888888"; + match std::env::var(CLAUDE_INDEX_LOCK_CHILD_ACTION).as_deref() { + Ok("remove") => remove_claude_project_index_entry(&cwd, native_id) + .expect("child removes Claude project index entry"), + _ => publish_claude_project_index( + &cwd, + native_id, + &[message("child-user", "user", "published by child")], + None, + ) + .expect("child publishes Claude project index entry"), + } + } + + #[cfg(unix)] + #[test] + fn claude_project_index_rmw_is_locked_across_processes() { + use std::process::Command; + use std::thread; + use std::time::{Duration, Instant}; + + let sandbox = test_env::sandbox(); + let cwd = sandbox.path().join("cross-process-claude-worktree"); + fs::create_dir_all(&cwd).expect("create cross-process Claude workspace"); + let native_id = "44444444-5555-4666-8777-888888888888"; + let index_path = claude_native_paths(None, &cwd, native_id) + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"); + fs::create_dir_all(index_path.parent().expect("Claude index parent")) + .expect("create Claude index parent"); + let ready = sandbox.path().join("claude-index-child-ready"); + let guard = lock_claude_project_index(&index_path).expect("lock Claude project index"); + + let mut child = Command::new(std::env::current_exe().expect("current test executable")) + .arg("claude_project_index_lock_child") + .arg("--ignored") + .env(CLAUDE_INDEX_LOCK_CHILD_CWD, &cwd) + .env(CLAUDE_INDEX_LOCK_CHILD_READY, &ready) + .spawn() + .expect("launch cross-process Claude index writer"); + + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(ready.exists(), "child must reach the locked RMW boundary"); + thread::sleep(Duration::from_millis(100)); + assert!( + !index_path.exists(), + "another process must not mutate the index while the advisory lock is held" + ); + assert!( + child.try_wait().expect("inspect child process").is_none(), + "child should still be waiting for the cross-process lock" + ); + + drop(guard); + let status = child.wait().expect("wait for cross-process index writer"); + assert!(status.success(), "cross-process index writer failed"); + let index: Value = serde_json::from_slice( + &fs::read(&index_path).expect("read child-published Claude project index"), + ) + .expect("decode child-published Claude project index"); + assert_eq!(index["entries"][0]["sessionId"].as_str(), Some(native_id)); + + let survivor_id = "55555555-6666-4777-8888-999999999999"; + publish_claude_project_index( + &cwd, + survivor_id, + &[message("survivor-user", "user", "must survive remove")], + None, + ) + .expect("publish peer index entry"); + let remove_ready = sandbox.path().join("claude-index-remove-child-ready"); + let guard = lock_claude_project_index(&index_path).expect("relock Claude project index"); + let mut child = Command::new(std::env::current_exe().expect("current test executable")) + .arg("claude_project_index_lock_child") + .arg("--ignored") + .env(CLAUDE_INDEX_LOCK_CHILD_CWD, &cwd) + .env(CLAUDE_INDEX_LOCK_CHILD_READY, &remove_ready) + .env(CLAUDE_INDEX_LOCK_CHILD_ACTION, "remove") + .spawn() + .expect("launch cross-process Claude index remover"); + let deadline = Instant::now() + Duration::from_secs(5); + while !remove_ready.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!( + remove_ready.exists(), + "remove child must reach the locked RMW boundary" + ); + thread::sleep(Duration::from_millis(100)); + let locked_index = fs::read_to_string(&index_path).expect("read index while remove waits"); + assert!( + locked_index.contains(native_id), + "another process must not remove an entry while the advisory lock is held" + ); + assert!( + child.try_wait().expect("inspect remove child").is_none(), + "remove child should still be waiting for the cross-process lock" + ); + + drop(guard); + let status = child.wait().expect("wait for cross-process index remover"); + assert!(status.success(), "cross-process index remover failed"); + let index = fs::read_to_string(&index_path).expect("read index after child remove"); + assert!(!index.contains(native_id)); + assert!( + index.contains(survivor_id), + "removing one entry must preserve peer updates" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn agent_native_maintenance_waits_for_the_session_scheduler_owner() { + let session_id = "sdeagent-native-maintenance-owner"; + let session = Arc::new(agent_core::state::AgentSession::new( + session_id.to_string(), + agent_core::definitions::sde_agent(), + )); + let release_first = Arc::new(tokio::sync::Notify::new()); + let release_first_task = Arc::clone(&release_first); + let (first_started_tx, first_started_rx) = oneshot::channel(); + session + .scheduler + .enqueue(ScheduledMessage { + kind: ScheduledKind::Maintenance, + message_id: "blocking-maintenance".to_string(), + generation: 0, + client_message_id: None, + turn_intent_id: String::new(), + org_run_id: None, + content: "[test maintenance]".to_string(), + execute: Box::new(move || { + Box::pin(async move { + let _ = first_started_tx.send(()); + release_first_task.notified().await; + Ok(String::new()) + }) + }), + }) + .await + .expect("enqueue blocking maintenance"); + first_started_rx + .await + .expect("blocking maintenance should start"); + + let mutation_ran = Arc::new(AtomicBool::new(false)); + let mutation_ran_task = Arc::clone(&mutation_ran); + let session_task = Arc::clone(&session); + let mutation = tokio::spawn(enqueue_agent_native_maintenance( + session_task, + session_id.to_string(), + move || { + mutation_ran_task.store(true, Ordering::Release); + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: 1, + }) + }, + )); + tokio::task::yield_now().await; + assert!( + !mutation_ran.load(Ordering::Acquire), + "native mutation must not bypass an active scheduler job" + ); + + release_first.notify_one(); + let receipt = mutation + .await + .expect("join native maintenance") + .expect("native maintenance succeeds"); + assert!(mutation_ran.load(Ordering::Acquire)); + assert_eq!(receipt.native_session_id, session_id); + assert_eq!(receipt.item_count, 1); + } + + #[test] + fn semantic_identity_ignores_provider_ids_and_timestamps() { + let left = message("canonical", "user", "hello"); + let right = NativeConversationItem::Message { + id: "provider".to_string(), + role: "user".to_string(), + text: "hello".to_string(), + images: Vec::new(), + created_at: "2027-01-01T00:00:00Z".to_string(), + turn_id: None, + }; + assert!(native_item_semantically_equal(&left, &right)); + } + + #[test] + fn agent_history_preserves_embedded_user_images() { + let history = vec![json!({ + "role": "user", + "content": [ + {"type": "text", "text": "inspect"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,QUJD"}} + ] + })]; + let projected = native_items_from_agent_history(&history); + assert!(matches!( + &projected[0], + NativeConversationItem::Message { text, images, .. } + if text == "inspect" && images == &["data:image/png;base64,QUJD"] + )); + } + + #[test] + fn codex_tool_arguments_are_not_polluted_with_orgii_fields() { + let item = NativeConversationItem::ToolCall { + id: "call-item".to_string(), + call_id: "call_1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"README.md"}"#.to_string(), + created_at: "2026-09-02T00:00:00Z".to_string(), + }; + let projected = codex_response_items(&[item]); + assert_eq!(projected[0]["arguments"], r#"{"path":"README.md"}"#); + assert!(!projected[0]["arguments"] + .as_str() + .unwrap_or_default() + .contains("__orgii")); + } + + #[test] + fn claude_project_index_preserves_unknown_fields_and_rejects_unknown_schema() { + let sandbox = test_env::sandbox(); + let cwd = sandbox.path().join("claude-index-schema-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + let native_id = "99999999-aaaa-4bbb-8ccc-dddddddddddd"; + let index_path = claude_native_paths(None, &cwd, native_id) + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"); + fs::create_dir_all(index_path.parent().expect("Claude index parent")) + .expect("create Claude index parent"); + fs::write( + &index_path, + serde_json::to_vec(&json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION, + "providerTopLevel": {"keep": true}, + "entries": [{ + "sessionId": native_id, + "messageCount": 1, + "providerEntryField": {"keep": true} + }] + })) + .expect("encode Claude index fixture"), + ) + .expect("write Claude index fixture"); + + publish_claude_project_index( + &cwd, + native_id, + &[message("index-user", "user", "updated")], + Some("feature/native-index"), + ) + .expect("update supported Claude index schema"); + let updated: Value = serde_json::from_slice( + &fs::read(&index_path).expect("read updated Claude project index"), + ) + .expect("decode updated Claude project index"); + assert_eq!(updated["providerTopLevel"]["keep"], true); + assert_eq!(updated["entries"][0]["providerEntryField"]["keep"], true); + assert_eq!(updated["entries"][0]["gitBranch"], "feature/native-index"); + + let unsupported = json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION + 1, + "entries": updated["entries"].clone() + }); + fs::write( + &index_path, + serde_json::to_vec(&unsupported).expect("encode unsupported Claude index"), + ) + .expect("write unsupported Claude index"); + let error = publish_claude_project_index( + &cwd, + native_id, + &[message("new-user", "user", "must not overwrite")], + None, + ) + .expect_err("unknown Claude index schemas must fail closed"); + assert!(error.contains("unsupported Claude project index schema version")); + let unchanged: Value = serde_json::from_slice( + &fs::read(&index_path).expect("read rejected Claude project index"), + ) + .expect("decode rejected Claude project index"); + assert_eq!(unchanged, unsupported); + } + + #[test] + fn claude_catalog_refresh_uses_provider_metadata_without_parsing_transcript() { + let sandbox = test_env::sandbox(); + let cwd = sandbox.path().join("claude-provider-index-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + let native_id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + let paths = claude_native_paths(Some("anthropic-provider-index"), &cwd, native_id); + assert_ne!(paths.native_path, paths.runner_path); + fs::create_dir_all( + paths + .native_path + .parent() + .expect("native transcript parent"), + ) + .expect("create native transcript parent"); + fs::create_dir_all( + paths + .runner_path + .parent() + .expect("runner transcript parent"), + ) + .expect("create runner transcript parent"); + // Deliberately not valid Claude JSONL: taking the provider metadata path + // must succeed without falling back to a transcript parse. + fs::write(&paths.native_path, b"not-json\n").expect("write native transcript fixture"); + replace_runner_link(&paths.native_path, &paths.runner_path) + .expect("link isolated runner to native transcript"); + let (file_mtime, _) = + transcript_modified_metadata(&paths.native_path).expect("read fixture mtime"); + + let native_index_path = paths + .native_path + .parent() + .expect("native project directory") + .join("sessions-index.json"); + fs::write( + &native_index_path, + serde_json::to_vec(&json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION, + "entries": [{ + "sessionId": native_id, + "messageCount": 1, + "nativeUnknown": "keep" + }] + })) + .expect("encode native index fixture"), + ) + .expect("write native index fixture"); + let runner_index_path = paths + .runner_path + .parent() + .expect("runner project directory") + .join("sessions-index.json"); + fs::write( + &runner_index_path, + serde_json::to_vec(&json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION, + "entries": [{ + "sessionId": native_id, + "fullPath": paths.runner_path, + "fileMtime": file_mtime, + "firstPrompt": "provider prompt", + "messageCount": 7, + "providerUnknown": "keep" + }] + })) + .expect("encode provider index fixture"), + ) + .expect("write provider index fixture"); + + assert!(refresh_claude_project_index_from_provider( + &cwd, + native_id, + &paths.native_path, + &paths.runner_path, + Some("feature/provider-index") + ) + .expect("refresh native catalog from provider metadata")); + let refreshed: Value = serde_json::from_slice( + &fs::read(&native_index_path).expect("read refreshed native index"), + ) + .expect("decode refreshed native index"); + let entry = &refreshed["entries"][0]; + assert_eq!(entry["messageCount"], 7); + assert_eq!(entry["fileMtime"], file_mtime); + assert_eq!(entry["fullPath"], json!(paths.native_path)); + assert_eq!(entry["workspacePath"], json!(cwd)); + assert_eq!(entry["nativeUnknown"], "keep"); + assert_eq!(entry["providerUnknown"], "keep"); + assert_eq!(entry["gitBranch"], "feature/provider-index"); + } + + #[test] + fn cold_claude_lookup_falls_back_to_the_native_app_transcript() { + let sandbox = test_env::sandbox(); + let account_id = "anthropic-cold-native-root"; + let native_id = "11111111-2222-4333-8444-555555555555"; + let cwd = sandbox.path().join("legacy-claude-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + + let paths = claude_native_paths(Some(account_id), &cwd, native_id); + assert_ne!(paths.native_path, paths.runner_path); + fs::create_dir_all(paths.native_path.parent().expect("native Claude parent")) + .expect("create legacy Claude transcript parent"); + fs::write(&paths.native_path, b"{}\n").expect("write legacy Claude transcript"); + + let resolved = existing_claude_native_paths(Some(account_id), &cwd, native_id) + .expect("cold lookup should retain an existing native-App transcript"); + assert_eq!(resolved.native_path, paths.native_path); + assert_eq!(resolved.runner_path, paths.runner_path); + } + + #[test] + fn profile_only_claude_transcript_is_promoted_without_losing_bytes() { + let sandbox = test_env::sandbox(); + let account_id = "anthropic-profile-only"; + let native_id = "22222222-3333-4444-8555-666666666666"; + let cwd = sandbox.path().join("profile-only-claude-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + let paths = claude_native_paths(Some(account_id), &cwd, native_id); + fs::create_dir_all(paths.runner_path.parent().expect("runner parent")) + .expect("create profile-only runner parent"); + let payload = serialize_jsonl( + &claude_records_with_resume_checkpoint( + native_id, + &cwd, + &[message("profile-user", "user", "preserve me")], + ) + .expect("render Claude transcript"), + ) + .expect("serialize Claude transcript"); + fs::write(&paths.runner_path, &payload).expect("write profile-only transcript"); + + assert!(ensure_durable_runner_alias(&paths, native_id) + .expect("promote profile-only transcript")); + assert_eq!( + fs::read(&paths.native_path).expect("read durable transcript"), + payload + ); + assert_eq!( + fs::read(&paths.runner_path).expect("read runner alias"), + payload + ); + assert!(paths_match(&paths.native_path, &paths.runner_path)); + } + + #[test] + fn claude_desktop_directory_scan_has_a_hard_entry_budget() { + let sandbox = test_env::sandbox(); + let root = sandbox.path().join("claude-desktop-catalog"); + fs::create_dir_all(root.join("a-directory")).expect("create first catalog directory"); + fs::create_dir_all(root.join("b-directory")).expect("create second catalog directory"); + fs::write(root.join("c-row.json"), b"{}").expect("create first catalog row"); + fs::write(root.join("d-row.json"), b"{}").expect("create second catalog row"); + + let mut budget = 2; + let paths = bounded_directory_paths(&root, &mut budget); + assert_eq!(paths.len(), 2); + assert_eq!(budget, 0); + assert!(bounded_directory_paths(&root, &mut budget).is_empty()); + } + + #[test] + fn claude_desktop_uuid_lookup_crosses_accounts_but_new_rows_use_active_account() { + let sandbox = test_env::sandbox(); + let _native_history = EnvVarGuard::set("ORGII_NATIVE_TRANSCRIPT_HOME", sandbox.path()); + let cwd = sandbox.path().join("shared-worktree"); + fs::create_dir_all(&cwd).expect("create shared Claude workspace"); + + let sessions_root = claude_desktop_sessions_root(); + let inactive_project = sessions_root + .join("11111111-1111-4111-8111-111111111111") + .join("22222222-2222-4222-8222-222222222222"); + let active_project = sessions_root + .join("33333333-3333-4333-8333-333333333333") + .join("44444444-4444-4444-8444-444444444444"); + fs::create_dir_all(&inactive_project).expect("create inactive account project"); + fs::create_dir_all(&active_project).expect("create active account project"); + fs::write( + sessions_root + .parent() + .expect("Claude data directory") + .join("config.json"), + serde_json::to_vec(&json!({ + "lastKnownAccountUuid": "33333333-3333-4333-8333-333333333333" + })) + .expect("encode active account config"), + ) + .expect("write active account config"); + + let existing_native_id = "55555555-5555-4555-8555-555555555555"; + let existing_path = inactive_project.join(format!("local_{existing_native_id}.json")); + fs::write( + &existing_path, + serde_json::to_vec(&json!({ + "sessionId": format!("local_{existing_native_id}"), + "cliSessionId": existing_native_id, + "cwd": cwd, + "createdAt": 1, + "lastActivityAt": 1 + })) + .expect("encode inactive account row"), + ) + .expect("write inactive account row"); + fs::write( + active_project.join("local-active-seed.json"), + serde_json::to_vec(&json!({ + "sessionId": "local-active-seed", + "cliSessionId": "active-seed", + "cwd": cwd, + "createdAt": 2, + "lastActivityAt": 2 + })) + .expect("encode active account seed"), + ) + .expect("write active account seed"); + + assert_eq!( + claude_desktop_session_path(&sessions_root, &cwd, existing_native_id), + Some(existing_path), + "an existing UUID must remain in its original account" + ); + + let new_native_id = "66666666-6666-4666-8666-666666666666"; + assert_eq!( + claude_desktop_session_path(&sessions_root, &cwd, new_native_id), + Some(active_project.join(format!("local_{new_native_id}.json"))), + "a new row must be placed under the active account" + ); + } + + #[test] + fn finalizer_publication_promotes_fresh_claude_session_and_catalog() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-finalizer-publish"; + let account_id = "anthropic-finalizer-publish"; + let native_id = "33333333-4444-4555-8666-777777777777"; + let cwd = sandbox.path().join("finalizer-publish-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + create_native_claude_session(session_id, account_id, &cwd); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("bind provider UUID"); + let session = persistence::get_session(session_id) + .expect("load fresh native session") + .expect("fresh native session exists"); + let canonical_cwd = execution_cwd(&session).expect("resolve provider cwd"); + let paths = claude_native_paths(Some(account_id), &canonical_cwd, native_id); + fs::create_dir_all(paths.runner_path.parent().expect("runner parent")) + .expect("create isolated runner transcript parent"); + let payload = serialize_jsonl( + &claude_records_with_resume_checkpoint( + native_id, + &cwd, + &[message("fresh-user", "user", "visible in Claude")], + ) + .expect("render Claude transcript"), + ) + .expect("serialize Claude transcript"); + fs::write(&paths.runner_path, &payload).expect("write isolated provider transcript"); + + let desktop_root = claude_desktop_sessions_root(); + let desktop_project = desktop_root + .join("11111111-1111-4111-8111-111111111111") + .join("22222222-2222-4222-8222-222222222222"); + fs::create_dir_all(&desktop_project).expect("create provider-owned Desktop project"); + fs::write( + desktop_root + .parent() + .expect("Claude data directory") + .join("config.json"), + serde_json::to_vec(&json!({ + "lastKnownAccountUuid": "11111111-1111-4111-8111-111111111111" + })) + .expect("encode Desktop config"), + ) + .expect("write Desktop config"); + fs::write( + desktop_project.join("local-existing.json"), + serde_json::to_vec(&json!({ + "sessionId": "local-existing", + "cliSessionId": "existing", + "cwd": cwd, + "createdAt": 1, + "lastActivityAt": 1 + })) + .expect("encode existing Desktop row"), + ) + .expect("seed provider-owned Desktop project row"); + + assert!(publish_bound_native_transcript(session_id).expect("publish native transcript")); + assert_eq!( + fs::read(&paths.native_path).expect("read native App transcript"), + payload + ); + assert!(paths_match(&paths.native_path, &paths.runner_path)); + let index = fs::read_to_string( + paths + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"), + ) + .expect("read Claude project index"); + assert!(index.contains(native_id)); + assert!(index.contains("visible in Claude")); + let desktop_row: Value = serde_json::from_slice( + &fs::read(desktop_project.join(format!("local_{native_id}.json"))) + .expect("read Claude Desktop discovery row"), + ) + .expect("decode Claude Desktop discovery row"); + assert_eq!(desktop_row["sessionId"], format!("local_{native_id}")); + assert_eq!(desktop_row["cliSessionId"], native_id); + assert!(paths_match( + Path::new(desktop_row["cwd"].as_str().expect("Desktop cwd")), + &cwd + )); + assert_eq!(desktop_row["completedTurns"], 0); + assert_eq!(desktop_row["orgiiMaterialization"], true); + + assert!(discard_cli_materialization(session_id, native_id) + .expect("discard materialized Claude transcript")); + assert!( + !desktop_project + .join(format!("local_{native_id}.json")) + .exists(), + "rollback must not leave a Desktop row pointing at a removed JSONL" + ); + } + + #[test] + fn explicit_native_home_survives_discovery_home_cleanup() { + let sandbox = test_env::sandbox(); + let discovery_home = sandbox.path().join("disposable-discovery-home"); + let official_home = sandbox.path().join("official-provider-home"); + let cwd = sandbox.path().join("durable-native-worktree"); + fs::create_dir_all(&discovery_home).expect("create isolated discovery home"); + fs::create_dir_all(&official_home).expect("create official provider home"); + fs::create_dir_all(&cwd).expect("create Claude workspace"); + let _external_history = EnvVarGuard::set("ORGII_EXTERNAL_HISTORY_HOME", &discovery_home); + let _native_history = EnvVarGuard::set("ORGII_NATIVE_TRANSCRIPT_HOME", &official_home); + let native_id = "34343434-5656-4787-8989-abababababab"; + let items = vec![ + message("durable-user", "user", "survive discovery cleanup"), + message("durable-assistant", "assistant", "still openable"), + ]; + let paths = claude_native_paths(None, &cwd, native_id); + let records = claude_records_with_resume_checkpoint(native_id, &cwd, &items) + .expect("render Claude transcript"); + + let desktop_root = claude_desktop_sessions_root(); + let desktop_project = desktop_root + .join("33333333-3333-4333-8333-333333333333") + .join("44444444-4444-4444-8444-444444444444"); + fs::create_dir_all(&desktop_project).expect("create official Desktop project"); + fs::write( + desktop_root + .parent() + .expect("Claude data directory") + .join("config.json"), + serde_json::to_vec(&json!({ + "lastKnownAccountUuid": "33333333-3333-4333-8333-333333333333" + })) + .expect("encode official Desktop config"), + ) + .expect("write official Desktop config"); + fs::write( + desktop_project.join("local-existing.json"), + serde_json::to_vec(&json!({ + "sessionId": "local-existing", + "cliSessionId": "existing", + "cwd": cwd, + "createdAt": 1, + "lastActivityAt": 1 + })) + .expect("encode official Desktop seed"), + ) + .expect("seed official Desktop project"); + let session_id = "cliagent-durable-native-desktop-proof"; + create_native_claude_session(session_id, "anthropic-durable-proof", &cwd); + let session = persistence::get_session(session_id) + .expect("load durable Desktop proof session") + .expect("durable Desktop proof session exists"); + + write_native_store_jsonl(&paths, &records).expect("publish official Claude transcript"); + publish_claude_project_index(&cwd, native_id, &items, Some("feature/native-proof")) + .expect("publish official Claude project index"); + publish_claude_desktop_session(&session, &cwd, native_id, &paths.native_path, &items) + .expect("publish official Claude Desktop metadata") + .expect("matching provider-owned Desktop project"); + fs::remove_dir_all(&discovery_home).expect("delete isolated discovery home"); + + assert!(paths.native_path.starts_with(&official_home)); + assert!(paths.native_path.is_file()); + let parsed = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + "claudecodeapp-durable-proof", + &paths.native_path, + ) + .expect("open published Claude transcript after discovery cleanup"); + assert!(parsed.iter().any(|chunk| { + chunk.function == "assistant" && chunk.result.to_string().contains("still openable") + })); + + let index_path = paths + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"); + let index: Value = serde_json::from_slice( + &fs::read(&index_path).expect("read durable Claude project index"), + ) + .expect("decode durable Claude project index"); + let entry = index["entries"] + .as_array() + .expect("Claude project index entries") + .iter() + .find(|entry| entry["sessionId"].as_str() == Some(native_id)) + .expect("durable Claude project index entry"); + assert_eq!(entry["fullPath"], json!(paths.native_path)); + assert!(Path::new(entry["fullPath"].as_str().expect("indexed path")).is_file()); + assert_eq!(entry["workspacePath"], json!(cwd)); + let desktop_row = desktop_project.join(format!("local_{native_id}.json")); + assert!(desktop_row.starts_with(&official_home)); + let desktop_row: Value = serde_json::from_slice( + &fs::read(desktop_row).expect("read durable Claude Desktop metadata"), + ) + .expect("decode durable Claude Desktop metadata"); + assert_eq!(desktop_row["cliSessionId"], native_id); + assert!(paths_match( + Path::new(desktop_row["cwd"].as_str().expect("Desktop cwd")), + &cwd + )); + assert_eq!(desktop_row["completedTurns"], 1); + } + + #[test] + fn finalizer_refreshes_claude_catalog_after_normal_linked_append() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-finalizer-linked-append"; + let account_id = "anthropic-finalizer-linked-append"; + let native_id = "44444444-5555-4666-8777-888888888888"; + let cwd = sandbox.path().join("finalizer-linked-append-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + create_native_claude_session(session_id, account_id, &cwd); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("bind provider UUID"); + let session = persistence::get_session(session_id) + .expect("load native session") + .expect("native session exists"); + let canonical_cwd = execution_cwd(&session).expect("resolve provider cwd"); + let paths = claude_native_paths(Some(account_id), &canonical_cwd, native_id); + fs::create_dir_all( + paths + .native_path + .parent() + .expect("native transcript parent"), + ) + .expect("create native transcript parent"); + fs::create_dir_all( + paths + .runner_path + .parent() + .expect("runner transcript parent"), + ) + .expect("create runner transcript parent"); + let payload = serialize_jsonl( + &claude_records_with_resume_checkpoint( + native_id, + &cwd, + &[message("linked-user", "user", "normal linked append")], + ) + .expect("render Claude transcript"), + ) + .expect("serialize Claude transcript"); + fs::write(&paths.native_path, &payload).expect("write native provider transcript"); + replace_runner_link(&paths.native_path, &paths.runner_path) + .expect("link isolated runner to native transcript"); + let (file_mtime, _) = + transcript_modified_metadata(&paths.native_path).expect("read transcript mtime"); + let runner_index_path = paths + .runner_path + .parent() + .expect("runner project directory") + .join("sessions-index.json"); + fs::write( + &runner_index_path, + serde_json::to_vec(&json!({ + "version": CLAUDE_PROJECT_INDEX_VERSION, + "entries": [{ + "sessionId": native_id, + "fullPath": paths.runner_path, + "fileMtime": file_mtime, + "firstPrompt": "normal linked append", + "messageCount": 9 + }] + })) + .expect("encode runner index"), + ) + .expect("write runner index"); + + // The alias already points at the durable transcript, so convergence + // performs no promotion. Claude catalog refresh must still be returned. + assert!(publish_bound_native_transcript(session_id) + .expect("publish normal linked provider append")); + let native_index_path = paths + .native_path + .parent() + .expect("native project directory") + .join("sessions-index.json"); + let index: Value = serde_json::from_slice( + &fs::read(native_index_path).expect("read refreshed native index"), + ) + .expect("decode refreshed native index"); + assert_eq!(index["entries"][0]["sessionId"], native_id); + assert_eq!(index["entries"][0]["messageCount"], 9); + assert_eq!(index["entries"][0]["fileMtime"], file_mtime); + } + + #[test] + fn divergent_native_and_runner_transcripts_fail_closed() { + let sandbox = test_env::sandbox(); + let paths = NativeTranscriptPaths { + native_path: sandbox.path().join("native.jsonl"), + runner_path: sandbox.path().join("runner.jsonl"), + }; + fs::write( + &paths.native_path, + b"{\"sessionId\":\"native-1\"}\n{\"message\":\"left\"}\n", + ) + .expect("write native transcript"); + fs::write( + &paths.runner_path, + b"{\"sessionId\":\"native-1\"}\n{\"message\":\"right\"}\n", + ) + .expect("write runner transcript"); + + let error = preferred_materialized_transcript_path(&paths) + .expect_err("two independently advanced transcripts must not be guessed by mtime"); + assert!(error.contains("both advanced")); + } + + #[test] + fn convergence_skips_a_codex_session_without_a_local_account() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-converge-codex-ambient"; + let native_id = "bbbbbbbb-1111-4222-8333-dddddddddddd"; + create_native_session(session_id, "codex", None, sandbox.path()); + persistence::update_cli_session_id_for_account(session_id, None, native_id) + .expect("bind provider UUID without a local account"); + + assert!(converge_bound_native_transcript(session_id) + .expect("a hosted-key Codex session is missing evidence, not diverged") + .is_none()); + } + + #[test] + fn convergence_skips_a_bound_session_with_no_provider_transcript() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-converge-no-transcript"; + let account_id = "anthropic-converge-no-transcript"; + let native_id = "cccccccc-1111-4222-8333-eeeeeeeeeeee"; + create_native_claude_session(session_id, account_id, sandbox.path()); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("bind provider UUID"); + + assert!(converge_bound_native_transcript(session_id) + .expect("an unwritten provider transcript must not fail the turn closed") + .is_none()); + } + + #[test] + fn convergence_still_fails_closed_on_a_divergent_bound_transcript() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-converge-divergent"; + let account_id = "anthropic-converge-divergent"; + let native_id = "dddddddd-1111-4222-8333-ffffffffffff"; + let cwd = sandbox.path().join("converge-divergent-worktree"); + fs::create_dir_all(&cwd).expect("create Claude fixture workspace"); + create_native_claude_session(session_id, account_id, &cwd); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("bind provider UUID"); + let session = persistence::get_session(session_id) + .expect("load bound native session") + .expect("bound native session exists"); + let canonical_cwd = execution_cwd(&session).expect("resolve provider cwd"); + let paths = claude_native_paths(Some(account_id), &canonical_cwd, native_id); + for path in [&paths.native_path, &paths.runner_path] { + fs::create_dir_all(path.parent().expect("transcript parent")) + .expect("create transcript parent"); + } + fs::write( + &paths.native_path, + format!("{{\"sessionId\":\"{native_id}\"}}\n{{\"message\":\"left\"}}\n"), + ) + .expect("write native App transcript"); + fs::write( + &paths.runner_path, + format!("{{\"sessionId\":\"{native_id}\"}}\n{{\"message\":\"right\"}}\n"), + ) + .expect("write isolated runner transcript"); + + let error = converge_bound_native_transcript(session_id) + .expect_err("two independently advanced copies of one UUID are a proven divergence"); + assert!(error.contains("both advanced"), "unexpected error: {error}"); + } + + #[test] + fn cold_codex_lookup_falls_back_to_the_native_app_transcript() { + let sandbox = test_env::sandbox(); + let account_id = "codex-cold-native-root"; + let native_id = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + let legacy_path = codex_native_app_sessions_root() + .join("2026") + .join("09") + .join("03") + .join(format!("rollout-2026-09-03T00-00-00-{native_id}.jsonl")); + assert!(legacy_path.starts_with(sandbox.path())); + fs::create_dir_all(legacy_path.parent().expect("legacy Codex parent")) + .expect("create legacy Codex transcript parent"); + fs::write(&legacy_path, b"{}\n").expect("write legacy Codex transcript"); + + let cache_key = (account_id.to_string(), native_id.to_string()); + CODEX_NATIVE_PATH_CACHE + .lock() + .expect("lock Codex native path cache") + .remove(&cache_key); + let resolved = existing_codex_native_paths(account_id, native_id) + .expect("scan Codex native roots") + .expect("cold lookup should retain an existing native-App rollout"); + assert_eq!(resolved.native_path, legacy_path); + assert_eq!( + resolved.runner_path, + codex_profile_sessions_root(account_id) + .join("2026") + .join("09") + .join("03") + .join(format!("rollout-2026-09-03T00-00-00-{native_id}.jsonl")) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn synchronize_materializes_unbound_then_preserves_existing_cli_transcript() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-sync-fresh"; + let account_id = "anthropic-native-sync-test"; + create_native_claude_session(session_id, account_id, sandbox.path()); + let complete_items = vec![ + message("user-1", "user", "Inspect the repository"), + NativeConversationItem::ToolCall { + id: "tool-call-1".to_string(), + call_id: "call_1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"README.md"}"#.to_string(), + created_at: "2026-09-02T00:00:01Z".to_string(), + }, + NativeConversationItem::ToolResult { + id: "tool-result-1".to_string(), + call_id: "call_1".to_string(), + name: "read_file".to_string(), + output: "repository read".to_string(), + is_error: false, + interrupted: false, + created_at: "2026-09-02T00:00:02Z".to_string(), + }, + message("assistant-1", "assistant", "Inspection complete"), + ]; + + let receipt = synchronize_native_conversation_with_owner( + None, + session_id.to_string(), + complete_items.clone(), + ) + .await + .expect("first synchronization should materialize the unbound episode"); + + assert_eq!(receipt.item_count, complete_items.len()); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read native binding") + .as_deref(), + Some(receipt.native_session_id.as_str()) + ); + let authoritative = + authoritative_native_items(session_id).expect("round-trip native transcript"); + assert_eq!(authoritative.len(), complete_items.len()); + assert!(authoritative + .iter() + .zip(&complete_items) + .all(|(native, canonical)| native_item_semantically_equal(native, canonical))); + + let session = persistence::get_session(session_id) + .expect("load materialized CLI episode") + .expect("materialized CLI episode exists"); + let cwd = execution_cwd(&session).expect("resolve materialized episode cwd"); + let paths = claude_native_paths(Some(account_id), &cwd, &receipt.native_session_id); + let transcript_before = fs::read(&paths.native_path).expect("read materialized transcript"); + assert_eq!( + fs::read(&paths.runner_path).expect("read runner transcript alias"), + transcript_before + ); + assert!(paths_match(&paths.native_path, &paths.runner_path)); + let project_index = fs::read_to_string( + paths + .native_path + .parent() + .expect("Claude project directory") + .join("sessions-index.json"), + ) + .expect("read Claude project index"); + assert!(project_index.contains(&receipt.native_session_id)); + + let second_receipt = synchronize_native_conversation_with_owner( + None, + session_id.to_string(), + complete_items.clone(), + ) + .await + .expect("an existing complete native transcript is already synchronized"); + + assert_eq!(second_receipt.native_session_id, receipt.native_session_id); + assert_eq!(second_receipt.item_count, complete_items.len()); + assert_eq!( + fs::read(&paths.native_path).expect("read synchronized transcript"), + transcript_before, + "synchronizing an existing complete transcript must not rewrite provider-native state" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn synchronize_repairs_a_bound_but_unpublished_claude_intent() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-sync-repair-intent"; + let account_id = "anthropic-native-repair-test"; + create_native_claude_session(session_id, account_id, sandbox.path()); + let abandoned_id = "aaaaaaaa-1111-4222-8333-bbbbbbbbbbbb"; + persistence::update_cli_session_id_for_account(session_id, Some(account_id), abandoned_id) + .expect("record incomplete materialization intent"); + + let complete_items = vec![message("repair-user", "user", "continue safely")]; + let receipt = synchronize_native_conversation_with_owner( + None, + session_id.to_string(), + complete_items, + ) + .await + .expect("repair incomplete intent"); + + assert_ne!(receipt.native_session_id, abandoned_id); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read repaired binding") + .as_deref(), + Some(receipt.native_session_id.as_str()) + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn synchronize_rebuilds_claude_binding_when_recorded_worktree_was_removed() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-sync-removed-worktree"; + let account_id = "anthropic-native-removed-worktree"; + let repo_path = sandbox.path().join("repo"); + let removed_worktree = sandbox.path().join("removed-worktree"); + fs::create_dir_all(&repo_path).expect("create fallback repository"); + create_native_claude_session(session_id, account_id, &repo_path); + persistence::update_worktree_info( + session_id, + removed_worktree + .to_str() + .expect("removed worktree path is utf-8"), + "agent/removed-worktree", + "develop", + ) + .expect("record removed worktree"); + + let stale_native_id = "03b9bc8b-1111-4222-8333-bbbbbbbbbbbb"; + let complete_items = vec![ + message("user-1", "user", "Inspect the repository"), + tool_call("call_1", "read_file", r#"{"path":"README.md"}"#), + tool_result("call_1", "read_file", "repository read", false, false), + message("assistant-1", "assistant", "Inspection complete"), + ]; + let stale_paths = claude_native_paths(Some(account_id), &removed_worktree, stale_native_id); + write_native_store_jsonl( + &stale_paths, + &claude_records_with_resume_checkpoint( + stale_native_id, + &removed_worktree, + &complete_items, + ) + .expect("render stale-worktree transcript"), + ) + .expect("publish stale-worktree transcript"); + persistence::update_cli_session_id_for_account( + session_id, + Some(account_id), + stale_native_id, + ) + .expect("record stale-worktree binding"); + + let receipt = synchronize_native_conversation_with_owner( + None, + session_id.to_string(), + complete_items.clone(), + ) + .await + .expect("rebuild under the runner's fallback cwd"); + + assert_ne!(receipt.native_session_id, stale_native_id); + assert_eq!(receipt.item_count, complete_items.len()); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read repaired binding") + .as_deref(), + Some(receipt.native_session_id.as_str()) + ); + let repaired_paths = claude_native_paths( + Some(account_id), + &provider_canonical_cwd(repo_path), + &receipt.native_session_id, + ); + assert!(repaired_paths.native_path.is_file()); + assert!(repaired_paths.runner_path.is_file()); + let authoritative = + authoritative_native_items(session_id).expect("round-trip rebuilt transcript"); + assert_eq!(authoritative.len(), complete_items.len()); + assert!(authoritative + .iter() + .zip(&complete_items) + .all(|(native, canonical)| native_item_semantically_equal(native, canonical))); + } + + #[tokio::test(flavor = "current_thread")] + async fn synchronize_leaves_an_empty_cli_episode_unbound() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-sync-empty"; + let account_id = "anthropic-native-sync-empty-test"; + create_native_claude_session(session_id, account_id, sandbox.path()); + + let receipt = + synchronize_native_conversation_with_owner(None, session_id.to_string(), Vec::new()) + .await + .expect("an empty canonical prefix is already synchronized"); + + assert_eq!(receipt.item_count, 0); + assert!(receipt.native_session_id.is_empty()); + assert_eq!( + persistence::get_cli_session_id_for_account(session_id, Some(account_id)) + .expect("read native binding"), + None + ); + assert!(!app_paths::claude_code_cli_profile_dir(account_id) + .join("projects") + .exists()); + } + + #[test] + fn failed_catalog_refresh_keeps_its_durable_receipt_pending() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-native-catalog-failure"; + let account_id = "anthropic-native-catalog-failure"; + let native_id = "eeeeeeee-1111-4222-8333-ffffffffffff"; + create_native_claude_session(session_id, account_id, sandbox.path()); + persistence::update_cli_session_id_for_account(session_id, Some(account_id), native_id) + .expect("publish provider UUID"); + let receipt = + persistence::request_native_catalog_refresh(session_id, Some(account_id), native_id) + .expect("request catalog refresh") + .expect("binding exists"); + let native_path = sandbox + .path() + .join("native") + .join(format!("{native_id}.jsonl")); + let runner_path = sandbox + .path() + .join("runner") + .join(format!("{native_id}.jsonl")); + fs::create_dir_all(native_path.parent().expect("native parent")) + .expect("create native parent"); + fs::create_dir_all(runner_path.parent().expect("runner parent")) + .expect("create runner parent"); + fs::write(&native_path, b"{}\n").expect("write native transcript"); + fs::write(&runner_path, b"{}\n").expect("write runner transcript"); + fs::write( + runner_path + .parent() + .expect("runner parent") + .join("sessions-index.json"), + b"not valid json", + ) + .expect("write invalid provider index"); + + let error = refresh_bound_native_catalog(BoundNativeCatalogRefresh::Claude { + receipt: receipt.clone(), + session_id: session_id.to_string(), + cwd: sandbox.path().to_path_buf(), + native_id: native_id.to_string(), + native_path, + runner_path, + branch: None, + }) + .expect_err("invalid provider index must fail refresh"); + assert!(error.contains("Claude"), "unexpected error: {error}"); + assert_eq!( + persistence::pending_native_catalog_refreshes(8).expect("load pending receipt")[0] + .receipt, + receipt, + "a failed refresh must remain durable for startup retry" + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn startup_catalog_repair_is_pending_only_and_idempotent() { + let sandbox = test_env::sandbox(); + let dirty_session_id = "cliagent-native-catalog-startup-dirty"; + let clean_session_id = "cliagent-native-catalog-startup-clean"; + let dirty_account_id = "anthropic-native-catalog-startup-dirty"; + let clean_account_id = "anthropic-native-catalog-startup-clean"; + let items = vec![message("startup-user", "user", "repair native catalog")]; + + for (session_id, account_id) in [ + (dirty_session_id, dirty_account_id), + (clean_session_id, clean_account_id), + ] { + create_native_claude_session(session_id, account_id, sandbox.path()); + synchronize_native_conversation_with_owner(None, session_id.to_string(), items.clone()) + .await + .expect("materialize startup fixture"); + } + let dirty_native_id = + persistence::get_cli_session_id_for_account(dirty_session_id, Some(dirty_account_id)) + .expect("load dirty native binding") + .expect("dirty native binding exists"); + persistence::request_native_catalog_refresh( + dirty_session_id, + Some(dirty_account_id), + &dirty_native_id, + ) + .expect("request startup repair") + .expect("dirty binding exists"); + + assert_eq!( + reconcile_pending_native_catalog_refreshes_on_startup().await, + (1, 0), + "startup visits only the dirty receipt" + ); + assert!(persistence::pending_native_catalog_refreshes(8) + .expect("load pending after repair") + .is_empty()); + assert_eq!( + reconcile_pending_native_catalog_refreshes_on_startup().await, + (0, 0), + "a completed startup repair is idempotent" + ); + } +} diff --git a/src-tauri/src/agent_sessions/cli/native_store.rs b/src-tauri/src/agent_sessions/cli/native_store.rs new file mode 100644 index 0000000000..aeb27c6915 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/native_store.rs @@ -0,0 +1,397 @@ +//! Provider-native transcript filesystem primitives. +//! +//! These operations are deliberately below the materialization coordinator: +//! they own cross-process exclusion and crash-safe replacement of a provider +//! transcript, but know nothing about canonical conversations or bindings. + +use std::fs; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; + +use uuid::Uuid; + +/// Opaque change token for one provider-native transcript file. +/// +/// This intentionally exposes no path to the frontend. Nanosecond mtime plus +/// byte length is the same filesystem identity used by imported-history scan +/// invalidation, and lets canonical readers detect an external native App +/// append even when the managed Session row itself did not change. +pub(super) fn native_transcript_revision(path: &Path) -> Result { + let (modified_at_ns, size_bytes) = + orgtrack_core::sources::imported_history::paths::file_metadata_signature( + path, + "provider-native transcript", + )?; + Ok(format!("native-file-v1:{modified_at_ns}:{size_bytes}")) +} + +pub(super) struct ClaudeTranscriptGuard { + lock_file: fs::File, +} + +impl Drop for ClaudeTranscriptGuard { + fn drop(&mut self) { + // Closing the descriptor also releases the lock; explicit unlock keeps + // lock ownership obvious to readers and is best effort during Drop. + let _ = self.lock_file.unlock(); + } +} + +/// Serialize every mutation of one Claude native UUID across ORG2 processes. +/// The adjacent lock file is stable even when the transcript inode is replaced. +pub(super) fn lock_claude_transcript(path: &Path) -> Result { + let parent = path.parent().ok_or_else(|| { + format!( + "Claude transcript has no parent directory: {}", + path.display() + ) + })?; + fs::create_dir_all(parent).map_err(|error| { + format!( + "create Claude transcript directory {}: {error}", + parent.display() + ) + })?; + + let file_name = path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("transcript"); + let lock_path = parent.join(format!(".{file_name}.orgii.lock")); + let lock_file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|error| { + format!( + "open Claude transcript lock {}: {error}", + lock_path.display() + ) + })?; + lock_file + .lock() + .map_err(|error| format!("lock Claude transcript {}: {error}", lock_path.display()))?; + Ok(ClaudeTranscriptGuard { lock_file }) +} + +#[cfg(windows)] +fn atomic_replace_file(staged: &Path, destination: &Path, label: &str) -> Result<(), String> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, + }; + + let staged_wide = staged + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let destination_wide = destination + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + unsafe { + MoveFileExW( + PCWSTR(staged_wide.as_ptr()), + PCWSTR(destination_wide.as_ptr()), + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, + ) + } + .map_err(|error| { + format!( + "commit {label} {} -> {}: {error}", + staged.display(), + destination.display() + ) + }) +} + +#[cfg(not(windows))] +fn atomic_replace_file(staged: &Path, destination: &Path, label: &str) -> Result<(), String> { + fs::rename(staged, destination).map_err(|error| { + format!( + "commit {label} {} -> {}: {error}", + staged.display(), + destination.display() + ) + }) +} + +#[cfg(unix)] +fn sync_parent(path: &Path, label: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{label} has no parent: {}", path.display()))?; + fs::File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|error| format!("sync {label} directory {}: {error}", parent.display())) +} + +#[cfg(not(unix))] +fn sync_parent(_path: &Path, _label: &str) -> Result<(), String> { + Ok(()) +} + +fn staged_path(destination: &Path, extension: &str) -> PathBuf { + destination.with_extension(format!("{extension}-{}", Uuid::new_v4().simple())) +} + +fn ensure_parent(path: &Path, label: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("{label} path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .map_err(|error| format!("create {label} directory {}: {error}", parent.display())) +} + +fn replace_with_staged_path( + destination: &Path, + staged_extension: &str, + label: &str, + sync_contents: bool, + prepare: impl FnOnce(&Path) -> Result<(), String>, +) -> Result<(), String> { + ensure_parent(destination, label)?; + let staged = staged_path(destination, staged_extension); + let result = (|| -> Result<(), String> { + prepare(&staged)?; + if sync_contents { + fs::File::open(&staged) + .and_then(|file| file.sync_all()) + .map_err(|error| format!("sync staged {label} {}: {error}", staged.display()))?; + } + atomic_replace_file(&staged, destination, label)?; + sync_parent(destination, label) + })(); + if result.is_err() { + let _ = fs::remove_file(&staged); + } + result +} + +/// Crash-safe whole-file replacement. The caller serializes content into the +/// provided staged file, while this filesystem owner handles directory setup, +/// fsync, atomic replacement, parent sync, and failed-stage cleanup. +pub(super) fn write_file_atomically( + destination: &Path, + staged_extension: &str, + label: &str, + write: impl FnOnce(&mut fs::File) -> Result<(), String>, +) -> Result<(), String> { + replace_with_staged_path(destination, staged_extension, label, true, |staged| { + let mut file = fs::File::create(staged) + .map_err(|error| format!("create staged {label} {}: {error}", staged.display()))?; + write(&mut file) + }) +} + +/// Crash-safe copy into a provider-owned destination. +pub(super) fn copy_file_atomically( + source: &Path, + destination: &Path, + label: &str, +) -> Result<(), String> { + replace_with_staged_path(destination, "jsonl.tmp", label, true, |staged| { + fs::copy(source, staged).map(|_| ()).map_err(|error| { + format!( + "copy {label} {} -> {}: {error}", + source.display(), + staged.display() + ) + }) + }) +} + +/// Atomically replace an account-profile runner alias with a link to the +/// provider-native transcript. +pub(super) fn replace_file_link_atomically( + source: &Path, + destination: &Path, + label: &str, +) -> Result<(), String> { + if source == destination { + return Ok(()); + } + replace_with_staged_path(destination, "jsonl.link", label, false, |staged| { + #[cfg(unix)] + return std::os::unix::fs::symlink(source, staged).map_err(|error| { + format!( + "link {label} {} -> {}: {error}", + staged.display(), + source.display() + ) + }); + + #[cfg(not(unix))] + fs::hard_link(source, staged).map_err(|error| { + format!( + "link {label} {} -> {}: {error}", + staged.display(), + source.display() + ) + }) + }) +} + +/// Append a serialized JSONL suffix without ever exposing a partially written +/// provider transcript. The caller must hold [`lock_claude_transcript`] from +/// inspection through this commit. +pub(super) fn append_suffix_atomically(path: &Path, suffix: &[u8]) -> Result<(), String> { + replace_with_staged_path(path, "jsonl.tmp", "Claude transcript", true, |staged| { + let mut source = fs::File::open(path) + .map_err(|error| format!("open Claude transcript {}: {error}", path.display()))?; + let source_permissions = source + .metadata() + .map_err(|error| { + format!( + "read Claude transcript metadata {}: {error}", + path.display() + ) + })? + .permissions(); + let mut output = fs::File::create(staged).map_err(|error| { + format!( + "create staged Claude transcript {}: {error}", + staged.display() + ) + })?; + fs::set_permissions(staged, source_permissions).map_err(|error| { + format!( + "preserve Claude transcript permissions on {}: {error}", + staged.display() + ) + })?; + let mut last_byte = None; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = source + .read(&mut buffer) + .map_err(|error| format!("read Claude transcript {}: {error}", path.display()))?; + if read == 0 { + break; + } + last_byte = Some(buffer[read - 1]); + output.write_all(&buffer[..read]).map_err(|error| { + format!("copy Claude transcript into {}: {error}", staged.display()) + })?; + } + if last_byte.is_some_and(|byte| byte != b'\n') { + output.write_all(b"\n").map_err(|error| { + format!( + "terminate staged Claude transcript {}: {error}", + staged.display() + ) + })?; + } + output.write_all(suffix).map_err(|error| { + format!( + "append staged Claude transcript {}: {error}", + staged.display() + ) + })?; + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const LOCK_CHILD_PATH: &str = "ORGII_CLAUDE_TRANSCRIPT_LOCK_CHILD_PATH"; + const LOCK_CHILD_READY: &str = "ORGII_CLAUDE_TRANSCRIPT_LOCK_CHILD_READY"; + + #[test] + fn native_revision_changes_when_an_external_app_appends() { + let temp = tempfile::tempdir().expect("temp native transcript root"); + let path = temp.path().join("session.jsonl"); + fs::write(&path, b"{\"type\":\"user\"}\n").expect("seed transcript"); + let before = native_transcript_revision(&path).expect("initial revision"); + + fs::OpenOptions::new() + .append(true) + .open(&path) + .expect("open transcript for append") + .write_all(b"{\"type\":\"assistant\"}\n") + .expect("append transcript"); + + let after = native_transcript_revision(&path).expect("appended revision"); + assert_ne!(before, after); + } + + #[test] + fn atomic_suffix_commit_preserves_prefix_and_repairs_missing_newline() { + let temp = tempfile::tempdir().expect("temp Claude transcript root"); + let path = temp.path().join("session.jsonl"); + fs::write(&path, br#"{"type":"user"}"#).expect("seed transcript"); + let _guard = lock_claude_transcript(&path).expect("lock transcript"); + + append_suffix_atomically(&path, b"{\"type\":\"assistant\"}\n") + .expect("append suffix atomically"); + + assert_eq!( + fs::read_to_string(&path).expect("read transcript"), + "{\"type\":\"user\"}\n{\"type\":\"assistant\"}\n" + ); + } + + #[test] + #[ignore = "launched by claude_transcript_mutation_is_locked_across_processes"] + fn claude_transcript_lock_child() { + let Some(path) = std::env::var_os(LOCK_CHILD_PATH).map(std::path::PathBuf::from) else { + return; + }; + let ready = std::path::PathBuf::from( + std::env::var_os(LOCK_CHILD_READY).expect("lock child ready marker"), + ); + fs::write(&ready, b"ready").expect("write child ready marker"); + let _guard = lock_claude_transcript(&path).expect("child lock transcript"); + append_suffix_atomically(&path, b"{\"type\":\"assistant\"}\n") + .expect("child append transcript"); + } + + #[test] + fn claude_transcript_mutation_is_locked_across_processes() { + use std::process::Command; + use std::thread; + use std::time::{Duration, Instant}; + + let temp = tempfile::tempdir().expect("temp Claude transcript root"); + let path = temp.path().join("session.jsonl"); + let ready = temp.path().join("child-ready"); + fs::write(&path, b"{\"type\":\"user\"}\n").expect("seed transcript"); + let guard = lock_claude_transcript(&path).expect("parent lock transcript"); + let mut child = Command::new(std::env::current_exe().expect("current test executable")) + .arg("claude_transcript_lock_child") + .arg("--ignored") + .env(LOCK_CHILD_PATH, &path) + .env(LOCK_CHILD_READY, &ready) + .spawn() + .expect("launch transcript lock child"); + + let deadline = Instant::now() + Duration::from_secs(5); + while !ready.exists() && Instant::now() < deadline { + thread::sleep(Duration::from_millis(10)); + } + assert!(ready.exists(), "child must reach transcript lock boundary"); + thread::sleep(Duration::from_millis(100)); + assert_eq!( + fs::read_to_string(&path).expect("read locked transcript"), + "{\"type\":\"user\"}\n" + ); + assert!( + child.try_wait().expect("inspect child").is_none(), + "child must wait for the cross-process lock" + ); + + drop(guard); + assert!(child.wait().expect("wait for child").success()); + assert_eq!( + fs::read_to_string(&path).expect("read committed transcript"), + "{\"type\":\"user\"}\n{\"type\":\"assistant\"}\n" + ); + } +} diff --git a/src-tauri/src/agent_sessions/cli/native_transcript.rs b/src-tauri/src/agent_sessions/cli/native_transcript.rs index 5104395ab1..c0dfe1caa3 100644 --- a/src-tauri/src/agent_sessions/cli/native_transcript.rs +++ b/src-tauri/src/agent_sessions/cli/native_transcript.rs @@ -94,6 +94,41 @@ pub fn native_store_key_for_managed_session( Some((binding, cli_session_id)) } +/// Resolve the provider-native transcript currently selected by this managed +/// session's account/profile. Unlike the append-only transcript ledger used +/// for history discovery and deduplication, this is the binding the next CLI +/// turn will actually resume. +pub fn current_native_store_key_for_session( + session: &super::persistence::CodeSession, +) -> Result, String> { + if session.transcript_source != TRANSCRIPT_SOURCE_NATIVE { + return Ok(None); + } + let Some(agent) = session + .cli_agent_type + .as_deref() + .and_then(ModelType::from_str) + else { + return Ok(None); + }; + let Some(binding) = native_transcript_binding(&agent) else { + return Ok(None); + }; + let account_id = session + .account_id + .as_deref() + .filter(|value| !value.trim().is_empty()); + let native_id = + super::persistence::get_cli_session_id_for_account(&session.session_id, account_id) + .map_err(|error| { + format!( + "Failed to read native transcript binding for {}: {error}", + session.session_id + ) + })?; + Ok(native_id.map(|native_id| (binding, native_id))) +} + /// Managed session id → imported-history transcript id, when the session is /// native-mode and a CLI-native id has been bound. Used by cross-provider /// projections (turn metadata, exporter) to route a managed id into the diff --git a/src-tauri/src/agent_sessions/cli/parsers/claude_code.rs b/src-tauri/src/agent_sessions/cli/parsers/claude_code.rs index 6d1cf8da93..95b31c5c1a 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/claude_code.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/claude_code.rs @@ -588,11 +588,50 @@ impl CliAgentParser for ClaudeCodeParser { .or_else(|| data.get("stopReason")) .or_else(|| data.get("subtype")) .and_then(|v| v.as_str()); + // A run that filled its context can report success with a + // structured `terminal_reason` — surface it as a failure so + // the run record classifies the overflow instead of + // treating the truncated answer as a delivered result. + let terminal_reason = data.get("terminal_reason").and_then(|v| v.as_str()); + let result_error = data + .get("result") + .and_then(|v| v.as_str()) + .filter(|text| !text.trim().is_empty()); + // Claude-compatible gateways do not all use Anthropic's + // `prompt_too_long` terminal reason. Some return a generic + // `blocking_limit` while preserving the classifiable provider + // message in `result`. Keep that specific message instead of + // replacing it with the generic terminal code so every + // runtime shares the same context-exhaustion classifier. + let context_exhausted = terminal_reason == Some("prompt_too_long") + || result_error + .is_some_and(app_utils::runtime_errors::is_context_exhausted_message); + let error_message = data + .get("error") + .and_then(|v| v.as_str()) + .map(str::to_string) + .or_else(|| { + (is_error || context_exhausted) + .then(|| { + result_error.map(|text| text.chars().take(320).collect::()) + }) + .flatten() + }) + .or_else(|| { + (is_error || context_exhausted) + .then(|| { + terminal_reason + .map(|reason| format!("{{\"terminal_reason\":\"{reason}\"}}")) + }) + .flatten() + }) + .or_else(|| is_error.then(|| stop_reason.map(str::to_string)).flatten()); let mut chunk = ActivityChunk::new(&self.session_id, "session_end", "session_end"); chunk.result = serde_json::json!({ - "success": !is_error, - "error_message": data.get("error").and_then(|v| v.as_str()), + "success": !is_error && !context_exhausted, + "error_message": error_message, "stop_reason": stop_reason, + "terminal_reason": terminal_reason, }); vec![chunk] } diff --git a/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs index b7fdf431a7..8615450d5e 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server.rs @@ -1,11 +1,9 @@ -//! Codex `app-server` JSON-RPC transport (experimental). +//! Codex `app-server` JSON-RPC transport. //! -//! Alternative to the per-turn `codex exec --json` shell-out: spawns +//! Native alternative to the per-turn `codex exec --json` shell-out: spawns //! `codex app-server` (a JSON-RPC-over-stdio server) and drives one turn per -//! managed-session message. Default OFF — enabled only when the codex CLI -//! launch profile carries `"transport": "app-server"` -//! (see `launch_profiles::uses_codex_app_server`). Shell-out stays the -//! fallback whenever the flag is absent. +//! managed-session message. It is the default Codex transport; a launch +//! profile may explicitly select `"transport": "exec"` as a recovery hatch. //! //! ## Verified protocol (codex-cli 0.143.0) //! @@ -17,16 +15,19 @@ //! Client → server requests: //! - `initialize` `{clientInfo: {name, title?, version}}` → `{userAgent, codexHome, ...}`; //! then the client sends the `initialized` notification. -//! - `thread/start` `{cwd?, model?, approvalPolicy?, sandbox?, ...}` → +//! - `thread/start` `{cwd?, model?, developerInstructions?, approvalPolicy?, sandbox?, ...}` → //! `{thread: {id, ...}, model, ...}`. `thread.id` (UUIDv7) is the rollout //! file stem suffix (`CODEX_HOME/sessions/YYYY/MM/DD/rollout--.jsonl`) //! — verified live: a non-ephemeral thread materializes the rollout on //! disk, so native-transcript replay and managed-mirror suffix dedup keep //! working unchanged. -//! - `thread/resume` `{threadId, cwd?, model?, approvalPolicy?, sandbox?}` → -//! same response shape; falls back to `thread/start` here on error. +//! - `thread/resume` `{threadId, cwd?, model?, developerInstructions?, approvalPolicy?, sandbox?}` → +//! same response shape. Resume failures are terminal: silently starting a +//! fresh thread would discard native conversation history. //! - `turn/start` `{threadId, input: [{type:"text",text} | {type:"localImage",path}]}` //! → `{turn: {id, status: "inProgress"}}`. +//! - A user-only context-overflow turn is recovered once with native +//! `thread/rollback` → `thread/compact/start` → the same `turn/start`. //! - `turn/interrupt` `{threadId, turnId}` → `{}`. //! //! Server → client notifications (subset we map): @@ -54,11 +55,14 @@ //! profile's permission mode and surfaced as `approval_response` chunks. use std::collections::HashMap; +use std::path::Path; +use std::process::Stdio; use std::sync::{LazyLock, Mutex as StdMutex}; +use std::time::Duration; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{ChildStdin, ChildStdout}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; use tokio::sync::mpsc; use core_types::activity::ActivityChunk; @@ -68,10 +72,21 @@ use super::normalizer::{normalize_tool_name, unwrap_codex_command}; use super::types::{CliAgentType, TokenUsage}; use crate::agent_sessions::cli::session_runner::launch_profiles::CliPermissionMode; +mod catalog; +pub(crate) use catalog::{ + archive_thread, native_codex_app_server_command, register_thread, synchronize_thread, +}; + /// How long to keep draining after `turn/interrupt` before giving up on a /// graceful `turn/completed`. const INTERRUPT_DRAIN_SECS: u64 = 10; +/// Keep provider-native context recovery bounded. Real large transcripts can +/// take well over a minute to compact even after the provider has accepted the +/// request, so this budget must not race Codex's own successful compactor. The +/// owning conversation turn still has its stricter end-to-end deadline. +const CONTEXT_RECOVERY_TIMEOUT_SECS: u64 = 180; + // ============================================ // Interrupt registry (session_id → signal) // ============================================ @@ -124,13 +139,21 @@ fn interrupt_registered(session_id: &str) -> bool { .contains_key(session_id) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GracefulInterruptOutcome { + NotRunning, + Completed, + TimedOut, +} + /// Ask a running app-server turn to interrupt gracefully and wait (bounded) /// for it to finish so codex can finalize the rollout before the caller -/// kills the process tree. No-op (returns false immediately) when the -/// session has no registered app-server turn. -pub async fn interrupt_session_gracefully(session_id: &str) -> bool { +/// kills the process tree. A timeout is deliberately distinct from success: +/// the runner JSONL may be syntactically valid while its current turn is only +/// partially flushed, so callers must not publish it over the native App copy. +pub async fn interrupt_session_gracefully(session_id: &str) -> GracefulInterruptOutcome { let Some(tx) = interrupt_sender(session_id) else { - return false; + return GracefulInterruptOutcome::NotRunning; }; if tx.try_send(()).is_err() { // Full (already signalled) or closed — either way just wait below. @@ -139,10 +162,13 @@ pub async fn interrupt_session_gracefully(session_id: &str) -> bool { session_id ); } - let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(2); + // The transport itself drains for INTERRUPT_DRAIN_SECS. Give its task one + // extra second to unregister after receiving turn/completed. + let deadline = + tokio::time::Instant::now() + tokio::time::Duration::from_secs(INTERRUPT_DRAIN_SECS + 1); while tokio::time::Instant::now() < deadline { if !interrupt_registered(session_id) { - return true; + return GracefulInterruptOutcome::Completed; } tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } @@ -150,7 +176,7 @@ pub async fn interrupt_session_gracefully(session_id: &str) -> bool { "[CodexAppServer] Graceful interrupt window elapsed for {}; caller will kill", session_id ); - true + GracefulInterruptOutcome::TimedOut } // ============================================ @@ -160,14 +186,22 @@ pub async fn interrupt_session_gracefully(session_id: &str) -> bool { /// Per-turn configuration for the app-server transport. pub struct CodexAppServerTurn { pub session_id: String, - pub task: String, + /// Literal user-authored text rendered in the native Codex transcript. + pub user_input: String, + /// ORGII execution/workspace/IDE context carried on Codex's native + /// developer channel. Never copied into `turn/start.input`. + pub developer_instructions: Option, pub working_dir: String, /// Stored codex thread id to resume; `None` starts a fresh thread. pub resume_thread_id: Option, /// Base model name for `thread/start` (already variant-mapped). pub model: Option, pub permission_mode: CliPermissionMode, + /// Secret-bearing MCP/session overrides sent only over JSON-RPC. + pub config: Option, pub image_paths: Vec, + /// Enabled only on a fresh episode rebuilt from canonical SessionEvents. + pub allow_native_context_recovery: bool, } /// Result of a completed app-server turn. @@ -193,6 +227,56 @@ pub(crate) fn thread_permission_params(mode: CliPermissionMode) -> (&'static str } } +/// Build the strict fresh/resume request for one app-server launch. +/// +/// `developerInstructions` is deliberately separate from `baseInstructions`: +/// Codex appends/overrides the caller-owned developer layer while retaining +/// its provider base prompt. The complete current context is sent on every +/// launch, including resume, so a per-launch replacement cannot drop prior +/// ORGII workspace instructions. +pub(crate) fn build_thread_launch_request(turn: &CodexAppServerTurn) -> (&'static str, Value) { + let (approval_policy, sandbox) = thread_permission_params(turn.permission_mode); + let mut params = serde_json::json!({ + "cwd": &turn.working_dir, + "approvalPolicy": approval_policy, + "sandbox": sandbox, + }); + if let Some(ref model) = turn.model { + params["model"] = Value::String(model.clone()); + } + if let Some(ref config) = turn.config { + params["config"] = config.clone(); + } + if let Some(instructions) = turn + .developer_instructions + .as_deref() + .filter(|instructions| !instructions.trim().is_empty()) + { + params["developerInstructions"] = Value::String(instructions.to_string()); + } + if let Some(ref resume_id) = turn.resume_thread_id { + params["threadId"] = Value::String(resume_id.clone()); + ("thread/resume", params) + } else { + ("thread/start", params) + } +} + +/// Build only the native user turn items. Provider context belongs on the +/// thread's developer channel and must never become a `userMessage` item. +pub(crate) fn build_turn_input(turn: &CodexAppServerTurn) -> Vec { + let mut input = vec![serde_json::json!({ + "type": "text", + "text": &turn.user_input, + })]; + input.extend( + turn.image_paths + .iter() + .map(|path| serde_json::json!({"type": "localImage", "path": path})), + ); + input +} + /// Whether an approval request is auto-accepted for this permission mode. /// Only FullPermission auto-accepts (mirroring exec's bypass flag). Manual /// and Plan follow codex default-deny semantics — the denial is surfaced as @@ -224,6 +308,10 @@ pub(crate) struct CodexAppServerEventParser { /// `turn/completed` that reports failure without an error body leaves the /// turn with no message at all, and this is the only thing left to say. last_retry_notice: Option, + /// `thread/rollback` removes history, not filesystem changes. Automatic + /// replay is therefore allowed only before output or tools have started. + replay_unsafe_output_seen: bool, + compaction_marker_emitted: bool, } impl CodexAppServerEventParser { @@ -239,6 +327,8 @@ impl CodexAppServerEventParser { error_deduper: super::BoundedCliErrorDeduper::default(), pending_error_message: None, last_retry_notice: None, + replay_unsafe_output_seen: false, + compaction_marker_emitted: false, } } @@ -262,6 +352,59 @@ impl CodexAppServerEventParser { self.turn_error.as_deref() } + fn completed_turn_error<'a>(&'a self, params: &'a Value) -> Option<&'a str> { + params + .get("turn") + .and_then(|turn| turn.get("error")) + .and_then(|error| error.get("message")) + .and_then(Value::as_str) + .or(self.pending_error_message.as_deref()) + .or(self.last_retry_notice.as_deref()) + } + + fn should_recover_context_exhaustion(&self, params: &Value) -> bool { + params + .get("turn") + .and_then(|turn| turn.get("status")) + .and_then(Value::as_str) + == Some("failed") + // A provider-observed compaction already advanced this logical + // turn's native history. Never issue ORG2's recovery compact on + // the same turn as well: that would roll twice and can discard + // the first compacted episode's resume boundary. + && !self.compaction_marker_emitted + && !self.replay_unsafe_output_seen + && self + .completed_turn_error(params) + .is_some_and(app_utils::runtime_errors::is_context_exhausted_message) + } + + fn reset_turn_state(&mut self) { + self.turn_id = None; + self.usage = None; + self.turn_status = None; + self.turn_error = None; + self.pending_error_message = None; + self.last_retry_notice = None; + self.error_deduper = super::BoundedCliErrorDeduper::default(); + self.replay_unsafe_output_seen = false; + } + + fn native_compaction_marker(&mut self) -> Vec { + if self.compaction_marker_emitted { + return vec![]; + } + self.compaction_marker_emitted = true; + let mut chunk = + ActivityChunk::new(&self.session_id, "context_compacted", "context_compacted"); + chunk.result = serde_json::json!({ + "success": true, + "native": true, + "provider": "codex", + }); + vec![chunk] + } + /// Record the thread id from a `thread/start` / `thread/resume` response /// and emit the `session_start` chunk (carrying `thread_id` so the /// runner can early-bind the rollout-compatible id). @@ -276,6 +419,32 @@ impl CodexAppServerEventParser { self.emit_session_start() } + /// Publish a provider-native UUID rollover immediately. + /// + /// A context-recovery fork happens inside one app-server transport turn, + /// after the ordinary `session_start` was already emitted. Waiting for + /// finalization to persist the fork id leaves a short but real window in + /// which an immediate follow-up resumes the overflowing source UUID and + /// compacts again. A lifecycle-only session_start chunk reuses the normal + /// CLI binding channel without adding a chat-visible transcript row. + fn on_thread_rebound(&mut self, result: &Value) -> Vec { + let tid = result + .get("thread") + .and_then(|thread| thread.get("id")) + .and_then(Value::as_str); + let Some(tid) = tid else { + return vec![]; + }; + self.thread_id = Some(tid.to_string()); + let mut chunk = ActivityChunk::new(&self.session_id, "session_start", "session_start"); + chunk.result = serde_json::json!({ + "success": true, + "native_rollover": true, + }); + chunk.thread_id = Some(tid.to_string()); + vec![chunk] + } + fn emit_session_start(&mut self) -> Vec { if self.session_start_emitted { return vec![]; @@ -315,6 +484,7 @@ impl CodexAppServerEventParser { "item/started" => self.parse_item(params, false), "item/completed" => self.parse_item(params, true), "item/agentMessage/delta" => { + self.replay_unsafe_output_seen = true; let text = params.get("delta").and_then(|v| v.as_str()).unwrap_or(""); if text.is_empty() { return vec![]; @@ -327,6 +497,7 @@ impl CodexAppServerEventParser { vec![chunk] } "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" => { + self.replay_unsafe_output_seen = true; let text = params.get("delta").and_then(|v| v.as_str()).unwrap_or(""); if text.is_empty() { return vec![]; @@ -340,6 +511,7 @@ impl CodexAppServerEventParser { vec![chunk] } "turn/plan/updated" => { + self.replay_unsafe_output_seen = true; let todos: Vec = params .get("plan") .and_then(|v| v.as_array()) @@ -394,6 +566,7 @@ impl CodexAppServerEventParser { } vec![] } + "thread/compacted" => self.native_compaction_marker(), "turn/completed" => { let status = params .get("turn") @@ -496,6 +669,10 @@ impl CodexAppServerEventParser { .and_then(|v| v.as_str()) .filter(|id| !id.is_empty()); + if !matches!(v2_type, "userMessage" | "hookPrompt" | "contextCompaction") { + self.replay_unsafe_output_seen = true; + } + match item_type { // The runner already emits the user bubble; codex echoes it back. "userMessage" | "hookPrompt" => vec![], @@ -624,6 +801,12 @@ impl CodexAppServerEventParser { Self::stamp_tool_call_identity(&mut chunk, call_id); vec![chunk] } + "contextCompaction" => { + if !completed { + return vec![]; + } + self.native_compaction_marker() + } other => { tracing::debug!("[CodexAppServer] Ignoring item type: {}", other); vec![] @@ -718,6 +901,117 @@ async fn read_message( } } +/// Reusable app-server RPC owner for non-turn operations such as native +/// thread registration. It shares the exact JSON-RPC codec used by managed +/// turns; callers no longer spawn a second blocking protocol client. +pub(crate) struct CodexAppServerRpcClient { + _child: Child, + stdin: ChildStdin, + reader: BufReader, + buffer: String, + next_id: u64, +} + +impl CodexAppServerRpcClient { + pub(crate) async fn launch( + command_path: &Path, + codex_home: &Path, + cwd: &Path, + ) -> Result { + std::fs::create_dir_all(codex_home).map_err(|error| { + format!( + "create Codex native profile {}: {error}", + codex_home.display() + ) + })?; + // This client publishes the *Codex App* catalog. It is intentionally + // independent from the CLI runner launch profile: that profile may + // select an older shell-installed binary or inject a session-scoped + // provider environment that the native App cannot read later. + let mut command = Command::new(command_path); + command + .arg("app-server") + .env("CODEX_HOME", codex_home) + .current_dir(cwd) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = command.spawn().map_err(|error| { + format!( + "start Codex app-server {} for native profile {}: {error}", + command_path.display(), + codex_home.display() + ) + })?; + let stdin = child + .stdin + .take() + .ok_or_else(|| "Codex app-server stdin was not piped".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "Codex app-server stdout was not piped".to_string())?; + let mut client = Self { + _child: child, + stdin, + reader: BufReader::new(stdout), + buffer: String::new(), + next_id: 0, + }; + client + .request( + "initialize", + serde_json::json!({ + "clientInfo": { + "name": "orgii", + "title": "ORGII", + "version": env!("CARGO_PKG_VERSION") + }, + "capabilities": {"experimentalApi": true} + }), + Duration::from_secs(20), + ) + .await?; + client.notify("initialized").await?; + Ok(client) + } + + pub(crate) async fn notify(&mut self, method: &str) -> Result<(), String> { + rpc_notify(&mut self.stdin, method).await + } + + pub(crate) async fn request( + &mut self, + method: &str, + params: Value, + timeout: Duration, + ) -> Result { + self.next_id += 1; + let request_id = self.next_id; + rpc_send(&mut self.stdin, request_id, method, params).await?; + tokio::time::timeout(timeout, async { + loop { + let response = read_message(&mut self.reader, &mut self.buffer).await?; + if response.get("id").and_then(Value::as_u64) != Some(request_id) + || response.get("method").is_some() + { + continue; + } + if let Some(error) = response.get("error") { + return Err(format!("Codex app-server {method} failed: {error}")); + } + return response + .get("result") + .cloned() + .ok_or_else(|| format!("Codex app-server {method} returned no result")); + } + }) + .await + .map_err(|_| format!("Codex app-server {method} reached its request deadline"))? + } +} + /// Await the response for `request_id`, feeding any interleaved /// notifications / server requests through the parser. async fn await_response( @@ -833,6 +1127,159 @@ async fn emit_approval_chunk( let _ = chunk_tx.send(chunk).await; } +struct ContextRecovery<'a> { + stdin: &'a mut ChildStdin, + reader: &'a mut BufReader, + buf: &'a mut String, + request_id: &'a mut u64, + parser: &'a mut CodexAppServerEventParser, + chunk_tx: &'a mpsc::Sender, + mode: CliPermissionMode, +} + +impl ContextRecovery<'_> { + async fn rollback_failed_turn(&mut self, thread_id: &str) -> Result<(), String> { + *self.request_id += 1; + rpc_send( + self.stdin, + *self.request_id, + "thread/rollback", + serde_json::json!({"threadId": thread_id, "numTurns": 1}), + ) + .await?; + match await_response( + self.reader, + self.stdin, + self.buf, + *self.request_id, + self.parser, + self.chunk_tx, + self.mode, + ) + .await? + { + Ok(_) => Ok(()), + Err(error) => Err(format!("app-server thread/rollback error: {error}")), + } + } + + /// Run Codex's provider-native compactor and drain its internal turn + /// without exposing that turn as the user's terminal `session_end`. + async fn compact_native_thread(&mut self, thread_id: &str) -> Result<(), String> { + *self.request_id += 1; + let compact_request_id = *self.request_id; + rpc_send( + self.stdin, + compact_request_id, + "thread/compact/start", + serde_json::json!({"threadId": thread_id}), + ) + .await?; + + let mut response_received = false; + let mut turn_completed = false; + while !response_received || !turn_completed { + let message = read_message(self.reader, self.buf).await?; + if message.get("id").and_then(Value::as_u64) == Some(compact_request_id) + && message.get("method").is_none() + { + if let Some(error) = message.get("error") { + return Err(format!("app-server thread/compact/start error: {error}")); + } + response_received = true; + continue; + } + + if message.get("method").and_then(Value::as_str) == Some("turn/completed") { + let params = message.get("params").cloned().unwrap_or(Value::Null); + // Record the compactor's terminal state, but suppress its + // session_end: the original user turn is still running. + let _ = self.parser.handle_notification("turn/completed", ¶ms); + if self.parser.turn_status() != Some("completed") { + return Err(format!( + "Codex native compaction ended with status {}: {}", + self.parser.turn_status().unwrap_or("unknown"), + self.parser.turn_error().unwrap_or("no error details") + )); + } + turn_completed = true; + continue; + } + + dispatch_server_message(&message, self.stdin, self.parser, self.chunk_tx, self.mode) + .await; + } + Ok(()) + } + + /// Fork the compacted provider thread before replaying the user's turn. + /// + /// Codex keeps cumulative window accounting on the source UUID. Resuming + /// that UUID after a successful compact can therefore auto-compact again + /// at the beginning of every later turn even though the replacement + /// history is small. `thread/fork` is Codex's native rollover primitive: + /// it carries the structured compacted history (including encrypted + /// provider state) into a fresh UUID without rendering it into a prompt. + async fn fork_compacted_thread(&mut self, thread_id: &str) -> Result { + *self.request_id += 1; + rpc_send( + self.stdin, + *self.request_id, + "thread/fork", + serde_json::json!({"threadId": thread_id}), + ) + .await?; + let result = match await_response( + self.reader, + self.stdin, + self.buf, + *self.request_id, + self.parser, + self.chunk_tx, + self.mode, + ) + .await? + { + Ok(result) => result, + Err(error) => return Err(format!("app-server thread/fork error: {error}")), + }; + for chunk in self.parser.on_thread_rebound(&result) { + let _ = self.chunk_tx.send(chunk).await; + } + self.parser + .thread_id() + .filter(|forked| *forked != thread_id) + .map(str::to_string) + .ok_or_else(|| "app-server thread/fork returned no fresh thread id".to_string()) + } + + async fn run(&mut self, thread_id: &str) -> Result { + self.rollback_failed_turn(thread_id).await?; + self.parser.reset_turn_state(); + self.compact_native_thread(thread_id).await?; + self.parser.reset_turn_state(); + self.fork_compacted_thread(thread_id).await + } +} + +async fn start_turn( + stdin: &mut ChildStdin, + request_id: &mut u64, + thread_id: &str, + input: &[Value], +) -> Result { + *request_id += 1; + let turn_request_id = *request_id; + rpc_send( + stdin, + turn_request_id, + "turn/start", + serde_json::json!({"threadId": thread_id, "input": input}), + ) + .await?; + Ok(turn_request_id) +} + // ============================================ // Protocol flow // ============================================ @@ -895,69 +1342,29 @@ pub async fn run_app_server_turn( } rpc_notify(&mut stdin, "initialized").await?; - // ── Step 2: thread/resume (with fallback) or thread/start ── - let (approval_policy, sandbox) = thread_permission_params(mode); - let mut thread_params = serde_json::json!({ - "cwd": &turn.working_dir, - "approvalPolicy": approval_policy, - "sandbox": sandbox, - }); - if let Some(ref model) = turn.model { - thread_params["model"] = Value::String(model.clone()); - } - - let mut thread_result: Option = None; - if let Some(ref resume_id) = turn.resume_thread_id { - let mut resume_params = thread_params.clone(); - resume_params["threadId"] = Value::String(resume_id.clone()); - request_id += 1; - rpc_send(&mut stdin, request_id, "thread/resume", resume_params).await?; - match await_response( - &mut reader, - &mut stdin, - &mut buf, - request_id, - &mut parser, - &chunk_tx, - mode, - ) - .await? - { - Ok(result) => thread_result = Some(result), - Err(err) => { - tracing::warn!( - "[CodexAppServer] thread/resume failed ({}); starting fresh thread", - err - ); - } - } - } - let thread_result = match thread_result { - Some(result) => result, - None => { - request_id += 1; - rpc_send(&mut stdin, request_id, "thread/start", thread_params).await?; - match await_response( - &mut reader, - &mut stdin, - &mut buf, - request_id, - &mut parser, - &chunk_tx, - mode, - ) - .await? - { - Ok(result) => result, - Err(err) => return Err(format!("app-server thread/start error: {}", err)), - } - } + // ── Step 2: strict thread/resume or explicit fresh thread/start ── + let (thread_method, thread_params) = build_thread_launch_request(&turn); + request_id += 1; + rpc_send(&mut stdin, request_id, thread_method, thread_params).await?; + let thread_result = match await_response( + &mut reader, + &mut stdin, + &mut buf, + request_id, + &mut parser, + &chunk_tx, + mode, + ) + .await? + { + Ok(result) => result, + Err(err) => return Err(format!("app-server {thread_method} error: {err}")), }; for chunk in parser.on_thread_response(&thread_result) { let _ = chunk_tx.send(chunk).await; } - let thread_id = parser + let mut thread_id = parser .thread_id() .ok_or_else(|| "app-server: thread response carried no thread id".to_string())? .to_string(); @@ -973,24 +1380,14 @@ pub async fn run_app_server_turn( ); // ── Step 3: turn/start ── - let mut input: Vec = vec![serde_json::json!({"type": "text", "text": &turn.task})]; - for path in &turn.image_paths { - input.push(serde_json::json!({"type": "localImage", "path": path})); - } - request_id += 1; - let turn_req_id = request_id; - rpc_send( - &mut stdin, - turn_req_id, - "turn/start", - serde_json::json!({"threadId": &thread_id, "input": input}), - ) - .await?; + let input = build_turn_input(&turn); + let mut turn_req_id = start_turn(&mut stdin, &mut request_id, &thread_id, &input).await?; // ── Step 4: notification loop until turn/completed ── let mut turn_started = false; let mut interrupt_sent = false; let mut interrupt_deadline: Option = None; + let mut context_recovery_attempted = false; loop { // After turn/interrupt is sent, drain with a bounded deadline so a @@ -1048,7 +1445,77 @@ pub async fn run_app_server_turn( continue; } - dispatch_server_message(&msg, &mut stdin, &mut parser, &chunk_tx, mode).await; + if msg.get("method").and_then(Value::as_str) == Some("turn/completed") { + let params = msg.get("params").cloned().unwrap_or(Value::Null); + let original_terminal_error = parser.completed_turn_error(¶ms).map(str::to_string); + let should_recover = turn.allow_native_context_recovery + && !context_recovery_attempted + && parser.should_recover_context_exhaustion(¶ms); + if should_recover { + context_recovery_attempted = true; + tracing::info!( + thread_id, + "Codex context exhausted before output; applying native compaction" + ); + let recovery = { + let mut recovery = ContextRecovery { + stdin: &mut stdin, + reader: &mut reader, + buf: &mut buf, + request_id: &mut request_id, + parser: &mut parser, + chunk_tx: &chunk_tx, + mode, + }; + tokio::time::timeout( + tokio::time::Duration::from_secs(CONTEXT_RECOVERY_TIMEOUT_SECS), + recovery.run(&thread_id), + ) + .await + }; + match recovery { + Ok(Ok(forked_thread_id)) => { + thread_id = forked_thread_id; + turn_req_id = + start_turn(&mut stdin, &mut request_id, &thread_id, &input).await?; + turn_started = false; + interrupt_deadline = None; + tracing::info!( + thread_id, + "Codex native compaction rolled to a fresh thread; retrying original turn" + ); + continue; + } + Ok(Err(error)) => tracing::warn!( + thread_id, + error = %error, + "Codex native context recovery failed" + ), + Err(_) => tracing::warn!( + thread_id, + timeout_secs = CONTEXT_RECOVERY_TIMEOUT_SECS, + "Codex native context recovery timed out" + ), + } + // Recovery maintenance turns reset parser-local state. If the + // authoritative failed completion carried its error through a + // preceding `error` notification rather than `turn.error`, + // restore it before parsing that original terminal event. + parser.pending_error_message = original_terminal_error; + } + // Successful recovery deliberately suppresses the overflowing + // attempt's terminal event. If rollback/compact/fork fails, parse + // the original authoritative completion only now. Recovery resets + // parser turn state while driving its maintenance turns; parsing + // up front used to lose the failed status and leave this loop + // waiting forever after a maintenance error. + let terminal_chunks = parser.handle_notification("turn/completed", ¶ms); + for chunk in terminal_chunks { + let _ = chunk_tx.send(chunk).await; + } + } else { + dispatch_server_message(&msg, &mut stdin, &mut parser, &chunk_tx, mode).await; + } if parser.turn_status().is_some() { break; diff --git a/src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs new file mode 100644 index 0000000000..17e086b163 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/parsers/codex_app_server/catalog.rs @@ -0,0 +1,581 @@ +//! Supported Codex app-server registration for provider-native continuations. +//! +//! A rollout file alone is not a Codex App conversation: the App reads its +//! catalog through the app-server, and intentionally hides catalog rows that +//! have never acquired a user turn. This module owns the supported JSON-RPC +//! path used to create/resume the real profile and to inject canonical raw +//! response items. It never reads or writes Codex's private SQLite state. + +use std::collections::{HashMap, HashSet}; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::{json, Value}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const CODEX_NATIVE_MODEL_PROVIDER: &str = "openai"; + +pub(crate) fn native_codex_app_server_command() -> PathBuf { + let mut preferred = Vec::new(); + if let Some(explicit) = std::env::var_os("ORGII_NATIVE_CODEX_APP_BINARY") { + preferred.push(PathBuf::from(explicit)); + } + + #[cfg(target_os = "macos")] + { + preferred.extend([ + PathBuf::from("/Applications/ChatGPT.app/Contents/Resources/codex"), + PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), + ]); + if let Some(home) = dirs::home_dir() { + preferred.push(home.join("Applications/ChatGPT.app/Contents/Resources/codex")); + preferred.push(home.join("Applications/Codex.app/Contents/Resources/codex")); + } + } + + // A machine without the desktop App can still use a compatible Codex CLI + // native store. Keep that fallback explicit and free of runner profile + // env/argument overrides. + PathBuf::from( + integrations::cli_binary_resolver::resolve_cli_binary_command_preferring( + integrations::cli_binary_resolver::CliBinaryId::Codex, + preferred, + ), + ) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CodexCatalogEntry { + pub id: String, + pub path: PathBuf, + pub title: String, + pub cwd: PathBuf, + pub model_provider: String, +} +fn with_rpc( + codex_home: &Path, + cwd: &Path, + operation: impl FnOnce( + &tokio::runtime::Runtime, + &mut super::CodexAppServerRpcClient, + ) -> Result, +) -> Result { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("create Codex app-server runtime: {error}"))?; + let command = native_codex_app_server_command(); + let mut client = runtime.block_on(super::CodexAppServerRpcClient::launch( + &command, codex_home, cwd, + ))?; + operation(&runtime, &mut client) +} + +fn request( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + method: &str, + params: Value, +) -> Result { + runtime.block_on(client.request(method, params, REQUEST_TIMEOUT)) +} +fn entry_from_thread(thread: &Value) -> Result { + let id = thread["id"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Codex app-server thread has no id".to_string())?; + let path = thread["path"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Codex app-server thread {id} has no rollout path"))?; + let title = thread["name"] + .as_str() + .or_else(|| thread["title"].as_str()) + .unwrap_or_default(); + let cwd = thread["cwd"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Codex app-server thread {id} has no cwd"))?; + let model_provider = thread["modelProvider"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("Codex app-server thread {id} has no model provider"))?; + Ok(CodexCatalogEntry { + id: id.to_string(), + path: PathBuf::from(path), + title: title.to_string(), + cwd: PathBuf::from(cwd), + model_provider: model_provider.to_string(), + }) +} + +fn effective_model_provider( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + cwd: &Path, +) -> Result { + let result = request( + runtime, + client, + "config/read", + json!({"cwd": cwd, "includeLayers": false}), + )?; + Ok(allowlisted_native_model_provider(&result)) +} + +fn allowlisted_native_model_provider(config_result: &Value) -> String { + let configured = config_result["config"]["model_provider"] + .as_str() + .filter(|value| !value.is_empty()) + // `openai` is Codex's built-in provider when config.toml omits an + // explicit provider. Keep that default local to the native profile; + // never borrow the ORGII runner profile's custom provider here. + .unwrap_or(CODEX_NATIVE_MODEL_PROVIDER); + if configured != CODEX_NATIVE_MODEL_PROVIDER { + tracing::warn!( + configured_provider = configured, + native_provider = CODEX_NATIVE_MODEL_PROVIDER, + "ignoring non-native Codex runner provider while publishing App catalog" + ); + } + // Native App artifacts must only reference providers the real Codex home + // can always resolve. Session-scoped ORGII compatible providers belong to + // the isolated runner profile and must never leak into this catalog. + CODEX_NATIVE_MODEL_PROVIDER.to_string() +} + +fn validate_target_profile( + entry: CodexCatalogEntry, + expected_id: &str, + expected_cwd: &Path, + expected_title: &str, + expected_provider: &str, +) -> Result { + if entry.id != expected_id + || !paths_have_same_identity(&entry.cwd, expected_cwd) + || entry.title != expected_title + || entry.model_provider != expected_provider + { + return Err(format!( + "Codex native profile mismatch: expected id={expected_id} cwd={} title={expected_title:?} provider={expected_provider:?}, got id={} cwd={} title={:?} provider={:?}", + expected_cwd.display(), + entry.id, + entry.cwd.display(), + entry.title, + entry.model_provider + )); + } + Ok(entry) +} + +fn paths_have_same_identity(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + match (left.canonicalize(), right.canonicalize()) { + (Ok(left), Ok(right)) => left == right, + _ => false, + } +} + +fn read_thread( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + thread_id: &str, +) -> Result { + let result = request( + runtime, + client, + "thread/read", + // Catalog validation only needs id/path/name/cwd/provider metadata. + // Loading every turn here makes a runtime switch O(full transcript) + // for exactly the large conversations this adapter must support. + json!({"threadId": thread_id, "includeTurns": false}), + )?; + entry_from_thread(&result["thread"]) +} + +fn set_thread_name( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + thread_id: &str, + title: &str, +) -> Result<(), String> { + request( + runtime, + client, + "thread/name/set", + json!({"threadId": thread_id, "name": title}), + )?; + Ok(()) +} + +fn inject_items( + runtime: &tokio::runtime::Runtime, + client: &mut super::CodexAppServerRpcClient, + thread_id: &str, + items: &[Value], +) -> Result<(), String> { + if items.is_empty() { + return Ok(()); + } + request( + runtime, + client, + "thread/inject_items", + json!({"threadId": thread_id, "items": items}), + )?; + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SuffixApplication { + Missing, + AlreadyApplied, +} + +fn response_item_identity(item: &Value) -> Option { + let item_type = item["type"].as_str()?; + match item_type { + "message" => item["id"] + .as_str() + .filter(|value| !value.is_empty()) + .map(|id| format!("{item_type}:{id}")), + "function_call" | "function_call_output" => item["call_id"] + .as_str() + .filter(|value| !value.is_empty()) + .map(|call_id| format!("{item_type}:{call_id}")), + _ => None, + } +} + +fn inspect_suffix_application( + path: &Path, + expected_items: &[Value], +) -> Result { + if expected_items.is_empty() { + return Ok(SuffixApplication::AlreadyApplied); + } + let mut expected = HashMap::with_capacity(expected_items.len()); + for item in expected_items { + let identity = response_item_identity(item).ok_or_else(|| { + format!( + "Codex native suffix item has no stable identity: type={:?}", + item["type"].as_str() + ) + })?; + if expected.insert(identity.clone(), item.clone()).is_some() { + return Err(format!( + "Codex native suffix contains duplicate stable identity {identity}" + )); + } + } + + let file = std::fs::File::open(path) + .map_err(|error| format!("open Codex rollout {}: {error}", path.display()))?; + let mut found = HashSet::with_capacity(expected.len()); + for (line_index, line) in BufReader::new(file).lines().enumerate() { + let line = line.map_err(|error| { + format!( + "read Codex rollout {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if line.trim().is_empty() { + continue; + } + let record = serde_json::from_str::(&line).map_err(|error| { + format!( + "decode Codex rollout {} line {}: {error}", + path.display(), + line_index + 1 + ) + })?; + if record["type"] != "response_item" { + continue; + } + let Some(identity) = response_item_identity(&record["payload"]) else { + continue; + }; + if let Some(expected_item) = expected.get(&identity) { + if &record["payload"] != expected_item { + return Err(format!( + "Codex rollout {} contains stable suffix identity {identity} with conflicting content", + path.display() + )); + } + if !found.insert(identity.clone()) { + return Err(format!( + "Codex rollout {} contains duplicate stable suffix identity {identity}", + path.display() + )); + } + } + } + + if found.is_empty() { + Ok(SuffixApplication::Missing) + } else if found.len() == expected.len() { + Ok(SuffixApplication::AlreadyApplied) + } else { + Err(format!( + "Codex rollout {} contains {} of {} stable suffix items; refusing a mixed retry", + path.display(), + found.len(), + expected.len() + )) + } +} + +pub(crate) fn register_thread( + codex_home: &Path, + cwd: &Path, + title: &str, + items: &[Value], +) -> Result { + with_rpc(codex_home, cwd, |runtime, client| { + let model_provider = effective_model_provider(runtime, client, cwd)?; + let result = request( + runtime, + client, + "thread/start", + json!({ + "cwd": cwd, + "modelProvider": model_provider, + "ephemeral": false, + "historyMode": "legacy", + "experimentalRawEvents": false + }), + )?; + let started_id = result["thread"]["id"] + .as_str() + .filter(|value| !value.is_empty()) + .ok_or_else(|| "Codex app-server thread/start returned no thread id".to_string())? + .to_string(); + let registered = (|| -> Result { + set_thread_name(runtime, client, &started_id, title)?; + let registered = read_thread(runtime, client, &started_id)?; + let registered = + validate_target_profile(registered, &started_id, cwd, title, &model_provider)?; + // Injection is deliberately last. Once this request succeeds there + // are no later fallible validation steps that could make a caller + // retry and duplicate the same canonical suffix. + inject_items(runtime, client, &started_id, items)?; + Ok(registered) + })(); + if registered.is_err() { + let _ = request( + runtime, + client, + "thread/archive", + json!({"threadId": &started_id}), + ); + } + registered + }) +} + +pub(crate) fn synchronize_thread( + codex_home: &Path, + path: &Path, + expected_id: &str, + cwd: &Path, + title: &str, + items: &[Value], +) -> Result { + // Inspect the durable rollout before any app-server mutation. A timed-out + // `thread/inject_items` may have committed even when ORGII lost the reply; + // retries must therefore prove all-missing or all-applied, never inject a + // mixed/unknown suffix blindly. + let suffix_application = inspect_suffix_application(path, items)?; + with_rpc(codex_home, cwd, |runtime, client| { + let model_provider = effective_model_provider(runtime, client, cwd)?; + let result = request( + runtime, + client, + "thread/resume", + json!({ + "threadId": expected_id, + "path": path, + "cwd": cwd, + "modelProvider": model_provider + }), + )?; + let resumed = entry_from_thread(&result["thread"])?; + if resumed.id != expected_id { + return Err(format!( + "Codex resumed the wrong native thread: expected {expected_id}, got {}", + resumed.id + )); + } + set_thread_name(runtime, client, expected_id, title)?; + let synchronized = read_thread(runtime, client, expected_id)?; + let synchronized = + validate_target_profile(synchronized, expected_id, cwd, title, &model_provider)?; + // Keep injection as the terminal mutation. If its response is lost, the + // next call re-inspects the durable rollout before deciding to inject. + if suffix_application == SuffixApplication::Missing { + inject_items(runtime, client, expected_id, items)?; + } + Ok(synchronized) + }) +} + +pub(crate) fn archive_thread( + codex_home: &Path, + path: &Path, + expected_id: &str, + cwd: &Path, +) -> Result<(), String> { + with_rpc(codex_home, cwd, |runtime, client| { + let model_provider = effective_model_provider(runtime, client, cwd)?; + let result = request( + runtime, + client, + "thread/resume", + json!({ + "threadId": expected_id, + "path": path, + "cwd": cwd, + "modelProvider": model_provider + }), + )?; + let resumed = entry_from_thread(&result["thread"])?; + if resumed.id != expected_id { + return Err(format!( + "refusing to archive Codex thread {} while rolling back {expected_id}", + resumed.id + )); + } + request( + runtime, + client, + "thread/archive", + json!({"threadId": expected_id}), + )?; + Ok(()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_supported_thread_catalog_shape() { + let entry = entry_from_thread(&json!({ + "id": "thread-1", + "path": "/tmp/rollout-thread-1.jsonl", + "name": "Native title", + "cwd": "/tmp/repo", + "modelProvider": "openai" + })) + .expect("catalog entry"); + assert_eq!(entry.id, "thread-1"); + assert_eq!(entry.title, "Native title"); + assert_eq!(entry.cwd, PathBuf::from("/tmp/repo")); + assert_eq!(entry.model_provider, "openai"); + } + + #[test] + fn rejects_catalog_rows_without_provider_identity() { + let error = entry_from_thread(&json!({"cwd": "/tmp/repo"})) + .expect_err("missing identity must fail"); + assert!(error.contains("no id")); + } + + #[test] + fn rejects_runner_provider_identity_in_native_profile() { + let entry = CodexCatalogEntry { + id: "thread-1".to_string(), + path: PathBuf::from("/tmp/rollout-thread-1.jsonl"), + title: "Native title".to_string(), + cwd: PathBuf::from("/tmp/repo"), + model_provider: "orgii_compatible".to_string(), + }; + let error = validate_target_profile( + entry, + "thread-1", + Path::new("/tmp/repo"), + "Native title", + "openai", + ) + .expect_err("runner-only provider must not enter the native catalog"); + assert!(error.contains("orgii_compatible")); + assert!(error.contains("openai")); + } + + #[test] + fn native_catalog_provider_is_a_builtin_allowlisted_identity() { + assert_eq!( + allowlisted_native_model_provider(&json!({ + "config": {"model_provider": "orgii_compatible"} + })), + "openai" + ); + assert_eq!( + allowlisted_native_model_provider(&json!({"config": {}})), + "openai" + ); + } + + #[test] + fn suffix_inspection_distinguishes_missing_applied_and_mixed() { + let temp = tempfile::tempdir().expect("temp Codex rollout root"); + let path = temp.path().join("rollout.jsonl"); + let expected = vec![ + json!({"type": "message", "id": "message-1"}), + json!({"type": "function_call", "call_id": "call-1"}), + ]; + let rollout = |items: &[Value]| { + items + .iter() + .map(|payload| json!({"type": "response_item", "payload": payload}).to_string()) + .collect::>() + .join("\n") + }; + + std::fs::write( + &path, + rollout(&[json!({"type": "message", "id": "unrelated"})]), + ) + .expect("write missing suffix fixture"); + assert_eq!( + inspect_suffix_application(&path, &expected).expect("inspect missing suffix"), + SuffixApplication::Missing + ); + + std::fs::write(&path, rollout(&expected[..1])).expect("write mixed suffix fixture"); + assert!(inspect_suffix_application(&path, &expected).is_err()); + + std::fs::write(&path, rollout(&expected)).expect("write applied suffix fixture"); + assert_eq!( + inspect_suffix_application(&path, &expected).expect("inspect applied suffix"), + SuffixApplication::AlreadyApplied + ); + } + + #[cfg(unix)] + #[test] + fn accepts_filesystem_equivalent_catalog_cwd() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("temp native catalog root"); + let canonical = temp.path().join("canonical-workspace"); + let alias = temp.path().join("workspace-alias"); + std::fs::create_dir(&canonical).expect("canonical workspace"); + symlink(&canonical, &alias).expect("workspace alias"); + let entry = CodexCatalogEntry { + id: "thread-1".to_string(), + path: temp.path().join("rollout-thread-1.jsonl"), + title: "Native title".to_string(), + cwd: alias, + model_provider: "openai".to_string(), + }; + + validate_target_profile(entry, "thread-1", &canonical, "Native title", "openai") + .expect("filesystem-equivalent cwd must preserve native identity"); + } +} diff --git a/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs b/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs index dcdf9fece8..8fc76c624a 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/tests/codex_app_server_tests.rs @@ -5,7 +5,10 @@ use serde_json::{json, Value}; -use super::{approval_auto_accept, thread_permission_params, CodexAppServerEventParser}; +use super::{ + approval_auto_accept, build_thread_launch_request, build_turn_input, thread_permission_params, + CodexAppServerEventParser, CodexAppServerTurn, +}; use crate::agent_sessions::cli::session_runner::launch_profiles::CliPermissionMode; const SESSION_ID: &str = "test-session"; @@ -49,6 +52,20 @@ fn thread_response_captures_id_and_emits_session_start_once() { assert!(dup.is_empty()); } +#[test] +fn native_thread_rebind_emits_fresh_id_after_initial_session_start() { + let mut p = parser(); + let _ = p.on_thread_response(&json!({"thread": {"id": "source-thread"}})); + + let chunks = p.on_thread_rebound(&json!({"thread": {"id": "forked-thread"}})); + + assert_eq!(p.thread_id(), Some("forked-thread")); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].action_type, "session_start"); + assert_eq!(chunks[0].thread_id.as_deref(), Some("forked-thread")); + assert_eq!(chunks[0].result["native_rollover"], true); +} + #[test] fn turn_started_captures_turn_id_without_chunks() { let mut p = parser(); @@ -381,6 +398,124 @@ fn failed_turn_emits_unsuccessful_session_end_with_error() { assert_eq!(p.turn_error(), Some("stream disconnected")); } +#[test] +fn context_overflow_is_recoverable_only_before_output_or_tools() { + let overflow = json!({"threadId": "t", "turn": { + "id": "u", "items": [], "status": "failed", + "error": {"message": "Codex ran out of room in the model's context window."}, + }}); + + let clean = parser(); + assert!(clean.should_recover_context_exhaustion(&overflow)); + assert!(!clean.should_recover_context_exhaustion(&json!({ + "turn": { + "status": "completed", + "error": {"message": "Codex ran out of room in the model's context window."} + } + }))); + assert!(!clean.should_recover_context_exhaustion(&json!({ + "turn": { + "status": "failed", + "error": {"message": "connection refused"} + } + }))); + + let mut with_output = parser(); + let chunks = notif( + &mut with_output, + "item/agentMessage/delta", + json!({"delta": "partial", "itemId": "msg_1"}), + ); + assert_eq!(chunks.len(), 1); + assert!(!with_output.should_recover_context_exhaustion(&overflow)); + + let mut with_tool = parser(); + let _ = notif( + &mut with_tool, + "item/started", + json!({"item": { + "type": "commandExecution", "id": "call_1", + "command": "touch changed", "cwd": "/repo", "status": "inProgress", + }}), + ); + assert!(!with_tool.should_recover_context_exhaustion(&overflow)); +} + +#[test] +fn turn_reset_preserves_thread_identity_and_clears_failed_attempt_state() { + let mut p = parser(); + let _ = p.on_thread_response(&json!({"thread": {"id": "thread-1"}})); + let _ = notif( + &mut p, + "turn/started", + json!({"turn": {"id": "turn-1", "status": "inProgress"}}), + ); + let _ = notif( + &mut p, + "error", + json!({"error": {"message": "Prompt is too long"}, "willRetry": false}), + ); + let _ = notif( + &mut p, + "turn/completed", + json!({"turn": {"id": "turn-1", "status": "failed"}}), + ); + assert_eq!(p.turn_status(), Some("failed")); + + p.reset_turn_state(); + + assert_eq!(p.thread_id(), Some("thread-1")); + assert_eq!(p.turn_id(), None); + assert_eq!(p.turn_status(), None); + assert_eq!(p.turn_error(), None); + assert!(p.usage().is_none()); +} + +#[test] +fn failed_context_recovery_restores_error_from_preceding_notification() { + let mut p = parser(); + let _ = notif( + &mut p, + "error", + json!({ + "error": {"message": "Codex ran out of room in the model's context window."}, + "willRetry": false + }), + ); + let completion = json!({"turn": {"id": "turn-1", "status": "failed"}}); + let original_error = p.completed_turn_error(&completion).map(str::to_string); + + // Native recovery drives maintenance turns and resets this transient + // parser state before it can report a failure of its own. + p.reset_turn_state(); + p.pending_error_message = original_error; + let chunks = notif(&mut p, "turn/completed", completion); + + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].result["success"], false); + assert_eq!( + chunks[0].result["error_message"], + "Codex ran out of room in the model's context window." + ); +} + +#[test] +fn native_compaction_notifications_emit_one_deduplicated_marker() { + let mut p = parser(); + let item = notif( + &mut p, + "item/completed", + json!({"item": {"type": "contextCompaction", "id": "compact-1"}}), + ); + assert_eq!(item.len(), 1); + assert_eq!(item[0].action_type, "context_compacted"); + assert_eq!(item[0].result["native"], true); + assert_eq!(item[0].result["provider"], "codex"); + + let legacy = notif(&mut p, "thread/compacted", json!({"threadId": "t"})); + assert!(legacy.is_empty()); +} + #[test] fn interrupted_turn_records_status() { let mut p = parser(); @@ -547,6 +682,80 @@ fn only_full_permission_auto_accepts_approvals() { assert!(!approval_auto_accept(CliPermissionMode::Plan)); } +fn native_turn( + user_input: &str, + developer_instructions: &str, + resume_thread_id: Option<&str>, +) -> CodexAppServerTurn { + CodexAppServerTurn { + session_id: SESSION_ID.to_string(), + user_input: user_input.to_string(), + developer_instructions: Some(developer_instructions.to_string()), + working_dir: "/workspace".to_string(), + resume_thread_id: resume_thread_id.map(str::to_string), + model: Some("gpt-5.6-sol".to_string()), + permission_mode: CliPermissionMode::Manual, + config: Some(json!({"mcp_servers": {"orgii": {"enabled": true}}})), + image_paths: vec!["/tmp/native-image.png".to_string()], + allow_native_context_recovery: false, + } +} + +#[test] +fn fresh_thread_keeps_agent_context_out_of_native_user_input() { + let developer_context = concat!( + "build\n\n", + "focused file" + ); + let turn = native_turn("Literal visible user text", developer_context, None); + + let (method, params) = build_thread_launch_request(&turn); + assert_eq!(method, "thread/start"); + assert_eq!(params["developerInstructions"], developer_context); + assert!(params.get("baseInstructions").is_none()); + + let input = build_turn_input(&turn); + assert_eq!( + input[0], + json!({"type": "text", "text": "Literal visible user text"}) + ); + assert_eq!( + input[1], + json!({"type": "localImage", "path": "/tmp/native-image.png"}) + ); + let visible_payload = serde_json::to_string(&input).expect("serialize turn input"); + assert!(!visible_payload.contains("")); +} + +#[test] +fn resumed_thread_receives_the_updated_developer_context() { + let first = native_turn("first", "WORKSPACE_CONTEXT_V1", None); + let (_, first_params) = build_thread_launch_request(&first); + assert_eq!( + first_params["developerInstructions"], + "WORKSPACE_CONTEXT_V1" + ); + + let resumed = native_turn( + "second literal user turn", + "WORKSPACE_CONTEXT_V2\nlatest", + Some("native-codex-thread"), + ); + let (method, params) = build_thread_launch_request(&resumed); + assert_eq!(method, "thread/resume"); + assert_eq!(params["threadId"], "native-codex-thread"); + assert_eq!( + params["developerInstructions"], + "WORKSPACE_CONTEXT_V2\nlatest" + ); + assert!(params.get("baseInstructions").is_none()); + assert_eq!( + build_turn_input(&resumed)[0], + json!({"type": "text", "text": "second literal user turn"}) + ); +} + // ─── live smoke (opt-in) ─── /// End-to-end smoke against a real `codex app-server` process. Requires the @@ -556,7 +765,7 @@ fn only_full_permission_auto_accepts_approvals() { #[tokio::test] #[ignore = "spawns real codex app-server; needs codex auth + network"] async fn live_smoke_trivial_turn() { - use super::{run_app_server_turn, CodexAppServerTurn}; + use super::run_app_server_turn; use std::process::Stdio; let mut child = match tokio::process::Command::new("codex") @@ -578,12 +787,15 @@ async fn live_smoke_trivial_turn() { let turn = CodexAppServerTurn { session_id: SESSION_ID.to_string(), - task: "Reply with exactly: pong".to_string(), + user_input: "Reply with exactly: pong".to_string(), + developer_instructions: None, working_dir: std::env::temp_dir().to_string_lossy().to_string(), resume_thread_id: None, model: None, permission_mode: CliPermissionMode::Plan, + config: None, image_paths: vec![], + allow_native_context_recovery: false, }; let protocol = diff --git a/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs b/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs index de92565b5d..b63282a316 100644 --- a/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs +++ b/src-tauri/src/agent_sessions/cli/parsers/tests/parser_integration_tests.rs @@ -387,3 +387,68 @@ mod tests { assert_eq!(chunks[0].result["stop_reason"], "end_turn"); } } + +#[cfg(test)] +mod claude_terminal_reason_tests { + use crate::agent_sessions::cli::parsers::claude_code::ClaudeCodeParser; + use crate::agent_sessions::cli::parsers::CliAgentParser; + + #[test] + fn prompt_too_long_false_success_is_demoted_to_a_failed_session_end() { + let mut parser = ClaudeCodeParser::new("test-session"); + let chunks = parser.parse_line( + r#"{"type":"result","subtype":"success","is_error":false,"terminal_reason":"prompt_too_long","result":"","session_id":"abc","usage":{"input_tokens":1,"output_tokens":1}}"#, + ); + let terminal = chunks + .iter() + .find(|chunk| chunk.action_type == "session_end") + .expect("result frame emits session_end"); + assert_eq!(terminal.result["success"], false); + assert_eq!(terminal.result["terminal_reason"], "prompt_too_long"); + let message = terminal.result["error_message"] + .as_str() + .expect("overflow carries a classifiable message"); + assert!( + app_utils::runtime_errors::is_context_exhausted_message(message), + "{message}" + ); + } + + #[test] + fn errored_result_without_error_field_falls_back_to_result_text() { + let mut parser = ClaudeCodeParser::new("test-session"); + let chunks = parser.parse_line( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Prompt is too long and cannot be compacted further.","session_id":"abc","usage":{"input_tokens":1,"output_tokens":1}}"#, + ); + let terminal = chunks + .iter() + .find(|chunk| chunk.action_type == "session_end") + .expect("result frame emits session_end"); + assert_eq!(terminal.result["success"], false); + assert_eq!( + terminal.result["error_message"], + "Prompt is too long and cannot be compacted further." + ); + } + + #[test] + fn gateway_blocking_limit_keeps_the_classifiable_prompt_error() { + let mut parser = ClaudeCodeParser::new("test-session"); + let chunks = parser.parse_line( + r#"{"type":"result","subtype":"error_during_execution","is_error":true,"terminal_reason":"blocking_limit","result":"Prompt is too long","session_id":"abc","usage":{"input_tokens":1,"output_tokens":1}}"#, + ); + let terminal = chunks + .iter() + .find(|chunk| chunk.action_type == "session_end") + .expect("result frame emits session_end"); + assert_eq!(terminal.result["success"], false); + assert_eq!(terminal.result["terminal_reason"], "blocking_limit"); + let message = terminal.result["error_message"] + .as_str() + .expect("gateway overflow keeps its provider message"); + assert_eq!(message, "Prompt is too long"); + assert!(app_utils::runtime_errors::is_context_exhausted_message( + message + )); + } +} diff --git a/src-tauri/src/agent_sessions/cli/persistence/resume_state_tests.rs b/src-tauri/src/agent_sessions/cli/persistence/resume_state_tests.rs index 8945c6b673..8ff29e30a0 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/resume_state_tests.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/resume_state_tests.rs @@ -41,6 +41,47 @@ fn create_test_session(session_id: &str, account_id: &str) { .expect("create test CLI session"); } +#[test] +fn model_account_switch_waits_for_a_concurrent_writer() { + let _sandbox = test_env::sandbox(); + let session_id = "cli-model-switch-contention"; + create_test_session(session_id, "account-a"); + let conn = database::db::get_connection().expect("sandbox database"); + let writer = database::db::begin_immediate(&conn).expect("hold another writer"); + writer + .execute( + "UPDATE code_sessions SET cli_session_id = 'native-latest' WHERE session_id = ?1", + [session_id], + ) + .expect("stage native identity update"); + + let (started_tx, started_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + started_tx.send(()).unwrap(); + let result = + update_model_and_account(session_id, Some("claude-opus-4-7"), Some("account-a")); + done_tx.send(result).unwrap(); + }); + started_rx.recv().unwrap(); + // A deferred transaction reads the old snapshot and fails its write + // upgrade immediately. An immediate transaction waits before reading. + let early = done_rx.recv_timeout(std::time::Duration::from_millis(200)); + writer.commit().expect("release writer"); + let result = match early { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => done_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .expect("model switch finishes after writer commits"), + Err(error) => panic!("model switch worker disconnected: {error}"), + }; + worker.join().unwrap(); + assert!(result.expect("concurrent model switch must not fail with SQLITE_BUSY")); + let session = get_session(session_id).unwrap().unwrap(); + assert_eq!(session.model.as_deref(), Some("claude-opus-4-7")); + assert_eq!(session.cli_session_id.as_deref(), Some("native-latest")); +} + #[test] fn status_snapshots_return_only_requested_existing_sessions() { let _sandbox = test_env::sandbox(); @@ -269,6 +310,83 @@ fn old_process_resume_id_does_not_overwrite_current_account_column() { ); } +#[test] +fn staged_native_binding_is_recoverable_but_not_yet_published() { + let _sandbox = test_env::sandbox(); + let session_id = "cli-resume-staged-binding"; + create_test_session(session_id, "account-a"); + + assert!( + stage_cli_session_id_for_account(session_id, Some("account-a"), "native-a-staged") + .expect("stage native materialization") + ); + assert_eq!( + get_cli_session_id_for_account(session_id, Some("account-a")) + .expect("load staged binding") + .as_deref(), + Some("native-a-staged") + ); + assert!( + native_transcript_ids_newest_first(session_id, "claude_code") + .expect("load unpublished ledger") + .is_empty(), + "an unpublished materialization must not become durable transcript history" + ); + + assert!( + update_cli_session_id_for_account(session_id, Some("account-a"), "native-a-staged") + .expect("publish staged materialization") + ); + assert_eq!( + native_transcript_ids_newest_first(session_id, "claude_code") + .expect("load published ledger"), + vec!["native-a-staged"] + ); +} + +#[test] +fn abandoning_one_staged_binding_preserves_other_account_resume_state() { + let _sandbox = test_env::sandbox(); + let session_id = "cli-resume-targeted-stage-abort"; + create_test_session(session_id, "account-a"); + update_cli_session_id_for_account(session_id, Some("account-a"), "native-a-published") + .expect("publish account A binding"); + update_model_and_account(session_id, Some("claude-sonnet-4-6"), Some("account-b")) + .expect("switch to account B"); + stage_cli_session_id_for_account(session_id, Some("account-b"), "native-b-staged") + .expect("stage account B binding"); + + assert!(clear_staged_cli_session_id_for_account( + session_id, + Some("account-b"), + "native-b-staged" + ) + .expect("abort account B stage")); + assert_eq!( + get_cli_session_id_for_account(session_id, Some("account-b")) + .expect("load account B binding"), + None + ); + assert_eq!( + get_cli_session_id_for_account(session_id, Some("account-a")) + .expect("load account A binding") + .as_deref(), + Some("native-a-published") + ); + assert_eq!( + native_transcript_ids_newest_first(session_id, "claude_code") + .expect("load published ledger"), + vec!["native-a-published"] + ); + assert_eq!( + get_session(session_id) + .expect("load session") + .expect("session exists") + .cli_session_id, + None + ); +} + #[test] fn clearing_cli_resume_state_removes_all_account_scoped_resume_state() { let _sandbox = test_env::sandbox(); @@ -446,3 +564,78 @@ fn late_resume_id_write_after_delete_does_not_create_orphan_state() { None ); } + +#[test] +fn native_catalog_receipt_uses_revision_cas_and_pending_only_reads() { + let _sandbox = test_env::sandbox(); + let dirty_session_id = "cli-native-catalog-dirty"; + let clean_session_id = "cli-native-catalog-clean"; + create_test_session(dirty_session_id, "account-a"); + create_test_session(clean_session_id, "account-a"); + update_cli_session_id_for_account(dirty_session_id, Some("account-a"), "native-dirty") + .expect("publish dirty binding"); + update_cli_session_id_for_account(clean_session_id, Some("account-a"), "native-clean") + .expect("publish clean binding"); + + let first = request_native_catalog_refresh(dirty_session_id, Some("account-a"), "native-dirty") + .expect("request first catalog revision") + .expect("binding still exists"); + let second = + request_native_catalog_refresh(dirty_session_id, Some("account-a"), "native-dirty") + .expect("request second catalog revision") + .expect("binding still exists"); + assert_eq!(first.requested_revision, 1); + assert_eq!(second.requested_revision, 2); + + assert!( + !acknowledge_native_catalog_refresh(&first).expect("reject stale catalog receipt"), + "an older worker must not clear a newer terminal request" + ); + let pending = pending_native_catalog_refreshes(8).expect("load dirty receipts"); + assert_eq!( + pending.len(), + 1, + "clean bindings must not enter startup repair" + ); + assert_eq!(pending[0].receipt, second); + assert_eq!(pending[0].source, "claude_code"); + + assert!(acknowledge_native_catalog_refresh(&second).expect("ack current revision")); + assert!(pending_native_catalog_refreshes(8) + .expect("reload dirty receipts") + .is_empty()); + assert!( + !acknowledge_native_catalog_refresh(&second).expect("repeat acknowledgement"), + "acknowledgement is idempotent" + ); +} + +#[test] +fn replacing_native_binding_resets_catalog_revisions() { + let _sandbox = test_env::sandbox(); + let session_id = "cli-native-catalog-binding-replaced"; + create_test_session(session_id, "account-a"); + update_cli_session_id_for_account(session_id, Some("account-a"), "native-old") + .expect("publish old binding"); + request_native_catalog_refresh(session_id, Some("account-a"), "native-old") + .expect("request old binding refresh") + .expect("old binding exists"); + + update_cli_session_id_for_account(session_id, Some("account-a"), "native-new") + .expect("replace native binding"); + assert!(pending_native_catalog_refreshes(8) + .expect("load pending after binding replacement") + .is_empty()); + assert!( + request_native_catalog_refresh(session_id, Some("account-a"), "native-old") + .expect("request stale native id") + .is_none() + ); + assert_eq!( + request_native_catalog_refresh(session_id, Some("account-a"), "native-new") + .expect("request new native id") + .expect("new binding exists") + .requested_revision, + 1 + ); +} diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs index 39817e3e15..1cadeeb29b 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud.rs @@ -29,8 +29,11 @@ pub use read::{ status_snapshots, }; pub use resume_state::{ - clear_cli_resume_state, get_cli_session_id_for_account, get_history_mutation, - update_cli_session_id, update_cli_session_id_for_account, + acknowledge_native_catalog_refresh, clear_cli_resume_state, + clear_staged_cli_session_id_for_account, get_cli_session_id_for_account, get_history_mutation, + pending_native_catalog_refreshes, request_native_catalog_refresh, + stage_cli_session_id_for_account, update_cli_session_id, update_cli_session_id_for_account, + NativeCatalogRefreshReceipt, PendingNativeCatalogRefresh, }; pub use transcript_source::{ latest_native_transcript_id, native_transcript_ids_newest_first, session_persists_chunks, diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud/create.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud/create.rs index dfa454bd33..851a8c42a7 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud/create.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud/create.rs @@ -1,10 +1,12 @@ //! Insert path for new CLI code-session rows, including the wire-typo //! guards and the frozen transcript-source decision. -use rusqlite::{params, Result as SqliteResult}; +use std::time::Duration; + +use rusqlite::{params, ErrorCode, Result as SqliteResult}; use agent_core::session::AgentExecMode; -use database::db::get_connection; +use database::db::{get_connection, with_sessions_writer}; use crate::agent_sessions::cli::native_transcript; use crate::agent_sessions::cli::persistence::types::{CodeSession, CreateCodeSessionParams}; @@ -16,12 +18,58 @@ use crate::agent_sessions::cli::types::{ use super::read::get_session; use super::shared::{now_iso, sync_orgtrack_mirror}; +const CREATE_SESSION_WRITE_MAX_ATTEMPTS: u32 = 3; +const CREATE_SESSION_WRITE_RETRY_BASE_MS: u64 = 50; + +fn is_transient_sqlite_writer_contention(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(inner, _) + if matches!(inner.code, ErrorCode::DatabaseBusy | ErrorCode::DatabaseLocked) + ) +} + +/// Serialize session creation with the existing sessions.db write owner and +/// retain a small cross-process fallback for SQLITE_BUSY/SQLITE_LOCKED. +/// +/// `get_connection` belongs inside the attempt: its schema/PRAGMA setup can +/// itself encounter a writer held by another ORG2 process. Sleep happens after +/// releasing the in-process mutex so a stale external lock cannot block this +/// process's healthy writers between attempts. +fn with_create_session_write_retry( + mut operation: impl FnMut() -> SqliteResult, + mut sleep: impl FnMut(Duration), +) -> SqliteResult { + for attempt in 0..CREATE_SESSION_WRITE_MAX_ATTEMPTS { + match with_sessions_writer(&mut operation) { + Ok(value) => return Ok(value), + Err(error) + if is_transient_sqlite_writer_contention(&error) + && attempt + 1 < CREATE_SESSION_WRITE_MAX_ATTEMPTS => + { + let delay = Duration::from_millis( + CREATE_SESSION_WRITE_RETRY_BASE_MS.saturating_mul(1_u64 << attempt), + ); + tracing::debug!( + "[CodeSession] create write contention on attempt {}/{}: {} — retrying in {}ms", + attempt + 1, + CREATE_SESSION_WRITE_MAX_ATTEMPTS, + error, + delay.as_millis() + ); + sleep(delay); + } + Err(error) => return Err(error), + } + } + unreachable!("CREATE_SESSION_WRITE_MAX_ATTEMPTS is non-zero") +} + /// Create a new code session. Returns the session ID. pub fn create_session( session_id: &str, params: &CreateCodeSessionParams, ) -> SqliteResult { - let conn = get_connection()?; let ts = now_iso(); let name = params .name @@ -95,28 +143,90 @@ pub fn create_session( .map(|_| native_transcript::TRANSCRIPT_SOURCE_NATIVE) .unwrap_or(native_transcript::TRANSCRIPT_SOURCE_CHUNKS); - conn.execute( - "INSERT INTO code_sessions - (session_id, name, status, flow, runner, cli_agent_type, model, tier, - account_id, repo_path, branch, proxy_token, proxy_url, hosted_token, - proxy_session_id, background, key_source, additional_directories, - parent_session_id, org_member_id, org_id, project_id, project_name, - project_slug, work_item_id, agent_role, created_at, updated_at, - transcript_source, product_mode, agent_exec_mode, agent_definition_id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32)", - params![ - session_id, name, SessionStatus::Pending.as_ref(), flow, runner, params.cli_agent_type, - params.model, params.tier, params.account_id, - params.repo_path, params.branch, params.proxy_token, params.proxy_url, - params.hosted_token, params.proxy_session_id, background, key_source_str, - additional_dirs_json, params.parent_session_id, params.org_member_id, - org_id, params.project_id, params.project_name, params.project_slug, - params.work_item_id, params.agent_role, ts, ts, transcript_source, - product_mode, AgentExecMode::Build.as_str(), params.agent_definition_id, - ], + with_create_session_write_retry( + || { + let conn = get_connection()?; + conn.execute( + "INSERT INTO code_sessions + (session_id, name, status, flow, runner, cli_agent_type, model, tier, + account_id, repo_path, branch, proxy_token, proxy_url, hosted_token, + proxy_session_id, background, key_source, additional_directories, + parent_session_id, org_member_id, org_id, project_id, project_name, + project_slug, work_item_id, agent_role, created_at, updated_at, + transcript_source, product_mode, agent_exec_mode, agent_definition_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30, ?31, ?32)", + params![ + session_id, name, SessionStatus::Pending.as_ref(), flow, runner, + params.cli_agent_type, params.model, params.tier, params.account_id, + params.repo_path, params.branch, params.proxy_token, params.proxy_url, + params.hosted_token, params.proxy_session_id, background, key_source_str, + additional_dirs_json, params.parent_session_id, params.org_member_id, org_id, + params.project_id, params.project_name, params.project_slug, + params.work_item_id, params.agent_role, ts, ts, transcript_source, + product_mode, AgentExecMode::Build.as_str(), params.agent_definition_id, + ], + )?; + Ok(()) + }, + std::thread::sleep, )?; let session = get_session(session_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)?; sync_orgtrack_mirror(session_id); Ok(session) } + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use rusqlite::ffi; + + use super::*; + + fn sqlite_failure(code: i32) -> rusqlite::Error { + rusqlite::Error::SqliteFailure(ffi::Error::new(code), None) + } + + #[test] + fn create_write_retries_only_transient_sqlite_contention() { + let attempts = Cell::new(0_u32); + let mut delays = Vec::new(); + let result = with_create_session_write_retry( + || { + let attempt = attempts.get(); + attempts.set(attempt + 1); + if attempt == 0 { + Err(sqlite_failure(ffi::SQLITE_BUSY)) + } else if attempt == 1 { + Err(sqlite_failure(ffi::SQLITE_LOCKED)) + } else { + Ok("created") + } + }, + |delay| delays.push(delay), + ); + + assert_eq!( + result.expect("transient contention should recover"), + "created" + ); + assert_eq!(attempts.get(), 3); + assert_eq!( + delays, + vec![Duration::from_millis(50), Duration::from_millis(100)] + ); + + let attempts = Cell::new(0_u32); + let error = with_create_session_write_retry( + || { + attempts.set(attempts.get() + 1); + Err::<(), _>(sqlite_failure(ffi::SQLITE_CONSTRAINT)) + }, + |_| panic!("permanent errors must not back off"), + ) + .expect_err("constraint failure must remain terminal"); + assert!(!is_transient_sqlite_writer_contention(&error)); + assert_eq!(attempts.get(), 1); + } +} diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud/field_updates.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud/field_updates.rs index 7642126623..7bdfedbe06 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud/field_updates.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud/field_updates.rs @@ -4,7 +4,7 @@ use rusqlite::{params, OptionalExtension, Result as SqliteResult}; use agent_core::session::AgentExecMode; -use database::db::get_connection; +use database::db::{begin_immediate, get_connection, sessions_writer_guard}; use super::resume_state::mapped_cli_session_id_for_account_with_conn; use super::shared::{now_iso, sync_orgtrack_mirror}; @@ -35,8 +35,11 @@ pub fn update_model_and_account( model: Option<&str>, account_id: Option<&str>, ) -> SqliteResult { + // Acquire write ownership before reading the identity. A deferred + // read transaction cannot wait when upgrading a stale WAL snapshot. + let writer_guard = sessions_writer_guard(); let conn = get_connection()?; - let tx = conn.unchecked_transaction()?; + let tx = begin_immediate(&conn)?; let current: Option<(Option, Option)> = tx .query_row( "SELECT account_id, cli_session_id FROM code_sessions WHERE session_id = ?1", @@ -82,6 +85,7 @@ pub fn update_model_and_account( (None, None) => 0, }; tx.commit()?; + drop(writer_guard); if affected > 0 { sync_orgtrack_mirror(session_id); } diff --git a/src-tauri/src/agent_sessions/cli/persistence/session_crud/resume_state.rs b/src-tauri/src/agent_sessions/cli/persistence/session_crud/resume_state.rs index 29e655c1d1..03a22c2089 100644 --- a/src-tauri/src/agent_sessions/cli/persistence/session_crud/resume_state.rs +++ b/src-tauri/src/agent_sessions/cli/persistence/session_crud/resume_state.rs @@ -26,32 +26,35 @@ fn resume_profile_key(account_id: Option<&str>) -> String { .to_string() } -/// Store the CLI agent's own session/conversation ID for resume support. -/// Internal bookkeeping — does not bump `updated_at`. -pub fn update_cli_session_id(session_id: &str, cli_session_id: &str) -> SqliteResult { - let conn = get_connection()?; - let account_id: Option = conn - .query_row( - "SELECT account_id FROM code_sessions WHERE session_id = ?1", - params![session_id], - |row| row.get(0), - ) - .optional()?; - update_cli_session_id_for_account(session_id, account_id.as_deref(), cli_session_id) +const SESSION_PROFILE_KEY: &str = "__session__"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeCatalogRefreshReceipt { + pub session_id: String, + pub profile_key: String, + pub cli_session_id: String, + pub requested_revision: i64, } -/// Store a CLI native session ID under the account/profile that launched the -/// process, not whatever account the session row may point at when the process -/// exits. This prevents a slow old process from writing account A's native -/// conversation id into account B's resume slot after a mid-turn switch. -pub fn update_cli_session_id_for_account( +impl NativeCatalogRefreshReceipt { + pub fn account_id(&self) -> Option<&str> { + (self.profile_key != SESSION_PROFILE_KEY).then_some(self.profile_key.as_str()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PendingNativeCatalogRefresh { + pub receipt: NativeCatalogRefreshReceipt, + pub source: String, +} + +fn stage_cli_session_id_for_account_with_tx( + tx: &rusqlite::Transaction<'_>, session_id: &str, account_id: Option<&str>, cli_session_id: &str, ) -> SqliteResult { - let conn = get_connection()?; let profile_key = resume_profile_key(account_id); - let tx = conn.unchecked_transaction()?; let affected = tx.execute( "UPDATE code_sessions SET cli_session_id = CASE @@ -62,7 +65,6 @@ pub fn update_cli_session_id_for_account( params![session_id, cli_session_id, account_id], )?; if affected == 0 { - tx.commit()?; return Ok(false); } tx.execute( @@ -71,12 +73,95 @@ pub fn update_cli_session_id_for_account( VALUES (?1, ?2, ?3, ?4) ON CONFLICT(session_id, profile_key) DO UPDATE SET cli_session_id = excluded.cli_session_id, + native_catalog_requested_revision = CASE + WHEN code_session_cli_resume_state.cli_session_id = excluded.cli_session_id + THEN code_session_cli_resume_state.native_catalog_requested_revision + ELSE 0 + END, + native_catalog_applied_revision = CASE + WHEN code_session_cli_resume_state.cli_session_id = excluded.cli_session_id + THEN code_session_cli_resume_state.native_catalog_applied_revision + ELSE 0 + END, updated_at = excluded.updated_at", params![session_id, profile_key, cli_session_id, now_iso()], )?; + Ok(true) +} + +/// Record a recoverable materialization intent in the existing resume binding +/// owner. Unlike publication, staging deliberately does not add the UUID to +/// the append-only native-transcript ledger until its artifact is durable. +pub fn stage_cli_session_id_for_account( + session_id: &str, + account_id: Option<&str>, + cli_session_id: &str, +) -> SqliteResult { + let conn = get_connection()?; + let tx = conn.unchecked_transaction()?; + let staged = + stage_cli_session_id_for_account_with_tx(&tx, session_id, account_id, cli_session_id)?; + tx.commit()?; + Ok(staged) +} + +/// Remove one unpublished materialization intent without invalidating resume +/// bindings for other accounts/providers attached to the canonical session. +pub fn clear_staged_cli_session_id_for_account( + session_id: &str, + account_id: Option<&str>, + expected_cli_session_id: &str, +) -> SqliteResult { + let conn = get_connection()?; + let tx = conn.unchecked_transaction()?; + let profile_key = resume_profile_key(account_id); + let removed = tx.execute( + "DELETE FROM code_session_cli_resume_state + WHERE session_id = ?1 AND profile_key = ?2 AND cli_session_id = ?3", + params![session_id, profile_key, expected_cli_session_id], + )?; + tx.execute( + "UPDATE code_sessions + SET cli_session_id = NULL + WHERE session_id = ?1 AND account_id IS ?2 AND cli_session_id = ?3", + params![session_id, account_id, expected_cli_session_id], + )?; + tx.commit()?; + Ok(removed > 0) +} + +/// Store the CLI agent's own session/conversation ID for resume support. +/// Internal bookkeeping — does not bump `updated_at`. +pub fn update_cli_session_id(session_id: &str, cli_session_id: &str) -> SqliteResult { + let conn = get_connection()?; + let account_id: Option = conn + .query_row( + "SELECT account_id FROM code_sessions WHERE session_id = ?1", + params![session_id], + |row| row.get(0), + ) + .optional()?; + update_cli_session_id_for_account(session_id, account_id.as_deref(), cli_session_id) +} + +/// Store a CLI native session ID under the account/profile that launched the +/// process, not whatever account the session row may point at when the process +/// exits. This prevents a slow old process from writing account A's native +/// conversation id into account B's resume slot after a mid-turn switch. +pub fn update_cli_session_id_for_account( + session_id: &str, + account_id: Option<&str>, + cli_session_id: &str, +) -> SqliteResult { + let conn = get_connection()?; + let tx = conn.unchecked_transaction()?; + if !stage_cli_session_id_for_account_with_tx(&tx, session_id, account_id, cli_session_id)? { + tx.commit()?; + return Ok(false); + } // Append-only binding ledger (native-transcript replay + sidebar dedup // keep recognizing superseded forks after account switch / message edit). - let binding = conn + let binding = tx .query_row( "SELECT COALESCE(cli_agent_type, platform) FROM code_sessions WHERE session_id = ?1", params![session_id], @@ -129,6 +214,96 @@ pub fn get_cli_session_id_for_account( .optional() } +/// Mark the exact provider-native binding as needing a catalog/index refresh. +/// The returned generation is acknowledged only after the native App update +/// succeeds; a newer terminal convergence makes an older worker's receipt +/// stale instead of allowing it to clear the newer request. +pub fn request_native_catalog_refresh( + session_id: &str, + account_id: Option<&str>, + cli_session_id: &str, +) -> SqliteResult> { + let conn = get_connection()?; + let profile_key = resume_profile_key(account_id); + conn.query_row( + "UPDATE code_session_cli_resume_state + SET native_catalog_requested_revision = native_catalog_requested_revision + 1 + WHERE session_id = ?1 AND profile_key = ?2 AND cli_session_id = ?3 + RETURNING session_id, profile_key, cli_session_id, + native_catalog_requested_revision", + params![session_id, profile_key, cli_session_id], + |row| { + Ok(NativeCatalogRefreshReceipt { + session_id: row.get(0)?, + profile_key: row.get(1)?, + cli_session_id: row.get(2)?, + requested_revision: row.get(3)?, + }) + }, + ) + .optional() +} + +/// Compare-and-set acknowledgement for one completed catalog refresh. +/// Returning false means the binding changed, a newer generation was +/// requested, or this receipt was already applied; in every case the caller +/// must not overwrite the current binding's durability state. +pub fn acknowledge_native_catalog_refresh( + receipt: &NativeCatalogRefreshReceipt, +) -> SqliteResult { + let conn = get_connection()?; + let affected = conn.execute( + "UPDATE code_session_cli_resume_state + SET native_catalog_applied_revision = ?4 + WHERE session_id = ?1 + AND profile_key = ?2 + AND cli_session_id = ?3 + AND native_catalog_requested_revision = ?4 + AND native_catalog_applied_revision < ?4", + params![ + receipt.session_id, + receipt.profile_key, + receipt.cli_session_id, + receipt.requested_revision, + ], + )?; + Ok(affected > 0) +} + +/// Load only dirty native-App catalog receipts. The startup repair path is +/// intentionally bounded and never scans provider transcripts or all sessions. +pub fn pending_native_catalog_refreshes( + limit: usize, +) -> SqliteResult> { + let conn = get_connection()?; + let mut statement = conn.prepare( + "SELECT r.session_id, r.profile_key, r.cli_session_id, + r.native_catalog_requested_revision, + l.source + FROM code_session_cli_resume_state r + JOIN code_session_native_transcript_ids l + ON l.session_id = r.session_id + AND l.source_session_id = r.cli_session_id + AND l.source IN ('claude_code', 'codex_app') + WHERE r.native_catalog_requested_revision + > r.native_catalog_applied_revision + ORDER BY r.updated_at ASC, r.session_id ASC, r.profile_key ASC + LIMIT ?1", + )?; + let rows = statement.query_map(params![i64::try_from(limit).unwrap_or(i64::MAX)], |row| { + Ok(PendingNativeCatalogRefresh { + receipt: NativeCatalogRefreshReceipt { + session_id: row.get(0)?, + profile_key: row.get(1)?, + cli_session_id: row.get(2)?, + requested_revision: row.get(3)?, + }, + source: row.get(4)?, + }) + })?; + rows.collect() +} + pub(in crate::agent_sessions::cli::persistence) fn bump_history_mutation_with_tx( tx: &rusqlite::Transaction<'_>, session_id: &str, diff --git a/src-tauri/src/agent_sessions/cli/session_runner/command.rs b/src-tauri/src/agent_sessions/cli/session_runner/command.rs index 18e238e69a..67d4760217 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/command.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/command.rs @@ -5,6 +5,7 @@ use crate::agent_sessions::cli::parsers::codex::CodexParser; use crate::agent_sessions::cli::parsers::cursor::CursorParser; use crate::agent_sessions::cli::parsers::plain_text::PlainTextParser; use crate::agent_sessions::cli::parsers::CliAgentParser; +use crate::agent_sessions::cli::session_runner::input_assembly::CliTurnEnvelope; use crate::agent_sessions::cli::session_runner::launch_profiles::{ defaults_for_agent, static_args_to_vec, uses_codex_app_server, ResolvedCliLaunchProfile, }; @@ -15,7 +16,7 @@ pub(super) struct CliCommandBuildRequest<'a> { pub agent: &'a ModelType, pub launch_profile: &'a ResolvedCliLaunchProfile, pub model: Option<&'a str>, - pub task: &'a str, + pub turn: &'a CliTurnEnvelope, pub resume_id: Option<&'a str>, pub api_key: Option<&'a str>, pub endpoint: Option<&'a str>, @@ -33,7 +34,7 @@ pub(super) fn build_command_with_launch_profile( agent, launch_profile, model, - task, + turn, resume_id, api_key, endpoint, @@ -59,13 +60,8 @@ pub(super) fn build_command_with_launch_profile( // travel over JSON-RPC (`thread/start` / `turn/start` params) instead. if uses_codex_app_server(agent, launch_profile) { let mut cmd = vec![launch_profile.command.clone()]; - // `app-server` does not expose `--profile` itself, but Codex's global - // option does. Keep it before the subcommand so the per-run MCP layer - // is loaded without putting its secret-bearing values in argv. - if let Some(profile) = codex_mcp_profile { - cmd.push("--profile".into()); - cmd.push(profile.into()); - } + // app-server rejects `--profile`; per-run MCP config travels in the + // thread JSON-RPC params so secrets never appear in argv. cmd.push("app-server".into()); if let Some(m) = model { let codex_model = map_codex_model_variant(m); @@ -117,7 +113,7 @@ pub(super) fn build_command_with_launch_profile( cmd.push(ws.into()); } cmd.push("-p".into()); - cmd.push(task.into()); + cmd.push(turn.merged_for_legacy()); cmd } ModelType::ClaudeCode => { @@ -151,8 +147,15 @@ pub(super) fn build_command_with_launch_profile( cmd.push("--add-dir".into()); cmd.push(dir.clone()); } + if let Some(provider_context) = turn.provider_context() { + // Claude Code appends this to its native system prompt. Keep + // `-p` reserved for the literal user-authored message so the + // provider JSONL and Claude app render the correct user row. + cmd.push("--append-system-prompt".into()); + cmd.push(provider_context); + } cmd.push("-p".into()); - cmd.push(task.into()); + cmd.push(turn.user_text().into()); cmd } ModelType::Codex => { @@ -186,7 +189,7 @@ pub(super) fn build_command_with_launch_profile( cmd.push("--add-dir".into()); cmd.push(dir.clone()); } - cmd.push(task.into()); + cmd.push(turn.merged_for_legacy()); cmd } ModelType::Copilot => { @@ -201,8 +204,6 @@ pub(super) fn build_command_with_launch_profile( } cmd } - // ACP agents: the task, cwd, and resume id all travel over JSON-RPC - // (`session/new` / `session/prompt`), never on the argv. ModelType::Kiro | ModelType::OpenCode | ModelType::DeepseekHarness => cmd, ModelType::Antigravity => { if let Some(rid) = resume_id { @@ -221,7 +222,7 @@ pub(super) fn build_command_with_launch_profile( cmd.push(dir.clone()); } cmd.push("--print".into()); - cmd.push(task.into()); + cmd.push(turn.merged_for_legacy()); cmd } ModelType::KimiCli @@ -247,8 +248,9 @@ pub(super) fn build_command_with_launch_profile( | ModelType::Pi | ModelType::QoderCli | ModelType::TraeCli => { - if !task.is_empty() { - cmd.push(task.into()); + let merged_task = turn.merged_for_legacy(); + if !merged_task.is_empty() { + cmd.push(merged_task); } cmd } @@ -419,17 +421,18 @@ fn strip_cli_date_suffix(model: &str) -> &str { /// Create the appropriate parser for a CLI agent type. /// -/// ACP agents (Copilot, Kiro, OpenCode, DeepSeek Harness) use bidirectional -/// JSON-RPC instead of CliAgentParser. API key providers are not CLI agents -/// and should never reach this function. +/// Copilot uses ACP (bidirectional JSON-RPC) instead of CliAgentParser. +/// API key providers are not CLI agents and should never reach this function. pub(super) fn create_parser(agent: &ModelType, session_id: &str) -> Box { match agent { ModelType::CursorCli => Box::new(CursorParser::new(session_id)), ModelType::ClaudeCode => Box::new(ClaudeCodeParser::new(session_id)), ModelType::Codex => Box::new(CodexParser::new(session_id)), - ModelType::Antigravity => Box::new(PlainTextParser::new(session_id)), + ModelType::Antigravity | ModelType::DeepseekHarness => { + Box::new(PlainTextParser::new(session_id)) + } other => panic!( - "ModelType::{:?} does not use CliAgentParser (Copilot/Kiro/OpenCode/DeepseekHarness use ACP; API providers are not CLI agents)", + "ModelType::{:?} does not use CliAgentParser (Copilot/Kiro/OpenCode use ACP; API providers are not CLI agents)", other ), } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs index 452e42d7c1..1b06304de8 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/env_setup.rs @@ -19,7 +19,7 @@ const OPENCODE_ZENMUX_PROVIDER_ID: &str = "zenmux"; const OPENCODE_ZENMUX_BASE_URL: &str = "https://zenmux.ai/api/v1"; const OPENCODE_DEFAULT_ZENMUX_MODEL: &str = "deepseek/deepseek-chat"; const ATLASCLOUD_PROVIDER_ID: &str = "atlascloud"; -const CODEX_COMPATIBLE_PROVIDER_ID: &str = "orgii_compatible"; +pub(crate) const CODEX_COMPATIBLE_PROVIDER_ID: &str = "orgii_compatible"; const ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1"; const ATLASCLOUD_DEFAULT_MODEL: &str = "zai-org/glm-5.1"; const OPENCODE_ZENMUX_MODEL_IDS: &[&str] = &[ @@ -248,7 +248,7 @@ fn codex_compatible_base_url(selected_key: &ModelKey) -> Result /// auth, WebSocket support and Codex's own retry defaults. Routing them through /// the synthetic compatible-provider table downgrades all four for no benefit. /// A custom endpoint override is the one case that still needs the table. -pub(super) fn codex_needs_compatible_profile(selected_key: &ModelKey) -> bool { +pub(crate) fn codex_needs_compatible_profile(selected_key: &ModelKey) -> bool { if selected_key.model_type != ModelType::OpenaiApi { return true; } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs index f62a25ed66..7dffd61d5f 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/finalize.rs @@ -1,10 +1,11 @@ //! Post-run finalization for CLI sessions. //! //! Everything after the spawn/stdout loop returns: compute the final session -//! status, extract a user-facing error message from stderr, persist status, -//! clear live-status, requeue Agent Org member turns, broadcast the terminal -//! event, commit worktree changes, fetch Cursor usage, and tear down the MITM -//! proxy / proxy token / synced skill files. Extracted from +//! status, extract a user-facing error message from stderr, flush and publish +//! provider-native history, persist status, clear live-status, requeue Agent +//! Org member turns, broadcast the terminal event, commit worktree changes, +//! fetch Cursor usage, and tear down the MITM proxy / proxy token / synced +//! skill files. Extracted from //! `session::run_session`. use std::collections::{HashSet, VecDeque}; @@ -334,7 +335,7 @@ pub(super) async fn finalize_session_run( }) .await; - let raw_final_status = if cli_plan_approval_gate_reached { + let mut raw_final_status = if cli_plan_approval_gate_reached { SessionStatus::Completed } else if use_codex_app_server { // exit_code is meaningless here — we kill the long-lived server @@ -355,27 +356,64 @@ pub(super) async fn finalize_session_run( } else { SessionStatus::Failed }; - if raw_final_status == SessionStatus::Failed { - super::input_assembly::forget_session_context(session_id); - } - + // A CLI that exhausted its context can exit 0 while its result frame + // reports `terminal_reason: prompt_too_long`. Demote the false success + // so the run records the overflow and the next wake starts fresh. + raw_final_status = if raw_final_status == SessionStatus::Completed + && terminal_error_message + .as_deref() + .is_some_and(app_utils::runtime_errors::is_context_exhausted_message) + { + SessionStatus::Failed + } else { + raw_final_status + }; // CLI member sessions inside an Agent Org run must land on `Idle` after each // successful turn so they remain available for the next coordinator dispatch. // `Completed` is terminal (is_terminal() == true) and would cause // `reconcile_run_finality` to prematurely end the run. let is_org_member = session.org_member_id.is_some(); - let final_status = if raw_final_status == SessionStatus::Completed && is_org_member { + let mut final_status = if raw_final_status == SessionStatus::Completed && is_org_member { SessionStatus::Idle } else { raw_final_status }; - let error_message: Option = if final_status == SessionStatus::Failed { + let mut error_message: Option = if final_status == SessionStatus::Failed { let buf = stderr_lines.lock().await; resolve_cli_failure_message(terminal_oauth_error.clone(), terminal_error_message, &buf) } else { None }; + // Native providers write the selected profile directly. Flushing final + // deltas is the only terminal persistence boundary. + flush_and_broadcast(session_id, turn_intent_id).await; + + // Converge the provider-written file before publishing the terminal + // lifecycle. Consumers may start the next runtime as soon as that durable + // status is visible, so the exact native transcript/alias must already be + // authoritative. Only the best-effort App catalog refresh is deferred. + if let Err(error) = + super::super::native_materializer::converge_bound_native_transcript_and_schedule_catalog( + session_id, + ) + .await + { + let convergence_error = + format!("Provider-native transcript could not be finalized safely: {error}"); + tracing::error!( + session_id, + error = %error, + "failing terminal lifecycle because provider-native transcript did not converge" + ); + raw_final_status = SessionStatus::Failed; + final_status = SessionStatus::Failed; + error_message = Some(convergence_error); + } + + if raw_final_status == SessionStatus::Failed { + super::input_assembly::forget_session_context(session_id); + } super::harness_hooks::finish_turn( session_id, @@ -520,9 +558,6 @@ pub(super) async fn finalize_session_run( agent_core::lifecycle::finalize_agent_org_member_turn(None, session_id, &outcome); } - // Flush any pending streaming deltas before signaling session end - flush_and_broadcast(session_id).await; - let mut status_msg = serde_json::json!({ "type": "code_session.status_changed", "session_id": session_id, diff --git a/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs b/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs index 9da4f224af..418da0d11c 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/helpers.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use tokio::sync::Mutex; @@ -20,7 +20,7 @@ type RunningSessionsMap = HashMap>; pub static RUNNING_SESSIONS: std::sync::LazyLock>> = std::sync::LazyLock::new(|| Arc::new(Mutex::new(HashMap::new()))); -type SessionControlLocksMap = HashMap>>; +type SessionControlLocksMap = HashMap>>; /// Per-session serialization of lifecycle control (cancel vs. new-turn /// dispatch). Without it, a slow `cancel_session` can interleave with a @@ -29,34 +29,33 @@ type SessionControlLocksMap = HashMap>>; static SESSION_CONTROL_LOCKS: std::sync::LazyLock> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); +// Provider identity (runtime/account/native UUID) is immutable for the whole +// runner lifetime. Unlike the short control lock, this guard travels with the +// background task through final native publication; a model picker may stage a +// next-turn choice but cannot retarget the active runner's filesystem binding. +static SESSION_IDENTITY_LOCKS: std::sync::LazyLock> = + std::sync::LazyLock::new(|| Mutex::new(HashMap::new())); + pub async fn session_control_lock(session_id: &str) -> Arc> { let mut locks = SESSION_CONTROL_LOCKS.lock().await; - locks - .entry(session_id.to_string()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(session_id).and_then(Weak::upgrade) { + return lock; + } + let lock = Arc::new(Mutex::new(())); + locks.insert(session_id.to_string(), Arc::downgrade(&lock)); + lock } -/// Strip the `...` block from user input. -/// IDE context is prepended by `inject_ide_context_into_prompt` for the CLI agent, -/// but should not be stored in the DB or shown to the user in chat history. -pub(super) fn strip_ide_context(input: &str) -> String { - const OPEN: &str = ""; - const CLOSE: &str = ""; - - let Some(start) = input.find(OPEN) else { - return input.to_string(); - }; - let Some(close_start) = input.find(CLOSE) else { - return input.to_string(); - }; - let mut after = close_start + CLOSE.len(); - while after < input.len() && input.as_bytes()[after].is_ascii_whitespace() { - after += 1; +pub async fn session_identity_lock(session_id: &str) -> Arc> { + let mut locks = SESSION_IDENTITY_LOCKS.lock().await; + locks.retain(|_, lock| lock.strong_count() > 0); + if let Some(lock) = locks.get(session_id).and_then(Weak::upgrade) { + return lock; } - let mut result = input[..start].to_string(); - result.push_str(&input[after..]); - result + let lock = Arc::new(Mutex::new(())); + locks.insert(session_id.to_string(), Arc::downgrade(&lock)); + lock } /// Persist an ActivityChunk to the database and broadcast it via WebSocket. @@ -76,6 +75,7 @@ pub(super) async fn emit_chunk( chunk: &core_types::activity::ActivityChunk, session_id: &str, sequence: &mut i64, + turn_intent_id: Option<&str>, ) { let action_type = chunk.action_type.as_str(); let is_delta = action_type.contains("delta") @@ -100,16 +100,22 @@ pub(super) async fn emit_chunk( // on the async runner; only chunks that may touch SQLite, the event cache, // or filesystem side effects cross onto the blocking pool. if is_delta && !delta_requires_flush { - emit_chunk_blocking(chunk, session_id, sequence); + emit_chunk_blocking(chunk, session_id, sequence, turn_intent_id); return; } let owned_chunk = chunk.clone(); let owned_session_id = session_id.to_string(); + let owned_turn_intent_id = turn_intent_id.map(str::to_string); let initial_sequence = *sequence; match tokio::task::spawn_blocking(move || { let mut next_sequence = initial_sequence; - emit_chunk_blocking(&owned_chunk, &owned_session_id, &mut next_sequence); + emit_chunk_blocking( + &owned_chunk, + &owned_session_id, + &mut next_sequence, + owned_turn_intent_id.as_deref(), + ); next_sequence }) .await @@ -123,6 +129,7 @@ fn emit_chunk_blocking( chunk: &core_types::activity::ActivityChunk, session_id: &str, sequence: &mut i64, + turn_intent_id: Option<&str>, ) { let action_type = chunk.action_type.as_str(); @@ -155,7 +162,7 @@ fn emit_chunk_blocking( .filter(|v| !v.is_empty()) .is_some(); if has_tool_identity { - flush_and_broadcast_blocking(session_id); + flush_and_broadcast_blocking(session_id, turn_intent_id); } } @@ -175,12 +182,7 @@ fn emit_chunk_blocking( } // Still broadcast the raw delta for the frontend typewriter effect - let ws_msg = serde_json::json!({ - "type": "code_session.activity", - "session_id": session_id, - "chunk": chunk, - }); - websocket_handler::broadcast(ws_msg.to_string()); + broadcast_activity_chunk(session_id, chunk, turn_intent_id); return; } @@ -190,7 +192,8 @@ fn emit_chunk_blocking( // Completion chunk: flush the matching stream from the buffer and // broadcast the Rust-accumulated SessionEvent. if is_message_type { - if let Some(event) = CLI_STREAMING_BUFFER.complete_message(session_id) { + if let Some(mut event) = CLI_STREAMING_BUFFER.complete_message(session_id) { + preserve_turn_intent(&mut event, turn_intent_id); persist_and_broadcast_streaming_complete( session_id, "message", @@ -198,7 +201,8 @@ fn emit_chunk_blocking( Some(sequence), ); } - } else if let Some(event) = CLI_STREAMING_BUFFER.complete_thinking(session_id) { + } else if let Some(mut event) = CLI_STREAMING_BUFFER.complete_thinking(session_id) { + preserve_turn_intent(&mut event, turn_intent_id); persist_and_broadcast_streaming_complete( session_id, "thinking", @@ -209,7 +213,7 @@ fn emit_chunk_blocking( } else { // Non-streaming chunk (tool_call, user_message, etc.): flush any // pending streams before appending, same as UnifiedEventHandler. - flush_and_broadcast_blocking(session_id); + flush_and_broadcast_blocking(session_id, turn_intent_id); } // Persist non-delta chunks to DB (legacy mode). Native-transcript @@ -233,12 +237,56 @@ fn emit_chunk_blocking( // Broadcast the original chunk as well (non-delta chunks like tool_call // are still consumed by the frontend via code_session.activity) - let ws_msg = serde_json::json!({ + broadcast_activity_chunk(session_id, chunk, turn_intent_id); +} + +/// Attach the existing runner intent to the wire envelope, not the provider +/// chunk. Provider `result` payloads are intentionally opaque and may be a +/// scalar, array, or null; wrapping or replacing them would corrupt native +/// tool/message semantics. +fn broadcast_activity_chunk( + session_id: &str, + chunk: &core_types::activity::ActivityChunk, + turn_intent_id: Option<&str>, +) { + websocket_handler::broadcast( + activity_chunk_message(session_id, chunk, turn_intent_id).to_string(), + ); +} + +fn activity_chunk_message( + session_id: &str, + chunk: &core_types::activity::ActivityChunk, + turn_intent_id: Option<&str>, +) -> serde_json::Value { + let mut message = serde_json::json!({ "type": "code_session.activity", "session_id": session_id, "chunk": chunk, }); - websocket_handler::broadcast(ws_msg.to_string()); + if let Some(turn_intent_id) = turn_intent_id.filter(|value| !value.is_empty()) { + message["turn_intent_id"] = serde_json::Value::String(turn_intent_id.to_string()); + } + message +} + +/// Streaming-buffer events are ORG2-owned normalized projections, so their +/// ordinary object result can carry the same intent identity durably. Refuse +/// to reshape an unexpected opaque result. +fn preserve_turn_intent( + event: &mut crate::agent_sessions::event_pipeline::types::SessionEvent, + turn_intent_id: Option<&str>, +) { + let Some(turn_intent_id) = turn_intent_id.filter(|value| !value.is_empty()) else { + return; + }; + let Some(result) = event.result.as_object_mut() else { + return; + }; + result.insert( + "turnIntentId".to_string(), + serde_json::Value::String(turn_intent_id.to_string()), + ); } /// Broadcast `agent:streaming_complete` for a flushed stream. @@ -263,14 +311,15 @@ fn persist_and_broadcast_streaming_complete( event: &crate::agent_sessions::event_pipeline::types::SessionEvent, sequence: Option<&mut i64>, ) { - // Native-transcript sessions broadcast only: neither the event cache - // nor a chunk row is written, but the sequence still advances so - // later persisted artifacts can't collide with broadcast ordering. + // The provider file remains the full transcript authority, but the event + // cache must durably own Rust's finalized stream suffix. Hidden canonical + // runners have no mounted renderer/CLI adapter, and an interrupted + // provider file may stop at the preceding complete item. Persisting this + // one normalized message/thinking row lets nativeTranscriptReconcile merge + // the safe suffix without creating a second chunk or conversation plane. let persists = persistence::session_persists_chunks(session_id); - if persists { - let cached = session_event_to_cached_event(event); - let _ = save_events_retry("cli-stream-flush", session_id, &[cached], 5); - } + let cached = session_event_to_cached_event(event); + let _ = save_events_retry("cli-stream-flush", session_id, &[cached], 5); if let Some(sequence) = sequence { if persists { persist_streaming_complete_chunk(session_id, stream_type, event, sequence); @@ -317,9 +366,10 @@ fn persist_streaming_complete_chunk( } /// Flush all pending CLI streams and broadcast completion events. -fn flush_and_broadcast_blocking(session_id: &str) { +fn flush_and_broadcast_blocking(session_id: &str, turn_intent_id: Option<&str>) { let mut sequence = next_chunk_sequence(session_id); - for event in agent_core::foundation::streaming::cli_flush_session(session_id) { + for mut event in agent_core::foundation::streaming::cli_flush_session(session_id) { + preserve_turn_intent(&mut event, turn_intent_id); let stream_type = if event.action_type == "assistant" { "message" } else { @@ -334,10 +384,11 @@ fn flush_and_broadcast_blocking(session_id: &str) { } } -pub(super) async fn flush_and_broadcast(session_id: &str) { +pub(super) async fn flush_and_broadcast(session_id: &str, turn_intent_id: Option<&str>) { let owned_session_id = session_id.to_string(); + let owned_turn_intent_id = turn_intent_id.map(str::to_string); if let Err(err) = tokio::task::spawn_blocking(move || { - flush_and_broadcast_blocking(&owned_session_id); + flush_and_broadcast_blocking(&owned_session_id, owned_turn_intent_id.as_deref()); }) .await { @@ -346,7 +397,7 @@ pub(super) async fn flush_and_broadcast(session_id: &str) { } pub async fn flush_cli_streams_for_session(session_id: &str) { - flush_and_broadcast(session_id).await; + flush_and_broadcast(session_id, None).await; } /// Drop hook-derived live status for a finished managed session. The @@ -571,6 +622,55 @@ pub(super) async fn persist_attached_images( mod tests { use super::*; + fn streaming_event() -> crate::agent_sessions::event_pipeline::types::SessionEvent { + let buffer = agent_core::foundation::streaming::StreamingBuffer::new(5_000); + buffer.append_message_delta("intent-test", "hello"); + buffer + .complete_message("intent-test") + .expect("streaming event") + } + + #[test] + fn activity_wire_identity_does_not_mutate_opaque_provider_result() { + for result in [ + serde_json::Value::Null, + serde_json::json!("opaque"), + serde_json::json!(["opaque"]), + ] { + let chunk = core_types::activity::ActivityChunk::new( + "intent-test", + "provider_event", + "provider_event", + ) + .with_result(result.clone()); + let message = activity_chunk_message("intent-test", &chunk, Some("turn-1")); + + assert_eq!(message["turn_intent_id"], "turn-1"); + assert_eq!(message["chunk"]["result"], result); + assert_eq!(chunk.result, result); + } + } + + #[test] + fn streaming_identity_preserves_non_object_result_shapes() { + for result in [ + serde_json::Value::Null, + serde_json::json!("opaque"), + serde_json::json!(["opaque"]), + ] { + let mut event = streaming_event(); + event.result = result.clone(); + + preserve_turn_intent(&mut event, Some("turn-1")); + + assert_eq!(event.result, result); + } + + let mut event = streaming_event(); + preserve_turn_intent(&mut event, Some("turn-1")); + assert_eq!(event.result["turnIntentId"], "turn-1"); + } + #[test] fn cli_file_edit_detection_covers_display_and_storage_names() { for function_name in [ diff --git a/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs b/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs index ab567bbfe8..4a50e4e2c9 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/input_assembly.rs @@ -1,15 +1,14 @@ -//! Prompt assembly for CLI sessions. +//! Typed turn assembly for CLI sessions. //! -//! Builds the effective user input sent to the agent: exec-mode bridge -//! preamble, prior-conversation context bridge, attached-image references, -//! and (for ACP agents without native rules-file sync) an inline skills -//! injection. Extracted from `session::run_session` to keep the runner's -//! orchestration readable. +//! Keeps the user's visible message separate from provider-only context such +//! as exec-mode, workspace, hook, IDE and prior-conversation bridges. Native +//! transports can route those fields to their system/developer channel while +//! legacy transports retain the historical merged-prompt behavior. use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; -use agent_core::session::AgentExecMode; +use agent_core::session::{AgentExecMode, IdeContext}; use key_vault::key_store::ModelType; use sha2::{Digest, Sha256}; @@ -26,6 +25,80 @@ type DeliveredContextDigests = HashMap> = LazyLock::new(|| Mutex::new(HashMap::new())); +/// One CLI turn before provider-specific transport encoding. +/// +/// `provider_context_prefix` / `provider_context_suffix` preserve the legacy +/// merged prompt's ordering for transports that do not yet expose a native +/// system/developer channel. Native transports consume `user_text` and +/// `provider_context()` independently, so provider context never becomes a +/// visible user message in their native transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct CliTurnEnvelope { + user_text: String, + provider_context_prefix: Vec, + provider_context_suffix: Vec, +} + +impl CliTurnEnvelope { + pub(super) fn new(user_text: impl Into) -> Self { + Self { + user_text: user_text.into(), + provider_context_prefix: Vec::new(), + provider_context_suffix: Vec::new(), + } + } + + #[cfg(test)] + pub(super) fn from_parts( + user_text: impl Into, + provider_context: impl Into, + ) -> Self { + let mut turn = Self::new(user_text); + turn.prepend_provider_context(provider_context); + turn + } + + pub(super) fn user_text(&self) -> &str { + &self.user_text + } + + pub(super) fn prepend_provider_context(&mut self, context: impl Into) { + let context = context.into(); + if !context.trim().is_empty() { + self.provider_context_prefix.insert(0, context); + } + } + + fn append_provider_context(&mut self, context: impl Into) { + let context = context.into(); + if !context.trim().is_empty() { + self.provider_context_suffix.push(context); + } + } + + pub(super) fn provider_context(&self) -> Option { + let context = self + .provider_context_prefix + .iter() + .chain(self.provider_context_suffix.iter()) + .map(String::as_str) + .collect::>() + .join("\n\n"); + (!context.is_empty()).then_some(context) + } + + /// Compatibility encoding for providers without a native context channel. + pub(super) fn merged_for_legacy(&self) -> String { + let mut sections = Vec::with_capacity( + self.provider_context_prefix.len() + self.provider_context_suffix.len() + 1, + ); + sections.extend(self.provider_context_prefix.iter().map(String::as_str)); + sections.push(self.user_text.as_str()); + sections.extend(self.provider_context_suffix.iter().map(String::as_str)); + sections.join("\n\n") + } +} + fn should_deliver_context( session_id: &str, agent: &ModelType, @@ -141,13 +214,14 @@ fn project_mode_bridge( )) } -/// Assemble the effective prompt from the raw user input plus the CLI-session -/// preambles. `is_fresh_session` is true when there is no `cli_resume_id` -/// (only a fresh conversation gets the prior-context bridge). `skills_enabled` -/// / `disabled_skills` come from the resolved SDE skills config. +/// Assemble the visible user turn and its provider-only context. +/// `is_fresh_session` is true when there is no `cli_resume_id` (only a fresh +/// conversation gets the prior-context bridge). `skills_enabled` / +/// `disabled_skills` come from the resolved SDE skills config. #[allow(clippy::too_many_arguments)] -pub(super) fn build_effective_input( +pub(super) fn build_turn_envelope( user_input: &str, + ide_context: Option<&IdeContext>, mode: Option<&str>, product_mode: Option<&str>, project_slug: Option<&str>, @@ -161,22 +235,30 @@ pub(super) fn build_effective_input( skills_enabled: bool, disabled_skills: &[String], status_catalog: Option<&str>, -) -> String { - let mut effective_input = user_input.to_string(); +) -> CliTurnEnvelope { + let mut turn = CliTurnEnvelope::new(user_input); + + if let Some(ide_context) = ide_context { + let context = + agent_core::core::session::prompt::ide_context::format_ide_context(ide_context); + if !context.is_empty() { + turn.prepend_provider_context(format!("\n{}\n", context)); + } + } if let Some(exec_mode_bridge) = cli_exec_mode_bridge(mode) { - effective_input = format!("{}\n\n{}", exec_mode_bridge, effective_input); + turn.prepend_provider_context(exec_mode_bridge); } if let Some(project_mode_bridge) = project_mode_bridge(product_mode, project_slug, work_item_id, status_catalog) { - effective_input = format!("{}\n\n{}", project_mode_bridge, effective_input); + turn.prepend_provider_context(project_mode_bridge); } if is_fresh_session { if let Some(context_bridge) = build_context_bridge(session_id) { - effective_input = format!("{}\n\n{}", context_bridge, effective_input); + turn.prepend_provider_context(context_bridge); } } @@ -186,21 +268,24 @@ pub(super) fn build_effective_input( .enumerate() .map(|(idx, path)| format!("Image {}: {}", idx + 1, path)) .collect(); - effective_input = format!( - "{}\n\nIMPORTANT: The user attached {} image(s). You MUST read each image file below before responding. Use your read_file or view_image tool on these absolute paths:\n{}", - effective_input, + turn.append_provider_context(format!( + "IMPORTANT: The user attached {} image(s). You MUST read each image file below before responding. Use your read_file or view_image tool on these absolute paths:\n{}", image_paths.len(), refs.join("\n"), - ); + )); } // Deliver one provider-neutral workspace contract to every CLI, even when // that provider also has a native rules file. Native discovery behavior // differs across versions and typically understands only one ecosystem // filename (for example CLAUDE.md *or* AGENTS.md); the shared envelope - // guarantees parity across providers. The digest gate sends unchanged - // context once per app process/provider conversation and re-sends it when - // rules or the progressive skill catalog change. + // guarantees parity across providers. Native context-channel transports + // re-send the complete current contract on every start/resume because + // their developer/system override is per launch and may replace the prior + // override. Legacy merged transports keep the digest gate to avoid paying + // for unchanged rules on every resumed turn. + let native_context_channel = matches!(agent, ModelType::ClaudeCode) + || (matches!(agent, ModelType::Codex) && use_codex_app_server); if let Some(path) = repo_path.and_then(|path| { let path = std::path::Path::new(path); path.is_dir().then_some(path) @@ -210,9 +295,11 @@ pub(super) fn build_effective_input( skills_enabled, disabled_skills, ) - .filter(|context| should_deliver_context(session_id, agent, context, is_fresh_session)) - { - effective_input = format!("{}\n\n{}", context, effective_input); + .filter(|context| { + native_context_channel + || should_deliver_context(session_id, agent, context, is_fresh_session) + }) { + turn.prepend_provider_context(context); } } @@ -229,18 +316,19 @@ pub(super) fn build_effective_input( if let Some(hook_prompt) = hook_executor .collect_prompt_hooks(agent_core::specialization::hooks::HookEvent::PrePromptBuild) { - effective_input = format!( - "\n{}\n\n\n{}", - hook_prompt, effective_input - ); + turn.prepend_provider_context(format!( + "\n{}\n", + hook_prompt + )); } - effective_input + turn } #[cfg(test)] mod tests { - use super::{build_effective_input, project_mode_bridge}; + use super::{build_turn_envelope, project_mode_bridge}; + use agent_core::session::IdeContext; use key_vault::key_store::ModelType; #[test] @@ -324,8 +412,9 @@ mod tests { for provider in providers { assert!(provider.is_cli_agent()); - let prompt = build_effective_input( + let turn = build_turn_envelope( "do the task", + None, Some("build"), Some("build"), None, @@ -340,13 +429,15 @@ mod tests { &[], None, ); + let context = turn.provider_context().expect("provider context"); + assert_eq!(turn.user_text(), "do the task"); assert!( - prompt.contains("PROVIDER_CONTEXT_SENTINEL"), + context.contains("PROVIDER_CONTEXT_SENTINEL"), "{} missed workspace context", provider.as_str() ); assert!( - !prompt.contains("orgii_project_mode"), + !context.contains("orgii_project_mode"), "{} received Project capabilities in ordinary Build", provider.as_str() ); @@ -360,8 +451,9 @@ mod tests { std::fs::write(&agents_md, "CONTEXT_V1").expect("write v1"); let build = || { - build_effective_input( + build_turn_envelope( "do the task", + None, Some("build"), Some("build"), None, @@ -377,11 +469,17 @@ mod tests { None, ) }; - assert!(build().contains("CONTEXT_V1")); - assert!(!build().contains("CONTEXT_V1")); + assert!(build() + .provider_context() + .is_some_and(|context| context.contains("CONTEXT_V1"))); + assert!(!build() + .provider_context() + .is_some_and(|context| context.contains("CONTEXT_V1"))); std::fs::write(&agents_md, "CONTEXT_V2").expect("write v2"); - assert!(build().contains("CONTEXT_V2")); + assert!(build() + .provider_context() + .is_some_and(|context| context.contains("CONTEXT_V2"))); } #[test] @@ -389,8 +487,9 @@ mod tests { let workspace = tempfile::tempdir().expect("workspace"); std::fs::write(workspace.path().join("AGENTS.md"), "FRESH_CONTEXT").expect("write context"); let build = |is_fresh_session| { - build_effective_input( + build_turn_envelope( "do the task", + None, Some("build"), Some("build"), None, @@ -406,8 +505,84 @@ mod tests { None, ) }; - assert!(build(true).contains("FRESH_CONTEXT")); - assert!(!build(false).contains("FRESH_CONTEXT")); - assert!(build(true).contains("FRESH_CONTEXT")); + assert!(build(true) + .provider_context() + .is_some_and(|context| context.contains("FRESH_CONTEXT"))); + assert!(!build(false) + .provider_context() + .is_some_and(|context| context.contains("FRESH_CONTEXT"))); + assert!(build(true) + .provider_context() + .is_some_and(|context| context.contains("FRESH_CONTEXT"))); + } + + #[test] + fn visible_user_text_is_never_polluted_by_agent_context() { + let ide_context = IdeContext { + active_file: Some("src/main.rs".to_string()), + git_branch: Some("feature/native-context".to_string()), + ..IdeContext::default() + }; + let turn = build_turn_envelope( + "Please inspect this exact message.", + Some(&ide_context), + Some("build"), + Some("build"), + None, + None, + "typed-envelope-session", + false, + &ModelType::ClaudeCode, + &[], + false, + None, + false, + &[], + None, + ); + + assert_eq!(turn.user_text(), "Please inspect this exact message."); + let context = turn.provider_context().expect("provider context"); + assert!(context.contains("")); + assert!(context.contains("")); + assert!(!turn.user_text().contains("")); + assert!(turn + .merged_for_legacy() + .ends_with("Please inspect this exact message.")); + } + + #[test] + fn native_context_channels_resend_current_workspace_context_on_resume() { + let workspace = tempfile::tempdir().expect("workspace"); + std::fs::write(workspace.path().join("AGENTS.md"), "NATIVE_CONTEXT") + .expect("write context"); + + for (agent, use_codex_app_server) in + [(ModelType::ClaudeCode, false), (ModelType::Codex, true)] + { + for _ in 0..2 { + let turn = build_turn_envelope( + "resume", + None, + Some("build"), + Some("build"), + None, + None, + &format!("native-resume-{}", agent.as_str()), + false, + &agent, + &[], + use_codex_app_server, + workspace.path().to_str(), + false, + &[], + None, + ); + assert!(turn + .provider_context() + .is_some_and(|context| context.contains("NATIVE_CONTEXT"))); + } + } } } diff --git a/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs b/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs index 65df576b3b..cb579428d6 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/lifecycle.rs @@ -1,6 +1,6 @@ //! Session lifecycle management — kill, cancel, cleanup. -use super::super::persistence; +use super::super::persistence::{self, CodeSession}; use super::super::types::SessionStatus; use super::helpers::{flush_cli_streams_for_session, RUNNING_SESSIONS}; use agent_core::state::control_flow::CancelReason; @@ -76,6 +76,19 @@ pub async fn kill_running_agent(session_id: &str) -> bool { // start/stop operations would serialize behind it. flush_cli_streams_for_session(session_id).await; handle.abort(); + // `abort()` only requests cancellation. Await the handle so the + // runner future has actually dropped its provider-identity guard + // before a follow-up publishes the interrupted snapshot or launches + // another turn against the same native UUID. + if let Err(error) = handle.await { + if !error.is_cancelled() { + tracing::warn!( + session_id, + error = %error, + "CLI runner failed while waiting for cancellation" + ); + } + } } let process_session_id = session_id.to_string(); @@ -92,98 +105,96 @@ pub async fn kill_running_agent(session_id: &str) -> bool { had_running_task } -/// Cancel a running session by killing the CLI subprocess. -/// -/// Does NOT release the proxy token — follow-up messages via -/// `cli_agent_message` always re-allocate a fresh token anyway. -/// The old token expires via the agent-proxy inactivity timeout or -/// is released on session deletion. -pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result { - // Serialize against `cli_agent_message` / `cli_agent_run` for this - // session: a cancel whose DB lookup lands after a follow-up turn was - // accepted would otherwise cancel the NEW intent and kill the new - // process ("stop then send loses both messages"). - let control_lock = super::helpers::session_control_lock(session_id).await; - let _control_guard = control_lock.lock().await; - - // The previous `.ok().flatten()` collapsed a DB error and a - // legitimate "session not found" into the same `None`. The - // status_changed broadcast below would then ship without - // `background` / `session_name` populated, and the UI would - // silently render an "unknown session cancelled" toast. Warn - // on the DB-error branch so the cause is visible while still - // proceeding with the cancel (we don't want to fail the cancel - // just because we couldn't decorate the broadcast). +async fn terminal_context( + session_id: &str, +) -> Result<(Option, Option), String> { let lookup_session_id = session_id.to_string(); - let (session, active_turn_intent_id) = match tokio::task::spawn_blocking(move || { + tokio::task::spawn_blocking(move || { let session = persistence::get_session(&lookup_session_id).map_err(|err| err.to_string())?; - let latest = session_persistence::turn_intents::latest_for_sessions(std::slice::from_ref( - &lookup_session_id, - )) + let active_turn_intent_id = session_persistence::turn_intents::latest_for_sessions( + std::slice::from_ref(&lookup_session_id), + ) .map_err(|err| err.to_string())? .remove(&lookup_session_id) .filter(|intent| { intent.status == session_persistence::turn_intents::TurnIntentStatus::Running }) .map(|intent| intent.turn_intent_id); - Ok::<_, String>((session, latest)) + Ok::<_, String>((session, active_turn_intent_id)) }) .await - { - Ok(Ok(result)) => result, - Ok(Err(err)) => { - tracing::warn!( - session_id = %session_id, - error = %err, - "cli::cancel_session: get_session DB error; broadcast will lack session metadata" - ); - (None, None) - } - Err(err) => { - tracing::warn!( - session_id = %session_id, - error = %err, - "cli::cancel_session: status lookup task failed" - ); - (None, None) - } - }; + .map_err(|err| format!("Task error: {err}"))? +} - // Codex app-server transport: ask the running turn to interrupt - // gracefully (bounded wait) so codex finalizes the rollout before we - // kill the process tree. No-op for every other transport/agent. - crate::agent_sessions::cli::parsers::codex_app_server::interrupt_session_gracefully(session_id) - .await; +/// One terminal owner for runner interruption paths. Callers must already have +/// stopped the runner and hold its identity boundary before invoking this. +struct InterruptedTerminal<'a> { + status: SessionStatus, + intent_status: session_persistence::turn_intents::TurnIntentStatus, + error: Option<&'a str>, + reason: Option<&'a str>, +} - let had_running = kill_running_agent(session_id).await; +async fn finalize_interrupted_runner( + session_id: &str, + session: Option<&CodeSession>, + active_turn_intent_id: Option<&str>, + terminal: InterruptedTerminal<'_>, +) -> Result<(), String> { + // Preserve every provider-durable partial row before publishing the + // terminal intent. A runtime switch may begin as soon as that intent is + // visible, so transcript/alias convergence belongs to the durable + // terminal boundary. Catalog refresh remains deferred and idempotent. + let convergence_error = if let Err(error) = + super::super::native_materializer::converge_bound_native_transcript_and_schedule_catalog( + session_id, + ) + .await + { + tracing::error!( + session_id, + error = %error, + "failing interrupted terminal because provider-native transcript did not converge" + ); + Some(format!( + "Provider-native transcript could not be finalized safely: {error}" + )) + } else { + None + }; let persist_session_id = session_id.to_string(); - let persist_turn_intent_id = active_turn_intent_id.clone(); + let persist_turn_intent_id = active_turn_intent_id.map(str::to_string); + let persist_error = convergence_error + .clone() + .or_else(|| terminal.error.map(str::to_string)); + let broadcast_error = persist_error.clone(); + let terminal_status = if convergence_error.is_some() { + SessionStatus::Failed + } else { + terminal.status + }; + let terminal_intent_status = if convergence_error.is_some() { + session_persistence::turn_intents::TurnIntentStatus::Failed + } else { + terminal.intent_status + }; tokio::task::spawn_blocking(move || { persistence::update_cli_turn_lifecycle( &persist_session_id, - SessionStatus::Cancelled, - None, - persist_turn_intent_id.as_deref().map(|turn_intent_id| { - ( - turn_intent_id, - session_persistence::turn_intents::TurnIntentStatus::Cancelled, - ) - }), + terminal_status, + persist_error.as_deref(), + persist_turn_intent_id + .as_deref() + .map(|turn_intent_id| (turn_intent_id, terminal_intent_status)), ) }) .await .map_err(|err| format!("Task error: {err}"))??; - // Cancelling also wakes any parked PermissionRequest hook long-poll - // (no-decision) — covered again by clear_live_status below when the - // agent type is known, but the else branches skip it. super::super::hook_approvals::unregister_session(session_id); - - // Cancelled is terminal: drop any hook-derived live status so the - // sidebar doesn't keep a ghost working/waiting entry for this session. - if let Some(ref session) = session { + if let Some(session) = session { if let Some(agent) = session .cli_agent_type .as_deref() @@ -204,16 +215,176 @@ pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result Result<(), String> { + let (session, active_turn_intent_id) = terminal_context(session_id).await?; + finalize_interrupted_runner( + session_id, + session.as_ref(), + active_turn_intent_id.as_deref(), + InterruptedTerminal { + status: SessionStatus::Failed, + intent_status: session_persistence::turn_intents::TurnIntentStatus::Failed, + error: Some(error), + reason: None, + }, + ) + .await +} + +/// Persist the old turn boundary before a force-follow-up rebinds runtime, +/// account, or model. The caller has already stopped the process and owns the +/// session identity lock, exactly like the user-cancel path. +pub(crate) async fn finalize_interrupted_follow_up( + session_id: &str, + interrupt_error: Option<&str>, +) -> Result<(), String> { + let (session, active_turn_intent_id) = terminal_context(session_id).await?; + finalize_interrupted_runner( + session_id, + session.as_ref(), + active_turn_intent_id.as_deref(), + InterruptedTerminal { + status: if interrupt_error.is_some() { + SessionStatus::Failed + } else { + SessionStatus::Cancelled + }, + intent_status: if interrupt_error.is_some() { + session_persistence::turn_intents::TurnIntentStatus::Failed + } else { + session_persistence::turn_intents::TurnIntentStatus::Cancelled + }, + error: interrupt_error, + reason: Some("replaced_by_follow_up"), + }, + ) + .await +} + +/// Cancel a running session by killing the CLI subprocess. +/// +/// Does NOT release the proxy token — follow-up messages via +/// `cli_agent_message` always re-allocate a fresh token anyway. +/// The old token expires via the agent-proxy inactivity timeout or +/// is released on session deletion. +pub async fn cancel_session(session_id: &str, reason: CancelReason) -> Result { + // Serialize against `cli_agent_message` / `cli_agent_run` for this + // session: a cancel whose DB lookup lands after a follow-up turn was + // accepted would otherwise cancel the NEW intent and kill the new + // process ("stop then send loses both messages"). + let control_lock = super::helpers::session_control_lock(session_id).await; + let control_guard = control_lock.lock().await; + + // The previous `.ok().flatten()` collapsed a DB error and a + // legitimate "session not found" into the same `None`. The + // status_changed broadcast below would then ship without + // `background` / `session_name` populated, and the UI would + // silently render an "unknown session cancelled" toast. Warn + // on the DB-error branch so the cause is visible while still + // proceeding with the cancel (we don't want to fail the cancel + // just because we couldn't decorate the broadcast). + let (session, active_turn_intent_id) = match terminal_context(session_id).await { + Ok(result) => result, + Err(err) => { + tracing::warn!( + session_id = %session_id, + error = %err, + "cli::cancel_session: terminal context unavailable; broadcast will lack session metadata" + ); + (None, None) + } + }; + + // Codex app-server transport: ask the running turn to interrupt + // gracefully (bounded wait) so codex finalizes the rollout before we + // kill the process tree. No-op for every other transport/agent. + let interrupt_outcome = + crate::agent_sessions::cli::parsers::codex_app_server::interrupt_session_gracefully( + session_id, + ) + .await; + + let had_running = kill_running_agent(session_id).await; + // `kill_running_agent` awaits the aborted runner, so its lifetime identity + // guard is gone. Reacquire identity while the control guard is still held + // before persisting the terminal turn boundary. + let _identity_guard = super::session_identity_lock(session_id) + .await + .lock_owned() + .await; + + // A timed-out Codex app-server interrupt cannot advertise a clean native + // resume boundary. Ordinary native runtimes already write their one + // authoritative profile directly, so no second copy step exists. + let interrupt_error = if matches!( + interrupt_outcome, + crate::agent_sessions::cli::parsers::codex_app_server::GracefulInterruptOutcome::TimedOut + ) { + Some("Codex did not finish its native interrupted turn".to_string()) + } else { + None + }; + let terminal_status = if interrupt_error.is_some() { + SessionStatus::Failed + } else { + SessionStatus::Cancelled + }; + let terminal_intent_status = if interrupt_error.is_some() { + session_persistence::turn_intents::TurnIntentStatus::Failed + } else { + session_persistence::turn_intents::TurnIntentStatus::Cancelled + }; + + finalize_interrupted_runner( + session_id, + session.as_ref(), + active_turn_intent_id.as_deref(), + InterruptedTerminal { + status: terminal_status, + intent_status: terminal_intent_status, + error: interrupt_error.as_deref(), + reason: Some(reason.as_str()), + }, + ) + .await?; + drop(control_guard); + + if let Some(error) = interrupt_error { + tracing::error!( + "[CodeSession] Session {} cancellation failed closed (reason={}, had_running={}): {}", + session_id, + reason.as_str(), + had_running, + error + ); + return Err(error); + } + tracing::info!( "[CodeSession] Session {} cancelled (reason={}, had_running={})", session_id, diff --git a/src-tauri/src/agent_sessions/cli/session_runner/mod.rs b/src-tauri/src/agent_sessions/cli/session_runner/mod.rs index 0a97dc4874..4d87b142c0 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/mod.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/mod.rs @@ -7,7 +7,7 @@ //! - `helpers` — shared state, emit_chunk, image persistence //! - `command` — CLI command building and parser factory //! - `session` — core run_session function -//! - `input_assembly` — effective-prompt assembly (bridges, images, skills) +//! - `input_assembly` — typed user/context turn assembly (bridges, images, skills) //! - `env_setup` — child-process env / profile-dir / proxy preparation //! - `finalize` — post-run status, error surfacing, resource teardown //! - `lifecycle` — kill, cancel, cleanup @@ -21,7 +21,7 @@ pub(crate) mod command; mod context_bridge; mod cursor_usage; -mod env_setup; +pub(crate) mod env_setup; mod finalize; mod harness_hooks; mod helpers; @@ -35,17 +35,17 @@ mod session; mod token_sync; pub(crate) use harness_hooks::stop_session as stop_session_hooks; -pub use helpers::{flush_cli_streams_for_session, session_control_lock, RUNNING_SESSIONS}; +pub use helpers::{ + flush_cli_streams_for_session, session_control_lock, session_identity_lock, RUNNING_SESSIONS, +}; pub(crate) use input_assembly::forget_session_context; pub use lifecycle::{ cancel_session, cleanup_cursor_config_dir, kill_running_agent, terminate_process_tree, }; +pub(crate) use lifecycle::{fail_interrupted_turn, finalize_interrupted_follow_up}; pub use proxy_release::release_proxy_token_for_session_pub; pub use session::run_session; - -#[cfg(test)] -#[path = "../tests/runner_tests.rs"] -mod tests; +pub(crate) use session::run_session_with_ide_context; #[cfg(test)] #[path = "../tests/runner_command_tests.rs"] diff --git a/src-tauri/src/agent_sessions/cli/session_runner/oauth_setup.rs b/src-tauri/src/agent_sessions/cli/session_runner/oauth_setup.rs index a2e5699950..97cf08327d 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/oauth_setup.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/oauth_setup.rs @@ -40,6 +40,13 @@ pub(super) fn is_cli_oauth_failure_message(message: &str) -> bool { } pub(super) fn chunk_error_message(chunk: &ActivityChunk) -> Option { + // Retry signals belong to provider failures. Successful replies and tool + // results may quote status codes or authentication errors as ordinary text. + let failed_session_end = chunk.action_type == "session_end" + && chunk.result.get("success").and_then(serde_json::Value::as_bool) == Some(false); + if chunk.action_type != "error" && !failed_session_end { + return None; + } let result = &chunk.result; result .get("error_message") diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session.rs b/src-tauri/src/agent_sessions/cli/session_runner/session.rs index 7ed7d527bd..a76faf6bd1 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session.rs @@ -8,7 +8,7 @@ //! - `spawn_retry` — transient subprocess-spawn retry helpers //! - `skills_resolve` — built-in SDE agent skills-config resolution -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::process::Stdio; use std::sync::Arc; @@ -17,7 +17,7 @@ use tokio::process::Command; use tokio::sync::Mutex; use crate::api::websocket_handler; -use agent_core::session::AgentExecMode; +use agent_core::session::{AgentExecMode, IdeContext}; use key_vault::key_store::{KeyService, ModelType, KEY_SERVICE}; use super::super::launch_profile_store::resolve_cli_launch_profile; @@ -26,7 +26,7 @@ use super::super::types::KeySource; use super::command::{ build_command_with_launch_profile, launch_profile_env, CliCommandBuildRequest, }; -use super::helpers::{emit_chunk, persist_attached_images, strip_ide_context}; +use super::helpers::{emit_chunk, persist_attached_images}; use super::oauth_setup::{ is_cli_oauth_retry_eligible, refresh_cli_oauth_for_retry, sanitize_cli_oauth_env_for_child, }; @@ -46,6 +46,44 @@ const OVERLOAD_RETRY_BASE_DELAY_SECS: u64 = 2; const MAX_STDERR_LINES: usize = 20; +/// 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 +/// removed or a prior shell/launcher setting can silently reroute the child. +const CLAUDE_ACCOUNT_ENV_KEYS: &[&str] = &[ + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS", + "DISABLE_INTERLEAVED_THINKING", + "CLAUDE_CONFIG_DIR", +]; + +fn merge_launch_profile_environment( + agent: &ModelType, + has_explicit_account: bool, + selected_environment: &mut HashMap, + profile_environment: HashMap, +) { + for (key, value) in profile_environment { + // A stored launch profile is a runtime default, never a credential or + // routing authority. In particular, after selecting a Claude account, + // absent account-owned keys must remain absent so apply_child_environment + // can remove stale ambient Atlas/Anthropic routing. + if has_explicit_account + && matches!(agent, ModelType::ClaudeCode) + && CLAUDE_ACCOUNT_ENV_KEYS.contains(&key.as_str()) + { + continue; + } + selected_environment.entry(key).or_insert(value); + } +} + /// How long to keep waiting for the stderr reader once the child is gone. A /// CLI that hands its stderr to a surviving grandchild keeps the pipe open /// forever, and no diagnostic is worth hanging the turn on. @@ -80,6 +118,25 @@ fn environment_key_is_sensitive(key: &str) -> bool { .any(|marker| key.contains(marker)) } +fn apply_child_environment( + command: &mut Command, + agent: &ModelType, + has_explicit_account: bool, + env_vars: &HashMap, +) { + // An ambient Claude launch intentionally inherits the user's shell/CLI + // profile. Once the composer selects an ORGII account, however, that + // account is the complete routing source and absent keys must stay absent. + if has_explicit_account && matches!(agent, ModelType::ClaudeCode) { + for key in CLAUDE_ACCOUNT_ENV_KEYS { + if !env_vars.contains_key(*key) { + command.env_remove(key); + } + } + } + command.envs(env_vars); +} + fn redacted_command_parts(cmd_parts: &[String]) -> Vec { cmd_parts .iter() @@ -278,6 +335,39 @@ fn resolve_session_model( } } +/// Claude Code accepts provider-specific model ids (for example Atlas Cloud's +/// `zai-org/glm-5.1`) through its Anthropic-compatible environment, not the +/// CLI's `--model` validator. `KeyService::get_env_for_agent` supplies a safe +/// account-level fallback, but the session's explicit model selection must win +/// whenever one is present. +fn apply_claude_cross_type_session_model( + agent: &ModelType, + key_model_type: Option<&ModelType>, + session_model: Option<&str>, + env_vars: &mut HashMap, +) { + let is_cross_type_key = key_model_type.is_some_and(|key_type| key_type != agent); + if !matches!(agent, ModelType::ClaudeCode) || !is_cross_type_key { + return; + } + + let Some(model) = session_model + .map(str::trim) + .filter(|model| !model.is_empty()) + else { + return; + }; + + for key in [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ] { + env_vars.insert(key.to_string(), model.to_string()); + } +} + fn resolve_cli_effective_mode( product_mode: Option<&str>, requested_mode: Option<&str>, @@ -293,6 +383,30 @@ fn resolve_cli_effective_mode( } } +fn scope_codex_transport_to_turn( + agent: &ModelType, + launch_profile: &mut super::launch_profiles::ResolvedCliLaunchProfile, + native_continuation_episode: bool, +) { + if matches!(agent, ModelType::Codex) { + launch_profile.transport = native_continuation_episode + .then(|| super::launch_profiles::CLI_TRANSPORT_APP_SERVER.to_string()); + } +} + +fn scope_native_codex_store( + command: &mut Vec, + binary: &std::path::Path, + native_home: &std::path::Path, +) { + command[0] = binary.to_string_lossy().into_owned(); + command.push("-c".into()); + command.push(format!( + "sqlite_home={}", + serde_json::to_string(&native_home.to_string_lossy()).expect("path serializes") + )); +} + /// Run a code session: spawn CLI, parse stdout, broadcast events. /// /// This is spawned as a background Tokio task. @@ -305,6 +419,36 @@ pub async fn run_session( mode: Option<&str>, images: Option>, turn_intent_id: Option<&str>, + allow_native_context_recovery: bool, +) -> Result<(), String> { + run_session_with_ide_context( + session_id, + user_input, + None, + cli_resume_id, + mode, + images, + turn_intent_id, + allow_native_context_recovery, + ) + .await +} + +/// Run a CLI turn while preserving the IDE snapshot as provider-only context. +/// +/// The public `run_session` wrapper remains for non-UI callers that do not +/// carry IDE state. UI run/message commands use this path so the snapshot can +/// be encoded in a native system/developer channel instead of the user row. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_session_with_ide_context( + session_id: String, + user_input: String, + ide_context: Option, + cli_resume_id: Option, + mode: Option<&str>, + images: Option>, + turn_intent_id: Option<&str>, + allow_native_context_recovery: bool, ) -> Result<(), String> { let session = persistence::get_session(&session_id) .map_err(|e| format!("DB error: {}", e))? @@ -448,10 +592,14 @@ pub async fn run_session( let run_started_at = chrono::Utc::now(); - // Resolved early: the experimental codex app-server transport gate + // Resolved early: the codex app-server transport gate // changes prompt assembly (images travel as native localImage inputs) // as well as argv and the stdout-processing branch below. - let launch_profile = resolve_cli_launch_profile(&agent)?; + let mut launch_profile = resolve_cli_launch_profile(&agent)?; + // Ordinary Codex sessions keep the established `codex exec --json` + // transport. A canonical/native continuation episode opts into app-server + // for this turn only, without mutating the user's saved launch profile. + scope_codex_transport_to_turn(&agent, &mut launch_profile, allow_native_context_recovery); let use_codex_app_server = super::launch_profiles::uses_codex_app_server(&agent, &launch_profile); @@ -472,8 +620,9 @@ pub async fn run_session( } else { None }; - let mut effective_input = super::input_assembly::build_effective_input( + let mut turn = super::input_assembly::build_turn_envelope( &user_input, + ide_context.as_ref(), Some(effective_mode_str), session.product_mode.as_deref(), session.project_slug.as_deref(), @@ -489,10 +638,10 @@ pub async fn run_session( status_catalog.as_deref(), ); if let Some(context) = lifecycle_hook_context { - effective_input = format!( - "\n{}\n\n\n{}", - context, effective_input - ); + turn.prepend_provider_context(format!( + "\n{}\n", + context + )); } // Build CLI command @@ -533,7 +682,7 @@ pub async fn run_session( // random owner-only profile layer and pass only the non-secret profile // name in argv. The guard stays alive through every transport retry and // finalization, then removes the profile on return/cancellation. - let codex_mcp_profile = if matches!(agent, ModelType::Codex) { + let codex_mcp_profile = if matches!(agent, ModelType::Codex) && !use_codex_app_server { let codex_home = super::env_setup::codex_home_for_session(&session, account_id, &session_id)?; session_mcp @@ -544,6 +693,11 @@ pub async fn run_session( } else { None }; + let codex_app_server_config = if matches!(agent, ModelType::Codex) && use_codex_app_server { + session_mcp.codex_app_server_config() + } else { + None + }; let acp_mcp_servers = session_mcp.acp_servers(); let stderr_mcp_servers = Arc::new(session_mcp); @@ -551,7 +705,7 @@ pub async fn run_session( agent: &agent, launch_profile: &launch_profile, model: model.as_deref(), - task: &effective_input, + turn: &turn, resume_id: cli_resume_id.as_deref(), api_key: api_key_for_cli, endpoint: endpoint_for_cli, @@ -564,6 +718,18 @@ pub async fn run_session( .map(|profile| profile.profile_name()), }); + if use_codex_app_server { + // Native rollouts and their pagination index belong to the same store. + // Keep CODEX_HOME account-scoped for auth/config, but use the native + // catalog's binary and SQLite home so an App migration cannot leave + // the runner reading a stale legacy index through its profile symlink. + scope_native_codex_store( + &mut cmd_parts, + &super::super::parsers::codex_app_server::native_codex_app_server_command(), + &app_paths::native_transcript_home_dir().join(".codex"), + ); + } + if matches!(agent, ModelType::Codex) && session.key_source == KeySource::HostedKey { if use_codex_app_server { // No trailing task argument in app-server argv; `-c` is a valid @@ -607,7 +773,19 @@ pub async fn run_session( KEY_SERVICE.get_env_for_agent(&agent, account_id) }; - env_vars.extend(launch_profile_env(&launch_profile)); + apply_claude_cross_type_session_model( + &agent, + key_model_type.as_ref(), + session.model.as_deref(), + &mut env_vars, + ); + + merge_launch_profile_environment( + &agent, + account_id.is_some(), + &mut env_vars, + launch_profile_env(&launch_profile), + ); // Inherited by the CLI child and, transitively, by its hook subprocesses: // lets live-status hook posts attribute directly to this managed session @@ -628,8 +806,9 @@ pub async fn run_session( env_vars.insert("CURSOR_CLI_COMPAT".to_string(), "1".to_string()); } - // Store user input (without IDE context) - let display_input = strip_ide_context(&user_input); + // Store only the literal user-authored input. IDE and other provider + // context live in the typed turn envelope and never enter this row. + let display_input = user_input.clone(); { let conn = session_persistence::get_connection().map_err(|e| format!("DB: {}", e))?; conn.execute( @@ -784,9 +963,14 @@ pub async fn run_session( let mut attempt_stderr = CliStderrCollector::new(); stderr_lines = attempt_stderr.lines(); let mut spawn_cmd = Command::new(program); + spawn_cmd.args(args); + apply_child_environment( + &mut spawn_cmd, + &agent, + session.key_source == KeySource::HostedKey || account_id.is_some(), + &env_vars, + ); spawn_cmd - .args(args) - .envs(&env_vars) .current_dir(working_dir) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -855,11 +1039,13 @@ pub async fn run_session( session_id.clone(), account_id, oauth_retry_eligible, - effective_input.clone(), + turn.user_text().to_string(), + turn.provider_context(), working_dir, cli_resume_id.clone(), model.as_deref(), &launch_profile, + codex_app_server_config.clone(), image_paths.clone(), session_timeout, pre_message_snapshot_id.clone(), @@ -868,6 +1054,8 @@ pub async fn run_session( &mut sequence, codex_app_server_turn_ok, &mut attempt_stderr, + allow_native_context_recovery, + turn_intent_id, ) .await?; exit_code = outcome.exit_code; @@ -881,7 +1069,7 @@ pub async fn run_session( let outcome = transport_acp::run_acp_branch( child, session_id.clone(), - effective_input.clone(), + turn.merged_for_legacy(), working_dir, cli_resume_id.clone(), agent.clone(), @@ -894,6 +1082,7 @@ pub async fn run_session( cli_session_id_out, &mut sequence, &env_vars, + turn_intent_id, ) .await?; exit_code = outcome.exit_code; @@ -917,6 +1106,7 @@ pub async fn run_session( cli_session_id_out, &mut sequence, &mut attempt_stderr, + turn_intent_id, ) .await; exit_code = outcome.exit_code; @@ -984,7 +1174,7 @@ pub async fn run_session( terminal_message, ); terminal_error_message = Some(terminal_message); - emit_chunk(&chunk, &session_id, &mut sequence).await; + emit_chunk(&chunk, &session_id, &mut sequence, turn_intent_id).await; break; } let delay_secs = OVERLOAD_RETRY_BASE_DELAY_SECS * (1u64 << overload_retry_count); diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/mcp_inject.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/mcp_inject.rs index ba83e0667a..405476389a 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/mcp_inject.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/mcp_inject.rs @@ -299,6 +299,45 @@ impl SessionMcpServers { entries } + /// In-memory config overrides for Codex app-server `thread/start` and + /// `thread/resume`. Unlike `-c` argv overrides, this JSON-RPC payload does + /// not expose MCP environment values or HTTP headers to process listings. + pub(super) fn codex_app_server_config(&self) -> Option { + let mut servers = serde_json::Map::new(); + for (name, server) in &self.servers { + let mut entry = serde_json::Map::new(); + match server.transport_type { + McpTransportType::Stdio => { + let Some(command) = trimmed(server.command.as_deref()) else { + continue; + }; + entry.insert("command".into(), serde_json::json!(command)); + if let Some(args) = server.args.as_ref().filter(|args| !args.is_empty()) { + entry.insert("args".into(), serde_json::json!(args)); + } + if let Some(cwd) = trimmed(server.cwd.as_deref()) { + entry.insert("cwd".into(), serde_json::json!(cwd)); + } + if let Some(env) = sorted_map(server.env.as_ref()) { + entry.insert("env".into(), serde_json::json!(env)); + } + } + McpTransportType::StreamableHttp => { + let Some(url) = trimmed(server.url.as_deref()) else { + continue; + }; + entry.insert("url".into(), serde_json::json!(url)); + if let Some(headers) = sorted_map(server.headers.as_ref()) { + entry.insert("http_headers".into(), serde_json::json!(headers)); + } + } + McpTransportType::Sse => continue, + } + servers.insert(name.clone(), serde_json::Value::Object(entry)); + } + (!servers.is_empty()).then(|| serde_json::json!({ "mcp_servers": servers })) + } + /// Write a per-run `$CODEX_HOME/.config.toml` layer and return the /// guard that owns cleanup. Only the random profile name is passed on the /// command line; the MCP values remain in this owner-only file. @@ -886,6 +925,17 @@ mod tests { &HashSet::new(), &HashSet::new(), ); + let app_server_config = resolved + .codex_app_server_config() + .expect("non-empty app-server MCP config"); + assert_eq!( + app_server_config["mcp_servers"]["docs"]["env"]["API_TOKEN"], + "stdio-secret" + ); + assert_eq!( + app_server_config["mcp_servers"]["remote"]["http_headers"]["Authorization"], + "Bearer url-secret" + ); let temp_dir = tempfile::tempdir().expect("Codex profile root"); let guard = resolved .write_codex_mcp_profile(temp_dir.path()) 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 b4a3ead543..21afb8c6f0 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 @@ -5,7 +5,9 @@ use super::super::env_setup::{ validate_codex_own_key_provider, }; use super::super::input_assembly::cli_exec_mode_bridge; -use super::super::oauth_setup::{is_api_overloaded_message, is_retryable_overloaded_chunk}; +use super::super::oauth_setup::{ + is_api_overloaded_message, is_retryable_cli_oauth_failure_chunk, is_retryable_overloaded_chunk, +}; use super::super::plan_approval::{ create_plan_content_from_chunk, looks_like_buildable_plan_body, plan_content_from_successful_write_chunk, synthetic_cli_plan_path, @@ -18,6 +20,39 @@ use serde_json::Value; use std::collections::{HashMap, VecDeque}; use std::path::Path; +#[test] +fn codex_app_server_is_scoped_to_native_continuation_episode() { + let profile = || super::super::launch_profiles::ResolvedCliLaunchProfile { + permission_mode: super::super::launch_profiles::CliPermissionMode::Manual, + command: "codex".to_string(), + args: vec!["exec".to_string()], + env: HashMap::new(), + // Even a stale persisted opt-in must not change an ordinary turn. + transport: Some(super::super::launch_profiles::CLI_TRANSPORT_APP_SERVER.to_string()), + }; + + let mut ordinary = profile(); + scope_codex_transport_to_turn(&ModelType::Codex, &mut ordinary, false); + assert!(!super::super::launch_profiles::uses_codex_app_server( + &ModelType::Codex, + &ordinary + )); + + let mut continuation = profile(); + scope_codex_transport_to_turn(&ModelType::Codex, &mut continuation, true); + assert!(super::super::launch_profiles::uses_codex_app_server( + &ModelType::Codex, + &continuation + )); + + let mut claude = profile(); + scope_codex_transport_to_turn(&ModelType::ClaudeCode, &mut claude, true); + assert_eq!( + claude.transport.as_deref(), + Some(super::super::launch_profiles::CLI_TRANSPORT_APP_SERVER) + ); +} + #[test] fn command_logging_redacts_mcp_config_values() { let raw = vec![ @@ -719,6 +754,61 @@ fn atlas_model_string_is_preserved_before_the_codex_provider_gate_rejects_it() { ); } +#[test] +fn claude_cross_type_session_model_overrides_the_account_fallback() { + let mut env = HashMap::from([ + ("ANTHROPIC_MODEL".to_string(), "zai-org/glm-5.1".to_string()), + ( + "ANTHROPIC_DEFAULT_SONNET_MODEL".to_string(), + "zai-org/glm-5.1".to_string(), + ), + ( + "ANTHROPIC_DEFAULT_OPUS_MODEL".to_string(), + "zai-org/glm-5.1".to_string(), + ), + ( + "ANTHROPIC_DEFAULT_HAIKU_MODEL".to_string(), + "zai-org/glm-5.1".to_string(), + ), + ]); + + apply_claude_cross_type_session_model( + &ModelType::ClaudeCode, + Some(&ModelType::AtlascloudApi), + Some("deepseek-ai/deepseek-v3.2"), + &mut env, + ); + + for key in [ + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ] { + assert_eq!( + env.get(key).map(String::as_str), + Some("deepseek-ai/deepseek-v3.2"), + ); + } +} + +#[test] +fn claude_native_session_keeps_its_cli_model_path() { + let mut env = HashMap::from([("ANTHROPIC_MODEL".to_string(), "account-default".to_string())]); + + apply_claude_cross_type_session_model( + &ModelType::ClaudeCode, + Some(&ModelType::ClaudeCode), + Some("claude-opus-4-8"), + &mut env, + ); + + assert_eq!( + env.get("ANTHROPIC_MODEL").map(String::as_str), + Some("account-default"), + ); +} + #[test] fn codex_rejects_chat_only_providers_and_zenmux_preserves_aggregator_namespace() { for provider in [ModelType::ZhipuApi, ModelType::AtlascloudApi] { @@ -833,6 +923,95 @@ fn child_env_sanitization_keeps_runtime_tokens_out_of_subprocess_env() { assert!(!codex_env.contains_key(CODEX_ID_TOKEN_ENV_KEY)); } +#[test] +fn explicit_claude_account_clears_inherited_routing_not_owned_by_source() { + let selected = HashMap::from([ + ( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "selected-oauth".to_string(), + ), + ( + "CLAUDE_CONFIG_DIR".to_string(), + "/selected/profile".to_string(), + ), + ]); + let mut command = Command::new("claude"); + apply_child_environment(&mut command, &ModelType::ClaudeCode, true, &selected); + + let explicit = command + .as_std() + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect::>(); + assert_eq!( + explicit.get("ANTHROPIC_AUTH_TOKEN"), + Some(&Some("selected-oauth".to_string())) + ); + assert_eq!(explicit.get("ANTHROPIC_API_KEY"), Some(&None)); + assert_eq!(explicit.get("ANTHROPIC_BASE_URL"), Some(&None)); + assert_eq!(explicit.get("ANTHROPIC_MODEL"), Some(&None)); + assert_eq!( + explicit.get("CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"), + Some(&None) + ); +} + +#[test] +fn explicit_claude_account_wins_over_stale_launch_profile_routing() { + let mut selected = HashMap::from([ + ( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "anthropic-1-oauth".to_string(), + ), + ( + "CLAUDE_CONFIG_DIR".to_string(), + "/accounts/anthropic-1".to_string(), + ), + ]); + let stale_profile = HashMap::from([ + ( + "ANTHROPIC_BASE_URL".to_string(), + "https://api.atlascloud.ai".to_string(), + ), + ("ANTHROPIC_MODEL".to_string(), "zai-org/glm-5.2".to_string()), + ( + "ANTHROPIC_AUTH_TOKEN".to_string(), + "stale-atlas-token".to_string(), + ), + ("PATH".to_string(), "/custom/bin".to_string()), + ]); + + merge_launch_profile_environment(&ModelType::ClaudeCode, true, &mut selected, stale_profile); + + assert_eq!( + selected.get("ANTHROPIC_AUTH_TOKEN").map(String::as_str), + Some("anthropic-1-oauth") + ); + assert_eq!( + selected.get("CLAUDE_CONFIG_DIR").map(String::as_str), + Some("/accounts/anthropic-1") + ); + assert!(!selected.contains_key("ANTHROPIC_BASE_URL")); + assert!(!selected.contains_key("ANTHROPIC_MODEL")); + assert_eq!( + selected.get("PATH").map(String::as_str), + Some("/custom/bin") + ); +} + +#[test] +fn ambient_claude_profile_keeps_shell_environment_available() { + let mut command = Command::new("claude"); + apply_child_environment(&mut command, &ModelType::ClaudeCode, false, &HashMap::new()); + + assert!(command.as_std().get_envs().next().is_none()); +} + #[test] fn overloaded_error_detection() { assert!(is_api_overloaded_message("overloaded_error")); @@ -886,6 +1065,35 @@ fn overloaded_chunk_detection() { assert!(is_retryable_overloaded_chunk(&no_error).is_none()); } +#[test] +fn retry_detection_requires_explicit_provider_failure() { + for action in [ + "assistant", "assistant_delta", "message", "tool_call", "user", "session_end", + ] { + let mut chunk = core_types::activity::ActivityChunk::new("s", action, "message"); + chunk.result = serde_json::json!({ + "success": true, + "message": "SessionFilterButton 第 429–444 行;529 overloaded;OAuth access token expired", + "error": "429 is an example in a tool result" + }); + assert!(is_retryable_overloaded_chunk(&chunk).is_none(), "{action}"); + assert!( + is_retryable_cli_oauth_failure_chunk(true, &chunk).is_none(), + "{action}" + ); + } + for action in ["error", "session_end"] { + let mut chunk = core_types::activity::ActivityChunk::new("s", action, action); + chunk.result = serde_json::json!({"success": false, "error_message": "429 Too Many Requests"}); + assert!(is_retryable_overloaded_chunk(&chunk).is_some(), "{action}"); + chunk.result = serde_json::json!({"success": false, "error_message": "OAuth access token expired"}); + assert!( + is_retryable_cli_oauth_failure_chunk(true, &chunk).is_some(), + "{action}" + ); + } +} + /// The whole point of the collector: a child can exit with its stderr still /// sitting unread in the pipe, and every consumer of the buffer runs after /// `wait()`. Reading without draining is how a session that failed loudly @@ -1036,3 +1244,32 @@ async fn draining_an_unattached_stderr_collector_returns_immediately() { .expect("drain of an unattached collector must not block"); assert!(collector.lines().lock().await.is_empty()); } + +#[test] +fn native_codex_store_uses_catalog_binary_and_index_without_changing_auth_home() { + let mut command = vec![ + "codex".into(), + "app-server".into(), + "-c".into(), + "model_reasoning_effort=\"low\"".into(), + ]; + scope_native_codex_store( + &mut command, + Path::new("/Applications/Codex.app/codex"), + Path::new("/native home/.codex"), + ); + assert_eq!( + command, + vec![ + "/Applications/Codex.app/codex", + "app-server", + "-c", + "model_reasoning_effort=\"low\"", + "-c", + "sqlite_home=\"/native home/.codex\"", + ] + ); + assert!(!command + .iter() + .any(|arg| arg.contains("CODEX_HOME") || arg.contains("auth"))); +} diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_acp.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_acp.rs index 04631c0797..3a71a55abe 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_acp.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_acp.rs @@ -35,6 +35,7 @@ pub(super) async fn run_acp_branch( mut cli_session_id_out: Option, sequence: &mut i64, env_vars: &HashMap, + turn_intent_id: Option<&str>, ) -> Result { // ── ACP agents (Copilot, Kiro, OpenCode, DeepSeek Harness): // bidirectional JSON-RPC ── @@ -119,7 +120,7 @@ pub(super) async fn run_acp_branch( if let Some(snap_id) = &pre_message_snapshot_id { snapshot_cli_file_edit(&session_id, snap_id, &chunk, &snapshot_working_dir).await; } - emit_chunk(&chunk, &session_id, sequence).await; + emit_chunk(&chunk, &session_id, sequence, turn_intent_id).await; } }) .await; diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs index f0461580e6..91706edd71 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_app_server.rs @@ -1,7 +1,7 @@ //! Codex app-server transport: long-lived JSON-RPC turn over stdio. //! -//! Experimental; gated by the launch-profile transport="app-server" setting -//! (see `super::super::launch_profiles::uses_codex_app_server`). +//! Native continuation episodes opt into this transport explicitly. Ordinary +//! Codex sessions retain the established per-turn `codex exec --json` path. use tokio::process::Child; @@ -23,17 +23,23 @@ pub(super) struct AppServerOutcome { pub(super) terminal_error_message: Option, } +fn is_successful_turn_status(status: &str) -> bool { + status == "completed" +} + #[allow(clippy::too_many_arguments)] pub(super) async fn run_codex_app_server_branch( mut child: Child, session_id: String, account_id: Option<&str>, oauth_retry_eligible: bool, - effective_input: String, + user_input: String, + developer_instructions: Option, working_dir: &str, cli_resume_id: Option, model: Option<&str>, launch_profile: &ResolvedCliLaunchProfile, + config: Option, image_paths: Vec, session_timeout: tokio::time::Duration, pre_message_snapshot_id: Option, @@ -42,9 +48,11 @@ pub(super) async fn run_codex_app_server_branch( sequence: &mut i64, mut codex_app_server_turn_ok: bool, attempt_stderr: &mut super::CliStderrCollector, + allow_native_context_recovery: bool, + turn_intent_id: Option<&str>, ) -> Result { // ── Codex app-server: long-lived JSON-RPC over stdio ── - // (experimental; gate = launch-profile transport="app-server"). + // The resolved launch profile may explicitly select the legacy exec path. // Same CODEX_HOME / auth env as the exec shell-out — the spawn // above already carries env_vars. use crate::agent_sessions::cli::parsers::codex_app_server; @@ -56,14 +64,17 @@ pub(super) async fn run_codex_app_server_branch( let turn = codex_app_server::CodexAppServerTurn { session_id: session_id.clone(), - task: effective_input.clone(), + user_input, + developer_instructions, working_dir: working_dir.to_string(), resume_thread_id: cli_resume_id.clone(), model: super::super::command::codex_app_server_thread_model(model), permission_mode: launch_profile.permission_mode, + config, image_paths: image_paths.clone(), + allow_native_context_recovery, }; - let app_server_handle = tokio::spawn(async move { + let mut app_server_handle = tokio::spawn(async move { codex_app_server::run_app_server_turn(stdin, stdout, turn, chunk_tx).await }); @@ -85,14 +96,14 @@ pub(super) async fn run_codex_app_server_branch( if is_cli_chunk_replay_unsafe(&chunk) { replay_unsafe_output_seen = true; } - // Bind the rollout-compatible thread id as soon as the - // session_start chunk carries it (mirrors the parser - // early-binding in the exec branch below): native - // transcript replay, managed-mirror dedup, and - // live-status attribution all key on it, and a crash - // mid-turn must not orphan the rollout. - if cli_session_id_out.is_none() { - if let Some(ref tid) = chunk.thread_id { + // Bind the rollout-compatible thread id as soon as a lifecycle + // chunk carries it (mirrors the parser early-binding in the exec + // branch below). Context recovery may natively fork the thread + // inside this same transport turn, so a DIFFERENT id must replace + // the initial binding immediately; otherwise an instant follow-up + // can resume the overflowing source UUID and compact again. + if let Some(ref tid) = chunk.thread_id { + if cli_session_id_out.as_deref() != Some(tid.as_str()) { cli_session_id_out = Some(tid.clone()); if let Err(err) = persistence::update_cli_session_id_for_account(&session_id, account_id, tid) @@ -115,16 +126,32 @@ pub(super) async fn run_codex_app_server_branch( if let Some(snap_id) = &pre_message_snapshot_id { snapshot_cli_file_edit(&session_id, snap_id, &chunk, &snapshot_working_dir).await; } - emit_chunk(&chunk, &session_id, sequence).await; + emit_chunk(&chunk, &session_id, sequence, turn_intent_id).await; } }) .await; - let timed_out = timeout_result.is_err(); + let mut timed_out = timeout_result.is_err(); + if timed_out { + app_server_handle.abort(); + terminal_error_message = Some("Codex app-server turn timed out".to_string()); + } + + // The chunk channel normally closes only after the protocol task exits, + // but a leaked sender or stuck cleanup must not turn the four-hour turn + // deadline into an unbounded JoinHandle wait. + let join_result = + tokio::time::timeout(tokio::time::Duration::from_secs(5), &mut app_server_handle).await; + if join_result.is_err() { + timed_out = true; + codex_app_server_turn_ok = false; + terminal_error_message = Some("Codex app-server shutdown timed out".to_string()); + app_server_handle.abort(); + } - match app_server_handle.await { - Ok(Ok(result)) => { + match join_result { + Ok(Ok(Ok(result))) if !timed_out => { cli_session_id_out = Some(result.thread_id); - codex_app_server_turn_ok = result.turn_status != "failed"; + codex_app_server_turn_ok = is_successful_turn_status(&result.turn_status); if let Some(ref usage) = result.usage { let round_model = usage.model.as_deref().or(model); if let Err(err) = session_persistence::token_usage::insert_token_usage_record( @@ -147,7 +174,7 @@ pub(super) async fn run_codex_app_server_branch( } } } - Ok(Err(err)) if !timed_out => { + Ok(Ok(Err(err))) if !timed_out => { if oauth_retry_eligible && !replay_unsafe_output_seen && is_cli_oauth_failure_message(&err) @@ -159,7 +186,7 @@ pub(super) async fn run_codex_app_server_branch( Some(super::super::super::parsers::canonicalize_cli_error_message(&err)); } } - Err(join_err) => { + Ok(Err(join_err)) if !timed_out => { tracing::error!("[CodeSession] app-server task panicked: {}", join_err); terminal_error_message = Some(format!("Codex app-server task failed: {join_err}")); } @@ -206,3 +233,16 @@ pub(super) async fn run_codex_app_server_branch( terminal_error_message, }) } + +#[cfg(test)] +mod tests { + use super::is_successful_turn_status; + + #[test] + fn only_completed_app_server_turns_succeed() { + assert!(is_successful_turn_status("completed")); + assert!(!is_successful_turn_status("failed")); + assert!(!is_successful_turn_status("interrupted")); + assert!(!is_successful_turn_status("cancelled")); + } +} diff --git a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_standard.rs b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_standard.rs index 6da30b38bb..ef8c7cc073 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/session/transport_standard.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/session/transport_standard.rs @@ -53,6 +53,7 @@ pub(super) async fn run_standard_branch( mut cli_session_id_out: Option, sequence: &mut i64, attempt_stderr: &mut super::CliStderrCollector, + turn_intent_id: Option<&str>, ) -> StandardOutcome { let mut retryable_oauth_message: Option = None; let mut retryable_overload_message: Option = None; @@ -198,7 +199,13 @@ pub(super) async fn run_standard_branch( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, sequence).await; + emit_chunk( + &plan_chunk, + &session_id, + sequence, + turn_intent_id, + ) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -227,7 +234,13 @@ pub(super) async fn run_standard_branch( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, sequence).await; + emit_chunk( + &plan_chunk, + &session_id, + sequence, + turn_intent_id, + ) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -252,7 +265,13 @@ pub(super) async fn run_standard_branch( .await { Ok(plan_chunk) => { - emit_chunk(&plan_chunk, &session_id, sequence).await; + emit_chunk( + &plan_chunk, + &session_id, + sequence, + turn_intent_id, + ) + .await; cli_plan_registered_this_turn = true; cli_plan_approval_gate_triggered = true; } @@ -273,7 +292,7 @@ pub(super) async fn run_standard_branch( } cli_plan_active = false; } - emit_chunk(&chunk, &session_id, sequence).await; + emit_chunk(&chunk, &session_id, sequence, turn_intent_id).await; if cli_plan_approval_gate_triggered && !cli_plan_gate_announced { cli_plan_gate_announced = true; tracing::info!( @@ -285,7 +304,7 @@ pub(super) async fn run_standard_branch( // instead of holding Stop for up to the 45s drain window // while the child process winds down. The final // status_changed after child exit is idempotent. - flush_and_broadcast(&session_id).await; + flush_and_broadcast(&session_id, turn_intent_id).await; // The plan card supersedes any hook-derived // waiting/working entry for this turn. clear_live_status( @@ -435,7 +454,7 @@ pub(super) async fn run_standard_branch( if let Some(snap_id) = &pre_message_snapshot_id { snapshot_cli_file_edit(&session_id, snap_id, chunk, &snapshot_working_dir).await; } - emit_chunk(chunk, &session_id, sequence).await; + emit_chunk(chunk, &session_id, sequence, turn_intent_id).await; } } diff --git a/src-tauri/src/agent_sessions/cli/skill_sync.rs b/src-tauri/src/agent_sessions/cli/skill_sync.rs index b6b74bae6e..7320bdf3a7 100644 --- a/src-tauri/src/agent_sessions/cli/skill_sync.rs +++ b/src-tauri/src/agent_sessions/cli/skill_sync.rs @@ -401,6 +401,9 @@ mod tests { #[test] fn provider_skill_catalog_is_stable_bounded_and_keeps_load_paths() { + // The production loader also reads global/user roots. Keep those + // roots fixed while sibling tests switch the process-wide home. + let _sandbox = crate::test_utils::test_env::sandbox(); let workspace = tempfile::tempdir().expect("workspace"); for index in 0..80 { let skill_dir = workspace diff --git a/src-tauri/src/agent_sessions/cli/tests/mod.rs b/src-tauri/src/agent_sessions/cli/tests/mod.rs index f80bfd166f..047552587e 100644 --- a/src-tauri/src/agent_sessions/cli/tests/mod.rs +++ b/src-tauri/src/agent_sessions/cli/tests/mod.rs @@ -1,6 +1,5 @@ // Test modules for cli_session pub mod runner_command_tests; -pub mod runner_tests; pub mod stages_tests; pub mod types_tests; diff --git a/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs b/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs index 4817d9bc6c..9c9c1b7227 100644 --- a/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs +++ b/src-tauri/src/agent_sessions/cli/tests/runner_command_tests.rs @@ -2,6 +2,7 @@ use super::command::{ build_command_with_launch_profile, codex_app_server_thread_model, map_claude_model, map_claude_model_variant, CliCommandBuildRequest, }; +use super::input_assembly::CliTurnEnvelope; use super::launch_profiles::{ bare_command_for_agent, default_args_for_mode, default_env_for_mode, defaults_for_agent, CliPermissionMode, ResolvedCliLaunchProfile, @@ -13,6 +14,7 @@ struct TestCommandBuildOptions<'a> { agent: &'a ModelType, model: Option<&'a str>, task: &'a str, + provider_context: Option<&'a str>, resume_id: Option<&'a str>, api_key: Option<&'a str>, endpoint: Option<&'a str>, @@ -29,6 +31,7 @@ impl<'a> TestCommandBuildOptions<'a> { agent, model: None, task, + provider_context: None, resume_id: None, api_key: None, endpoint: None, @@ -77,12 +80,16 @@ fn build_command_from_options(options: TestCommandBuildOptions<'_>) -> Vecbuild\n\n", + "focused file" + ); + let cmd = build_command!( + ModelType::ClaudeCode, + task = user_text, + provider_context = Some(provider_context), + resume_id = Some("native-claude-uuid"), + ); + + let prompt_index = cmd.iter().position(|part| part == "-p").expect("-p"); + assert_eq!(cmd[prompt_index + 1], user_text); + assert!(!cmd[prompt_index + 1].contains("")); + + let system_index = cmd + .iter() + .position(|part| part == "--append-system-prompt") + .expect("native Claude system context flag"); + assert_eq!(cmd[system_index + 1], provider_context); + assert!(cmd[system_index + 1].contains("")); + assert!(cmd[system_index + 1].contains("")); +} + #[test] fn build_codex_with_mcp_profile_before_task() { let cmd = build_command!( @@ -547,7 +582,7 @@ fn app_server_profile(agent: &ModelType, transport: Option<&str>) -> ResolvedCli fn uses_codex_app_server_requires_codex_and_explicit_flag() { use super::launch_profiles::uses_codex_app_server; - // Default (no flag) stays on the shell-out path. + // Ordinary Codex turns retain the per-turn shell-out path. let default_profile = app_server_profile(&ModelType::Codex, None); assert!(!uses_codex_app_server(&ModelType::Codex, &default_profile)); @@ -555,7 +590,7 @@ fn uses_codex_app_server_requires_codex_and_explicit_flag() { let opted_in = app_server_profile(&ModelType::Codex, Some("app-server")); assert!(uses_codex_app_server(&ModelType::Codex, &opted_in)); - // Unknown transport values are ignored. + // Unknown transport values stay off app-server. let unknown = app_server_profile(&ModelType::Codex, Some("websocket")); assert!(!uses_codex_app_server(&ModelType::Codex, &unknown)); @@ -567,11 +602,12 @@ fn uses_codex_app_server_requires_codex_and_explicit_flag() { #[test] fn build_codex_app_server_argv_is_bare_subcommand() { let profile = app_server_profile(&ModelType::Codex, Some("app-server")); + let turn = CliTurnEnvelope::new("fix the bug"); let cmd = build_command_with_launch_profile(CliCommandBuildRequest { agent: &ModelType::Codex, launch_profile: &profile, model: None, - task: "fix the bug", + turn: &turn, resume_id: Some("thread-123"), api_key: None, endpoint: None, @@ -587,14 +623,41 @@ fn build_codex_app_server_argv_is_bare_subcommand() { assert_eq!(cmd[1..], ["app-server".to_string()]); } +#[test] +fn build_codex_default_profile_uses_exec_argv() { + let profile = app_server_profile(&ModelType::Codex, None); + let turn = CliTurnEnvelope::new("native task travels over JSON-RPC"); + let cmd = build_command_with_launch_profile(CliCommandBuildRequest { + agent: &ModelType::Codex, + launch_profile: &profile, + model: Some("gpt-5.5-high"), + turn: &turn, + resume_id: Some("thread-123"), + api_key: None, + endpoint: None, + mode: None, + repo_path: Some("/workspace"), + additional_dirs: &[], + mcp_config_path: None, + codex_mcp_profile: None, + }); + + assert_eq!(command_name(&cmd[0]), "codex"); + assert_eq!(cmd[1], "exec"); + assert!(cmd.contains(&"model_reasoning_effort=\"high\"".to_string())); + assert!(cmd.iter().any(|part| part.contains("native task"))); + assert!(cmd.contains(&"thread-123".to_string())); +} + #[test] fn build_codex_app_server_argv_keeps_gpt_5_6_max_overrides() { let profile = app_server_profile(&ModelType::Codex, Some("app-server")); + let turn = CliTurnEnvelope::new("write tests"); let cmd = build_command_with_launch_profile(CliCommandBuildRequest { agent: &ModelType::Codex, launch_profile: &profile, model: Some("gpt-5.6-sol-max-fast"), - task: "write tests", + turn: &turn, resume_id: None, api_key: None, endpoint: None, @@ -617,13 +680,14 @@ fn build_codex_app_server_argv_keeps_gpt_5_6_max_overrides() { } #[test] -fn build_codex_app_server_argv_keeps_mcp_profile_before_subcommand() { +fn build_codex_app_server_argv_never_exposes_mcp_profile() { let profile = app_server_profile(&ModelType::Codex, Some("app-server")); + let turn = CliTurnEnvelope::new("write tests"); let cmd = build_command_with_launch_profile(CliCommandBuildRequest { agent: &ModelType::Codex, launch_profile: &profile, model: None, - task: "write tests", + turn: &turn, resume_id: Some("thread-123"), api_key: None, endpoint: None, @@ -634,5 +698,6 @@ fn build_codex_app_server_argv_keeps_mcp_profile_before_subcommand() { codex_mcp_profile: Some("orgii-mcp-random"), }); - assert_eq!(cmd[1..], ["--profile", "orgii-mcp-random", "app-server"]); + assert_eq!(cmd[1..], ["app-server"]); + assert!(!cmd.contains(&"orgii-mcp-random".to_string())); } diff --git a/src-tauri/src/agent_sessions/cli/tests/runner_tests.rs b/src-tauri/src/agent_sessions/cli/tests/runner_tests.rs deleted file mode 100644 index b4f7ad0960..0000000000 --- a/src-tauri/src/agent_sessions/cli/tests/runner_tests.rs +++ /dev/null @@ -1,52 +0,0 @@ -use super::helpers::strip_ide_context; - -// ============================================ -// strip_ide_context -// ============================================ - -#[test] -fn strip_ide_context_no_tag() { - assert_eq!(strip_ide_context("Hello world"), "Hello world"); -} - -#[test] -fn strip_ide_context_with_tag() { - let input = "some dataActual message"; - assert_eq!(strip_ide_context(input), "Actual message"); -} - -#[test] -fn strip_ide_context_in_middle() { - let input = "Before data After"; - assert_eq!(strip_ide_context(input), "Before After"); -} - -#[test] -fn strip_ide_context_trailing_whitespace_newlines() { - let input = "data\n\nHello"; - assert_eq!(strip_ide_context(input), "Hello"); -} - -#[test] -fn strip_ide_context_missing_close_tag() { - let input = "data without close"; - assert_eq!(strip_ide_context(input), "data without close"); -} - -#[test] -fn strip_ide_context_missing_open_tag() { - let input = "just text"; - assert_eq!(strip_ide_context(input), "just text"); -} - -#[test] -fn strip_ide_context_empty() { - let input = "Content"; - assert_eq!(strip_ide_context(input), "Content"); -} - -#[test] -fn strip_ide_context_only_tag() { - let input = "data"; - assert_eq!(strip_ide_context(input), ""); -} diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs index cccc56d0a7..c5671e82d6 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/batch_update.rs @@ -53,7 +53,28 @@ pub async fn es_remove_by_id_prefix( prefix: String, ) -> Result { let sid = state.resolve_session_id(session_id)?; - let removed = state.with_store_mut(&sid, |store| store.remove_by_id_prefix(&prefix)); + let removed_ids = state + .with_store_opt(&sid, |store| { + store + .events() + .iter() + .filter(|event| event.id.starts_with(&prefix)) + .map(|event| event.id.clone()) + .collect::>() + }) + .unwrap_or_default(); + if !removed_ids.is_empty() { + let persist_sid = sid.clone(); + let persisted_ids = removed_ids.clone(); + tokio::task::spawn_blocking(move || { + session_persistence::delete_events_by_ids(&persist_sid, &persisted_ids) + .map(|_| ()) + .map_err(|err| err.to_string()) + }) + .await + .map_err(|err| format!("es_remove_by_id_prefix worker failed: {err}"))??; + } + let removed = state.with_store_mut(&sid, |store| store.remove_by_ids(&removed_ids)); if removed > 0 { schedule_notify(&app, &state, &sid); } @@ -61,24 +82,30 @@ pub async fn es_remove_by_id_prefix( } /// Remove frontend-injected user placeholders after the backend user turn arrives. -/// `matching_contents` + `older_than` scope removal to placeholders that are -/// echoed by one of those messages or predate the newest real user turn; -/// omitted, every placeholder in the session is removed. +/// Intent-bearing placeholders are removed only by their matching durable +/// turn id. Legacy placeholders use `matching_contents` + `older_than`. +/// Omit the whole scope to remove every placeholder in the session. #[tauri::command] pub async fn es_remove_synthetic_user_inputs( app: AppHandle, state: State<'_, EventStoreState>, session_id: Option, matching_contents: Option>, + matching_turn_intent_ids: Option>, older_than: Option, ) -> Result { let sid = state.resolve_session_id(session_id)?; let removed = state.with_store_mut(&sid, |store| { - store.remove_synthetic_user_inputs( - matching_contents - .as_deref() - .map(|contents| (contents, older_than.as_deref())), - ) + let is_scoped = matching_contents.is_some() + || matching_turn_intent_ids.is_some() + || older_than.is_some(); + store.remove_synthetic_user_inputs(is_scoped.then(|| { + ( + matching_contents.as_deref().unwrap_or_default(), + matching_turn_intent_ids.as_deref().unwrap_or_default(), + older_than.as_deref(), + ) + })) }); if removed > 0 { schedule_notify(&app, &state, &sid); diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge_tests.rs index e63a46e24f..b25923f1a4 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/cache_bridge_tests.rs @@ -100,6 +100,46 @@ fn cached_event_normalizes_legacy_string_args() { ); } +#[test] +fn cached_event_repairs_legacy_image_only_raw_user_metadata() { + let images = ["data:image/png;base64,QUJD", "data:image/webp;base64,REVG"]; + let cached = session_persistence::CachedEvent { + id: "claudecode-user-107".to_string(), + session_id: "imported-session-legacy-image-user".to_string(), + event_type: "raw".to_string(), + function_name: Some("user".to_string()), + thread_id: None, + args_json: "{}".to_string(), + result_json: serde_json::json!({ + "images": images, + "message": { "content": "", "role": "user" }, + "type": "user" + }) + .to_string(), + content: "Activity".to_string(), + created_at: "2026-08-21T00:00:00.000Z".to_string(), + meta_json: Some( + serde_json::json!({ + "source": "assistant", + "displayText": "Activity", + "displayStatus": "completed", + "displayVariant": "tool_call", + "activityStatus": "agent", + "uiCanonical": "user" + }) + .to_string(), + ), + history_sequence: Some(107), + }; + + let event = cached_event_to_session_event(&cached); + + assert_eq!(event.source, EventSource::User); + assert_eq!(event.display_variant, EventDisplayVariant::Message); + assert_eq!(event.display_text, ""); + assert_eq!(event.result["images"], serde_json::json!(images)); +} + #[test] fn rust_authoritative_ids_do_not_match() { assert!(!is_ts_placeholder_id( diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/event_conversion.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/event_conversion.rs index 55b8a8dac7..19f70d400b 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/event_conversion.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/event_conversion.rs @@ -5,6 +5,9 @@ use std::collections::{HashMap, HashSet}; use crate::agent_sessions::event_pipeline::extractors::extract_event_data_with_bounded_shell_output; use crate::agent_sessions::event_pipeline::ingestion::function_map::resolve_ui_canonical; +use crate::agent_sessions::event_pipeline::ingestion::normalizer::{ + is_raw_user_message, raw_message_text, +}; use crate::agent_sessions::event_pipeline::payload_compaction::is_compacted_event; use crate::agent_sessions::event_pipeline::types::{ ActivityStatus, EventDisplayStatus, EventDisplayVariant, EventSource, SessionEvent, @@ -750,21 +753,37 @@ pub(crate) fn cached_event_to_session_event(cached: &sqlite_cache::CachedEvent) } }; - let source_str = meta_obj - .and_then(|m| m.get("source")) - .and_then(|v| v.as_str()) - .unwrap_or("system"); - let source = match source_str { - "user" => EventSource::User, - "assistant" => EventSource::Assistant, - _ => EventSource::System, + // Old replay snapshots could persist image-only raw user messages with + // assistant renderer metadata because the original normalizer used text + // presence as its role signal. The durable payload is unambiguous + // (`type=user` / `message.role=user`) and is the canonical source of + // truth. Repair that contradiction on read through the same predicate as + // live ingestion so historical Team Sessions remain losslessly portable. + let is_semantic_raw_user = + matches!(cached.event_type.as_str(), "raw" | "raw_event") && is_raw_user_message(&result); + let source = if is_semantic_raw_user { + EventSource::User + } else { + match meta_obj + .and_then(|m| m.get("source")) + .and_then(|v| v.as_str()) + .unwrap_or("system") + { + "user" => EventSource::User, + "assistant" => EventSource::Assistant, + _ => EventSource::System, + } }; - let display_text = meta_obj - .and_then(|m| m.get("displayText")) - .and_then(|v| v.as_str()) - .unwrap_or_else(|| cached.function_name.as_deref().unwrap_or("unknown")) - .to_string(); + let display_text = if is_semantic_raw_user { + raw_message_text(&result).unwrap_or_default() + } else { + meta_obj + .and_then(|m| m.get("displayText")) + .and_then(|v| v.as_str()) + .unwrap_or_else(|| cached.function_name.as_deref().unwrap_or("unknown")) + .to_string() + }; let display_status_str = meta_obj .and_then(|m| m.get("displayStatus")) @@ -773,12 +792,16 @@ pub(crate) fn cached_event_to_session_event(cached: &sqlite_cache::CachedEvent) let display_status = serde_json::from_value(serde_json::json!(display_status_str)) .unwrap_or(EventDisplayStatus::Running); - let display_variant_str = meta_obj - .and_then(|m| m.get("displayVariant")) - .and_then(|v| v.as_str()) - .unwrap_or("tool_call"); - let display_variant = serde_json::from_value(serde_json::json!(display_variant_str)) - .unwrap_or(EventDisplayVariant::ToolCall); + let display_variant = if is_semantic_raw_user { + EventDisplayVariant::Message + } else { + let display_variant_str = meta_obj + .and_then(|m| m.get("displayVariant")) + .and_then(|v| v.as_str()) + .unwrap_or("tool_call"); + serde_json::from_value(serde_json::json!(display_variant_str)) + .unwrap_or(EventDisplayVariant::ToolCall) + }; let activity_status_str = meta_obj .and_then(|m| m.get("activityStatus")) diff --git a/src-tauri/src/agent_sessions/event_pipeline/commands/store_commands.rs b/src-tauri/src/agent_sessions/event_pipeline/commands/store_commands.rs index 55c72d81c4..eab9ceffc2 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/commands/store_commands.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/commands/store_commands.rs @@ -39,6 +39,25 @@ fn is_synthetic_user_input(event: &SessionEvent) -> bool { .unwrap_or(false) } +/// A provider-rejected frontend turn has no native transcript row to reload. +/// Keep that terminal delivery projection in the existing event cache so the +/// failed bubble (and its retry/edit payload) survives a renderer/app restart. +/// Pending/accepted placeholders remain transient: their durable owners are +/// the message-delivery registry and provider transcript respectively. +fn is_persisted_failed_user_delivery(event: &SessionEvent) -> bool { + is_synthetic_user_input(event) + && event + .result + .get("deliveryStatus") + .and_then(|value| value.as_str()) + == Some("failed") + && event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.is_empty()) +} + /// Set the active repository context on a session's store. #[tauri::command] pub async fn es_set_repo_context( @@ -112,19 +131,17 @@ pub async fn es_append( // only resurface as duplicate user bubbles on the next replay merge. // Their edit path (`cli_agent_truncate_after_chunk`) truncates chunks by // timestamp and does not consult the `events` table. - let user_event_ids: Vec<_> = if session_providers::skips_event_cache_save(&sid) { - Vec::new() - } else { - events - .iter() - .filter(|event| { - event.source == EventSource::User - && !is_ts_placeholder_id(&event.id) - && !is_synthetic_user_input(event) - }) - .map(|event| event.id.clone()) - .collect() - }; + let skips_event_cache_save = session_providers::skips_event_cache_save(&sid); + let user_event_ids: Vec<_> = events + .iter() + .filter(|event| { + event.source == EventSource::User + && !is_ts_placeholder_id(&event.id) + && (!skips_event_cache_save && !is_synthetic_user_input(event) + || is_persisted_failed_user_delivery(event)) + }) + .map(|event| event.id.clone()) + .collect(); let user_events = state.with_store_mut(&sid, |store| { store.append(events); @@ -197,14 +214,98 @@ pub async fn es_update_by_id( id: String, patch: SessionEventPatch, ) -> Result { + use super::event_conversion::session_event_to_cached_event; + use super::{save_events_retry, BULK_WRITE_MAX_RETRIES}; + let sid = state.resolve_session_id(session_id)?; - let found = state.with_store_mut(&sid, |store| store.update_by_id(&id, &patch)); + let (found, failed_delivery) = state.with_store_mut(&sid, |store| { + let found = store.update_by_id(&id, &patch); + let failed_delivery = found + .then(|| store.get_by_id(&id)) + .flatten() + .filter(|event| is_persisted_failed_user_delivery(event)) + .map(session_event_to_cached_event); + (found, failed_delivery) + }); if found { schedule_notify(&app, &state, &sid); } + if let Some(failed_delivery) = failed_delivery { + let persist_sid = sid.clone(); + let persist_result = tokio::task::spawn_blocking(move || { + save_events_retry( + "es_update_failed_user_delivery", + &persist_sid, + &[failed_delivery], + BULK_WRITE_MAX_RETRIES, + ) + }) + .await + .map_err(|err| format!("es_update_by_id spawn_blocking join failed: {err}"))?; + // This update is the ownership-transfer barrier for a rejected send: + // the durable queue may retire its recovery row only after the failed + // transcript projection is queryable from SQLite. Returning success + // after a write failure leaves the bubble only in renderer memory and + // makes it disappear on restart. + persist_result?; + } Ok(found) } +#[cfg(test)] +mod failed_user_delivery_tests { + use super::*; + + fn synthetic_delivery(status: &str, turn_intent_id: Option<&str>) -> SessionEvent { + let display_status = match status { + "pending" => "pending", + "failed" => "failed", + _ => "completed", + }; + serde_json::from_value(serde_json::json!({ + "id": "queued-user:q1:", + "chunk_id": null, + "sessionId": "cliagent-test", + "createdAt": "2026-09-05T00:00:00Z", + "functionName": "user_message", + "uiCanonical": "", + "actionType": "raw", + "args": {}, + "result": { + "syntheticUserInput": true, + "deliveryStatus": status, + "turnIntentId": turn_intent_id, + "message": { "role": "user", "content": "retry me" } + }, + "source": "user", + "displayText": "retry me", + "displayStatus": display_status, + "displayVariant": "message", + "activityStatus": "agent" + })) + .expect("valid delivery event") + } + + #[test] + fn only_terminal_identified_failed_user_delivery_is_persisted() { + assert!(is_persisted_failed_user_delivery(&synthetic_delivery( + "failed", + Some("turn-1") + ))); + assert!(!is_persisted_failed_user_delivery(&synthetic_delivery( + "pending", + Some("turn-1") + ))); + assert!(!is_persisted_failed_user_delivery(&synthetic_delivery( + "sent", + Some("turn-1") + ))); + assert!(!is_persisted_failed_user_delivery(&synthetic_delivery( + "failed", None + ))); + } +} + /// Merge tool_result events into their matching tool_call events (pure transform). /// /// Uses O(1) HashMap lookup instead of the TS-side O(n) `findIndex`. diff --git a/src-tauri/src/agent_sessions/event_pipeline/derived.rs b/src-tauri/src/agent_sessions/event_pipeline/derived.rs index ba04de2772..a573f81a05 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/derived.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/derived.rs @@ -85,11 +85,19 @@ pub fn is_visible_in_chat(event: &SessionEvent) -> bool { return false; } - // Hide user messages from failed turns. When an `agent:error` arrives the - // frontend marks the preceding user message as `Failed`; the original text - // stays in the store for audit / replay but should not appear in chat so - // retries don't produce a wall of duplicate inputs. - if event.source == EventSource::User && event.display_status == EventDisplayStatus::Failed { + // Legacy runtime failures mark the accepted user turn `Failed`; keep those + // hidden to avoid duplicating the provider's error card. A frontend + // delivery failure is different: the provider never accepted it, and the + // failed bubble is the user's only retry/edit surface. + let is_delivery_failure = event + .result + .get("deliveryStatus") + .and_then(|value| value.as_str()) + == Some("failed"); + if event.source == EventSource::User + && event.display_status == EventDisplayStatus::Failed + && !is_delivery_failure + { return false; } diff --git a/src-tauri/src/agent_sessions/event_pipeline/ingestion/consolidator.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/consolidator.rs index 4bc8b948e7..db34db4b4f 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/ingestion/consolidator.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/ingestion/consolidator.rs @@ -6,8 +6,6 @@ //! Uses dual accumulators so interleaved thinking + message streams //! (e.g. from Copilot ACP) are handled correctly. -use std::collections::HashSet; - use chrono::DateTime; use crate::agent_sessions::event_pipeline::ingestion::types::RawActivityChunk; @@ -337,25 +335,37 @@ fn extract_message_content(chunk: &RawActivityChunk) -> String { String::new() } +/// Collapse adjacent replays of the same identified assistant row only. +/// Distinct complete messages may legitimately have identical bodies. Delta +/// accumulation belongs to the streaming groups above; text equality alone +/// cannot prove that a complete native message is a replay of those deltas. fn dedup_assistant_messages(chunks: Vec) -> Vec { - let mut seen: HashSet = HashSet::new(); - let mut result = Vec::with_capacity(chunks.len()); + let mut result: Vec = Vec::with_capacity(chunks.len()); for chunk in chunks { let at = chunk.action_type.as_deref().unwrap_or(""); let func = chunk.function.as_deref().unwrap_or(""); let is_assistant = at == "assistant" || func == "message"; - if is_assistant { - let text = extract_message_content(&chunk).trim().to_string(); - if !text.is_empty() && seen.contains(&text) { - continue; - } - if !text.is_empty() { - seen.insert(text); - } + // Equal text is not message identity, even within one assistant run. + // Only collapse an exact replay of an explicitly identified row. + if is_assistant + && result.last().is_some_and(|previous| { + chunk.chunk_id.as_deref().is_some_and(|id| !id.is_empty()) + && previous.chunk_id == chunk.chunk_id + && previous.session_id == chunk.session_id + && previous.thread_id == chunk.thread_id + && previous.process_id == chunk.process_id + && previous.call_id == chunk.call_id + && previous.created_at == chunk.created_at + && previous.action_type == chunk.action_type + && previous.function == chunk.function + && previous.args == chunk.args + && previous.result == chunk.result + }) + { + continue; } - result.push(chunk); } diff --git a/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs index 741c0649b3..7420c2807e 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/ingestion/normalizer.rs @@ -144,6 +144,10 @@ fn infer_display_variant( function_name: &str, result: &serde_json::Value, ) -> EventDisplayVariant { + if action_type == "context_compacted" || function_name == "context_compacted" { + return EventDisplayVariant::Message; + } + let is_failed_session_end = (action_type == "session_end" || function_name == "session_end") && result.get("success").and_then(|value| value.as_bool()) == Some(false) && ["error", "error_message", "observation"] @@ -159,7 +163,7 @@ fn infer_display_variant( } // User messages - if (action_type == "raw" || action_type == "raw_event") && raw_message_text(result).is_some() { + if (action_type == "raw" || action_type == "raw_event") && is_raw_user_message(result) { return EventDisplayVariant::Message; } @@ -420,7 +424,10 @@ fn infer_activity_status(action_type: &str, result: &serde_json::Value) -> Activ // ============================================================================ fn infer_source(action_type: &str, result: &serde_json::Value) -> EventSource { - if (action_type == "raw" || action_type == "raw_event") && raw_message_text(result).is_some() { + if action_type == "context_compacted" { + return EventSource::System; + } + if (action_type == "raw" || action_type == "raw_event") && is_raw_user_message(result) { return EventSource::User; } EventSource::Assistant @@ -440,7 +447,14 @@ fn infer_display_text( let result_obj = result.as_object(); match action_type { - "raw" | "raw_event" => raw_message_text(result).unwrap_or_else(|| "Activity".to_string()), + "raw" | "raw_event" if is_raw_user_message(result) => { + // Image-only user turns deliberately have no display text. Their + // attachment list renders the bubble; fabricating "Activity" + // would alter the native conversation when it is materialized. + raw_message_text(result).unwrap_or_default() + } + + "raw" | "raw_event" => "Activity".to_string(), "assistant" | "assistant_delta" | "message" | "message_delta" => result_obj .and_then(|o| str_field(o, "observation").or_else(|| str_field(o, "content"))) @@ -533,11 +547,32 @@ fn infer_display_text( } } -fn raw_message_text(result: &serde_json::Value) -> Option { - let obj = result.as_object()?; - if obj.get("type").and_then(|v| v.as_str()) != Some("user") && !obj.contains_key("message") { +pub(crate) fn is_raw_user_message(result: &serde_json::Value) -> bool { + let Some(obj) = result.as_object() else { + return false; + }; + let result_type = obj.get("type").and_then(|value| value.as_str()); + if result_type == Some("user") { + return true; + } + if result_type.is_some() { + return false; + } + let Some(message) = obj.get("message") else { + return false; + }; + message + .as_object() + .and_then(|value| value.get("role")) + .and_then(|value| value.as_str()) + .is_none_or(|role| role == "user") +} + +pub(crate) fn raw_message_text(result: &serde_json::Value) -> Option { + if !is_raw_user_message(result) { return None; } + let obj = result.as_object()?; let text = obj .get("message") diff --git a/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/consolidator_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/consolidator_tests.rs index 31a8b4b433..e81f449fa5 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/consolidator_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/consolidator_tests.rs @@ -181,7 +181,7 @@ fn test_empty_thinking_filtered() { } #[test] -fn test_dedup_assistant_messages() { +fn test_distinct_adjacent_assistant_messages_with_equal_text_are_preserved() { let chunks = vec![ RawActivityChunk { chunk_id: Some("msg1".to_string()), @@ -216,7 +216,90 @@ fn test_dedup_assistant_messages() { ]; let result = consolidate_activity_chunks(&chunks); - assert_eq!(result.len(), 1); + assert_eq!(result.len(), 2); + let replay = vec![chunks[0].clone(), chunks[0].clone()]; + assert_eq!(consolidate_activity_chunks(&replay).len(), 1); + + let mut different_scope = chunks[0].clone(); + different_scope.session_id = Some("another-session".to_string()); + assert_eq!( + consolidate_activity_chunks(&[chunks[0].clone(), different_scope]).len(), + 2 + ); + + let mut updated = chunks[0].clone(); + updated.result = Some(serde_json::json!({"content": "Hello! ", "is_delta": false})); + assert_eq!( + consolidate_activity_chunks(&[chunks[0].clone(), updated]).len(), + 2 + ); +} + +#[test] +fn test_repeated_assistant_answer_in_a_later_turn_is_kept() { + // The model may legitimately give the same answer to a repeated question. + // Only a consecutive streaming duplicate collapses; a user turn between + // two equal answers makes the second one a real assistant message. + let assistant = |id: &str, at: &str| RawActivityChunk { + chunk_id: Some(id.to_string()), + action_type: Some("assistant".to_string()), + function: Some("message".to_string()), + result: Some(serde_json::json!({ + "content": "The interrupted review did not reach a final answer.", + "is_delta": false + })), + created_at: Some(at.to_string()), + session_id: Some("sess-1".to_string()), + args: None, + thread_id: None, + process_id: None, + call_id: None, + }; + let chunks = vec![ + assistant("asst-1", "2025-01-15T10:00:01.000Z"), + RawActivityChunk { + chunk_id: Some("user-2".to_string()), + action_type: Some("raw".to_string()), + function: Some("user_message".to_string()), + result: Some(serde_json::json!({ "content": "ask it again" })), + created_at: Some("2025-01-15T10:00:02.000Z".to_string()), + session_id: Some("sess-1".to_string()), + args: None, + thread_id: None, + process_id: None, + call_id: None, + }, + assistant("asst-3", "2025-01-15T10:00:03.000Z"), + ]; + + let result = consolidate_activity_chunks(&chunks); + let ids: Vec<&str> = result + .iter() + .filter_map(|c| c.chunk_id.as_deref()) + .collect(); + assert_eq!(ids, vec!["asst-1", "user-2", "asst-3"]); +} + +#[test] +fn test_distinct_assistant_messages_a_b_a_are_preserved() { + let chunks: Vec = ["A", "B", "A"] + .iter() + .enumerate() + .map(|(index, text)| RawActivityChunk { + chunk_id: Some(format!("distinct-{index}")), + session_id: Some("same-turn".to_string()), + action_type: Some("assistant".to_string()), + function: Some("message".to_string()), + result: Some(serde_json::json!({"content": text, "is_delta": false})), + ..Default::default() + }) + .collect(); + let result = consolidate_activity_chunks(&chunks); + let ids: Vec<&str> = result + .iter() + .filter_map(|row| row.chunk_id.as_deref()) + .collect(); + assert_eq!(ids, vec!["distinct-0", "distinct-1", "distinct-2"]); } #[test] diff --git a/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/normalizer_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/normalizer_tests.rs index da8efb2383..216a72e15f 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/normalizer_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/ingestion/tests/normalizer_tests.rs @@ -139,6 +139,47 @@ fn test_normalize_user_message() { ); } +#[test] +fn image_only_raw_user_message_keeps_user_role_without_fabricated_text() { + let chunk = RawActivityChunk { + chunk_id: Some("chunk-image-only".to_string()), + action_type: Some("raw".to_string()), + result: Some(serde_json::json!({ + "type": "user", + "message": {"content": "", "role": "user"}, + "images": ["data:image/png;base64,aGVsbG8="] + })), + created_at: Some("2025-01-15T10:30:03.000Z".to_string()), + ..Default::default() + }; + + let event = normalize_chunk(&chunk, "sess-1"); + assert_eq!(event.source, EventSource::User); + assert_eq!(event.display_variant, EventDisplayVariant::Message); + assert_eq!(event.display_text, ""); + assert_eq!( + event.result["images"], + serde_json::json!(["data:image/png;base64,aGVsbG8="]) + ); +} + +#[test] +fn raw_assistant_envelope_with_text_does_not_become_a_user_message() { + let chunk = RawActivityChunk { + chunk_id: Some("chunk-raw-assistant".to_string()), + action_type: Some("raw".to_string()), + result: Some(serde_json::json!({ + "type": "assistant", + "message": {"content": "provider plumbing", "role": "assistant"} + })), + created_at: Some("2025-01-15T10:30:03.000Z".to_string()), + ..Default::default() + }; + + let event = normalize_chunk(&chunk, "sess-1"); + assert_eq!(event.source, EventSource::Assistant); +} + #[test] fn test_raw_tool_use_message_is_not_user_message() { let chunk = RawActivityChunk { @@ -444,6 +485,27 @@ fn test_ui_canonical_precomputed() { assert_eq!(event_thinking.ui_canonical, "thinking"); } +#[test] +fn native_context_compaction_is_a_system_message() { + let chunk = RawActivityChunk { + action_type: Some("context_compacted".to_string()), + function: Some("context_compacted".to_string()), + result: Some(serde_json::json!({ + "success": true, + "native": true, + "provider": "codex", + })), + ..Default::default() + }; + + let event = normalize_chunk(&chunk, "sess-1"); + assert_eq!(event.function_name, "context_compacted"); + assert_eq!(event.ui_canonical, "context_compacted"); + assert_eq!(event.source, EventSource::System); + assert_eq!(event.display_variant, EventDisplayVariant::Message); + assert_eq!(event.display_status, EventDisplayStatus::Completed); +} + #[test] fn ingest_backfills_opencode_subagent_prompt_from_child_session() { let chunk = RawActivityChunk { diff --git a/src-tauri/src/agent_sessions/event_pipeline/store/event_ops.rs b/src-tauri/src/agent_sessions/event_pipeline/store/event_ops.rs index 7da09e877c..f99d7155ba 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/store/event_ops.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/store/event_ops.rs @@ -7,8 +7,10 @@ use std::collections::HashSet; use super::helpers::{ is_authoritative_transcript_message, is_completed_authoritative_stream_transcript, - is_synthetic_transcript_placeholder, normalize_user_text, normalized_event_text, + is_synthetic_transcript_placeholder, logical_user_turn_key, normalize_user_text, + normalized_event_text, preserve_synthetic_turn_intent, stream_placeholder_prefix_for_authoritative, transcript_message_key, transcript_text, + user_turn_projection_authority, }; use super::{ active_shell_replays_for_session, bound_shell_replay_state, capture_shell_replay_bookmarks, @@ -55,6 +57,12 @@ impl EventStore { self.version += 1; return; } + if let Some(changed) = self.reconcile_duplicate_user_turn(&mut event) { + if changed { + self.version += 1; + } + return; + } if let Some(&idx) = self.id_index.get(&event.id) { if Self::would_downgrade_terminal_tool_call(&self.events[idx], &event) { @@ -72,7 +80,7 @@ impl EventStore { self.mark_changed(event_id); } else { if is_authoritative_transcript_message(&event) { - self.remove_matching_synthetic_transcript_placeholders(&event); + self.remove_matching_synthetic_transcript_placeholder(&mut event); } let event_id = event.id.clone(); let idx = self.events.len(); @@ -220,30 +228,38 @@ impl EventStore { } /// With no scope, removes every synthetic placeholder (legacy behavior). - /// A scope removes only placeholders that are echoed by one of the given - /// user-message contents, or that predate `older_than` (a placeholder - /// older than the newest real user turn can no longer receive an echo, - /// e.g. skill-pill messages whose wire content differs from the pill) — - /// a NEWER unmatched placeholder is a message whose echo has not arrived - /// yet and must survive history merges carrying older real user turns. + /// Intent-bearing placeholders are removed only by the same durable turn + /// id. Native history replay may re-stamp an older turn with a timestamp + /// later than a new optimistic row, so `older_than` is not valid evidence + /// for modern rows. Legacy placeholders retain content/time reconciliation. pub fn remove_synthetic_user_inputs( &mut self, - scope: Option<(&[String], Option<&str>)>, + scope: Option<(&[String], &[String], Option<&str>)>, ) -> usize { - let scope = scope.map(|(contents, older_than)| { + let scope = scope.map(|(contents, turn_intent_ids, older_than)| { let targets: std::collections::HashSet = contents .iter() .map(|content| normalize_user_text(content)) .collect(); - (targets, older_than.map(str::to_string)) + let intent_targets: std::collections::HashSet = + turn_intent_ids.iter().cloned().collect(); + (targets, intent_targets, older_than.map(str::to_string)) }); let should_remove = |event: &SessionEvent| -> bool { if event.source != EventSource::User || !is_synthetic_transcript_placeholder(event) { return false; } - let Some((targets, older_than)) = &scope else { + let Some((targets, intent_targets, older_than)) = &scope else { return true; }; + if let Some(turn_intent_id) = event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + { + return intent_targets.contains(turn_intent_id); + } let content_matched = transcript_text(event) .map(|text| targets.contains(&normalize_user_text(&text))) .unwrap_or(false); @@ -449,30 +465,76 @@ impl EventStore { /// marker, while backend parser/runtime events do not. Matching is scoped to /// transcript source and normalized message text so legitimate repeated /// authoritative messages are preserved. - pub(super) fn remove_matching_synthetic_transcript_placeholders( + pub(super) fn remove_matching_synthetic_transcript_placeholder( &mut self, - authoritative: &SessionEvent, + authoritative: &mut SessionEvent, ) -> usize { let Some(authoritative_key) = transcript_message_key(authoritative) else { return 0; }; - let removed_ids = self.matching_synthetic_transcript_placeholder_ids(&authoritative_key); - self.remove_events_by_ids(removed_ids) + let Some((removed_id, turn_intent_id)) = + self.matching_synthetic_transcript_placeholder(&authoritative_key) + else { + return 0; + }; + preserve_synthetic_turn_intent(authoritative, turn_intent_id.as_deref()); + self.remove_events_by_ids(vec![removed_id]) } - fn matching_synthetic_transcript_placeholder_ids( + /// Reconcile two transport projections of the same accepted user turn. + /// + /// SDE first emits a low-level `user_input` activity and then persists the + /// canonical `user_message`. Both are useful producer-side signals, but + /// EventStore is the transcript boundary and must expose exactly one row. + /// Return `Some(changed)` when the incoming event was consumed here. + pub(super) fn reconcile_duplicate_user_turn( + &mut self, + incoming: &mut SessionEvent, + ) -> Option { + let incoming_key = logical_user_turn_key(incoming)?; + let existing_idx = self.events.iter().position(|existing| { + existing.id != incoming.id + && logical_user_turn_key(existing).as_deref() == Some(incoming_key.as_str()) + })?; + + if user_turn_projection_authority(incoming) + <= user_turn_projection_authority(&self.events[existing_idx]) + { + return Some(false); + } + + incoming.created_at = self.events[existing_idx].created_at.clone(); + preserve_first_insert_replay(&self.events[existing_idx], incoming); + let removed_id = self.events[existing_idx].id.clone(); + let incoming_id = incoming.id.clone(); + self.events[existing_idx] = incoming.clone(); + self.mark_removed(removed_id); + self.mark_changed(incoming_id); + self.rebuild_indexes(); + Some(true) + } + + fn matching_synthetic_transcript_placeholder( &self, authoritative_key: &(EventSource, String), - ) -> Vec { + ) -> Option<(String, Option)> { self.events .iter() - .filter(|event| { + .find(|event| { is_synthetic_transcript_placeholder(event) && transcript_message_key(event).as_ref() == Some(authoritative_key) }) - .map(|event| event.id.clone()) - .collect() + .map(|event| { + ( + event.id.clone(), + event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .map(str::to_string), + ) + }) } pub(super) fn remove_events_by_ids(&mut self, removed_ids: Vec) -> usize { diff --git a/src-tauri/src/agent_sessions/event_pipeline/store/helpers.rs b/src-tauri/src/agent_sessions/event_pipeline/store/helpers.rs index 7eefc5d053..b7e258ffae 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/store/helpers.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/store/helpers.rs @@ -3,7 +3,7 @@ //! These helpers operate on `SessionEvent` slices and values but hold no //! store state themselves, making them easy to test in isolation. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use crate::agent_sessions::event_pipeline::types::{ EventDisplayStatus, EventDisplayVariant, EventSource, SessionEvent, @@ -89,6 +89,88 @@ pub(super) fn is_authoritative_transcript_message(event: &SessionEvent) -> bool transcript_message_key(event).is_some() && !is_synthetic_transcript_placeholder(event) } +/// Stable identity of one accepted user turn across the frontend placeholder, +/// the Rust runtime's low-level `user_input` row, and the persisted +/// `user_message` row. +/// +/// Modern submissions carry `turnIntentId`. Older Agent rows still expose the +/// same relationship through `user_message.result.messageId == user_input.id`. +/// Text is deliberately not part of this key: two consecutive turns may have +/// identical words and must remain distinct. +pub(super) fn logical_user_turn_key(event: &SessionEvent) -> Option { + if event.source != EventSource::User { + return None; + } + if let Some(turn_intent_id) = event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + { + return Some(format!("intent:{turn_intent_id}")); + } + let message_id = event + .result + .get("messageId") + .and_then(|value| value.as_str()) + .filter(|value| !value.is_empty()) + .or_else(|| { + (event.function_name == "user_input" && !event.id.is_empty()) + .then_some(event.id.as_str()) + })?; + Some(format!("message:{message_id}")) +} + +/// Prefer the single durable projection when several transport layers report +/// the same logical user turn. +pub(super) fn user_turn_projection_authority(event: &SessionEvent) -> u8 { + if is_synthetic_transcript_placeholder(event) { + return 0; + } + if event + .result + .get("backendPersisted") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return 3; + } + if event.function_name == "user_message" { + return 2; + } + 1 +} + +/// Collapse duplicate user-turn projections during full hydration while +/// retaining the first slot in timeline order and the strongest event body. +pub(super) fn reconcile_loaded_duplicate_user_turns(events: &mut Vec) -> usize { + let mut owner_by_key = HashMap::::new(); + let mut reconciled = Vec::with_capacity(events.len()); + let mut removed = 0usize; + + for mut event in events.drain(..) { + let Some(key) = logical_user_turn_key(&event) else { + reconciled.push(event); + continue; + }; + let Some(&existing_idx) = owner_by_key.get(&key) else { + owner_by_key.insert(key, reconciled.len()); + reconciled.push(event); + continue; + }; + removed += 1; + if user_turn_projection_authority(&event) + > user_turn_projection_authority(&reconciled[existing_idx]) + { + event.created_at = reconciled[existing_idx].created_at.clone(); + reconciled[existing_idx] = event; + } + } + + *events = reconciled; + removed +} + // --------------------------------------------------------------------------- // Placeholder / turn helpers // --------------------------------------------------------------------------- @@ -96,23 +178,45 @@ pub(super) fn is_authoritative_transcript_message(event: &SessionEvent) -> bool pub(super) fn reconcile_loaded_synthetic_transcript_placeholders( events: &mut Vec, ) -> usize { - let authoritative_keys: Vec<(EventSource, String)> = events + let synthetic_candidates: Vec<((EventSource, String), String, Option)> = events .iter() - .filter(|event| is_authoritative_transcript_message(event)) - .filter_map(transcript_message_key) - .collect(); - - let removed_ids: HashSet = events - .iter() - .filter(|event| { - is_synthetic_transcript_placeholder(event) - && transcript_message_key(event) - .as_ref() - .is_some_and(|key| authoritative_keys.iter().any(|candidate| candidate == key)) + .filter(|event| is_synthetic_transcript_placeholder(event)) + .filter_map(|event| { + transcript_message_key(event).map(|key| { + ( + key, + event.id.clone(), + event + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .map(str::to_string), + ) + }) }) - .map(|event| event.id.clone()) .collect(); + let mut removed_ids = HashSet::new(); + for authoritative in events + .iter_mut() + .filter(|event| is_authoritative_transcript_message(event)) + { + let Some(authoritative_key) = transcript_message_key(authoritative) else { + continue; + }; + let Some((_, candidate_id, turn_intent_id)) = + synthetic_candidates + .iter() + .find(|(candidate_key, candidate_id, _)| { + candidate_key == &authoritative_key && !removed_ids.contains(candidate_id) + }) + else { + continue; + }; + removed_ids.insert(candidate_id.clone()); + preserve_synthetic_turn_intent(authoritative, turn_intent_id.as_deref()); + } + let removed = removed_ids.len(); if removed > 0 { events.retain(|event| !removed_ids.contains(&event.id)); @@ -120,6 +224,35 @@ pub(super) fn reconcile_loaded_synthetic_transcript_placeholders( removed } +/// Preserve ORGII's durable user-intent identity when a provider transcript +/// row replaces the optimistic frontend placeholder. Provider JSONL rows do +/// not carry this id, but turn indexing and conversation publishing require it. +pub(super) fn preserve_synthetic_turn_intent( + authoritative: &mut SessionEvent, + turn_intent_id: Option<&str>, +) { + let Some(turn_intent_id) = turn_intent_id.filter(|value| !value.is_empty()) else { + return; + }; + if authoritative + .result + .get("turnIntentId") + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.is_empty()) + { + return; + } + if !authoritative.result.is_object() { + authoritative.result = serde_json::json!({}); + } + if let Some(result) = authoritative.result.as_object_mut() { + result.insert( + "turnIntentId".to_string(), + serde_json::Value::String(turn_intent_id.to_string()), + ); + } +} + pub(super) fn is_turn_placeholder(event: &SessionEvent) -> bool { event.function_name == TURN_PLACEHOLDER_FUNCTION_NAME || event.id.starts_with(TURN_PLACEHOLDER_ID_PREFIX) diff --git a/src-tauri/src/agent_sessions/event_pipeline/store/hydration.rs b/src-tauri/src/agent_sessions/event_pipeline/store/hydration.rs index c985c1f52a..ad6bdf0392 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/store/hydration.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/store/hydration.rs @@ -7,7 +7,8 @@ use std::collections::HashSet; use super::helpers::{ is_authoritative_transcript_message, is_turn_placeholder, loaded_turn_ids_from_events, - placeholder_turn_id, reconcile_loaded_synthetic_transcript_placeholders, timeline_source_order, + placeholder_turn_id, reconcile_loaded_duplicate_user_turns, + reconcile_loaded_synthetic_transcript_placeholders, timeline_source_order, }; use super::{ active_shell_replays_for_session, capture_shell_replay_bookmarks, hydrate_shell_event_bounded, @@ -43,6 +44,7 @@ impl EventStore { hydration_mode: HydrationMode, ) { reconcile_loaded_synthetic_transcript_placeholders(&mut events); + reconcile_loaded_duplicate_user_turns(&mut events); for event in &mut events { hydrate_shell_event_bounded(event); } @@ -74,8 +76,12 @@ impl EventStore { continue; } self.stamp_repo(&mut event); + if let Some(replaced) = self.reconcile_duplicate_user_turn(&mut event) { + changed |= replaced; + continue; + } if is_authoritative_transcript_message(&event) { - self.remove_matching_synthetic_transcript_placeholders(&event); + self.remove_matching_synthetic_transcript_placeholder(&mut event); } let event_id = event.id.clone(); let idx = self.events.len(); @@ -137,6 +143,10 @@ impl EventStore { } else { hydrate_shell_event_bounded(&mut event); } + if let Some(replaced) = self.reconcile_duplicate_user_turn(&mut event) { + changed |= replaced; + continue; + } if event.action_type == "tool_result" { if let Some(ref call_id) = event.call_id { if let Some(&call_idx) = self.call_id_index.get(call_id) { @@ -248,7 +258,7 @@ impl EventStore { changed = true; } else { if is_authoritative_transcript_message(&event) { - self.remove_matching_synthetic_transcript_placeholders(&event); + self.remove_matching_synthetic_transcript_placeholder(&mut event); } let event_id = event.id.clone(); let idx = self.events.len(); diff --git a/src-tauri/src/agent_sessions/event_pipeline/tests/derived_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/tests/derived_tests.rs index 74e5c57010..a866ac2dff 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/tests/derived_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/tests/derived_tests.rs @@ -149,6 +149,17 @@ fn test_chat_hides_failed_user_message() { assert!(!is_visible_in_chat(&event)); } +#[test] +fn test_chat_shows_failed_user_delivery_for_retry() { + let mut event = make_user_message("u_delivery_failed"); + event.display_status = EventDisplayStatus::Failed; + event.result = serde_json::json!({ + "deliveryStatus": "failed", + "deliveryError": "backend unavailable", + }); + assert!(is_visible_in_chat(&event)); +} + #[test] fn test_chat_shows_completed_user_message() { let event = make_user_message("u_ok"); diff --git a/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs b/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs index 7f20cb26fb..8b56cf789e 100644 --- a/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs +++ b/src-tauri/src/agent_sessions/event_pipeline/tests/store_tests.rs @@ -576,6 +576,7 @@ fn test_scoped_synthetic_removal_keeps_unechoed_newer_placeholder() { // the fresh follow-up whose echo has not arrived yet. let removed = store.remove_synthetic_user_inputs(Some(( &["first message".to_string()], + &[], Some("2026-08-14T10:00:00Z"), ))); @@ -598,6 +599,7 @@ fn test_scoped_synthetic_removal_drops_placeholder_predating_newest_real_turn() // newest real user turn instead. let removed = store.remove_synthetic_user_inputs(Some(( &["expanded yaml payload".to_string()], + &[], Some("2026-08-14T09:30:00Z"), ))); @@ -605,6 +607,39 @@ fn test_scoped_synthetic_removal_drops_placeholder_predating_newest_real_turn() assert!(store.get_by_id("user-input-stale-pill").is_none()); } +#[test] +fn test_scoped_synthetic_removal_does_not_timestamp_evict_new_intent() { + let mut store = EventStore::new(); + let mut pending = make_synthetic_user_event( + "user-input-next", + "continue exploring", + "2026-08-14T10:00:00Z", + ); + pending.result["turnIntentId"] = serde_json::json!("turn-next"); + store.set(vec![pending]); + + // A replayed OLD turn may be materialized later and therefore carry a + // misleadingly newer timestamp. It cannot settle the current intent. + let old_contents = vec!["old request".to_string()]; + let old_intents = vec!["turn-old".to_string()]; + let removed = store.remove_synthetic_user_inputs(Some(( + &old_contents, + &old_intents, + Some("2026-08-14T11:00:00Z"), + ))); + assert_eq!(removed, 0); + assert!(store.get_by_id("user-input-next").is_some()); + + let matching_intents = vec!["turn-next".to_string()]; + let removed = store.remove_synthetic_user_inputs(Some(( + &[], + &matching_intents, + Some("2026-08-14T11:00:00Z"), + ))); + assert_eq!(removed, 1); + assert!(store.get_by_id("user-input-next").is_none()); +} + #[test] fn test_merge_authoritative_user_message_evicts_matching_synthetic_placeholder() { let mut store = EventStore::new(); @@ -612,7 +647,10 @@ fn test_merge_authoritative_user_message_evicts_matching_synthetic_placeholder() synthetic.source = EventSource::User; synthetic.function_name = "user_message".to_string(); synthetic.ui_canonical = "user_message".to_string(); - synthetic.result = serde_json::json!({ "syntheticUserInput": true }); + synthetic.result = serde_json::json!({ + "syntheticUserInput": true, + "turnIntentId": "turn-live-1", + }); synthetic.chunk_id = None; synthetic.display_text = "hello from user".to_string(); @@ -628,7 +666,13 @@ fn test_merge_authoritative_user_message_evicts_matching_synthetic_placeholder() store.merge_events(vec![backend]); assert!(store.get_by_id("user-input-synthetic").is_none()); - assert!(store.get_by_id("user-input-cliagent-real").is_some()); + assert_eq!( + store + .get_by_id("user-input-cliagent-real") + .and_then(|event| event.result.get("turnIntentId")) + .and_then(|value| value.as_str()), + Some("turn-live-1") + ); } #[test] @@ -637,7 +681,10 @@ fn test_set_reconciles_persisted_matching_synthetic_placeholder() { let mut synthetic = make_event("user-input-synthetic", "raw"); synthetic.source = EventSource::User; synthetic.function_name = "user_message".to_string(); - synthetic.result = serde_json::json!({ "syntheticUserInput": true }); + synthetic.result = serde_json::json!({ + "syntheticUserInput": true, + "turnIntentId": "turn-reload-1", + }); synthetic.display_text = "persisted duplicate".to_string(); let mut backend = make_event("user-input-real", "raw"); @@ -648,7 +695,46 @@ fn test_set_reconciles_persisted_matching_synthetic_placeholder() { store.set(vec![synthetic, backend]); assert!(store.get_by_id("user-input-synthetic").is_none()); - assert!(store.get_by_id("user-input-real").is_some()); + assert_eq!( + store + .get_by_id("user-input-real") + .and_then(|event| event.result.get("turnIntentId")) + .and_then(|value| value.as_str()), + Some("turn-reload-1") + ); +} + +#[test] +fn test_repeated_user_text_reconciles_one_intent_per_authoritative_row() { + let mut store = EventStore::new(); + let mut first = make_synthetic_user_event( + "user-input-synthetic-1", + "repeat me", + "2026-08-29T00:00:00Z", + ); + first.result["turnIntentId"] = serde_json::json!("turn-repeat-1"); + let mut second = make_synthetic_user_event( + "user-input-synthetic-2", + "repeat me", + "2026-08-29T00:00:01Z", + ); + second.result["turnIntentId"] = serde_json::json!("turn-repeat-2"); + store.append(vec![first, second]); + + let mut authoritative = make_event("user-input-real-1", "raw"); + authoritative.source = EventSource::User; + authoritative.display_text = "repeat me".to_string(); + store.merge_events(vec![authoritative]); + + assert!(store.get_by_id("user-input-synthetic-1").is_none()); + assert!(store.get_by_id("user-input-synthetic-2").is_some()); + assert_eq!( + store + .get_by_id("user-input-real-1") + .and_then(|event| event.result.get("turnIntentId")) + .and_then(|value| value.as_str()), + Some("turn-repeat-1") + ); } #[test] @@ -673,6 +759,90 @@ fn test_merge_authoritative_message_keeps_legitimate_repeated_user_text() { assert!(store.get_by_id("user-input-second").is_some()); } +fn make_runtime_user_projection( + id: &str, + function_name: &str, + turn_intent_id: &str, + backend_persisted: bool, +) -> SessionEvent { + let mut event = make_event(id, "raw"); + event.source = EventSource::User; + event.function_name = function_name.to_string(); + event.ui_canonical = function_name.to_string(); + event.display_text = "one logical user turn".to_string(); + event.result = serde_json::json!({ + "type": "user", + "message": { "content": "one logical user turn", "role": "user" }, + "turnIntentId": turn_intent_id, + "backendPersisted": backend_persisted, + }); + event +} + +#[test] +fn test_merge_user_turn_prefers_persisted_projection_by_turn_intent() { + let mut store = EventStore::new(); + let mut live = + make_runtime_user_projection("message-42", "user_input", "turn-intent-42", false); + live.created_at = "2026-08-30T10:00:00.000Z".to_string(); + let mut persisted = make_runtime_user_projection( + "user-message-message-42", + "user_message", + "turn-intent-42", + true, + ); + persisted.result["messageId"] = serde_json::json!("message-42"); + persisted.created_at = "2026-08-30T10:00:00.001Z".to_string(); + + store.append(vec![live]); + store.merge_events(vec![persisted]); + + assert_eq!(store.event_count(), 1); + assert!(store.get_by_id("message-42").is_none()); + let canonical = store + .get_by_id("user-message-message-42") + .expect("persisted projection survives"); + assert_eq!(canonical.created_at, "2026-08-30T10:00:00.000Z"); + assert_eq!(canonical.result["backendPersisted"], true); +} + +#[test] +fn test_late_low_level_user_projection_cannot_duplicate_persisted_turn() { + let mut store = EventStore::new(); + let mut persisted = make_runtime_user_projection( + "user-message-message-43", + "user_message", + "turn-intent-43", + true, + ); + persisted.result["messageId"] = serde_json::json!("message-43"); + let live = make_runtime_user_projection("message-43", "user_input", "turn-intent-43", false); + + store.append(vec![persisted]); + store.merge_events(vec![live]); + + assert_eq!(store.event_count(), 1); + assert!(store.get_by_id("message-43").is_none()); + assert!(store.get_by_id("user-message-message-43").is_some()); +} + +#[test] +fn test_hydration_collapses_legacy_message_id_pair_without_text_dedup() { + let mut store = EventStore::new(); + let live = make_runtime_user_projection("message-44", "user_input", "", false); + let mut persisted = + make_runtime_user_projection("user-message-message-44", "user_message", "", true); + persisted.result["messageId"] = serde_json::json!("message-44"); + let repeated = make_runtime_user_projection("message-45", "user_input", "", false); + + store.set(vec![live, persisted, repeated]); + + assert_eq!(store.event_count(), 2); + assert!(store.get_by_id("message-44").is_none()); + assert!(store.get_by_id("user-message-message-44").is_some()); + assert!(store.get_by_id("message-45").is_some()); +} + #[test] fn test_merge_authoritative_message_keeps_non_matching_synthetic_text() { let mut store = EventStore::new(); diff --git a/src-tauri/src/agent_sessions/mod.rs b/src-tauri/src/agent_sessions/mod.rs index caa033c241..5122d427b0 100644 --- a/src-tauri/src/agent_sessions/mod.rs +++ b/src-tauri/src/agent_sessions/mod.rs @@ -20,3 +20,4 @@ pub mod external_cli_adapter; pub mod follow_up_suggestions; pub mod human; pub mod session_directory; +pub mod turn_intents; diff --git a/src-tauri/src/agent_sessions/session_directory/patch.rs b/src-tauri/src/agent_sessions/session_directory/patch.rs index a9fa118a49..c7612afeec 100644 --- a/src-tauri/src/agent_sessions/session_directory/patch.rs +++ b/src-tauri/src/agent_sessions/session_directory/patch.rs @@ -466,7 +466,22 @@ pub async fn session_patch( session_id: String, patch: SessionPatch, ) -> Result<(), String> { - let identity_changed = patch.model.is_some(); + let identity_changed = patch.model.is_some() || patch.account_id.is_some(); + // Model/account identity participates in provider-native publication. + // Serialize that patch with interrupt/finalize/follow-up so an in-flight + // runner that started as account A can never be published through a newly + // patched account B binding. The UI remains responsive; the selection is + // committed for the next turn once the current provider boundary settles. + let _identity_guard = if identity_changed { + Some( + crate::agent_sessions::cli::session_runner::session_identity_lock(&session_id) + .await + .lock_owned() + .await, + ) + } else { + None + }; let switched_to_project = patch.product_mode.as_deref() == Some("project"); let renamed = patch .name diff --git a/src-tauri/src/agent_sessions/turn_intents.rs b/src-tauri/src/agent_sessions/turn_intents.rs new file mode 100644 index 0000000000..4b1624ea0a --- /dev/null +++ b/src-tauri/src/agent_sessions/turn_intents.rs @@ -0,0 +1,99 @@ +//! Provider-neutral durable turn-intent reads. +//! +//! Canonical conversation recovery uses the same `session_turn_intents` rows +//! already written by Agent and CLI runtimes. Keeping this query above either +//! adapter avoids a second frontend receipt/claim database. + +use serde::Serialize; + +// One IPC call is deliberately bounded so renderer shutdown/update can tear +// it down promptly. The frontend chains these windows while the exact durable +// intent remains queued/running; a legitimate long provider turn therefore +// has no arbitrary wall-clock deadline. +const MAX_TURN_WAIT_MS: u64 = 60_000; +const TURN_WAIT_INITIAL_POLL_MS: u64 = 100; +const TURN_WAIT_MAX_POLL_MS: u64 = 1_000; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionTurnIntentStatus { + pub session_id: String, + pub turn_intent_id: String, + pub status: String, + pub updated_at: String, +} + +fn read_status( + session_id: &str, + turn_intent_id: &str, +) -> Result, String> { + session_persistence::turn_intents::read_intent(session_id, turn_intent_id) + .map(|row| { + row.map(|intent| SessionTurnIntentStatus { + session_id: intent.session_id, + turn_intent_id: intent.turn_intent_id, + status: intent.status.as_str().to_string(), + updated_at: intent.updated_at, + }) + }) + .map_err(|err| format!("DB error: {err}")) +} + +#[tauri::command] +pub async fn session_turn_intent_status( + session_id: String, + turn_intent_id: String, +) -> Result, String> { + if session_id.is_empty() || turn_intent_id.is_empty() { + return Err("session_id and turn_intent_id are required".to_string()); + } + tokio::task::spawn_blocking(move || read_status(&session_id, &turn_intent_id)) + .await + .map_err(|err| format!("Task error: {err}"))? +} + +#[tauri::command] +pub async fn session_wait_for_turn_terminal( + session_id: String, + turn_intent_id: String, + timeout_ms: u64, +) -> Result { + if session_id.is_empty() || turn_intent_id.is_empty() { + return Err("session_id and turn_intent_id are required".to_string()); + } + let timeout_ms = timeout_ms.clamp(1, MAX_TURN_WAIT_MS); + let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms); + let mut poll_ms = TURN_WAIT_INITIAL_POLL_MS; + + loop { + let read_session_id = session_id.clone(); + let read_turn_intent_id = turn_intent_id.clone(); + let intent = tokio::task::spawn_blocking(move || { + read_status(&read_session_id, &read_turn_intent_id) + }) + .await + .map_err(|err| format!("Task error: {err}"))??; + + if let Some(intent) = intent.filter(|row| { + matches!( + row.status.as_str(), + "completed" | "failed" | "cancelled" | "stale" | "coalesced" | "rejected" + ) + }) { + return Ok(intent); + } + + let now = tokio::time::Instant::now(); + if now >= deadline { + return Err(format!( + "turn {turn_intent_id} for session {session_id} timed out" + )); + } + tokio::time::sleep(std::cmp::min( + tokio::time::Duration::from_millis(poll_ms), + deadline - now, + )) + .await; + poll_ms = poll_ms.saturating_mul(2).min(TURN_WAIT_MAX_POLL_MS); + } +} diff --git a/src-tauri/src/app/setup_hook/services.rs b/src-tauri/src/app/setup_hook/services.rs index a41f7440dd..e66b183e55 100644 --- a/src-tauri/src/app/setup_hook/services.rs +++ b/src-tauri/src/app/setup_hook/services.rs @@ -23,38 +23,45 @@ pub(crate) fn start_backend_services( tracing::info!("[Transport] Transport layer initialized"); } } - match agent_sessions::cli::persistence::sweep_stale_sessions() { - Ok(orphans) if !orphans.is_empty() => { - tracing::info!( - count = orphans.len(), - "[CLI Sessions] swept stale sessions to failed" - ); - // Kill the orphaned CLI process trees. After a backend - // restart (crash, quit, or dev-mode Rust recompile) the - // old CLI agents keep running unsupervised — the new - // backend has no RUNNING_SESSIONS handle, so the user's - // cancel button can't reach them. Resume previously did - // this lazily per-session; do it eagerly for all. - tauri::async_runtime::spawn(async move { - for (session_id, pid) in orphans { - tracing::info!( - "[CLI Sessions] terminating orphaned process tree pid={} (session {})", - pid, - session_id - ); - agent_sessions::cli::session_runner::terminate_process_tree( - pid, - &session_id, - ) - .await; - } - }); + let stale_cli_processes = match agent_sessions::cli::persistence::sweep_stale_sessions() { + Ok(orphans) => { + if !orphans.is_empty() { + tracing::info!( + count = orphans.len(), + "[CLI Sessions] swept stale sessions to failed" + ); + } + orphans } - Ok(_) => {} Err(err) => { tracing::warn!(error = %err, "[CLI Sessions] Failed to sweep stale sessions"); + Vec::new() } - } + }; + // Reuse the existing startup lifecycle: first terminate provider processes + // left behind by the previous backend, then make one bounded pass over + // durable native-App catalog receipts. There is no timer or parallel + // coordinator, and clean sessions are never visited. + tauri::async_runtime::spawn(async move { + for (session_id, pid) in stale_cli_processes { + tracing::info!( + "[CLI Sessions] terminating orphaned process tree pid={} (session {})", + pid, + session_id + ); + agent_sessions::cli::session_runner::terminate_process_tree(pid, &session_id).await; + } + let (repaired, failed) = agent_sessions::cli::native_materializer:: + reconcile_pending_native_catalog_refreshes_on_startup() + .await; + if repaired > 0 || failed > 0 { + tracing::info!( + repaired, + failed, + "[CLI Sessions] reconciled pending native App catalog refreshes" + ); + } + }); system_services::app_menu::setup_menu_events(app.handle()); tracing::info!("[AppMenu] Menu event handlers registered"); diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 2e1ac025f9..1760cf9dbf 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -404,6 +404,9 @@ api::mobile_bridge::commands::mobile_remote_notify_cloud_auth_changed, api::mobile_bridge::commands::mobile_remote_sync_sidebar_sessions, // Code session commands (spawn CLI agents, manage sessions) agent_sessions::cli::commands::cli_agent_create, +agent_sessions::cli::native_materializer::materialize_native_conversation, +agent_sessions::cli::native_materializer::synchronize_native_conversation, +agent_sessions::cli::native_materializer::discard_native_conversation_materialization, agent_sessions::cli::commands::cli_agent_message, agent_sessions::cli::commands::cli_agent_approval_response, agent_sessions::cli::commands::cli_agent_status, @@ -412,6 +415,7 @@ agent_sessions::cli::commands::cli_agent_history_mutation, agent_sessions::cli::commands::cli_agent_cancel, agent_sessions::cli::commands::cli_agent_tui_release, agent_sessions::cli::commands::cli_agent_chunks, +agent_sessions::cli::commands::cli_agent_transcript_revision, agent_sessions::cli::commands::cli_agent_transcript_path, agent_sessions::cli::commands::cli_agent_truncate_after_chunk, agent_sessions::cli::commands::cli_agent_delete, @@ -1058,6 +1062,8 @@ agent_sessions::session_directory::commands::session_aggregate_list, agent_sessions::session_directory::commands::session_native_sidebar_page, agent_sessions::session_directory::commands::session_external_history_sidebar_list, agent_sessions::session_directory::patch::session_patch, +agent_sessions::turn_intents::session_turn_intent_status, +agent_sessions::turn_intents::session_wait_for_turn_terminal, // Flow Awareness commands (user activity tracking for intent inference) agent_core::flow_awareness::commands::flow_record_activity, agent_core::flow_awareness::commands::flow_record_activities, diff --git a/src-tauri/src/infrastructure/dev_bundled_auth.rs b/src-tauri/src/infrastructure/dev_bundled_auth.rs index f9a207e150..5c7066e83c 100644 --- a/src-tauri/src/infrastructure/dev_bundled_auth.rs +++ b/src-tauri/src/infrastructure/dev_bundled_auth.rs @@ -179,8 +179,10 @@ fn decode_webkit_string(value: ValueRef<'_>) -> Option { ValueRef::Text(bytes) => std::str::from_utf8(bytes).ok().map(str::to_owned), ValueRef::Blob(bytes) if bytes.len() % 2 == 0 => { let units = bytes - .chunks_exact(2) - .map(|pair| u16::from_le_bytes([pair[0], pair[1]])); + .as_chunks::<2>() + .0 + .iter() + .map(|pair| u16::from_le_bytes(*pair)); char::decode_utf16(units) .collect::>() .ok() diff --git a/src-tauri/src/orgtrack/history_commands/scan.rs b/src-tauri/src/orgtrack/history_commands/scan.rs index 3d988b1d2a..ca60236fb9 100644 --- a/src-tauri/src/orgtrack/history_commands/scan.rs +++ b/src-tauri/src/orgtrack/history_commands/scan.rs @@ -311,38 +311,74 @@ pub struct ExternalHistoryAppOpenPlanWire { pub source_available: bool, } -/// Plan how to reopen an imported external session in the app that owns it. -/// `Ok(None)` when the session is unknown, a subagent child, or its source -/// has no verified per-session deep link (everything but Claude Code and -/// Codex today). +fn external_history_app_open_plan_from_conn( + conn: &rusqlite::Connection, + session_id: &str, +) -> Result, String> { + if let Some((plan, session)) = + orgtrack_core::sources::app_open::app_open_plan_for_cached_session(conn, session_id)? + { + let source_available = + !session.source_path.is_empty() && Path::new(&session.source_path).exists(); + return Ok(Some(ExternalHistoryAppOpenPlanWire { + plan, + source_available, + })); + } + + // Managed native sessions use their current account/profile binding, not + // the append-only discovery ledger: after A -> B -> A, this must open the + // same native conversation the next CLI turn will resume. + let Some(session) = crate::agent_sessions::cli::persistence::get_session(session_id) + .map_err(|error| format!("Failed to read managed session {session_id}: {error}"))? + else { + return Ok(None); + }; + let Some((binding, native_id)) = + crate::agent_sessions::cli::native_transcript::current_native_store_key_for_session( + &session, + )? + else { + return Ok(None); + }; + let Some(plan) = orgtrack_core::sources::app_open::app_open_plan(binding.source, &native_id) + else { + return Ok(None); + }; + let source_available = + crate::agent_sessions::cli::native_materializer::native_app_transcript_path( + &session, &native_id, + )? + .is_some(); + Ok(Some(ExternalHistoryAppOpenPlanWire { + plan, + source_available, + })) +} + +/// Plan how to reopen an imported or managed native session in the app that +/// owns it. `Ok(None)` when the session is unknown, has no current native +/// binding, is an imported subagent child, or its source has no verified +/// per-session deep link (everything but Claude Code and Codex today). #[tauri::command] pub async fn external_history_app_open_plan( session_id: String, ) -> Result, String> { tokio::task::spawn_blocking(move || { let conn = open_cache_conn()?; - let Some((plan, session)) = - orgtrack_core::sources::app_open::app_open_plan_for_cached_session(&conn, &session_id)? - else { - return Ok(None); - }; - let source_available = - !session.source_path.is_empty() && Path::new(&session.source_path).exists(); - Ok(Some(ExternalHistoryAppOpenPlanWire { - plan, - source_available, - })) + external_history_app_open_plan_from_conn(&conn, &session_id) }) .await .map_err(|err| format!("Task join error: {err}"))? } -/// Open an imported external session in the app that owns it. +/// Open an imported or managed native session in the app that owns it. /// -/// The deep link is rebuilt from the cache row here instead of being -/// accepted from the frontend, so the webview never names a URL the host -/// hands to the OS: the only links this can fire are the uuid-validated -/// vendor routes [`orgtrack_core::sources::app_open`] knows how to spell. +/// The deep link is rebuilt from the authoritative imported cache row or +/// managed native binding here instead of being accepted from the frontend, +/// so the webview never names a URL the host hands to the OS: the only links +/// this can fire are the uuid-validated vendor routes +/// [`orgtrack_core::sources::app_open`] knows how to spell. /// That also keeps the `opener:allow-open-url` capability scope limited to /// `http(s)`, since no custom-scheme URL ever crosses the IPC boundary. /// @@ -359,14 +395,10 @@ pub async fn external_history_open_in_app( let deep_link = tokio::task::spawn_blocking(move || { let conn = open_cache_conn()?; - let Some((plan, _)) = - orgtrack_core::sources::app_open::app_open_plan_for_cached_session(&conn, &session_id)? - else { - return Err(format!( - "No native app deep link for imported session {session_id}" - )); + let Some(plan) = external_history_app_open_plan_from_conn(&conn, &session_id)? else { + return Err(format!("No native app deep link for session {session_id}")); }; - Ok(plan.deep_link) + Ok(plan.plan.deep_link) }) .await .map_err(|err| format!("Task join error: {err}"))??; @@ -375,3 +407,209 @@ pub async fn external_history_open_in_app( .open_url(deep_link.clone(), None::<&str>) .map_err(|err| format!("Failed to open {deep_link}: {err}")) } + +#[cfg(test)] +mod managed_app_open_plan_tests { + use super::*; + use std::fs; + + use crate::agent_sessions::cli::persistence::{self, CreateCodeSessionParams}; + use crate::test_utils::test_env; + + const CLAUDE_A_UUID: &str = "11111111-1111-4111-8111-111111111111"; + const CLAUDE_B_UUID: &str = "22222222-2222-4222-8222-222222222222"; + const CODEX_UUID: &str = "33333333-3333-4333-8333-333333333333"; + + fn create_managed_session( + session_id: &str, + cli_agent_type: &str, + account_id: &str, + repo_path: &Path, + ) { + persistence::create_session( + session_id, + &CreateCodeSessionParams { + name: Some("managed app-open fixture".to_string()), + flow: None, + runner: None, + cli_agent_type: cli_agent_type.to_string(), + model: Some("test-model".to_string()), + tier: None, + account_id: Some(account_id.to_string()), + repo_path: Some(repo_path.to_string_lossy().into_owned()), + branch: None, + worktree_path: None, + worktree_base_ref: None, + proxy_token: None, + proxy_url: None, + hosted_token: None, + proxy_session_id: None, + isolate: None, + background: Some(false), + key_source: Some("own_key".to_string()), + additional_directories: None, + parent_session_id: None, + org_member_id: None, + agent_definition_id: None, + org_id: None, + project_id: None, + project_name: None, + project_slug: None, + work_item_id: None, + agent_role: None, + product_mode: None, + }, + ) + .expect("create managed native session"); + } + + fn plan_for(session_id: &str) -> Option { + let conn = open_cache_conn().expect("open imported-history cache"); + external_history_app_open_plan_from_conn(&conn, session_id) + .expect("resolve native app-open plan") + } + + fn claude_transcript_path(cwd: &Path, native_id: &str) -> std::path::PathBuf { + let project_slug: String = cwd + .to_string_lossy() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } + }) + .collect(); + app_paths::native_transcript_home_dir() + .join(".claude/projects") + .join(project_slug) + .join(format!("{native_id}.jsonl")) + } + + fn claude_runner_transcript_path( + account_id: &str, + cwd: &Path, + native_id: &str, + ) -> std::path::PathBuf { + let native = claude_transcript_path(cwd, native_id); + let relative = native + .strip_prefix(app_paths::native_transcript_home_dir().join(".claude")) + .expect("Claude native transcript relative path"); + app_paths::claude_code_cli_profile_dir(account_id).join(relative) + } + + fn write_file(path: &Path) { + fs::create_dir_all(path.parent().expect("fixture parent")) + .expect("create fixture directory"); + fs::write(path, b"{}\n").expect("write provider transcript fixture"); + } + + #[test] + fn managed_claude_plan_uses_the_current_account_binding() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-claude"; + create_managed_session(session_id, "claude_code", "account-a", sandbox.path()); + persistence::update_cli_session_id_for_account( + session_id, + Some("account-a"), + CLAUDE_A_UUID, + ) + .expect("bind account A"); + + let conn = database::db::get_connection().expect("open sessions database"); + conn.execute( + "UPDATE code_sessions SET account_id = 'account-b' WHERE session_id = ?1", + [session_id], + ) + .expect("switch to account B"); + persistence::update_cli_session_id_for_account( + session_id, + Some("account-b"), + CLAUDE_B_UUID, + ) + .expect("bind account B"); + conn.execute( + "UPDATE code_sessions SET account_id = 'account-a' WHERE session_id = ?1", + [session_id], + ) + .expect("switch back to account A"); + let cwd = fs::canonicalize(sandbox.path()).expect("canonical fixture workspace"); + write_file(&claude_transcript_path(&cwd, CLAUDE_A_UUID)); + + let plan = plan_for(session_id).expect("managed Claude plan"); + assert_eq!(plan.plan.source, "claude_code"); + assert_eq!(plan.plan.native_session_id, CLAUDE_A_UUID); + assert_eq!( + plan.plan.deep_link, + format!("claude://resume?session={CLAUDE_A_UUID}") + ); + assert!(plan.source_available); + } + + #[test] + fn managed_codex_plan_addresses_the_bound_thread_uuid() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-codex"; + create_managed_session(session_id, "codex", "openai-1", sandbox.path()); + persistence::update_cli_session_id_for_account(session_id, Some("openai-1"), CODEX_UUID) + .expect("bind Codex thread"); + write_file( + &app_paths::native_transcript_home_dir() + .join(".codex/sessions/2026/09/06") + .join(format!("rollout-2026-09-06T00-00-00-{CODEX_UUID}.jsonl")), + ); + + let plan = plan_for(session_id).expect("managed Codex plan"); + assert_eq!(plan.plan.source, "codex_app"); + assert_eq!(plan.plan.native_session_id, CODEX_UUID); + assert_eq!(plan.plan.deep_link, format!("codex://threads/{CODEX_UUID}")); + assert!(plan.source_available); + } + + #[test] + fn managed_session_without_a_native_binding_has_no_plan() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-unbound"; + create_managed_session(session_id, "claude_code", "account-a", sandbox.path()); + + assert!(plan_for(session_id).is_none()); + } + + #[test] + fn managed_plan_does_not_treat_a_runner_only_alias_as_native_app_available() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-runner-only"; + create_managed_session(session_id, "claude_code", "account-a", sandbox.path()); + persistence::update_cli_session_id_for_account( + session_id, + Some("account-a"), + CLAUDE_A_UUID, + ) + .expect("bind account A"); + let cwd = fs::canonicalize(sandbox.path()).expect("canonical fixture workspace"); + write_file(&claude_runner_transcript_path( + "account-a", + &cwd, + CLAUDE_A_UUID, + )); + + let plan = plan_for(session_id).expect("managed Claude plan"); + assert!(!plan.source_available); + } + + #[test] + fn managed_session_with_an_unsafe_native_id_has_no_plan() { + let sandbox = test_env::sandbox(); + let session_id = "cliagent-app-open-unsafe"; + create_managed_session(session_id, "claude_code", "account-a", sandbox.path()); + persistence::update_cli_session_id_for_account( + session_id, + Some("account-a"), + "not-a-uuid?launch=anything", + ) + .expect("bind malformed provider id fixture"); + + assert!(plan_for(session_id).is_none()); + } +} diff --git a/src/api/tauri/externalHistory/appOpen.ts b/src/api/tauri/externalHistory/appOpen.ts index e11c4102a7..6b5bc4009d 100644 --- a/src/api/tauri/externalHistory/appOpen.ts +++ b/src/api/tauri/externalHistory/appOpen.ts @@ -1,8 +1,8 @@ import { invoke } from "@tauri-apps/api/core"; /** - * Backend plan for reopening an imported external session in the vendor's - * own app via a per-session deep link (`claude://resume?session=…`, + * Backend plan for reopening an imported or managed native session in the + * vendor's own app via a per-session deep link (`claude://resume?session=…`, * `codex://threads/…`). Mirrors `ExternalHistoryAppOpenPlanWire` in * `src-tauri/src/orgtrack/history_commands/scan.rs` (camelCase JSON). * @@ -11,7 +11,7 @@ import { invoke } from "@tauri-apps/api/core"; * frontend never gets to name what the OS opens. */ export interface ExternalHistoryAppOpenPlan { - /** Imported-history source id (`claude_code` / `codex_app`). */ + /** Native transcript source id (`claude_code` / `codex_app`). */ source: string; /** Name of the app the deep link opens, for labels and tooltips. */ appDisplayName: string; @@ -28,9 +28,9 @@ export interface ExternalHistoryAppOpenPlan { } /** - * `null` when the session is unknown to the imported-history cache, is a - * subagent child, or its source has no verified per-session app deep link - * (everything but Claude Code and Codex today). + * `null` when the session is unknown, has no current native binding, is an + * imported subagent child, or its source has no verified per-session app + * deep link (everything but Claude Code and Codex today). */ export async function externalHistoryAppOpenPlan( sessionId: string @@ -42,7 +42,7 @@ export async function externalHistoryAppOpenPlan( } /** - * Open the imported session in the app that owns it. Rejects when the + * Open the imported or managed native session in the app that owns it. * session has no deep link or the OS refuses the URL; a link that routes * nowhere cannot be detected, so callers must not treat resolution as proof * the app surfaced the conversation. diff --git a/src/api/tauri/rpc/procedures/cli.ts b/src/api/tauri/rpc/procedures/cli.ts index 2135df3dec..03c6c8406b 100644 --- a/src/api/tauri/rpc/procedures/cli.ts +++ b/src/api/tauri/rpc/procedures/cli.ts @@ -20,6 +20,10 @@ export const cli = { .input(schemas.cli.CliSessionIdInputSchema) .output(schemas.cli.CliChunksSchema) .build(), + transcriptRevision: defineProcedure("cli_agent_transcript_revision") + .input(schemas.cli.CliSessionIdInputSchema) + .output(schemas.cli.CliTranscriptRevisionSchema) + .build(), cancel: defineProcedure("cli_agent_cancel") .input(schemas.cli.CliCancelInputSchema) .output(z.boolean()) diff --git a/src/api/tauri/rpc/procedures/sessionCore.ts b/src/api/tauri/rpc/procedures/sessionCore.ts index 2b133a7a7e..42b334d118 100644 --- a/src/api/tauri/rpc/procedures/sessionCore.ts +++ b/src/api/tauri/rpc/procedures/sessionCore.ts @@ -261,8 +261,20 @@ const shellReplay = { .build(), } as const; +const turnIntents = { + status: defineProcedure("session_turn_intent_status") + .input(schemas.sessionCore.SessionTurnIntentInput) + .output(schemas.sessionCore.SessionTurnIntentStatusSchema.nullable()) + .build(), + waitForTerminal: defineProcedure("session_wait_for_turn_terminal") + .input(schemas.sessionCore.SessionTurnIntentWaitInput) + .output(schemas.sessionCore.SessionTurnIntentStatusSchema) + .build(), +} as const; + export const sessionCore = { cache, eventStore, shellReplay, + turnIntents, } as const; diff --git a/src/api/tauri/rpc/schemas/__tests__/agentSessionMessages.test.ts b/src/api/tauri/rpc/schemas/__tests__/agentSessionMessages.test.ts new file mode 100644 index 0000000000..7c5453daa8 --- /dev/null +++ b/src/api/tauri/rpc/schemas/__tests__/agentSessionMessages.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +import { describe, expect, it } from "vitest"; + +import { SessionMessageSchema } from "../agentSession"; + +describe("SessionMessageSchema", () => { + it("normalizes nullable Rust tool fields on ordinary messages", () => { + expect( + SessionMessageSchema.parse({ + id: "message-1", + role: "assistant", + content: "done", + toolName: null, + toolInput: null, + createdAt: "2026-08-26T00:00:00.000Z", + }) + ).toMatchObject({ + id: "message-1", + role: "assistant", + content: "done", + toolName: undefined, + toolInput: undefined, + }); + }); + + it("preserves native tool metadata", () => { + expect( + SessionMessageSchema.parse({ + id: "message-2", + role: "tool_call", + content: "Tool call: read_file", + toolName: "read_file", + toolInput: '{"file_path":"README.md"}', + createdAt: "2026-08-26T00:00:00.000Z", + }) + ).toMatchObject({ + toolName: "read_file", + toolInput: '{"file_path":"README.md"}', + }); + }); +}); diff --git a/src/api/tauri/rpc/schemas/agentSession.ts b/src/api/tauri/rpc/schemas/agentSession.ts index 16f8b7cbee..dd49ed2209 100644 --- a/src/api/tauri/rpc/schemas/agentSession.ts +++ b/src/api/tauri/rpc/schemas/agentSession.ts @@ -185,8 +185,17 @@ export const SessionMessageSchema = z id: z.string(), role: z.string(), content: z.string(), - toolName: z.string().optional(), - toolInput: z.string().optional(), + // Rust serializes absent Option fields as null. Normalize those + // values at the RPC boundary so callers keep the established optional + // string contract without rejecting ordinary non-tool messages. + toolName: z.preprocess( + (value) => value ?? undefined, + z.string().optional() + ), + toolInput: z.preprocess( + (value) => value ?? undefined, + z.string().optional() + ), createdAt: z.string(), compactFromSequence: z.number().nullable().optional(), }) diff --git a/src/api/tauri/rpc/schemas/cli.ts b/src/api/tauri/rpc/schemas/cli.ts index 44866bd7fa..9fe5e67ef8 100644 --- a/src/api/tauri/rpc/schemas/cli.ts +++ b/src/api/tauri/rpc/schemas/cli.ts @@ -12,6 +12,7 @@ export const CliMessageRequestSchema = z.object({ ideContext: z.unknown().optional(), mode: z.string().optional(), images: z.array(z.string()).optional(), + allowNativeContextRecovery: z.boolean().optional(), }); /** `cli_agent_message` takes a single `request` struct, like the other @@ -57,3 +58,8 @@ export const CliStatusBatchItemSchema = z.object({ }); export const CliChunksSchema = z.array(ActivityChunkSchema); + +export const CliTranscriptRevisionSchema = z.object({ + native: z.boolean(), + revision: z.string().nullable().optional(), +}); diff --git a/src/api/tauri/rpc/schemas/sessionCore.ts b/src/api/tauri/rpc/schemas/sessionCore.ts index 5c269c0234..9a153649e2 100644 --- a/src/api/tauri/rpc/schemas/sessionCore.ts +++ b/src/api/tauri/rpc/schemas/sessionCore.ts @@ -35,6 +35,32 @@ export const EventDisplayStatusSchema = z.enum([ "awaiting_user", ]); +export const SessionTurnIntentInput = z.object({ + sessionId: z.string().min(1), + turnIntentId: z.string().min(1), +}); + +export const SessionTurnIntentWaitInput = SessionTurnIntentInput.extend({ + timeoutMs: z.number().int().positive().max(60_000), +}); + +export const SessionTurnIntentStatusSchema = z.object({ + sessionId: z.string().min(1), + turnIntentId: z.string().min(1), + status: z.enum([ + "optimistic", + "queued", + "running", + "completed", + "failed", + "cancelled", + "stale", + "coalesced", + "rejected", + ]), + updatedAt: z.string(), +}); + export const EventDisplayVariantSchema = z.enum([ "tool_call", "message", @@ -263,6 +289,7 @@ export const NullableSessionIdInput = z.object({ export const RemoveSyntheticUserInputsInput = z.object({ sessionId: z.string().nullable(), matchingContents: z.array(z.string()).optional(), + matchingTurnIntentIds: z.array(z.string()).optional(), olderThan: z.string().optional(), }); diff --git a/src/api/tauri/session/__tests__/session.test.ts b/src/api/tauri/session/__tests__/session.test.ts index 16be5a995f..d9f1d96a43 100644 --- a/src/api/tauri/session/__tests__/session.test.ts +++ b/src/api/tauri/session/__tests__/session.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; +import { isPrimarySessionListSession } from "@src/util/session/sessionVisibility"; + import { type SessionAggregateRecord, toFrontendSession, @@ -39,6 +41,18 @@ function makeAggregateRecord( // ============================================================================ describe("toFrontendSession", () => { + it("preserves native mirror provenance through exact-ID hydration", () => { + const result = toFrontendSession( + makeAggregateRecord({ + sessionId: "codexapp-rollout-native-mirror", + clientOrigin: "org2", + clientOriginRaw: "orgii", + }) + ); + expect(result.clientOrigin).toBe("org2"); + expect(result.clientOriginRaw).toBe("orgii"); + expect(isPrimarySessionListSession(result)).toBe(false); + }); it("converts basic fields correctly", () => { const record = makeAggregateRecord({ sessionId: "session-abc", diff --git a/src/api/tauri/session/index.ts b/src/api/tauri/session/index.ts index d10b363df8..4d91f60ae3 100644 --- a/src/api/tauri/session/index.ts +++ b/src/api/tauri/session/index.ts @@ -147,6 +147,8 @@ export function toFrontendSession(record: SessionAggregateRecord): Session { repoRootPath: record.repoRootPath, repoRemoteUrls: record.repoRemoteUrls, storagePath: record.storagePath, + clientOrigin: record.clientOrigin, + clientOriginRaw: record.clientOriginRaw, worktreePath: record.worktreePath, worktreeBranch: record.worktreeBranch, baseBranch: record.baseBranch, diff --git a/src/app/root/e2e/helpers/cloud.ts b/src/app/root/e2e/helpers/cloud.ts index 8dc72fe5a2..f91c777f9b 100644 --- a/src/app/root/e2e/helpers/cloud.ts +++ b/src/app/root/e2e/helpers/cloud.ts @@ -528,6 +528,8 @@ export function createCloudHelpers({ store }: CloudHelperDeps) { body: comment.body, editedAt: comment.editedAt ?? null, deletedAt: comment.deletedAt ?? null, + clientDeliveryStatus: comment.clientDeliveryStatus ?? null, + clientDeliveryError: comment.clientDeliveryError ?? null, })), addressableHeadIds: addressableThreads.map( (thread) => thread.headId diff --git a/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts b/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts index 67727c09e4..a4798ca007 100644 --- a/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts +++ b/src/app/root/e2e/helpers/sessionHelpers/inspectChatState.ts @@ -35,7 +35,6 @@ import { type QueuedMessage, messageQueueAtom, queueEditingAtom, - queueFlushRequestAtom, } from "@src/store/ui/messageQueueAtom"; import { stationModeAtom } from "@src/store/ui/simulatorAtom"; import { todosAtom } from "@src/store/ui/todoAtom"; @@ -65,7 +64,6 @@ export function createInspectChatStateHelper(store: E2EStore) { userInitiatedCancel: boolean; turnPhase: string; turnGeneration: number; - queueFlushRequest: number; queuedMessages: Array<{ id: string; sessionId: string; @@ -223,7 +221,6 @@ export function createInspectChatStateHelper(store: E2EStore) { turnGeneration: activeSessionId ? getTurnGeneration(activeSessionId) : 0, - queueFlushRequest: store.get(queueFlushRequestAtom), queuedMessages, forceSendPendingMessages, fileReviewCount: store.get(fileReviewMapAtom).size, diff --git a/src/app/root/e2e/helpers/sessions.ts b/src/app/root/e2e/helpers/sessions.ts index 7ae60cdc9b..9228e8a023 100644 --- a/src/app/root/e2e/helpers/sessions.ts +++ b/src/app/root/e2e/helpers/sessions.ts @@ -67,7 +67,6 @@ import { chatWidthAtom } from "@src/store/ui/chatPanel/widthAtoms"; import { messageQueueAtom, queueEditTargetAtom, - queueFlushRequestAtom, } from "@src/store/ui/messageQueueAtom"; import { stationModeAtom } from "@src/store/ui/simulatorAtom"; import { @@ -292,7 +291,6 @@ export function createSessionHelpers(store: E2EStore) { store.set(sessionIdAtom, null); store.set(messageQueueAtom, []); store.set(queueEditTargetAtom, null); - store.set(queueFlushRequestAtom, 0); resetTurnLifecycleForTests(); store.set(chatImageAttachmentsAtom, []); store.set(isPendingCancelAtom, false); diff --git a/src/app/root/e2e/types.ts b/src/app/root/e2e/types.ts index 4b44756b4b..aaa223adc1 100644 --- a/src/app/root/e2e/types.ts +++ b/src/app/root/e2e/types.ts @@ -663,7 +663,6 @@ export interface E2EHelpers { isPendingCancel: boolean; isQueueEditing: boolean; userInitiatedCancel: boolean; - queueFlushRequest: number; queuedMessages: Array<{ id: string; sessionId: string; content: string }>; runtimeError: string | null; rawEvents: Array<{ diff --git a/src/app/root/services/GlobalSessionSync/index.tsx b/src/app/root/services/GlobalSessionSync/index.tsx index 7c6bdd3b3c..1b28e4ef54 100644 --- a/src/app/root/services/GlobalSessionSync/index.tsx +++ b/src/app/root/services/GlobalSessionSync/index.tsx @@ -14,6 +14,7 @@ import React from "react"; import { useEventStoreBridge } from "@src/engines/SessionCore/core/store/useEventStoreBridge"; import GlobalPlanningIndicatorBridgeSync from "@src/engines/SessionCore/hooks/replay/GlobalPlanningIndicatorBridgeSync"; import { useQueueDispatch } from "@src/engines/SessionCore/hooks/session/useQueueDispatch"; +import { dispatchQueuedCanonicalConversation } from "@src/features/ConversationContinuation/canonicalConversationDispatcher"; import { useBackgroundSessionMonitor } from "@src/hooks/cliSession/useBackgroundSessionMonitor"; import { useNotificationApprovalBridge } from "@src/hooks/notifications/useNotificationApprovalBridge"; import { useNativeSessionStatusMonitor } from "@src/hooks/session/useNativeSessionStatusMonitor"; @@ -25,7 +26,7 @@ const GlobalSessionSync: React.FC = () => { useNotificationApprovalBridge(); useNativeSessionStatusMonitor(); useTeamInboxNotifications(); - useQueueDispatch(); + useQueueDispatch(dispatchQueuedCanonicalConversation); return ; }; diff --git a/src/components/ModelSelectorPill/index.tsx b/src/components/ModelSelectorPill/index.tsx index 2a4289e196..208f082374 100644 --- a/src/components/ModelSelectorPill/index.tsx +++ b/src/components/ModelSelectorPill/index.tsx @@ -63,6 +63,8 @@ interface ModelSelectorPillProps { settingsMenuDefaultAdvanced?: boolean; /** Mobile uses the combined settings menu whenever variant rows exist. */ preferCombinedSettingsMenu?: boolean; + /** Prevent opening a picker while its execution inventory is unresolved. */ + disabled?: boolean; } const ModelSelectorPill = forwardRef( @@ -84,6 +86,7 @@ const ModelSelectorPill = forwardRef( effortSegmentOverride, settingsMenuDefaultAdvanced = false, preferCombinedSettingsMenu = false, + disabled = false, }, ref ) => { @@ -182,7 +185,8 @@ const ModelSelectorPill = forwardRef( tooltipFramedWide: true, ariaLabel: ariaLabel ?? defaultLabel, active, - danger: !hasModelSelection, + danger: !disabled && !hasModelSelection, + disabled, onClick, dataTestId: dataTestId, buttonRef: modelSegmentRef, @@ -190,7 +194,7 @@ const ModelSelectorPill = forwardRef( leadingFlush: triggerLeadingFlush, }; - if (!effortEditable || !effortModelId) { + if (disabled || !effortEditable || !effortModelId) { return [modelSegment]; } @@ -248,6 +252,7 @@ const ModelSelectorPill = forwardRef( ariaLabel, dataTestId, defaultLabel, + disabled, displayParts.label, displayParts.rawValue, displayParts.thinking, @@ -279,14 +284,18 @@ const ModelSelectorPill = forwardRef( variantOptions.fastAvailableAnywhere || variantOptions.thinkingToggleable; const useCombinedSettingsMenu = - preferCombinedSettingsMenu && effortModelId && canEditVariants; + !disabled && + preferCombinedSettingsMenu && + Boolean(effortModelId) && + canEditVariants; const useSliderSettingsMenu = + !disabled && !preferCombinedSettingsMenu && effortEditable && - effortModelId && - variant && + Boolean(effortModelId) && + Boolean(variant) && variantOptions.availableLevels.length > 1; - if (useCombinedSettingsMenu || useSliderSettingsMenu) { + if ((useCombinedSettingsMenu || useSliderSettingsMenu) && effortModelId) { return ( [0]["surfaceState"]; @@ -124,6 +125,7 @@ const ChatFloatingComposer: React.FC = memo( chatPanelPosition, sessionId, inputAreaSessionId, + controlSessionId, currentPlanApproval, shouldShowCurrentPlanSurface, currentPlanSurfaceState, @@ -334,6 +336,7 @@ const ChatFloatingComposer: React.FC = memo( omitChatHeader chatPanelPosition={chatPanelPosition} sessionId={inputAreaSessionId} + controlSessionId={controlSessionId} onSubmitOverride={onSubmitOverride} customMentionOptions={customMentionOptions} topRowPills={ diff --git a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts index 5c14eb3972..cb9776412e 100644 --- a/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts +++ b/src/engines/ChatPanel/ChatHistory/ChatHistory.types.ts @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import type { AgentOrgRunMemberView } from "@src/api/tauri/agent"; +import type { QueuedConversationDispatch } from "@src/engines/SessionCore/conversations/queuedConversationContract"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanel/displayPrefsAtoms"; export interface FollowAgentNavState { @@ -91,6 +92,18 @@ export interface ChatHistoryProps { groupChatViewActive?: boolean; onGroupChatViewToggle?: (active: boolean) => void; mutationActionsDisabled?: boolean; + /** Re-admit a failed canonical Agent intent through its canonical queue. */ + onFailedUserIntentRetry?: (input: { + displayText: string; + agentContent?: string; + imageDataUrls?: string[]; + turnIntentId?: string; + }) => Promise; + /** + * The canonical dispatch a retry of a held Agent row should carry: the + * current root and the runtime the picker shows now. + */ + resolveFailedUserIntentDispatch?: () => QueuedConversationDispatch | null; /** * Session-scoped source for the planning footer. Session-scoped surfaces * should set `isLive` to false while showing a replay slice. diff --git a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/dedup.test.ts b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/dedup.test.ts index 9feb4dda34..65de20107f 100644 --- a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/dedup.test.ts +++ b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/__tests__/dedup.test.ts @@ -7,6 +7,49 @@ import { import { buildDedupMaps, isAssistantMessageEvent } from "../dedup"; +describe("delivery failure presentation ownership", () => { + it("keeps one local Retry presentation without hiding remote or provider failures", () => { + const user = makeSessionEvent({ + source: "user", + function: "user_message", + action_type: "raw", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + turnIntentId: "turn-1", + }, + }); + const failure = makeSessionEvent({ + id: "convturn-error-turn-1", + source: "system", + action_type: "error", + result: { turnIntentId: "turn-1", error: "launch failed" }, + }); + const provider = { ...failure, id: "provider-error-1" }; + const other = { + ...failure, + id: "convturn-error-turn-2", + result: { ...failure.result, turnIntentId: "turn-2" }, + }; + expect( + buildDedupMaps([user, failure, provider, other]) + .duplicateDeliveryFailureIds + ).toEqual(new Set([failure.id])); + expect(buildDedupMaps([failure]).duplicateDeliveryFailureIds.size).toBe(0); + expect( + buildDedupMaps([ + { + ...user, + displayStatus: "completed", + result: { ...user.result, deliveryStatus: "sent" }, + }, + failure, + ]).duplicateDeliveryFailureIds.size + ).toBe(0); + }); +}); + function makeRunningToolCall( functionName: string, overrides: Record = {} @@ -354,6 +397,27 @@ describe("buildDedupMaps — user message dedup", () => { expect(duplicateUserIds.has(persisted.id)).toBe(false); }); + it("never collapses a failed optimistic row into the provider's copy of its prompt", () => { + const failed = makeUserMessage("Reply with the marker", { + id: "queued-user:queue-rejected:", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: "model not supported", + message: { content: "Reply with the marker" }, + }, + }); + const landedCopy = makeUserMessage("Reply with the marker", { + id: "runlanded-user-rejected", + result: { message: { content: "Reply with the marker" } }, + }); + + const { duplicateUserIds } = buildDedupMaps([failed, landedCopy]); + + expect(duplicateUserIds.has(failed.id)).toBe(false); + expect(duplicateUserIds.has(landedCopy.id)).toBe(false); + }); + it("does not treat user-input-prefixed backend events as optimistic echoes", () => { const first = makeUserMessage("Repeat this", { id: "user-input-backend-1", diff --git a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/dedup.ts b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/dedup.ts index ad1e6eb07a..d3aff7a03b 100644 --- a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/dedup.ts +++ b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/dedup.ts @@ -28,6 +28,8 @@ export interface DedupResult { duplicateAssistantIds: Set; /** Chunk IDs of duplicate optimistic/persisted user messages that should be skipped */ duplicateUserIds: Set; + /** Shared dispatch failures already represented by a local Retry message. */ + duplicateDeliveryFailureIds: Set; } function getEventCallId(event: SessionEvent): string | undefined { @@ -115,9 +117,41 @@ export function buildDedupMaps(events: SessionEvent[]): DedupResult { runningArgsMap, duplicateAssistantIds, duplicateUserIds, + duplicateDeliveryFailureIds: buildDeliveryFailureDedupSet(events), }; } +function buildDeliveryFailureDedupSet(events: SessionEvent[]): Set { + const failedIntents = new Set(); + for (const event of events) { + const intent = event.result?.turnIntentId; + if ( + isSyntheticUserInputEvent(event) && + event.displayStatus === "failed" && + event.result?.deliveryStatus === "failed" && + typeof intent === "string" && + intent.length > 0 + ) + failedIntents.add(intent); + } + const duplicates = new Set(); + for (const event of events) { + const intent = event.result?.turnIntentId; + // Only the canonical dispatch-failure event has this exact identity. + // Keep provider errors and remote-only failures; no text matching, and + // no mutation of the shared terminal record required by other members. + if ( + typeof intent === "string" && + failedIntents.has(intent) && + event.id === `convturn-error-${intent}` && + event.source === "system" && + event.actionType === "error" + ) + duplicates.add(event.id); + } + return duplicates; +} + /** * Public predicate: true when the event is a user-visible assistant/agent * message (as opposed to a tool call, system message, or raw event). @@ -152,7 +186,12 @@ function buildUserDedupSet(events: SessionEvent[]): Set { if (!text) continue; if (isOptimisticUserEvent(event)) { - pendingOptimisticByText.set(text, event); + // A failed optimistic row is the visible retry owner. The provider's + // copy of that prompt (recorded before it rejected the turn) must not + // collapse the failure and its Retry into a plain duplicate bubble. + if (event.result?.["deliveryStatus"] !== "failed") { + pendingOptimisticByText.set(text, event); + } continue; } diff --git a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts index 3401710d08..f877d6577d 100644 --- a/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts +++ b/src/engines/ChatPanel/ChatHistory/chatItemPipeline/pipeline.ts @@ -416,6 +416,7 @@ export function processChatItems( runningArgsMap, duplicateAssistantIds, duplicateUserIds, + duplicateDeliveryFailureIds, } = buildDedupMaps(events); // ------------------------------------------ @@ -429,7 +430,8 @@ export function processChatItems( if ( runningChunksToSkip.has(event.id) || duplicateAssistantIds.has(event.id) || - duplicateUserIds.has(event.id) + duplicateUserIds.has(event.id) || + duplicateDeliveryFailureIds.has(event.id) ) { continue; } diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts index d9d2feb646..e13ffea54f 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryListEquality.ts @@ -57,6 +57,13 @@ const RESULT_RENDER_KEYS = [ "linesAdded", "linesRemoved", "status", + // These fields also determine the payload captured by Retry/Edit handlers. + "queueMessageId", + "deliveryOwnerRetired", + "deliveryStatus", + "deliveryError", + "turnIntentId", + "syntheticUserInput", ] as const; const ARG_RENDER_KEYS = [ diff --git a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx index f71ccf60f3..9c975ed458 100644 --- a/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/ChatHistoryView.tsx @@ -19,7 +19,10 @@ import type { UseChatHistoryStateReturn } from "../hooks/useChatHistoryState"; import type { useChatNavigationController } from "../hooks/useChatNavigationController"; import type { UseChatSearchReturn } from "../hooks/useChatSearch"; import type { useChatViewportController } from "../hooks/useChatViewportController"; -import { useGroupHeaderRenderer } from "../hooks/useGroupHeaderRenderer"; +import { + isRetryableFailedUserIntentHeader, + useGroupHeaderRenderer, +} from "../hooks/useGroupHeaderRenderer"; import type { useReloadSession } from "../hooks/useReloadSession"; import ChatHistoryEmptyState from "./ChatHistoryEmptyState"; import ChatPinnedHeaderLayer from "./ChatPinnedHeaderLayer"; @@ -233,6 +236,7 @@ const ChatHistoryView: React.FC = ({ defaultTurnCollapsed, turnCollapseInteractionAtRef, onEditSubmit: mutationActionsDisabled ? undefined : handleEditUserMessage, + onFailedUserIntentEdit: handleEditUserMessage, onRestoreCheckpoint: mutationActionsDisabled ? undefined : handleHeaderRestoreCheckpoint, @@ -314,7 +318,10 @@ const ChatHistoryView: React.FC = ({ defaultTurnCollapsed={defaultTurnCollapsed} turnCollapseInteractionAtRef={turnCollapseInteractionAtRef} onEditSubmit={ - mutationActionsDisabled ? undefined : handlePinnedEditSubmit + mutationActionsDisabled && + !isRetryableFailedUserIntentHeader(activePinnedHeader) + ? undefined + : handlePinnedEditSubmit } onRestoreCheckpoint={ mutationActionsDisabled ? undefined : handleHeaderRestoreCheckpoint diff --git a/src/engines/ChatPanel/ChatHistory/components/PlanningIndicatorBridge.tsx b/src/engines/ChatPanel/ChatHistory/components/PlanningIndicatorBridge.tsx index 2bd2bedc79..252d5c64fa 100644 --- a/src/engines/ChatPanel/ChatHistory/components/PlanningIndicatorBridge.tsx +++ b/src/engines/ChatPanel/ChatHistory/components/PlanningIndicatorBridge.tsx @@ -1,6 +1,6 @@ -import { useAtomValue } from "jotai"; +import { type PrimitiveAtom, atom, useAtomValue, useSetAtom } from "jotai"; import type { ComponentProps, FC } from "react"; -import { useEffect } from "react"; +import { useEffect, useLayoutEffect, useState } from "react"; import { useAgentStatusTrail } from "@src/engines/ChatPanel/hooks/useAgentStatusTrail"; import { manualCompactInFlightSessionAtom } from "@src/engines/ChatPanel/hooks/useManualCompact"; @@ -12,7 +12,6 @@ import { type PlanningIndicatorState, usePlanningIndicator, } from "@src/engines/SessionCore/hooks/replay/usePlanningIndicator"; -import { useConversationRunnerScope } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerScope"; import ChatHistoryList from "./ChatHistoryList"; @@ -101,31 +100,20 @@ function PlanningIndicatorBridgeContent({ ); } -function ScopedPlanningIndicatorBridge({ +function ScopedPlanningIndicatorSync({ effectiveScope, - ...props -}: PlanningIndicatorBridgeProps & { + outputAtom, +}: { effectiveScope: PlanningIndicatorScope; + outputAtom: PrimitiveAtom; }) { const planningState = usePlanningIndicator(effectiveScope); - return ( - - ); -} - -function GlobalPlanningIndicatorBridge(props: PlanningIndicatorBridgeProps) { - const planningState = useAtomValue(globalPlanningIndicatorBridgeOutputAtom); - return ( - - ); + const publish = useSetAtom(outputAtom); + const { count, variantIndex } = planningState; + useLayoutEffect(() => { + publish({ count, variantIndex }); + }, [count, variantIndex, publish]); + return null; } /** @@ -139,26 +127,32 @@ const PlanningIndicatorBridge: FC = ({ planningIndicatorScope, ...props }) => { - const runnerScope = useConversationRunnerScope(); - const effectiveScope = runnerScope - ? { sessionId: runnerScope, isLive: true } - : planningIndicatorScope; + const [scopedOutputAtom] = useState(() => + atom({ count: 0, variantIndex: 0 }) + ); + const planningState = useAtomValue( + planningIndicatorScope + ? scopedOutputAtom + : globalPlanningIndicatorBridgeOutputAtom + ); - if (effectiveScope) { - return ( - + {planningIndicatorScope && ( + + )} + - ); - } - - return ( - + ); }; diff --git a/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts b/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts index 734bac341c..e21245d22f 100644 --- a/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts +++ b/src/engines/ChatPanel/ChatHistory/components/__tests__/ChatHistoryListIdentity.test.ts @@ -23,6 +23,7 @@ import { HIDDEN_AGENT_STATUS_TRAIL_STATE } from "@src/engines/ChatPanel/hooks/ag import type { OptimizedChatItem } from "../../chatItemPipeline/types"; import type { GroupHeaderRenderPart } from "../../renderers/GroupHeaderRenderer"; import ChatHistoryList from "../ChatHistoryList"; +import { sameChatHistoryListProps } from "../ChatHistoryListEquality"; import { buildChatGroupRenderKeys } from "../ChatHistoryListLayout"; import type { ChatHistoryListHandle, @@ -117,6 +118,36 @@ describe("ChatHistoryList turn identity", () => { }); } + it.each([ + ["queueMessageId", "old-owner", undefined], + ["deliveryOwnerRetired", undefined, true], + ["deliveryStatus", "failed", "pending"], + ["deliveryError", "old failure", "new failure"], + ["turnIntentId", "old-intent", "retry-intent"], + ])("invalidates cached actions when %s changes", (key, before, after) => { + const item = bodyItem(0); + const previous = listProps( + [ + { + ...item, + event: { ...item.event!, result: { [key]: before } }, + }, + ], + "same-session" + ); + const next = { + ...previous, + flatItems: [ + { + ...item, + event: { ...item.event!, result: { [key]: after } }, + }, + ], + }; + expect(sameChatHistoryListProps(previous, next)).toBe(false); + expect(sameChatHistoryListProps(previous, previous)).toBe(true); + }); + function listProps( flatItems: OptimizedChatItem[], virtualListDataKey: string diff --git a/src/engines/ChatPanel/ChatHistory/components/__tests__/PlanningIndicatorBridge.test.ts b/src/engines/ChatPanel/ChatHistory/components/__tests__/PlanningIndicatorBridge.test.ts new file mode 100644 index 0000000000..4a8c6ed72d --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/components/__tests__/PlanningIndicatorBridge.test.ts @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +import PlanningIndicatorBridge from "../PlanningIndicatorBridge"; + +const lifecycle = vi.hoisted(() => ({ mounts: 0, unmounts: 0 })); +vi.mock("@src/engines/SessionCore", () => ({ + useStreamingDeltaForSession: () => null, +})); +vi.mock("@src/engines/ChatPanel/hooks/useAgentStatusTrail", () => ({ + useAgentStatusTrail: () => ({ phase: "hidden" }), +})); +vi.mock("@src/engines/ChatPanel/hooks/useManualCompact", async () => { + const { atom } = await import("jotai"); + return { manualCompactInFlightSessionAtom: atom(null) }; +}); +vi.mock("@src/engines/SessionCore/hooks/replay/usePlanningIndicator", () => ({ + usePlanningIndicator: () => ({ count: 1, variantIndex: 7 }), +})); +vi.mock("../ChatHistoryList", () => ({ + default: function MockHistoryList({ + planningIndicatorCount, + }: { + planningIndicatorCount: number; + }) { + useEffect(() => { + lifecycle.mounts++; + return () => { + lifecycle.unmounts++; + }; + }, []); + return createElement( + "div", + { "data-testid": "scroll-root" }, + planningIndicatorCount + ); + }, +})); + +it("preserves the history scroll root across runner start, Stop and runtime changes", () => { + const environment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + environment.IS_REACT_ACT_ENVIRONMENT = true; + lifecycle.mounts = 0; + lifecycle.unmounts = 0; + const container = document.createElement("div"); + const root = createRoot(container); + // List props are deliberately stubbed: this tests the production bridge's + // ownership of the list, not the virtualizer's geometry implementation. + const noop = () => undefined; + const props: Omit< + Parameters[0], + "planningIndicatorScope" + > = { + flatItems: [], + groupCounts: [], + turnIds: [], + totalFlatItems: 0, + codeBlockContainerWidth: 800, + footerSpacerHeight: 0, + bottomInset: 0, + topPaddingPx: 0, + virtualListRef: { current: null }, + virtualListDataKey: "same-conversation", + getIsWpGeneWorking: () => false, + getIsExploring: () => false, + renderGroupHeader: () => null, + onAtBottomStateChange: noop, + onRangeChanged: noop, + onEndReached: noop, + onSubmit: noop, + onSkip: noop, + virtualScrollerRef: { current: null }, + planningIndicatorEnabled: true, + onPlanningIndicatorCount: vi.fn(), + tailTurnStartedAtMs: null, + tailTurnLastActivityAtMs: null, + }; + try { + act(() => + root.render( + createElement(PlanningIndicatorBridge, { + ...props, + planningIndicatorScope: null, + }) + ) + ); + const scrollRoot = container.firstElementChild as HTMLElement; + scrollRoot.scrollTop = 3200; + for (const scope of [ + { sessionId: "codex-child", isLive: true }, + null, + { sessionId: "claude-child", isLive: true }, + null, + ]) { + act(() => + root.render( + createElement(PlanningIndicatorBridge, { + ...props, + planningIndicatorScope: scope, + }) + ) + ); + expect(container.querySelector('[data-testid="scroll-root"]')).toBe( + scrollRoot + ); + expect(scrollRoot.scrollTop).toBe(3200); + expect(scrollRoot.textContent).toBe(scope ? "1" : "0"); + } + expect(lifecycle.mounts).toBe(1); + expect(lifecycle.unmounts).toBe(0); + } finally { + act(() => root.unmount()); + delete environment.IS_REACT_ACT_ENVIRONMENT; + } +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatGroupsProjection.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatGroupsProjection.test.ts index d57c1629f1..1944566341 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatGroupsProjection.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatGroupsProjection.test.ts @@ -25,6 +25,86 @@ import { let counter = 0; +it.each([false, true])( + "uses native timing without rendering lifecycle rows (collapsed=%s)", + (collapsed) => { + const user = userItem("retry this request"); + user.event!.createdAt = "2026-09-08T04:59:09.964Z"; + const failure = cliErrorItem("database is locked"); + failure.event!.createdAt = "2026-09-08T04:59:12.170Z"; + const start = item( + makeEvent({ + actionType: "task_start", + functionName: "task_start", + createdAt: "2026-09-08T06:09:11.351Z", + }) + ); + const end = item( + makeEvent({ + actionType: "task_completed", + functionName: "task_completed", + createdAt: "2026-09-08T06:09:14.496Z", + }) + ); + const answer = assistantItem("recovered"); + answer.event!.createdAt = "2026-09-08T06:09:14.475Z"; + const projected = projectChatGroups([user, failure, start, answer, end], { + tailTurnPhase: "complete", + allTurnsCollapsed: collapsed, + }); + expect(projected.groupMeta[0]).toMatchObject({ + startMs: Date.parse(start.event!.createdAt), + endMs: Date.parse(end.event!.createdAt), + durationMs: 3145, + }); + expect(user.event!.createdAt).toBe("2026-09-08T04:59:09.964Z"); + expect(projected.flatItems.map((entry) => entry.event?.id)).not.toContain( + start.event!.id + ); + expect(projected.flatItems.map((entry) => entry.event?.id)).not.toContain( + end.event!.id + ); + expect(projected.flatItems.map((entry) => entry.event?.id)).toContain( + failure.event!.id + ); + expect(projected.flatItems.map((entry) => entry.event?.id)).toContain( + answer.event!.id + ); + expect(projected.flatItems.map((entry) => entry.event?.id)).toEqual([ + failure.event!.id, + answer.event!.id, + ]); + expect(projected.originalToFlatIndex.size).toBe(5); + for (const index of projected.originalToFlatIndex.values()) { + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(projected.totalFlatItems); + } + } +); + +it.each([undefined, "invalid", "2026-09-08T03:00:00Z"])( + "keeps legacy timing without a valid execution start (%s)", + (timestamp) => { + const user = userItem("request"); + user.event!.createdAt = "2026-09-08T04:00:00Z"; + const answer = assistantItem("answer"); + answer.event!.createdAt = "2026-09-08T04:00:05Z"; + const events = [user]; + if (timestamp !== undefined) { + events.push( + item( + makeEvent({ + actionType: "task_start", + createdAt: timestamp, + }) + ) + ); + } + events.push(answer); + expect(projectChatGroups(events).groupMeta[0].durationMs).toBe(5000); + } +); + function makeEvent(overrides: Partial): SessionEvent { counter++; return { diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatScroll.followIntent.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatScroll.followIntent.test.ts new file mode 100644 index 0000000000..d280716412 --- /dev/null +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useChatScroll.followIntent.test.ts @@ -0,0 +1,328 @@ +// @vitest-environment jsdom +import { act, createElement, useEffect, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { useChatScroll } from "../useChatScroll"; +import { useChatScrollPin } from "../useChatScrollPin"; + +describe("useChatScroll tail-follow intent", () => { + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + + afterEach(() => { + Reflect.deleteProperty(actEnvironment, "IS_REACT_ACT_ENVIRONMENT"); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("recovers from layout jumps but preserves an explicit manual pause", () => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const scrollTo = vi.fn(); + const scrollRoot = document.createElement("div"); + Object.defineProperties(scrollRoot, { + clientHeight: { value: 600 }, + scrollHeight: { value: 4_000 }, + scrollTo: { value: scrollTo }, + }); + const scrollerRef = { current: scrollRoot }; + const manualScrollAtRef = { current: 0 }; + + vi.spyOn(performance, "now").mockReturnValue(1_000); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserverMock { + observe = vi.fn(); + disconnect = vi.fn(); + } + ); + + function Harness({ + reportNotAtBottom, + tailFollowKey, + }: { + reportNotAtBottom: boolean; + tailFollowKey: string; + }) { + const visibleRangeEndRef = useRef(50); + const pinLastGroupRef = useRef(false); + const programmaticScrollAtRef = useRef(0); + const turnCollapseInteractionAtRef = useRef(0); + const contentOverflowingRef = useRef(true); + const pendingCancelRef = useRef(false); + const { handleAtBottomStateChange } = useChatScroll({ + optimizedChatHistoryLength: 50, + virtuosoScrollerRef: scrollerRef, + atBottom: true, + setAtBottom: vi.fn(), + setIsChatScrolledToBottom: vi.fn(), + isPendingCancelRef: pendingCancelRef, + visibleRangeEndRef, + pinLastGroupRef, + manualScrollAtRef, + programmaticScrollAtRef, + turnCollapseInteractionAtRef, + isContentOverflowingRef: contentOverflowingRef, + activeSessionId: "session-1", + footerSpacerHeight: 0, + bottomInset: 0, + tailFollowKey, + }); + useEffect(() => { + if (reportNotAtBottom) handleAtBottomStateChange(false); + }, [handleAtBottomStateChange, reportNotAtBottom]); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => + root.render( + createElement(Harness, { + reportNotAtBottom: true, + tailFollowKey: "tail-1", + }) + ) + ); + scrollTo.mockClear(); + + act(() => + root.render( + createElement(Harness, { + reportNotAtBottom: true, + tailFollowKey: "tail-2", + }) + ) + ); + expect(scrollTo).toHaveBeenCalled(); + + scrollTo.mockClear(); + manualScrollAtRef.current = 500; + act(() => + root.render( + createElement(Harness, { + reportNotAtBottom: true, + tailFollowKey: "tail-3", + }) + ) + ); + expect(scrollTo).not.toHaveBeenCalled(); + + act(() => root.unmount()); + container.remove(); + }); + + it.each([false, true])( + "pauses on explicit input with delayed mount=%s", + (delayedMount) => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const scrollRoot = document.createElement("div"); + const textarea = document.createElement("textarea"); + scrollRoot.appendChild(textarea); + const scrollerRef: { current: HTMLDivElement | null } = { + current: delayedMount ? null : scrollRoot, + }; + const manualScrollAtRef = { current: 0 }; + const onPinToTopChange = vi.fn(); + + vi.spyOn(performance, "now").mockReturnValue(1_000); + + vi.stubGlobal("requestAnimationFrame", () => 1); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + function Harness({ ready = false }: { ready?: boolean }) { + const pinLastGroupRef = useRef(true); + const programmaticScrollAtRef = useRef(0); + const pendingCancelRef = useRef(false); + const contentOverflowingRef = useRef(true); + useChatScrollPin({ + activeId: "session-1", + groupCounts: [], + totalFlatItems: 0, + footerSpacerHeight: 0, + bottomInset: 0, + sessionLoadStatus: "loaded", + virtuosoScrollerRef: scrollerRef, + atBottom: true, + isPendingCancelRef: pendingCancelRef, + isContentOverflowingRef: contentOverflowingRef, + optimizedChatHistoryLength: ready ? 10 : 0, + latestLocalSubmitId: null, + pinLastGroupRef, + manualScrollAtRef, + programmaticScrollAtRef, + onPinToTopChange, + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(createElement(Harness))); + if (delayedMount) { + scrollerRef.current = scrollRoot; + act(() => root.render(createElement(Harness, { ready: true }))); + } + + // A virtualizer remeasure emits the same native scroll event as movement, + // but without an input gesture it must not cancel tail follow. + act(() => scrollRoot.dispatchEvent(new Event("scroll"))); + expect(manualScrollAtRef.current).toBe(0); + + act(() => scrollRoot.dispatchEvent(new WheelEvent("wheel"))); + expect(manualScrollAtRef.current).toBe(1_000); + + manualScrollAtRef.current = 0; + act(() => scrollRoot.dispatchEvent(new Event("touchmove"))); + expect(manualScrollAtRef.current).toBe(1_000); + + manualScrollAtRef.current = 0; + act(() => + scrollRoot.dispatchEvent( + new MouseEvent("pointerdown", { button: 0, clientX: 0 }) + ) + ); + expect(manualScrollAtRef.current).toBe(1_000); + + manualScrollAtRef.current = 0; + act(() => { + scrollRoot.dispatchEvent( + new KeyboardEvent("keydown", { key: "PageUp", bubbles: true }) + ); + }); + expect(manualScrollAtRef.current).toBe(1_000); + expect(onPinToTopChange).toHaveBeenCalledWith(false); + + manualScrollAtRef.current = 0; + act(() => { + textarea.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true }) + ); + }); + expect(manualScrollAtRef.current).toBe(0); + + act(() => root.unmount()); + container.remove(); + } + ); + + it("keeps a manual pause for remote groups but re-arms for a local submit", () => { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + const scrollTo = vi.fn(); + const scrollRoot = document.createElement("div"); + Object.defineProperties(scrollRoot, { + clientHeight: { value: 600 }, + scrollHeight: { value: 4_000 }, + scrollTo: { value: scrollTo }, + }); + const scrollerRef = { current: scrollRoot }; + const manualScrollAtRef = { current: 0 }; + + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + + function Harness({ + groupCount, + latestLocalSubmitId, + }: { + groupCount: number; + latestLocalSubmitId: string | null; + }) { + const pinLastGroupRef = useRef(false); + const programmaticScrollAtRef = useRef(0); + const pendingCancelRef = useRef(false); + const contentOverflowingRef = useRef(true); + useChatScrollPin({ + activeId: "session-1", + groupCounts: Array.from({ length: groupCount }, () => 1), + totalFlatItems: groupCount, + footerSpacerHeight: 0, + bottomInset: 0, + sessionLoadStatus: "loaded", + virtuosoScrollerRef: scrollerRef, + atBottom: false, + isPendingCancelRef: pendingCancelRef, + isContentOverflowingRef: contentOverflowingRef, + optimizedChatHistoryLength: groupCount, + latestLocalSubmitId, + pinLastGroupRef, + manualScrollAtRef, + programmaticScrollAtRef, + }); + return null; + } + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => + root.render( + createElement(Harness, { + groupCount: 1, + latestLocalSubmitId: null, + }) + ) + ); + scrollTo.mockClear(); + + manualScrollAtRef.current = 1_000; + act(() => + root.render( + createElement(Harness, { + groupCount: 2, + latestLocalSubmitId: null, + }) + ) + ); + expect(scrollTo).not.toHaveBeenCalled(); + expect(manualScrollAtRef.current).toBe(1_000); + + act(() => + root.render( + createElement(Harness, { + groupCount: 3, + latestLocalSubmitId: "submit-1", + }) + ) + ); + expect(scrollTo).toHaveBeenCalled(); + expect(manualScrollAtRef.current).toBe(0); + + scrollTo.mockClear(); + manualScrollAtRef.current = 2_000; + act(() => + root.render( + createElement(Harness, { + groupCount: 4, + latestLocalSubmitId: "submit-1", + }) + ) + ); + expect(scrollTo).not.toHaveBeenCalled(); + expect(manualScrollAtRef.current).toBe(2_000); + + act(() => + root.render( + createElement(Harness, { + groupCount: 5, + latestLocalSubmitId: "submit-2", + }) + ) + ); + expect(scrollTo).toHaveBeenCalled(); + expect(manualScrollAtRef.current).toBe(0); + + act(() => root.unmount()); + container.remove(); + }); +}); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts index 2d10781257..acec81297d 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useEditUserMessage.test.ts @@ -12,18 +12,55 @@ import { vi, } from "vitest"; +import type { QueuedConversationDispatch } from "@src/engines/SessionCore/conversations/queuedConversationContract"; + import type { OptimizedChatItem } from "../../chatItemPipeline/types"; import { useEditUserMessage } from "../useEditUserMessage"; -const { submitUserIntentSpy, storeSessionId } = vi.hoisted(() => ({ +const { + checkSnapshotChangesSpy, + durableHydrationRows, + flushMessageQueueSpy, + hydrateMessageQueueSpy, + messageQueueHydrated, + queuedDeliveries, + removeByIdPrefixSpy, + updateByIdSpy, + storeSetSpy, + surfaceSessionId, + submitUserIntentSpy, + refreshMessageDeliveriesSpy, + storeSessionId, + truncateBeforeIdSpy, +} = vi.hoisted(() => ({ + checkSnapshotChangesSpy: vi.fn(async () => false), + durableHydrationRows: { current: [] as Array> }, + flushMessageQueueSpy: vi.fn(async () => undefined), + hydrateMessageQueueSpy: vi.fn(async () => undefined), + messageQueueHydrated: { current: true }, + queuedDeliveries: { current: [] as Array> }, + removeByIdPrefixSpy: vi.fn(async () => 1), + updateByIdSpy: vi.fn(async () => true), + storeSetSpy: vi.fn((_atom: unknown, _update: unknown) => true), + surfaceSessionId: { current: undefined as string | undefined }, submitUserIntentSpy: vi.fn(async (..._args: unknown[]) => undefined), + refreshMessageDeliveriesSpy: vi.fn(async () => undefined), storeSessionId: { current: "osagent-session-1" }, + truncateBeforeIdSpy: vi.fn(async () => undefined), })); vi.mock("jotai", async (importOriginal) => ({ ...(await importOriginal()), useSetAtom: () => vi.fn(), - useStore: () => ({ get: () => storeSessionId.current }), + useStore: () => ({ + get: (atom: { debugLabel?: string }) => + atom.debugLabel === "messageQueueAtom" + ? queuedDeliveries.current + : atom.debugLabel === "messageQueueHydratedAtom" + ? messageQueueHydrated.current + : storeSessionId.current, + set: storeSetSpy, + }), })); vi.mock("react-i18next", () => ({ @@ -33,7 +70,7 @@ vi.mock("react-i18next", () => ({ })); vi.mock("@src/api/tauri/agent", () => ({ - checkSnapshotChanges: vi.fn(async () => false), + checkSnapshotChanges: checkSnapshotChangesSpy, truncateAfterMessage: vi.fn(async () => undefined), })); @@ -41,6 +78,10 @@ vi.mock("@src/components/Message", () => ({ default: { warning: vi.fn(), error: vi.fn(), info: vi.fn() }, })); +vi.mock("@src/engines/ChatPanel/ChatSessionContext", () => ({ + useChatSessionId: () => surfaceSessionId.current, +})); + vi.mock( "@src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit", () => ({ @@ -67,11 +108,22 @@ vi.mock("@src/engines/SessionCore/core/atoms", () => ({ vi.mock("@src/engines/SessionCore/core/store/EventStoreProxy", () => ({ eventStoreProxy: { - truncateBeforeId: vi.fn(async () => undefined), + removeByIdPrefix: removeByIdPrefixSpy, + updateById: updateByIdSpy, + truncateBeforeId: truncateBeforeIdSpy, evictSession: vi.fn(async () => undefined), }, })); +vi.mock( + "@src/engines/SessionCore/hooks/session/messageQueuePersistence", + () => ({ + flushMessageQueuePersistence: flushMessageQueueSpy, + hydrateMessageQueue: hydrateMessageQueueSpy, + refreshMessageDeliveries: refreshMessageDeliveriesSpy, + }) +); + vi.mock("@src/engines/SessionCore/storage/cacheAdapter", () => ({ deleteSession: vi.fn(async () => undefined), })); @@ -122,8 +174,16 @@ type EditUserMessageFn = ( imageDataUrls?: string[] ) => Promise; +let resolveDispatchForTest: + | (() => QueuedConversationDispatch | null) + | undefined; + +function resolveDispatchViaTest(): QueuedConversationDispatch | null { + return resolveDispatchForTest?.() ?? null; +} + function Harness({ onReady }: { onReady: (fn: EditUserMessageFn) => void }) { - const editUserMessage = useEditUserMessage(); + const editUserMessage = useEditUserMessage(undefined, resolveDispatchViaTest); useEffect(() => { onReady(editUserMessage); }, [editUserMessage, onReady]); @@ -143,8 +203,24 @@ describe("useEditUserMessage resend projection", () => { }); beforeEach(() => { + checkSnapshotChangesSpy.mockClear(); + durableHydrationRows.current = []; + flushMessageQueueSpy.mockClear(); + hydrateMessageQueueSpy.mockClear(); + hydrateMessageQueueSpy.mockImplementation(async () => { + queuedDeliveries.current = [...durableHydrationRows.current]; + messageQueueHydrated.current = true; + }); + messageQueueHydrated.current = true; + queuedDeliveries.current = []; + removeByIdPrefixSpy.mockClear(); + updateByIdSpy.mockClear(); + storeSetSpy.mockClear(); submitUserIntentSpy.mockClear(); + refreshMessageDeliveriesSpy.mockClear(); + truncateBeforeIdSpy.mockClear(); storeSessionId.current = "osagent-session-1"; + surfaceSessionId.current = undefined; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); @@ -215,4 +291,448 @@ describe("useEditUserMessage resend projection", () => { expect(call.displayContent).toBe("/canvas build a timer"); expect(call.agentContent).toBeUndefined(); }); + + it("retries a failed delivery without truncating later history", async () => { + const failed = { + event: { + id: "user-input-failed", + createdAt: "2026-01-01T00:00:00.000Z", + source: "user", + functionName: "user_message", + uiCanonical: "", + displayText: "retry this exact request", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + turnIntentId: "turn-intent-failed", + }, + }, + chunk_id: "user-input-failed", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "retry this exact request"); + }); + + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + displayContent: "retry this exact request", + turnIntentId: "turn-intent-failed", + }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "user-input-failed", + "osagent-session-1" + ); + expect(checkSnapshotChangesSpy).not.toHaveBeenCalled(); + expect(truncateBeforeIdSpy).not.toHaveBeenCalled(); + }); + + it("retries a reconciled orphan through the current submit path", async () => { + const failed = { + event: { + id: "queued-user:legacy-orphan:", + createdAt: "2026-01-01T00:00:00.000Z", + source: "user", + functionName: "user_message", + uiCanonical: "", + displayText: "@VantaNode inspect this", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: + "This message was not sent because its pending delivery could not be recovered. Retry to send it again.", + turnIntentId: "turn-intent-orphan", + message: { + role: "user", + content: "@VantaNode inspect this", + }, + images: ["data:image/png;base64,keep"], + mentions: [{ id: "vanta", label: "VantaNode" }], + }, + }, + chunk_id: "queued-user:legacy-orphan:", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "@VantaNode inspect this", [ + "data:image/png;base64,keep", + ]); + }); + + expect(refreshMessageDeliveriesSpy).not.toHaveBeenCalled(); + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "osagent-session-1", + displayContent: "@VantaNode inspect this", + imageDataUrls: ["data:image/png;base64,keep"], + turnIntentId: "turn-intent-orphan", + }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "queued-user:legacy-orphan:", + "osagent-session-1" + ); + expect(storeSetSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "forceSendMessageAtom" }), + expect.anything() + ); + }); + + it("retries a hydrated failed queue row in place without losing attachments", async () => { + queuedDeliveries.current = [ + { + id: "queue-failed", + turnIntentId: "turn-intent-failed", + sessionId: "osagent-session-1", + content: "retry this exact request", + displayContent: "retry this exact request", + imageDataUrls: ["data:image/png;base64,keep"], + priority: "next", + status: "queued", + requiresExplicitDispatch: true, + deliveryError: "provider unavailable", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]; + const failed = { + event: { + id: "queued-user-turn-intent-failed", + displayText: "retry this exact request", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-failed", + turnIntentId: "turn-intent-failed", + }, + }, + chunk_id: "queued-user-turn-intent-failed", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "retry this exact request"); + }); + + expect(updateByIdSpy).toHaveBeenCalledWith( + "queued-user:queue-failed:", + expect.objectContaining({ + displayText: "retry this exact request", + displayStatus: "pending", + result: expect.objectContaining({ + images: ["data:image/png;base64,keep"], + turnIntentId: expect.not.stringMatching("turn-intent-failed"), + deliveryStatus: "pending", + queueMessageId: "queue-failed", + }), + }), + "osagent-session-1" + ); + expect(removeByIdPrefixSpy).not.toHaveBeenCalled(); + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "forceSendMessageAtom" }), + "queue-failed" + ); + }); + + it("retries a held canonical row with the runtime the picker shows now", async () => { + const admittedDispatch: QueuedConversationDispatch = { + kind: "canonical_conversation", + root: { + authority: "local-session", + authorityScope: [], + conversationId: "osagent-session-1", + }, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.3-codex-medium", + }, + }; + const currentDispatch: QueuedConversationDispatch = { + ...admittedDispatch, + target: { + cliAgentType: "codex", + accountId: "openai-1", + model: "gpt-5.6-sol", + }, + }; + resolveDispatchForTest = () => currentDispatch; + queuedDeliveries.current = [ + { + id: "queue-rejected", + turnIntentId: "turn-intent-rejected", + sessionId: "osagent-session-1", + content: "reply with the marker", + displayContent: "reply with the marker", + priority: "next", + status: "queued", + requiresExplicitDispatch: true, + deliveryError: "model not supported", + createdAt: "2026-01-01T00:00:00.000Z", + conversationDispatch: admittedDispatch, + }, + ]; + const failed = { + event: { + id: "queued-user-turn-intent-rejected", + displayText: "reply with the marker", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-rejected", + turnIntentId: "turn-intent-rejected", + }, + }, + chunk_id: "queued-user-turn-intent-rejected", + } as unknown as OptimizedChatItem; + + try { + await act(async () => { + await editUserMessage?.(failed, "reply with the marker"); + }); + } finally { + resolveDispatchForTest = undefined; + } + + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "editMessageAtom" }), + expect.objectContaining({ + messageId: "queue-rejected", + conversationDispatch: currentDispatch, + }) + ); + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "forceSendMessageAtom" }), + "queue-rejected" + ); + }); + + it("hydrates a cold failed owner and patches its runner row from the root surface", async () => { + messageQueueHydrated.current = false; + durableHydrationRows.current = [ + { + id: "queue-cold", + turnIntentId: "turn-intent-cold", + // The canonical root is mounted, but the queue projection belongs to + // the concrete local execution Session selected at admission. + sessionId: "cliagent-runner-child", + content: "cold retry", + displayContent: "cold retry", + priority: "next", + status: "queued", + requiresExplicitDispatch: true, + deliveryError: "database is locked", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]; + const failed = { + event: { + id: "queued-user:queue-cold:", + sessionId: "cliagent-runner-child", + displayText: "cold retry", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-cold", + turnIntentId: "turn-intent-cold", + }, + }, + chunk_id: "queued-user:queue-cold:", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "cold retry"); + }); + + expect(hydrateMessageQueueSpy).toHaveBeenCalledOnce(); + expect(refreshMessageDeliveriesSpy).not.toHaveBeenCalled(); + expect(updateByIdSpy).toHaveBeenCalledWith( + "queued-user:queue-cold:", + expect.objectContaining({ + displayText: "cold retry", + displayStatus: "pending", + }), + "cliagent-runner-child" + ); + expect(removeByIdPrefixSpy).not.toHaveBeenCalled(); + expect(submitUserIntentSpy).not.toHaveBeenCalled(); + }); + + it("never deletes a queue-owned bubble while its cold owner is unavailable", async () => { + messageQueueHydrated.current = false; + const failed = { + event: { + id: "queued-user:queue-missing:", + displayText: "keep this failed row", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-missing", + turnIntentId: "turn-intent-missing", + }, + }, + chunk_id: "queued-user:queue-missing:", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "keep this failed row"); + }); + + expect(hydrateMessageQueueSpy).toHaveBeenCalledOnce(); + expect(refreshMessageDeliveriesSpy).toHaveBeenCalledOnce(); + expect(removeByIdPrefixSpy).not.toHaveBeenCalled(); + expect(submitUserIntentSpy).not.toHaveBeenCalled(); + expect(updateByIdSpy).not.toHaveBeenCalled(); + }); + + it("retries a retired failed delivery as a fresh intent", async () => { + messageQueueHydrated.current = true; + const failed = { + event: { + id: "queued-user:queue-retired:", + source: "user", + displayText: "retry after the owner retired", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryOwnerRetired: true, + queueMessageId: "queue-retired", + turnIntentId: "turn-intent-retired", + }, + }, + chunk_id: "queued-user:queue-retired:", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "retry after the owner retired"); + }); + + expect(refreshMessageDeliveriesSpy).toHaveBeenCalledOnce(); + expect(submitUserIntentSpy).toHaveBeenCalledOnce(); + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ + displayContent: "retry after the owner retired", + turnIntentId: "turn-intent-retired", + }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "queued-user:queue-retired:", + expect.any(String) + ); + }); + + it("edits a hydrated failed queue row and patches its existing bubble", async () => { + queuedDeliveries.current = [ + { + id: "queue-failed", + turnIntentId: "turn-intent-failed", + sessionId: "osagent-session-1", + content: "retry this exact request", + displayContent: "retry this exact request", + imageDataUrls: ["data:image/png;base64,old"], + priority: "next", + status: "queued", + requiresExplicitDispatch: true, + deliveryError: "provider unavailable", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]; + const failed = { + event: { + id: "queued-user-turn-intent-failed", + displayText: "retry this exact request", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + queueMessageId: "queue-failed", + turnIntentId: "turn-intent-failed", + }, + }, + chunk_id: "queued-user-turn-intent-failed", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "@VantaNode inspect the retry", [ + "data:image/png;base64,new", + ]); + }); + + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "editMessageAtom" }), + expect.objectContaining({ + messageId: "queue-failed", + content: "@VantaNode inspect the retry", + imageDataUrls: ["data:image/png;base64,new"], + turnIntentId: expect.any(String), + }) + ); + expect(flushMessageQueueSpy).toHaveBeenCalledOnce(); + expect(updateByIdSpy).toHaveBeenCalledWith( + "queued-user:queue-failed:", + expect.objectContaining({ + displayText: "@VantaNode inspect the retry", + displayStatus: "pending", + result: expect.objectContaining({ + message: { + role: "user", + content: "@VantaNode inspect the retry", + }, + images: ["data:image/png;base64,new"], + turnIntentId: expect.any(String), + deliveryStatus: "pending", + queueMessageId: "queue-failed", + }), + }), + "osagent-session-1" + ); + expect(removeByIdPrefixSpy).not.toHaveBeenCalled(); + expect(storeSetSpy).toHaveBeenCalledWith( + expect.objectContaining({ debugLabel: "forceSendMessageAtom" }), + "queue-failed" + ); + expect(submitUserIntentSpy).not.toHaveBeenCalled(); + }); + + it("retries against the mounted SideChat session instead of global active", async () => { + surfaceSessionId.current = "osagent-side-chat"; + storeSessionId.current = "osagent-main-chat"; + act(() => + root.render( + createElement(Harness, { + onReady: (fn: EditUserMessageFn) => { + editUserMessage = fn; + }, + }) + ) + ); + const failed = { + event: { + id: "side-chat-failed", + displayText: "retry in side chat", + displayStatus: "failed", + result: { syntheticUserInput: true, deliveryStatus: "failed" }, + }, + chunk_id: "side-chat-failed", + } as unknown as OptimizedChatItem; + + await act(async () => { + await editUserMessage?.(failed, "retry in side chat"); + }); + + expect(submitUserIntentSpy).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: "osagent-side-chat" }) + ); + expect(removeByIdPrefixSpy).toHaveBeenCalledWith( + "side-chat-failed", + "osagent-side-chat" + ); + }); }); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useGroupHeaderRenderer.truncation.test.ts b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useGroupHeaderRenderer.truncation.test.ts index 5405d7449d..38827af52c 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useGroupHeaderRenderer.truncation.test.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/__tests__/useGroupHeaderRenderer.truncation.test.ts @@ -25,11 +25,19 @@ const message = makeChatItem( const headers = [message]; const interactionRef = { current: 0 }; -function Header({ paginated }: { paginated: boolean }) { +function Header({ + paginated, + groupHeaders = headers, + onFailedUserIntentEdit, +}: { + paginated: boolean; + groupHeaders?: typeof headers; + onFailedUserIntentEdit?: () => void; +}) { const renderHeader = useGroupHeaderRenderer({ displaySourceGroupIndices: [0], sourceGroupCount: 1, - displayGroupHeaders: headers, + displayGroupHeaders: groupHeaders, displayGroupMeta: [], displayGroupCount: 1, turnPaginationEnabled: paginated, @@ -38,6 +46,7 @@ function Header({ paginated }: { paginated: boolean }) { defaultTurnCollapsed: false, turnCollapseInteractionAtRef: interactionRef, onEditSubmit: undefined, + onFailedUserIntentEdit, onRestoreCheckpoint: undefined, }); return renderHeader(0); @@ -153,4 +162,69 @@ describe("continuous chat user-message previews", () => { act(() => root.render(null)); expect(disconnect).toHaveBeenCalledOnce(); }); + + it("keeps Retry and edit actions on a rehydrated failed user turn", () => { + const retry = vi.fn(); + const failed = makeChatItem( + makeSessionEvent({ + id: "queued-user:restart:", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "retry after restart", + displayVariant: "message", + displayStatus: "failed", + result: { + syntheticUserInput: true, + deliveryStatus: "failed", + deliveryError: "provider unavailable", + turnIntentId: "turn-restart", + message: { role: "user", content: "retry after restart" }, + }, + }) + ); + + act(() => + root.render( + createElement(Header, { + paginated: false, + groupHeaders: [failed], + onFailedUserIntentEdit: retry, + }) + ) + ); + + const retryButton = container.querySelector( + '[data-testid="chat-message-delivery-retry"]' + ); + const editButton = container.querySelector( + '[data-testid="chat-message-user-edit-button"]' + ); + expect(retryButton).not.toBeNull(); + expect(editButton).not.toBeNull(); + act(() => retryButton!.click()); + expect(retry).toHaveBeenCalledWith( + failed, + "retry after restart", + undefined + ); + }); + + it("does not enable mutation actions for accepted read-only history", () => { + act(() => + root.render( + createElement(Header, { + paginated: false, + onFailedUserIntentEdit: vi.fn(), + }) + ) + ); + + expect( + container.querySelector('[data-testid="chat-message-delivery-retry"]') + ).toBeNull(); + expect( + container.querySelector('[data-testid="chat-message-user-edit-button"]') + ).toBeNull(); + }); }); diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatGroupsProjection.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatGroupsProjection.ts index b41ad12290..446788cf8d 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatGroupsProjection.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatGroupsProjection.ts @@ -1,3 +1,5 @@ +import { isInternalLifecycleEvent } from "@src/engines/SessionCore/ingestion/visibilityFilters"; + import { isAgentOrgGroupChatUserMessage, isAgentOrgInboxTranscriptEvent, @@ -118,6 +120,10 @@ function isUnloadedTurnItem(item: OptimizedChatItem | undefined): boolean { return getUnloadedTurnMeta(item) !== null; } +function isLifecycleItem(item: OptimizedChatItem): boolean { + return Boolean(item.event && isInternalLifecycleEvent(item.event)); +} + export function isTurnPreviewItem( item: OptimizedChatItem | undefined ): boolean { @@ -327,7 +333,22 @@ export function projectChatGroups( const groupMeta: ChatGroupMeta[] = groups.map((group) => { const headerEvent = group.header?.event; const turnId = headerEvent?.id ?? null; - const startMs = parseEpochMs(headerEvent?.createdAt); + const messageMs = parseEpochMs(headerEvent?.createdAt); + // Native runtimes can accept a queued/retried message long after it was + // written. Their execution boundary, when available, owns worked-for + // timing; the user-message timestamp remains unchanged. + let executionStartMs: number | null = null; + for (const item of group.items) { + if (item.event?.actionType !== "task_start") continue; + const candidate = parseEpochMs(item.event.createdAt); + if ( + candidate !== null && + (messageMs === null || candidate >= messageMs) + ) { + executionStartMs = candidate; + } + } + const startMs = executionStartMs ?? messageMs; let endMs: number | null = null; for (let i = group.items.length - 1; i >= 0; i--) { const itemMs = parseEpochMs(group.items[i].event?.createdAt); @@ -396,14 +417,13 @@ export function projectChatGroups( if (!isCollapsed) { const keepStructuralPlaceholder = meta.unloadedTurn !== null; - const surviving = keepStructuralPlaceholder - ? group.items - : group.items.filter((item) => !isUnloadedTurnItem(item)); + const shouldKeep = (item: OptimizedChatItem) => + !isLifecycleItem(item) && + (keepStructuralPlaceholder || !isUnloadedTurnItem(item)); + const surviving = group.items.filter(shouldKeep); survivingPerGroup[groupIndex] = surviving; droppedItemTargetByGroup[groupIndex] = group.items.map((item) => - !keepStructuralPlaceholder && isUnloadedTurnItem(item) - ? runningFlatIdx - : null + shouldKeep(item) ? null : runningFlatIdx ); groupCounts[groupIndex] = surviving.length; runningFlatIdx += surviving.length; @@ -424,10 +444,13 @@ export function projectChatGroups( groupCounts[groupIndex] = previews.length; runningFlatIdx += previews.length; } else { - survivingPerGroup[groupIndex] = group.items; - droppedItemTargetByGroup[groupIndex] = group.items.map(() => null); - groupCounts[groupIndex] = group.items.length; - runningFlatIdx += group.items.length; + const surviving = group.items.filter((item) => !isLifecycleItem(item)); + survivingPerGroup[groupIndex] = surviving; + droppedItemTargetByGroup[groupIndex] = group.items.map((item) => + isLifecycleItem(item) ? runningFlatIdx : null + ); + groupCounts[groupIndex] = surviving.length; + runningFlatIdx += surviving.length; } continue; } @@ -465,7 +488,7 @@ export function projectChatGroups( if (keepIndex === -1) { const structuralSourceIndex = group.items.findIndex( - (item) => !isUnloadedTurnItem(item) + (item) => !isUnloadedTurnItem(item) && !isLifecycleItem(item) ); const structuralSource = group.items[structuralSourceIndex]; if (!structuralSource) { @@ -488,7 +511,9 @@ export function projectChatGroups( continue; } - const keptIndices = [keepIndex, ...pinnedIndices]; + // Collapse changes visibility, not chronology: a failed attempt before + // a successful retry must not become the apparent final result. + const keptIndices = [keepIndex, ...pinnedIndices].sort((a, b) => a - b); const keptIndexSet = new Set(keptIndices); const kept = keptIndices.map((index) => group.items[index]); survivingPerGroup[groupIndex] = kept; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts index fdf4f1697d..f39566fdf5 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatHistoryItemActions.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from "react"; +import type { ChatHistoryProps } from "../ChatHistory.types"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; import type { UseChatHistoryStateReturn } from "./useChatHistoryState"; import { useEditUserMessage } from "./useEditUserMessage"; @@ -10,6 +11,8 @@ interface UseChatHistoryItemActionsOptions { groupHeaders: (OptimizedChatItem | null)[]; handleIgnoreQuestionRef: UseChatHistoryStateReturn["handleIgnoreQuestionRef"]; handleReplyQuestionRef: UseChatHistoryStateReturn["handleReplyQuestionRef"]; + onFailedUserIntentRetry?: ChatHistoryProps["onFailedUserIntentRetry"]; + resolveFailedUserIntentDispatch?: ChatHistoryProps["resolveFailedUserIntentDispatch"]; } /** Stabilizes history mutation callbacks passed into virtualized row renderers. */ @@ -18,8 +21,13 @@ export function useChatHistoryItemActions({ groupHeaders, handleIgnoreQuestionRef, handleReplyQuestionRef, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, }: UseChatHistoryItemActionsOptions) { - const handleEditUserMessage = useEditUserMessage(); + const handleEditUserMessage = useEditUserMessage( + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch + ); const handleRestoreCheckpoint = useRestoreCheckpoint(); const pinnedEditSubmitRef = useRef(handleEditUserMessage); useEffect(() => { diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts index 2020cfbcea..a774e3d85b 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatScroll.ts @@ -94,6 +94,19 @@ const MANUAL_SCROLL_AUTO_FOLLOW_SUPPRESS_MS = 450; const TURN_COLLAPSE_AUTO_FOLLOW_SUPPRESS_MS = 700; const FOLLOW_SETTLE_FRAME_COUNT = 4; +/** + * `atBottom` is geometry, not user intent. A virtualizer remeasure or an + * async projection swap can temporarily move a tail-following viewport away + * from the bottom without any user input. Only an explicit manual scroll + * should suspend streaming follow in that state. + */ +function isTailFollowPaused( + atBottom: boolean, + manualScrollAt: number +): boolean { + return !atBottom && manualScrollAt > 0; +} + export function useChatScroll({ optimizedChatHistoryLength, virtuosoScrollerRef, @@ -142,11 +155,17 @@ export function useChatScroll({ const handleAtBottomStateChange = useCallback( (bottom: boolean) => { + if (bottom) { + // Reaching the tail explicitly re-arms follow after the user has read + // older content and scrolled back down. + // eslint-disable-next-line react-hooks/immutability -- This caller-owned ref is the scroll hooks' existing non-rendering intent channel. + effectiveManualScrollAtRef.current = 0; + } if (atBottomRef.current === bottom) return; atBottomRef.current = bottom; debouncedSetAtBottom(bottom); }, - [debouncedSetAtBottom] + [debouncedSetAtBottom, effectiveManualScrollAtRef] ); const scrollElementToBottom = useCallback( @@ -234,7 +253,15 @@ export function useChatScroll({ ) { return; } - if (!alwaysFollowTail && !atBottomRef.current) return; + if ( + !alwaysFollowTail && + isTailFollowPaused( + atBottomRef.current, + effectiveManualScrollAtRef.current + ) + ) { + return; + } scheduleSettledFollow(); }); }; @@ -262,7 +289,15 @@ export function useChatScroll({ useEffect(() => { if (!tailFollowKey) return; if (pinLastGroupRef.current) return; - if (!alwaysFollowTail && !atBottomRef.current) return; + if ( + !alwaysFollowTail && + isTailFollowPaused( + atBottomRef.current, + effectiveManualScrollAtRef.current + ) + ) { + return; + } if ( performance.now() - effectiveManualScrollAtRef.current < MANUAL_SCROLL_AUTO_FOLLOW_SUPPRESS_MS diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts index 8b40666257..b5f80bbe2d 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatScrollPin.ts @@ -21,6 +21,8 @@ export interface UseChatScrollPinOptions { isPendingCancelRef: MutableRefObject; isContentOverflowingRef: MutableRefObject; optimizedChatHistoryLength: number; + /** The newest group is this surface's optimistic, still-pending submit. */ + latestLocalSubmitId: string | null; /** * Shared ref owned by the parent. Both useChatScroll and useChatScrollPin * read/write this ref so they coordinate pin intent without re-renders. @@ -42,6 +44,51 @@ export interface UseChatScrollPinReturn { programmaticScrollAtRef: MutableRefObject; } +function isScrollbarPointerDown( + event: PointerEvent, + element: HTMLElement +): boolean { + if (event.button !== 0) return false; + const rect = element.getBoundingClientRect(); + const nativeScrollbarWidth = Math.max( + 0, + element.offsetWidth - element.clientWidth + ); + const hitWidth = Math.max(12, nativeScrollbarWidth); + return event.clientX >= rect.right - hitWidth; +} + +const KEYBOARD_SCROLL_KEYS = new Set([ + "ArrowUp", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", + " ", + "Spacebar", +]); + +function isEditableKeyboardTarget(target: EventTarget | null): boolean { + return ( + target instanceof Element && + target.closest( + "input, textarea, select, [contenteditable='true'], [role='textbox']" + ) !== null + ); +} + +function isKeyboardScrollIntent(event: KeyboardEvent): boolean { + return ( + !event.defaultPrevented && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !isEditableKeyboardTarget(event.target) && + KEYBOARD_SCROLL_KEYS.has(event.key) + ); +} + /** * Manages three scroll-pin behaviours for ChatHistory: * @@ -50,10 +97,10 @@ export interface UseChatScrollPinReturn { * 2. Pin the latest user-message group to the viewport top when a new * group is added. Re-fires as the temporary footer * reserve grows so the first scroll lands at the correct offset. - * 3. Breaks pin intent on user-initiated scroll. Distinguishes programmatic - * scrolls (ignored) from user scrolls (releases pin) via a time window. - * The listener is mounted once and reads live state via refs — it is NOT - * re-registered on every new message, eliminating monitoring gaps. + * 3. Breaks pin intent on explicit user scroll input. A plain `scroll` event + * is deliberately insufficient: virtualizer remeasurement and projection + * replacement also emit trusted scroll events, and treating those as user + * intent strands a streaming conversation at the top. */ export function useChatScrollPin({ activeId, @@ -67,6 +114,7 @@ export function useChatScrollPin({ isPendingCancelRef: _isPendingCancelRef, isContentOverflowingRef: _isContentOverflowingRef, optimizedChatHistoryLength, + latestLocalSubmitId, pinLastGroupRef, manualScrollAtRef, programmaticScrollAtRef, @@ -148,8 +196,11 @@ export function useChatScrollPin({ programmaticScrollAtRef, ]); - // Effect 2: scroll to bottom only when a new user-message group is added. + // Effect 2: a local optimistic submit explicitly re-arms bottom follow. + // Remote groups continue following only while the viewer has not paused by + // scrolling up; their arrival must not steal the viewport from older text. const prevGroupLenRef = useRef(groupCounts.length); + const lastFollowedSubmitIdRef = useRef(latestLocalSubmitId); useEffect(() => { const prevGroupLen = prevGroupLenRef.current; @@ -157,45 +208,65 @@ export function useChatScrollPin({ const newGroupAdded = groupCounts.length > prevGroupLen; if (!newGroupAdded) return; + const newLocalSubmit = + latestLocalSubmitId !== null && + latestLocalSubmitId !== lastFollowedSubmitIdRef.current; + if (newLocalSubmit) lastFollowedSubmitIdRef.current = latestLocalSubmitId; + if (effectiveManualScrollAtRef.current > 0 && !newLocalSubmit) { + return; + } pinLastGroupRef.current = false; onPinToTopChange?.(false); return scheduleFollowToEnd(); }, [ groupCounts.length, + latestLocalSubmitId, + effectiveManualScrollAtRef, onPinToTopChange, pinLastGroupRef, scheduleFollowToEnd, ]); - // Effect 3: break pin intent on user-initiated scroll. - // - // Mounted once per scroller element change — intentionally does NOT - // depend on totalFlatItems or sessionLoadStatus. Those changes used to - // force a remove+re-add cycle that created a short window where user - // scrolls went undetected. Live values are accessed via refs instead. + // Effect 3: break follow/pin intent only on explicit user input. Layout and + // TanStack Virtual corrections use the same native `scroll` event as a + // wheel gesture, so listening to `scroll` itself cannot distinguish intent. + // The empty/loading surface has no scroller. Refs alone do not wake an + // effect when the history mounts, so bind again at that boundary (and on + // session changes), without rebinding on every streamed item. + const hasHistory = optimizedChatHistoryLength > 0; useEffect(() => { - const el = virtuosoScrollerRef.current; + const el = virtuosoScrollerRef.current ?? staticScrollerRef?.current; if (!el) return; - const PROGRAMMATIC_WINDOW_MS = 250; - const handleScroll = (): void => { - const now = performance.now(); - const elapsed = now - programmaticScrollAtRef.current; - if (elapsed < PROGRAMMATIC_WINDOW_MS) return; - effectiveManualScrollAtRef.current = now; + const markManualScroll = (): void => { + effectiveManualScrollAtRef.current = performance.now(); if (!pinLastGroupRef.current) return; pinLastGroupRef.current = false; onPinToTopChangeRef.current?.(false); }; - el.addEventListener("scroll", handleScroll, { passive: true }); + const handlePointerDown = (event: PointerEvent): void => { + if (isScrollbarPointerDown(event, el)) markManualScroll(); + }; + const handleKeyDown = (event: KeyboardEvent): void => { + if (isKeyboardScrollIntent(event)) markManualScroll(); + }; + el.addEventListener("wheel", markManualScroll, { passive: true }); + el.addEventListener("touchmove", markManualScroll, { passive: true }); + el.addEventListener("pointerdown", handlePointerDown); + el.addEventListener("keydown", handleKeyDown); return () => { - el.removeEventListener("scroll", handleScroll); + el.removeEventListener("wheel", markManualScroll); + el.removeEventListener("touchmove", markManualScroll); + el.removeEventListener("pointerdown", handlePointerDown); + el.removeEventListener("keydown", handleKeyDown); }; }, [ + activeId, + hasHistory, virtuosoScrollerRef, pinLastGroupRef, - programmaticScrollAtRef, effectiveManualScrollAtRef, + staticScrollerRef, ]); return { scrollToEnd, programmaticScrollAtRef }; diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts b/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts index 7df8a972f8..dbb42fd200 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useChatViewportController.ts @@ -29,6 +29,7 @@ interface UseChatViewportControllerOptions { displayTotalFlatItems: number; followAgentNav: FollowAgentNavState; isPendingCancelRef: UseChatEmptyStateReturn["isPendingCancelRef"]; + latestLocalSubmitId: string | null; onScrollNavChange?: (state: ScrollNavState) => void; planningIndicatorCount: 0 | 1; sessionLoadStatus: UseChatHistoryStateReturn["sessionLoadStatus"]; @@ -57,6 +58,7 @@ export function useChatViewportController({ displayTotalFlatItems, followAgentNav, isPendingCancelRef, + latestLocalSubmitId, onScrollNavChange, planningIndicatorCount, sessionLoadStatus, @@ -227,6 +229,7 @@ export function useChatViewportController({ isPendingCancelRef, isContentOverflowingRef, optimizedChatHistoryLength: activeProjectionHistoryLength, + latestLocalSubmitId, pinLastGroupRef, manualScrollAtRef, programmaticScrollAtRef, diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts b/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts index 75f846f9b8..384c3f8a06 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts +++ b/src/engines/ChatPanel/ChatHistory/hooks/useEditUserMessage.ts @@ -20,6 +20,7 @@ import { truncateAfterMessage, } from "@src/api/tauri/agent"; import Message from "@src/components/Message"; +import { useChatSessionId } from "@src/engines/ChatPanel/ChatSessionContext"; import { projectOutgoingUserMessage } from "@src/engines/ChatPanel/hooks/useInputArea/projectOutgoingUserMessage"; import { useUserIntentSubmit } from "@src/engines/ChatPanel/hooks/useWorkspaceChat/useUserIntentSubmit"; import { editTruncationTimestampAtom } from "@src/engines/SessionCore"; @@ -30,13 +31,30 @@ import { import { cancelTurnForTimelineBoundary } from "@src/engines/SessionCore/control/sessionTimelineBoundary"; import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import { + flushMessageQueuePersistence, + hydrateMessageQueue, + refreshMessageDeliveries, +} from "@src/engines/SessionCore/hooks/session/messageQueuePersistence"; +import { + isUserIntentSendError, + setOptimisticQueueUserDelivery, +} from "@src/engines/SessionCore/services/userIntentDispatch"; import { deleteSession as deleteCachedSession } from "@src/engines/SessionCore/storage/cacheAdapter"; +import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; +import { turnIntentIdOf } from "@src/engines/SessionCore/sync/utils/activityIds"; import { createLogger } from "@src/hooks/logger"; import { clearPendingPlanApproval, pendingPlanApprovalsAtom, } from "@src/store/session/planApprovalAtom"; import { activeSessionIdAtom } from "@src/store/session/viewAtom"; +import { + editMessageAtom, + forceSendMessageAtom, + messageQueueAtom, + messageQueueHydratedAtom, +} from "@src/store/ui/messageQueueAtom"; import { clearTodosForSessionAtom } from "@src/store/ui/todoAtom"; import { invokeTauri } from "@src/util/platform/tauri/init"; import { @@ -44,6 +62,7 @@ import { isCliSession, } from "@src/util/session/sessionDispatch"; +import type { ChatHistoryProps } from "../ChatHistory.types"; import type { OptimizedChatItem } from "../chatItemPipeline/types"; import { showRevertConfirm } from "../components/RevertConfirmDialog"; @@ -58,7 +77,10 @@ function agentMessageIdFromUserEventId(eventId: string): string | undefined { : undefined; } -export function useEditUserMessage(): ( +export function useEditUserMessage( + onFailedUserIntentRetry?: ChatHistoryProps["onFailedUserIntentRetry"], + resolveFailedUserIntentDispatch?: ChatHistoryProps["resolveFailedUserIntentDispatch"] +): ( chatItem: OptimizedChatItem, newText: string, imageDataUrls?: string[] @@ -67,9 +89,13 @@ export function useEditUserMessage(): ( const setPendingPlanApprovals = useSetAtom(pendingPlanApprovalsAtom); const clearTodosForSession = useSetAtom(clearTodosForSessionAtom); const store = useStore(); + const surfaceSessionId = useChatSessionId(); const resolveCurrentSessionId = useCallback( - () => store.get(activeSessionIdAtom) ?? store.get(sessionIdAtom), - [store] + () => + surfaceSessionId ?? + store.get(activeSessionIdAtom) ?? + store.get(sessionIdAtom), + [store, surfaceSessionId] ); const submitUserIntent = useUserIntentSubmit({ getSessionId: resolveCurrentSessionId, @@ -96,6 +122,7 @@ export function useEditUserMessage(): ( const initiatedSessionId = resolveCurrentSessionId(); const isStillOnInitiatingSession = (): boolean => { if (!initiatedSessionId) return false; + if (surfaceSessionId) return surfaceSessionId === initiatedSessionId; const activeSessionId = store.get(activeSessionIdAtom); if (activeSessionId) return activeSessionId === initiatedSessionId; return store.get(sessionIdAtom) === initiatedSessionId; @@ -106,6 +133,171 @@ export function useEditUserMessage(): ( if (!eventId) return; const createdAt = chatItem.event?.createdAt; + const failedSyntheticIntent = Boolean( + initiatedSessionId && + chatItem.event?.displayStatus === "failed" && + chatItem.event.result?.syntheticUserInput === true + ); + + // A delivery failure happened before the provider accepted this turn, + // so it is not a history-edit boundary. Retry through the ordinary + // submit/queue path and remove only the superseded failed placeholder; + // never truncate later turns or offer a file rewind for this case. + if (failedSyntheticIntent && initiatedSessionId && chatItem.event) { + const originalText = chatItem.event.displayText ?? ""; + const originalTurnIntentId = turnIntentIdOf(chatItem.event); + const queueMessageId = + typeof chatItem.event.result?.queueMessageId === "string" + ? chatItem.event.result.queueMessageId + : null; + const resendImages = + imageDataUrls && imageDataUrls.length > 0 ? imageDataUrls : undefined; + const projection = projectOutgoingUserMessage({ + displayText: newText, + allowCanvasInterception: + !resendImages && !isCliSession(initiatedSessionId), + }); + try { + if (queueMessageId && !store.get(messageQueueHydratedAtom)) { + // The transcript can hydrate before the durable delivery registry + // after an app restart. Never interpret that temporary absence as + // permission to create a second owner and delete the only bubble. + await hydrateMessageQueue(store); + } + const findDurableFailedQueueRow = () => + queueMessageId + ? store + .get(messageQueueAtom) + .find( + (message) => + message.id === queueMessageId && + Boolean(message.deliveryError) + ) + : undefined; + let durableFailedQueueRow = findDurableFailedQueueRow(); + if (queueMessageId && !durableFailedQueueRow) { + // Cover a cross-window mutation that landed after initial hydrate. + await refreshMessageDeliveries(store); + durableFailedQueueRow = findDurableFailedQueueRow(); + } + 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"); + } + // The dispatcher retired this owner after a terminal provider/ + // Cloud verdict and stamped the row. The failed bubble is the only + // remaining owner, so retry it as a fresh intent below. + log.warn( + "[useEditUserMessage] failed delivery owner was retired; retrying as a new intent" + ); + } + if (durableFailedQueueRow) { + const retryTurnIntentId = mintTurnIntentId(); + // A held canonical row keeps the runtime it was admitted with. + // The user changes the picker precisely because that runtime + // failed, so the retry must carry the runtime shown now. + const retryDispatch = + durableFailedQueueRow.conversationDispatch?.kind === + "canonical_conversation" + ? (resolveFailedUserIntentDispatch?.() ?? undefined) + : undefined; + const updated = store.set(editMessageAtom, { + messageId: durableFailedQueueRow.id, + content: projection.displayContent, + imageDataUrls: resendImages, + turnIntentId: retryTurnIntentId, + ...(retryDispatch ? { conversationDispatch: retryDispatch } : {}), + }); + if (!updated) { + throw new Error("failed delivery is no longer retryable"); + } + // Commit the edited payload while the row is still explicitly + // held. Then patch the SAME queue-owned transcript row back to + // pending before releasing the existing owner. The provider sees + // a fresh terminal intent, while the user never gets a duplicate + // bubble or loses serialized mention/image payloads. + try { + await flushMessageQueuePersistence(store); + const pendingUpdated = await setOptimisticQueueUserDelivery( + { + // Queue admission owns the concrete EventStore projection + // session. The mounted surface can be the canonical root + // while this row lives on a local execution child. + sessionId: durableFailedQueueRow.sessionId, + visibleText: projection.displayContent, + imageDataUrls: + resendImages ?? durableFailedQueueRow.imageDataUrls, + turnIntentId: retryTurnIntentId, + queueMessageId: durableFailedQueueRow.id, + createdAt: durableFailedQueueRow.createdAt, + }, + "pending" + ); + if (!pendingUpdated) { + throw new Error( + "failed delivery projection is no longer available" + ); + } + } catch (retryPreparationError) { + // The old failed bubble is still authoritative. Restore its + // matching held queue owner instead of leaving a new pending + // owner whose EventStore projection never moved with it. + store.set(messageQueueAtom, (queue) => + queue.map((message) => + message.id === durableFailedQueueRow.id + ? durableFailedQueueRow + : message + ) + ); + await flushMessageQueuePersistence(store).catch(() => undefined); + throw retryPreparationError; + } + store.set(forceSendMessageAtom, durableFailedQueueRow.id); + return; + } + const turnIntentId = + newText === originalText + ? (originalTurnIntentId ?? undefined) + : undefined; + const handled = await onFailedUserIntentRetry?.({ + displayText: projection.displayContent, + agentContent: projection.agentContent, + imageDataUrls: resendImages, + turnIntentId, + }); + if (!handled) { + await submitUserIntent({ + sessionId: initiatedSessionId, + displayContent: projection.displayContent, + agentContent: projection.agentContent, + imageDataUrls: resendImages, + source: "dispatch", + turnIntentId, + }); + } + await eventStoreProxy.removeByIdPrefix(eventId, initiatedSessionId); + } catch (error) { + // A send-stage error already produced the replacement failed row. + // A preparation/storage error did not, so retain the original row. + if (isUserIntentSendError(error)) { + await eventStoreProxy + .removeByIdPrefix(eventId, initiatedSessionId) + .catch(() => 0); + } + log.error( + "[useEditUserMessage] failed delivery retry failed:", + error + ); + Message.error(t("errors.errorOccurred")); + } + return; + } + let revertFiles = true; if ( @@ -246,9 +438,12 @@ export function useEditUserMessage(): ( setPendingPlanApprovals, clearTodosForSession, resolveCurrentSessionId, + surfaceSessionId, submitUserIntent, t, store, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, ] ); } diff --git a/src/engines/ChatPanel/ChatHistory/hooks/useGroupHeaderRenderer.tsx b/src/engines/ChatPanel/ChatHistory/hooks/useGroupHeaderRenderer.tsx index 4a790bf63b..c32ddd77bf 100644 --- a/src/engines/ChatPanel/ChatHistory/hooks/useGroupHeaderRenderer.tsx +++ b/src/engines/ChatPanel/ChatHistory/hooks/useGroupHeaderRenderer.tsx @@ -28,9 +28,25 @@ interface UseGroupHeaderRendererOptions { defaultTurnCollapsed: boolean; turnCollapseInteractionAtRef: React.MutableRefObject; onEditSubmit: GroupHeaderRendererProps["onEditSubmit"]; + /** Retry/edit stays valid for a rejected synthetic turn on read-only source history. */ + onFailedUserIntentEdit: GroupHeaderRendererProps["onEditSubmit"]; onRestoreCheckpoint: GroupHeaderRendererProps["onRestoreCheckpoint"]; } +export function isRetryableFailedUserIntentHeader( + header: OptimizedChatItem | null | undefined +): boolean { + const result = header?.event?.result; + return Boolean( + header?.event?.source === "user" && + header.event.displayStatus === "failed" && + result?.syntheticUserInput === true && + result.deliveryStatus === "failed" && + typeof result.turnIntentId === "string" && + result.turnIntentId.length > 0 + ); +} + export function useGroupHeaderRenderer({ displaySourceGroupIndices, sourceGroupCount, @@ -44,6 +60,7 @@ export function useGroupHeaderRenderer({ defaultTurnCollapsed, turnCollapseInteractionAtRef, onEditSubmit, + onFailedUserIntentEdit, onRestoreCheckpoint, }: UseGroupHeaderRendererOptions) { return useCallback( @@ -73,7 +90,12 @@ export function useGroupHeaderRenderer({ defaultTurnCollapsed={defaultTurnCollapsed} renderPart={renderPart} turnCollapseInteractionAtRef={turnCollapseInteractionAtRef} - onEditSubmit={onEditSubmit} + onEditSubmit={ + onEditSubmit ?? + (isRetryableFailedUserIntentHeader(header) + ? onFailedUserIntentEdit + : undefined) + } onRestoreCheckpoint={onRestoreCheckpoint} /> ); @@ -91,6 +113,7 @@ export function useGroupHeaderRenderer({ defaultTurnCollapsed, turnCollapseInteractionAtRef, onEditSubmit, + onFailedUserIntentEdit, onRestoreCheckpoint, ] ); diff --git a/src/engines/ChatPanel/ChatHistory/index.tsx b/src/engines/ChatPanel/ChatHistory/index.tsx index e8460e3b4a..eaee5fa808 100644 --- a/src/engines/ChatPanel/ChatHistory/index.tsx +++ b/src/engines/ChatPanel/ChatHistory/index.tsx @@ -7,17 +7,12 @@ import { useAtomValue } from "jotai"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import { loadEventComponent } from "@src/engines/SessionCore/rendering/registry/events"; -import { usePinnedSession } from "@src/features/Org2Cloud/SessionConversation/usePinnedSession"; -import { org2CloudRemoteSessionsAtom } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; -import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; -import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; import { isSessionActiveAtom } from "@src/store/session/cliSessionStatusAtom"; import { cursorIdeTurnSummariesAtomFamily } from "@src/store/session/cursorIdeTurnSummariesAtom"; -import { type Session, sessionByIdAtom } from "@src/store/session/sessionAtom"; +import { sessionByIdAtom } from "@src/store/session/sessionAtom"; import { isCursorIdeSession } from "@src/util/session/sessionDispatch"; import { ParentAgentSenderProvider } from "../ChatItems/ParentAgentSenderContext"; -import { SharedConversationSenderProvider } from "../ChatItems/SharedConversationSenderContext"; import { resolveParentAgentSenderSessionId } from "../ChatItems/parentAgentSender"; import { useChatSessionId } from "../ChatSessionContext"; import { @@ -43,45 +38,6 @@ export type { ScrollNavState } from "./ChatHistory.types"; const EMPTY_ORG_MEMBERS: ChatHistoryProps["agentOrgMembers"] = []; -function resolveSharedConversationSender( - session: Session | undefined, - remoteEntries: Record< - string, - { rows?: readonly RemoteTeammateSessionMetadata[] } | undefined - > -) { - // Pre-lineage imports recorded no owner name; the live listing row still - // knows it, so resolve through the cloud rows before giving up on the - // "Shared user" placeholder. - const rowOwnerName = (orgId: string, sourceSessionId: string) => - remoteEntries[orgId]?.rows - ?.find((row) => row.sourceSessionId === sourceSessionId) - ?.ownerDisplayName?.trim(); - if (session?.importedFrom) { - const lineage = session.importedFrom; - return { - displayName: - lineage.ownerDisplayName?.trim() || - rowOwnerName(lineage.orgId, lineage.sourceSessionId) || - "Shared user", - avatarUrl: lineage.ownerAvatarUrl, - }; - } - // Row-field lineage is stripped on some reload paths; the registry - // fallback keeps the SOURCE owner's name resolvable so inherited rows - // never regress to the "Shared user" placeholder. - const forkedFrom = session ? getSessionForkedFrom(session) : undefined; - if (forkedFrom) { - return { - displayName: - forkedFrom.ownerDisplayName?.trim() || - rowOwnerName(forkedFrom.orgId, forkedFrom.sourceSessionId) || - "Shared user", - }; - } - return null; -} - const ChatHistory: React.FC = ({ surfaceBgClass = "bg-chat-pane", chatPanelPosition = "right", @@ -108,25 +64,21 @@ const ChatHistory: React.FC = ({ groupChatViewActive = false, onGroupChatViewToggle, mutationActionsDisabled = false, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, planningIndicatorScope = null, }) => { const activeId = useChatSessionId() ?? null; const rawCursorIdeTurnSummaries = useAtomValue( cursorIdeTurnSummariesAtomFamily(activeId ?? "") ); - const activeSession = usePinnedSession(activeId ?? ""); + const activeSession = useAtomValue(sessionByIdAtom(activeId ?? "")); const isCursorIde = activeId ? isCursorIdeSession(activeId) : false; const cursorIdeTurnSummaries = isCursorIde ? rawCursorIdeTurnSummaries : []; const handleReloadSession = useReloadSession(activeId); const historyState = useChatHistoryState(); const isAgentWorking = useAtomValue(isSessionActiveAtom); const groupChat = useGroupChatContext(); - const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); - const sharedConversationSender = useMemo( - () => resolveSharedConversationSender(activeSession, remoteEntries), - [activeSession, remoteEntries] - ); - useEffect(() => { // Canvas payloads can reach the WorkStation as soon as the tool call is // stored. Warm the chat renderer while the user is still waiting for the @@ -209,6 +161,12 @@ const ChatHistory: React.FC = ({ displayTotalFlatItems: projection.displayTotalFlatItems, followAgentNav, isPendingCancelRef: emptyState.isPendingCancelRef, + latestLocalSubmitId: (() => { + const event = projection.displayGroupHeaders.at(-1)?.event; + return event?.source === "user" && event.displayStatus === "pending" + ? event.id + : null; + })(), onScrollNavChange, planningIndicatorCount, sessionLoadStatus: historyState.sessionLoadStatus, @@ -255,47 +213,47 @@ const ChatHistory: React.FC = ({ groupHeaders: projection.groupHeaders, handleIgnoreQuestionRef: historyState.handleIgnoreQuestionRef, handleReplyQuestionRef: historyState.handleReplyQuestionRef, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, }); return ( - - - - - + + + ); }; diff --git a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx index 155eb1f7d9..14e08fb339 100644 --- a/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx +++ b/src/engines/ChatPanel/ChatHistory/renderers/GroupItemRenderer.tsx @@ -83,6 +83,13 @@ const RESULT_RENDER_KEYS = [ "linesAdded", "linesRemoved", "status", + // Keep retry actions current even when the visible message body is unchanged. + "queueMessageId", + "deliveryOwnerRetired", + "deliveryStatus", + "deliveryError", + "turnIntentId", + "syntheticUserInput", ] as const; const ARG_RENDER_KEYS = [ diff --git a/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx b/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx new file mode 100644 index 0000000000..13c85f3d16 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/ConversationSenderMetadataContext.tsx @@ -0,0 +1,54 @@ +import { createContext, useContext } from "react"; + +import { + CONVERSATION_VIEWER_SIGNED_OUT, + type ConversationSenderIdentity, + type ConversationSenderRelationship, + type ConversationSenderStamp, + type ConversationViewerState, + conversationSenderStampOf, + resolveConversationSenderRelationship, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +interface ConversationSenderMetadataContextValue { + viewer: ConversationViewerState; + /** + * Enrich a validated event stamp or provide source-owner presentation for + * inherited unstamped rows. Returning an identity never changes row side; + * only a stable event stamp can establish viewer/other ownership. + */ + resolveSender: ( + event: SessionEvent, + stampedSender: ConversationSenderStamp | null + ) => ConversationSenderIdentity | null; +} + +const ConversationSenderMetadataContext = + createContext(null); + +export const ConversationSenderMetadataProvider = + ConversationSenderMetadataContext.Provider; + +export interface ConversationSenderResolution { + identity: ConversationSenderIdentity | null; + relationship: ConversationSenderRelationship; +} + +/** Resolve one row without importing any transport/account implementation. */ +export function useConversationSenderResolution( + event: SessionEvent | undefined +): ConversationSenderResolution { + const context = useContext(ConversationSenderMetadataContext); + const stampedSender = conversationSenderStampOf(event); + const identity = event + ? context + ? context.resolveSender(event, stampedSender) + : stampedSender + : null; + const relationship = resolveConversationSenderRelationship( + stampedSender, + context?.viewer ?? CONVERSATION_VIEWER_SIGNED_OUT + ); + return { identity, relationship }; +} diff --git a/src/engines/ChatPanel/ChatItems/ParentAgentSenderContext.tsx b/src/engines/ChatPanel/ChatItems/ParentAgentSenderContext.tsx index 0ebae1e5f1..63c68533bd 100644 --- a/src/engines/ChatPanel/ChatItems/ParentAgentSenderContext.tsx +++ b/src/engines/ChatPanel/ChatItems/ParentAgentSenderContext.tsx @@ -16,8 +16,8 @@ export interface ParentAgentSender { * Resolved once per chat rather than per message: every user row in a session * shares one answer, and reading it from the session store per row would * subscribe hundreds of memoized rows to a session object that churns on every - * status update. `SharedConversationSenderContext` carries teammate identity - * the same way and for the same reason. + * status update. `ConversationSenderMetadataContext` carries human account + * identity through the same one-provider-per-surface boundary. */ const ParentAgentSenderContext = createContext(null); diff --git a/src/engines/ChatPanel/ChatItems/SharedConversationSenderContext.tsx b/src/engines/ChatPanel/ChatItems/SharedConversationSenderContext.tsx deleted file mode 100644 index b51af018a1..0000000000 --- a/src/engines/ChatPanel/ChatItems/SharedConversationSenderContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { createContext, useContext } from "react"; - -export interface SharedConversationSender { - displayName: string; - avatarUrl?: string; -} - -const SharedConversationSenderContext = - createContext(null); - -export const SharedConversationSenderProvider = - SharedConversationSenderContext.Provider; - -export function useSharedConversationSender(): SharedConversationSender | null { - return useContext(SharedConversationSenderContext); -} diff --git a/src/engines/ChatPanel/ChatItems/UserChatItem.tsx b/src/engines/ChatPanel/ChatItems/UserChatItem.tsx index dc7178fdda..cc16ff084f 100644 --- a/src/engines/ChatPanel/ChatItems/UserChatItem.tsx +++ b/src/engines/ChatPanel/ChatItems/UserChatItem.tsx @@ -1,4 +1,3 @@ -import { useAtomValue } from "jotai"; import React, { type FC, type MouseEvent, @@ -13,24 +12,21 @@ import { useTranslation } from "react-i18next"; import { CHAT_BUBBLE_TOOLBAR_BUTTON_CLASS } from "@src/components/ChatBubble"; import ClampedContent from "@src/components/ClampedContent"; +import type { ComposerSnapshot } from "@src/components/ComposerInput"; import ExpandOverlay from "@src/components/ExpandOverlay"; +import Message from "@src/components/Message"; import PersonAvatar from "@src/components/PersonAvatar"; import { REPO_SETUP_PROMPT_MARKER } from "@src/config/repoSetupMarker"; import type { OptimizedChatItem } from "@src/engines/ChatPanel/ChatHistory/chatItemPipeline/types"; +import { conversationSenderStampOf } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; -import type { ConversationSenderStamp } from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; -import { CONVERSATION_SENDER_ARG } from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; import { discussionPayloadOf } from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; -import { resolveTeamChatMentions } from "@src/features/Org2Cloud/SessionConversation/teamChatMentions"; -import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; import { ClipboardCheckIcon, - Edit03Icon, File01Icon, HugeiconsIcon, Image01Icon, PencilEdit01Icon, - RotateLeft01Icon, SparklesIcon, Undo02Icon, } from "@src/icons"; @@ -43,27 +39,16 @@ import UserMessageContent, { import InputArea from "../InputArea"; import { stripExpandedPillContent } from "../InputArea/utils/pillContentParser"; import SessionIdentityIcon from "../components/SessionIdentityIcon"; +import { useConversationSenderResolution } from "./ConversationSenderMetadataContext"; import { useParentAgentSender } from "./ParentAgentSenderContext"; import RawPromptToggle from "./RawPromptToggle"; -import { useSharedConversationSender } from "./SharedConversationSenderContext"; import { normalizeUserMessageText } from "./normalizeUserMessageText"; import { wasSubmittedByViewer } from "./parentAgentSender"; import { describeModelLabel } from "./rawPromptModelLabel"; import { resolveRawUserPrompt } from "./rawUserPrompt"; +import { useUserMessageDeliveryActions } from "./useUserMessageDeliveryActions"; import { resolveUserMessageSide } from "./userMessageSide"; -function readConversationSenderStamp( - event: { args?: Record } | undefined -): ConversationSenderStamp | null { - const raw = event?.args?.[CONVERSATION_SENDER_ARG]; - if (!raw || typeof raw !== "object") return null; - const stamp = raw as Partial; - return typeof stamp.userId === "string" && - typeof stamp.displayName === "string" - ? (stamp as ConversationSenderStamp) - : null; -} - const USER_MSG_MAX_LINES = 3; const USER_MSG_MAX_CHARS = 120; // Continuous chat leaves roughly ten rendered lines visible before folding. @@ -204,8 +189,6 @@ const UserChatItem = ({ onRestoreCheckpoint, }: UserChatItemProps) => { const { t } = useTranslation("sessions"); - const sharedConversationSender = useSharedConversationSender(); - const viewerCloudUserId = useAtomValue(org2CloudAuthAtom)?.userId ?? null; const [isEditing, setIsEditing] = useState(false); const [isExpanded, setIsExpanded] = useState(false); @@ -225,7 +208,7 @@ const UserChatItem = ({ [messageTimestamp] ); const modelLabel = useMemo(() => describeModelLabel(modelId), [modelId]); - const discussion = event ? discussionPayloadOf(event) : null; + const senderResolution = useConversationSenderResolution(event); // Who wrote this turn. In a session an agent started, a `user` turn is the // parent's dispatch rather than the reader's own message, so the row is // attributed to the parent session — same identity icon the header shows. @@ -235,8 +218,9 @@ const UserChatItem = ({ // the org roster so the `@name` text renders as a member pill. const comments = useSessionCommentsContext(); const mentionableMembers = comments?.mentionableMembers; - const mentionedUserIds = discussion?.mentionedUserIds; - const mentions = useMemo((): UserMessageMention[] | undefined => { + const discussionPayload = event ? discussionPayloadOf(event) : null; + const mentionedUserIds = discussionPayload?.mentionedUserIds; + const mentions: UserMessageMention[] | undefined = (() => { if (!mentionedUserIds?.length) return undefined; const resolved: UserMessageMention[] = []; for (const userId of mentionedUserIds) { @@ -247,7 +231,7 @@ const UserChatItem = ({ if (displayName) resolved.push({ userId, displayName }); } return resolved.length > 0 ? resolved : undefined; - }, [mentionedUserIds, mentionableMembers]); + })(); const editedText = event?.displayText ? stripExpandedPillContent(String(event.displayText)) : ""; @@ -267,6 +251,23 @@ const UserChatItem = ({ if (!Array.isArray(images) || images.length === 0) return undefined; return images.filter((image): image is string => typeof image === "string"); }, [activityResult]); + const deliveryStatus = (() => { + const raw = activityResult?.result?.deliveryStatus; + if (raw === "pending" || raw === "sent" || raw === "failed") { + return raw; + } + if (event?.displayStatus === "pending") return "pending"; + if (event?.displayStatus === "failed") return "failed"; + return null; + })(); + const deliveryError = + typeof activityResult?.result?.deliveryError === "string" + ? activityResult.result.deliveryError + : null; + const deliveryActions = useUserMessageDeliveryActions({ + event, + deliveryStatus, + }); const fullContent = useMemo(() => { // When display_text is present on the event it is the pill-format string @@ -299,6 +300,12 @@ const UserChatItem = ({ // Extract images from activity result for display in chat history. const messageImages = isAgentOrgInboxTranscript ? undefined : activityImages; + const retryDelivery = discussionPayload + ? deliveryActions.retry + : (deliveryActions.retry ?? + (onEditSubmit + ? () => onEditSubmit(editedText || fullContent, messageImages) + : null)); const needsTruncation = useMemo(() => { if (!compactPreview) return false; @@ -343,15 +350,6 @@ const UserChatItem = ({ setIsEditing(true); }, [messageImages]); - const failedLocalDiscussion = Boolean( - discussion?.deliveryStatus === "failed" && - discussion.authorUserId === viewerCloudUserId - ); - const retryFailedDiscussion = useCallback(() => { - if (!comments || !discussion || !failedLocalDiscussion) return; - void comments.retryComment(discussion.commentId).catch(() => undefined); - }, [comments, discussion, failedLocalDiscussion]); - const handleEditCancel = useCallback(() => { setIsEditing(false); }, []); @@ -361,18 +359,23 @@ const UserChatItem = ({ }, []); const handleEditSubmitInternal = useCallback( - (newText: string, addedImageDataUrls?: string[]) => { - setIsEditing(false); - if (failedLocalDiscussion && discussion && comments) { - const mentionedUserIds = resolveTeamChatMentions( - newText, - comments.mentionableMembers - ); - void comments - .retryComment(discussion.commentId, newText, mentionedUserIds) - .catch(() => undefined); + ( + newText: string, + addedImageDataUrls?: string[], + composerSnapshot?: ComposerSnapshot + ) => { + const retryEdit = deliveryActions.editAndRetry; + if (retryEdit) { + void retryEdit(newText, composerSnapshot) + .then((accepted) => { + if (accepted) setIsEditing(false); + }) + .catch((error: unknown) => { + Message.error(String(error)); + }); return; } + setIsEditing(false); const rustImages = [ ...((editImageList && editImageList.length > 0 ? editImageList.map(imageRefToRustPath) @@ -381,7 +384,7 @@ const UserChatItem = ({ ]; onEditSubmit?.(newText, rustImages.length > 0 ? rustImages : undefined); }, - [comments, discussion, editImageList, failedLocalDiscussion, onEditSubmit] + [deliveryActions, editImageList, onEditSubmit] ); // Edit mode @@ -406,12 +409,15 @@ const UserChatItem = ({ const planApprovedEdited = isPlanApproved && fullContent.startsWith("[Plan approved (edited)"); const isEditableDisplay = Boolean( - (onEditSubmit || failedLocalDiscussion) && + (onEditSubmit || deliveryActions.canEditFailed) && + deliveryStatus !== "pending" && !isRepoSetup && !isAgentOrgInboxTranscript && !isPlanApproved && - (!event?.args?.["sessionDiscussion"] || failedLocalDiscussion) && - !readConversationSenderStamp(event) + (!event?.args?.["sessionDiscussion"] || deliveryStatus === "failed") && + (!conversationSenderStampOf(event) || + senderResolution.relationship === "viewer" || + deliveryActions.canEditFailed) ); const hasDisplayContent = Boolean( fullContent.trim() || @@ -423,15 +429,12 @@ const UserChatItem = ({ if (!hasDisplayContent) return null; const displayNeedsTruncation = needsTruncation; - const senderStamp = readConversationSenderStamp(event); - const stampIsViewer = Boolean( - senderStamp && viewerCloudUserId && senderStamp.userId === viewerCloudUserId - ); - const ownerSide = senderStamp - ? stampIsViewer + const ownerSide = + senderResolution.relationship === "viewer" ? "right" - : "left" - : resolveUserMessageSide(event); + : senderResolution.relationship === "other" + ? "left" + : resolveUserMessageSide(event); // Only turns that would otherwise read as the viewer's own are reattributed // — a teammate's shared message already names its own sender and keeps it — // and only those the viewer did not actually submit. Someone can open a @@ -446,9 +449,7 @@ const UserChatItem = ({ const senderName = isParentAgentMessage ? parentAgentSender?.parentSession?.name?.trim() || t("chat.parentAgentSender") - : senderStamp?.displayName.trim() || - sharedConversationSender?.displayName.trim() || - "Shared user"; + : senderResolution.identity?.displayName?.trim() || null; const containerClass = `${DISPLAY_CONTAINER_BASE} ${isEditableDisplay ? "cursor-pointer outline-none" : ""}`; const messageContent = ( @@ -460,35 +461,6 @@ const UserChatItem = ({ ); // Display mode - const discussionDeliveryActions = failedLocalDiscussion ? ( - <> - - - - ) : null; - const effectiveToolbarActions = toolbarActions ?? discussionDeliveryActions; const display = ( <>
)} - {discussion?.deliveryStatus === "pending" && ( - - {t("common:status.sending")} - - )} - {discussion?.deliveryStatus === "failed" && ( - - {discussion.deliveryError || - t("chat.failedToSendMessage", "Failed to send message")} - - )}
- {(rawPrompt.trim() || isEditableDisplay || effectiveToolbarActions) && ( + {(rawPrompt.trim() || + isEditableDisplay || + toolbarActions || + deliveryStatus === "pending" || + deliveryStatus === "failed") && (
-
- {(timestampLabel || modelLabel) && ( - - {timestampLabel && messageTimestamp && ( - - )} - {timestampLabel && modelLabel && ( - - )} - {modelLabel && ( + {(rawPrompt.trim() || + isEditableDisplay || + toolbarActions || + timestampLabel || + modelLabel) && ( +
+ {(timestampLabel || modelLabel) && ( + + {timestampLabel && messageTimestamp && ( + + )} + {timestampLabel && modelLabel && ( + + )} + {modelLabel && ( + + {modelLabel.name} + {modelLabel.variant ? ` · ${modelLabel.variant}` : ""} + + )} + + )} + {rawPrompt.trim() && event?.sessionId && ( + + )} + {isEditableDisplay && onRestoreCheckpoint && ( + + )} + {isEditableDisplay && ( + + )} + {toolbarActions} +
+ )} + {(deliveryStatus === "pending" || deliveryStatus === "failed") && ( + + {deliveryStatus === "pending" && ( + + {t("common:status.sending")} + + )} + {deliveryStatus === "failed" && ( + <> - {modelLabel.name} - {modelLabel.variant ? ` · ${modelLabel.variant}` : ""} + {t("chat.failedToSendMessage")} + {deliveryError && + deliveryError !== t("chat.failedToSendMessage") + ? `: ${deliveryError}` + : null} - )} - - )} - {rawPrompt.trim() && event?.sessionId && ( - - )} - {isEditableDisplay && onRestoreCheckpoint && ( - - )} - {isEditableDisplay && ( - - )} - {effectiveToolbarActions} -
+ {retryDelivery && ( + + )} + + )} + + )}
)} @@ -696,7 +704,7 @@ const UserChatItem = ({ }`} data-message-side={messageSide} > - {isRemoteSharedMessage ? ( + {isRemoteSharedMessage && senderName ? (
)} diff --git a/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts b/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts index b63863c1f6..d9939f26a0 100644 --- a/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts +++ b/src/engines/ChatPanel/ChatItems/__tests__/UserChatItem.test.ts @@ -2,6 +2,10 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; +import { + CONVERSATION_SENDER_ARG, + type ConversationViewerState, +} from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; import { makeChatItem, makeSessionEvent, @@ -9,8 +13,8 @@ import { import { namespaceCopyEventId } from "@src/features/TeamCollaboration/copyEventId"; import type { Session } from "@src/store/session"; +import { ConversationSenderMetadataProvider } from "../ConversationSenderMetadataContext"; import { ParentAgentSenderProvider } from "../ParentAgentSenderContext"; -import { SharedConversationSenderProvider } from "../SharedConversationSenderContext"; import UserChatItem from "../UserChatItem"; function renderMessage(id: string): string { @@ -27,11 +31,15 @@ function renderMessage(id: string): string { return renderToStaticMarkup( createElement( - SharedConversationSenderProvider, + ConversationSenderMetadataProvider, { value: { - displayName: "Ada Lovelace", - avatarUrl: "https://example.com/ada.png", + viewer: { status: "known", userId: "viewer-user" }, + resolveSender: () => ({ + userId: "ada-user", + displayName: "Ada Lovelace", + avatarUrl: "https://example.com/ada.png", + }), }, }, createElement(UserChatItem, { chatItem: makeChatItem(event) }) @@ -89,13 +97,190 @@ describe("UserChatItem shared sender presentation", () => { expect(markup).toContain('data-message-side="right"'); expect(markup).not.toContain("shared-message-sender-avatar"); + expect(markup).not.toContain("Ada Lovelace"); }); + it("keeps the viewer's stamped plane row on the right without an alias", () => { + const event = makeSessionEvent({ + id: "convplane-self", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Optimistic self message", + displayVariant: "message", + args: { + [CONVERSATION_SENDER_ARG]: { + userId: "viewer-user", + displayName: "Viewer Name", + }, + }, + }); + const markup = renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { + value: { + viewer: { status: "known", userId: "viewer-user" }, + resolveSender: (_event, stamp) => stamp, + }, + }, + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ) + ); + + expect(markup).toContain('data-message-side="right"'); + expect(markup).not.toContain("Viewer Name"); + expect(markup).not.toContain("shared-message-sender-avatar"); + }); + + it("resolves a known remote account without inventing a fallback label", () => { + const event = makeSessionEvent({ + id: "convplane-remote", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Remote account message", + displayVariant: "message", + args: { + [CONVERSATION_SENDER_ARG]: { userId: "remote-user" }, + }, + }); + const markup = renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { + value: { + viewer: { status: "known", userId: "viewer-user" }, + resolveSender: (_event, stamp) => + stamp?.userId === "remote-user" + ? { + userId: stamp.userId, + displayName: "Grace Hopper", + avatarUrl: "https://example.com/grace.png", + } + : stamp, + }, + }, + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ) + ); + + expect(markup).toContain('data-message-side="left"'); + expect(markup).toContain("Grace Hopper"); + expect(markup).toContain('src="https://example.com/grace.png"'); + expect(markup).not.toContain("Shared user"); + }); it("does not render a message-level copy control", () => { const markup = renderMessage("user-message-without-footer"); expect(markup).not.toContain('data-icon="copy"'); }); + + it("keeps a stamped local self twin on the right before and after auth hydration", () => { + const event = makeSessionEvent({ + id: "user-message-local-self", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Local self while auth hydrates", + displayVariant: "message", + args: { + [CONVERSATION_SENDER_ARG]: { + userId: "viewer-user", + displayName: "Viewer Name", + }, + }, + }); + const renderWithViewer = (viewer: ConversationViewerState) => + renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { + value: { + viewer, + resolveSender: (_event, stamp) => stamp, + }, + }, + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ) + ); + + const loading = renderWithViewer({ status: "loading" }); + const hydrated = renderWithViewer({ + status: "known", + userId: "viewer-user", + }); + for (const markup of [loading, hydrated]) { + expect(markup).toContain('data-message-side="right"'); + expect(markup).not.toContain("shared-message-sender-avatar"); + expect(markup).not.toContain("Shared user"); + } + }); + + it("keeps stamped remote provenance left while auth hydrates without inventing a name", () => { + const sessionId = "agentsession-local"; + const event = makeSessionEvent({ + id: namespaceCopyEventId(sessionId, "user-message-remote-stamped"), + sessionId, + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Remote while auth hydrates", + displayVariant: "message", + args: { + [CONVERSATION_SENDER_ARG]: { userId: "remote-user" }, + }, + }); + const renderWithViewer = (viewer: ConversationViewerState) => + renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { + value: { + viewer, + resolveSender: (_event, stamp) => stamp, + }, + }, + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ) + ); + + const loading = renderWithViewer({ status: "loading" }); + const hydrated = renderWithViewer({ + status: "known", + userId: "viewer-user", + }); + for (const markup of [loading, hydrated]) { + expect(markup).toContain('data-message-side="left"'); + expect(markup).toContain("Remote while auth hydrates"); + expect(markup).not.toContain("shared-message-sender-avatar"); + expect(markup).not.toContain("Shared user"); + } + }); + + it("does not invent a Shared user while remote provenance hydrates", () => { + const sessionId = "agentsession-local"; + const event = makeSessionEvent({ + id: namespaceCopyEventId(sessionId, "user-message-remote"), + sessionId, + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Loading provenance", + displayVariant: "message", + }); + const markup = renderToStaticMarkup( + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ); + + expect(markup).toContain('data-message-side="left"'); + expect(markup).toContain("Loading provenance"); + expect(markup).not.toContain("Shared user"); + expect(markup).not.toContain("shared-message-sender-avatar"); + }); }); describe("UserChatItem raw prompt affordance", () => { @@ -151,6 +336,35 @@ describe("UserChatItem raw prompt affordance", () => { }); }); +describe("UserChatItem delivery failure", () => { + it("renders the underlying provider error beside the failed message", () => { + const event = makeSessionEvent({ + id: "user-message-failed", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Continue this conversation", + displayVariant: "message", + displayStatus: "failed", + result: { + deliveryStatus: "failed", + deliveryError: + "provider-native transcript is not a semantic prefix of the canonical conversation", + }, + }); + + const markup = renderToStaticMarkup( + createElement(UserChatItem, { chatItem: makeChatItem(event) }) + ); + + expect(markup).toContain('data-testid="chat-message-delivery-failed"'); + expect(markup).toContain( + "provider-native transcript is not a semantic prefix of the canonical conversation" + ); + }); +}); + describe("UserChatItem parent-agent attribution", () => { const parentSession = { session_id: "agentsession-root", @@ -234,3 +448,42 @@ describe("UserChatItem parent-agent attribution", () => { expect(markup).not.toContain("parent-agent-sender-avatar"); }); }); + +describe("stamped user message editing", () => { + it.each([ + [{ status: "known", userId: "viewer-user" }, true], + [{ status: "known", userId: "other-user" }, false], + [{ status: "loading" }, false], + ] as const)( + "keeps editing scoped to the known author: %j", + (viewer, editable) => { + const event = makeSessionEvent({ + id: "native-replay-user", + sessionId: "agentsession-local", + source: "user", + actionType: "raw", + functionName: "user_message", + displayText: "Keep the title search scope visible", + displayVariant: "message", + args: { [CONVERSATION_SENDER_ARG]: { userId: "viewer-user" } }, + }); + const markup = renderToStaticMarkup( + createElement( + ConversationSenderMetadataProvider, + { value: { viewer, resolveSender: (_event, stamp) => stamp } }, + createElement(UserChatItem, { + chatItem: makeChatItem(event), + onEditSubmit: () => undefined, + onRestoreCheckpoint: () => undefined, + }) + ) + ); + expect( + markup.includes('data-testid="chat-message-user-edit-button"') + ).toBe(editable); + expect( + markup.includes('data-testid="chat-message-restore-checkpoint"') + ).toBe(editable); + } + ); +}); diff --git a/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts b/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts new file mode 100644 index 0000000000..2e580fdc89 --- /dev/null +++ b/src/engines/ChatPanel/ChatItems/useUserMessageDeliveryActions.ts @@ -0,0 +1,102 @@ +import { useCallback } from "react"; + +import type { ComposerSnapshot } from "@src/components/ComposerInput"; +import { Message } from "@src/components/Message"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; +import { discussionPayloadOf } from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; + +import { useGroupChatContext } from "../ChatHistory/GroupChatView/GroupChatContext"; + +interface UserMessageDeliveryActions { + /** The current viewer may edit this failed transport row. */ + canEditFailed: boolean; + retry: (() => void) | null; + /** Returns true when a transport accepted responsibility for the edit. */ + editAndRetry: + | ((text: string, composerSnapshot?: ComposerSnapshot) => Promise) + | null; +} + +/** + * Transport adapter for failed user rows. + * + * `UserChatItem` renders only these neutral actions. Cloud comments and + * Agent-team chat retain ownership of retry validation, idempotency and wire + * delivery in their existing contexts. + */ +export function useUserMessageDeliveryActions(params: { + event: SessionEvent | undefined; + deliveryStatus: "pending" | "sent" | "failed" | null; +}): UserMessageDeliveryActions { + const comments = useSessionCommentsContext(); + const groupChat = useGroupChatContext(); + const discussion = params.event ? discussionPayloadOf(params.event) : null; + const groupChatInboxId = + typeof params.event?.args?.groupChatInboxId === "number" + ? params.event.args.groupChatInboxId + : null; + const canEditFailed = Boolean( + params.deliveryStatus === "failed" && + comments?.viewerUserId && + discussion?.authorUserId === comments.viewerUserId + ); + + const reportFailure = useCallback((error: unknown) => { + Message.error(error instanceof Error ? error.message : String(error)); + }, []); + + if ( + params.deliveryStatus === "failed" && + canEditFailed && + comments && + discussion?.commentId + ) { + return { + canEditFailed: true, + retry: () => { + void comments.retryComment(discussion.commentId).catch(reportFailure); + }, + editAndRetry: async ( + text: string, + composerSnapshot?: ComposerSnapshot + ) => { + try { + await comments.retryComment( + discussion.commentId, + text, + composerSnapshot + ); + return true; + } catch (error) { + reportFailure(error); + return false; + } + }, + }; + } + if ( + params.deliveryStatus === "failed" && + groupChat && + groupChatInboxId !== null + ) { + return { + canEditFailed: true, + retry: () => groupChat.retryFailedMessage(groupChatInboxId), + editAndRetry: async (text: string) => { + try { + groupChat.retryFailedMessage(groupChatInboxId, text); + return true; + } catch (error) { + reportFailure(error); + return false; + } + }, + }; + } + return { + canEditFailed: false, + retry: null, + editAndRetry: null, + }; +} diff --git a/src/engines/ChatPanel/ChatPanelContent.test.ts b/src/engines/ChatPanel/ChatPanelContent.test.ts index e1eb08bb9c..2b439ea3a1 100644 --- a/src/engines/ChatPanel/ChatPanelContent.test.ts +++ b/src/engines/ChatPanel/ChatPanelContent.test.ts @@ -17,7 +17,6 @@ function render(sessionViewMode: SessionViewMode): string { currentSessionId: "s-1", displayMode: "full" as const, emptyChatContent: createElement("div", { "data-empty": "true" }), - onSessionContinuation: () => undefined, paginationEnabled: false, position: "right" as const, showPanelContent: true, diff --git a/src/engines/ChatPanel/ChatPanelContent.tsx b/src/engines/ChatPanel/ChatPanelContent.tsx index 9c6728773b..5d1f0f5102 100644 --- a/src/engines/ChatPanel/ChatPanelContent.tsx +++ b/src/engines/ChatPanel/ChatPanelContent.tsx @@ -1,15 +1,15 @@ import React from "react"; -import type { SessionContinuation } from "@src/store/session/sessionTabPlacementAtom"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanel/displayPrefsAtoms"; import SessionContentView from "./SessionContentView"; +import type { ConversationTargetBinding } from "./conversationTargetSelection"; import type { SessionViewMode } from "./hooks/useSessionViewMode"; interface ChatPanelContentProps { currentSessionId: string | null; + conversationTargetBinding?: ConversationTargetBinding | null; emptyChatContent: React.ReactNode; - onSessionContinuation: (continuation: SessionContinuation) => void; displayMode: ChatHistoryDisplayMode; paginationEnabled: boolean; position: "left" | "right"; @@ -32,8 +32,8 @@ interface ChatPanelContentProps { */ export function ChatPanelContent({ currentSessionId, + conversationTargetBinding, emptyChatContent, - onSessionContinuation, displayMode, paginationEnabled, position, @@ -58,11 +58,11 @@ export function ChatPanelContent({ >
{/* Mounted only while active. Each alternate view windows its own diff --git a/src/engines/ChatPanel/ChatPanelHeader.tsx b/src/engines/ChatPanel/ChatPanelHeader.tsx index d14dd80117..933185f5e1 100644 --- a/src/engines/ChatPanel/ChatPanelHeader.tsx +++ b/src/engines/ChatPanel/ChatPanelHeader.tsx @@ -53,6 +53,7 @@ interface ChatPanelHeaderProps { chatPanelPosition: ChatPanelPosition; copyEventJsonLabel: "idle" | "copied" | "failed"; currentSessionId: string | null; + appOpenSessionId?: string | null; displayMode: ChatHistoryDisplayMode; eventsLength: number; handleChatFocusToggle: () => void; @@ -112,6 +113,7 @@ export function ChatPanelHeader({ chatPanelPosition, copyEventJsonLabel, currentSessionId, + appOpenSessionId, displayMode, eventsLength, handleChatFocusToggle, @@ -242,6 +244,7 @@ export function ChatPanelHeader({ activeSessionExists={activeSessionExists} copyEventJsonLabel={copyEventJsonLabel} currentSessionId={currentSessionId} + appOpenSessionId={appOpenSessionId} displayMode={displayMode} eventsLength={eventsLength} handleCompactDisplayModeToggle={handleCompactDisplayModeToggle} diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index f9fda1e07f..1e7f914bcd 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -22,7 +22,7 @@ * - Session tab bar / header * - Session creator (shown when no session) */ -import { useAtomValue, useStore } from "jotai"; +import { useAtomValue } from "jotai"; import { selectAtom } from "jotai/utils"; import React, { memo, @@ -32,27 +32,18 @@ import React, { useRef, useState, } from "react"; -import { useTranslation } from "react-i18next"; -import { getImportedHistoryCliResume } from "@src/api/tauri/externalHistory"; -import Message from "@src/components/Message"; import { useShowInteractArea } from "@src/contexts/workspace/ChatContext"; -import { forkExternalHistoryIntoOrgiiSession } from "@src/engines/ChatPanel/externalHistoryFork"; import { derivePlanApprovalViewState } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; import { useTodoSync } from "@src/engines/SessionCore/hooks/session/useTodoSync"; -import { usePinnedSession } from "@src/features/Org2Cloud/SessionConversation/usePinnedSession"; import { useCloudSessionHasDownloadSurface } from "@src/features/Org2Cloud/useCloudSessionDownloadSurface"; -import { ForkCancelledError } from "@src/features/TeamCollaboration/forkSession"; import { useFileReviewSync } from "@src/hooks/fileReview"; -import { createLogger } from "@src/hooks/logger"; import { usePendingPlanApproval } from "@src/hooks/session/usePendingPlanApproval"; import { useSessionWorkspaceSync } from "@src/hooks/session/useSessionWorkspaceSync"; -import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; import { loadSessions, sessionByIdAtom } from "@src/store/session"; import type { Session } from "@src/store/session"; import { - restoreToInputAtom, sessionRuntimeStatusAtom, streamRetryStatusAtom, } from "@src/store/session/cliSessionStatusAtom"; @@ -71,12 +62,16 @@ import { ChatViewHistorySurface } from "./ChatViewHistorySurface"; import { ChatViewLiveRegion } from "./ChatViewLiveRegion"; import { ChatViewPostHistoryOverlays } from "./ChatViewPostHistoryOverlays"; import type { ChatViewProps } from "./ChatViewTypes"; +import { ConversationExecutionBindingContext } from "./ConversationExecutionBindingContext"; +import { resolveConversationRunnerBindings } from "./ConversationStreamProvider"; import { useComposerSections } from "./InputArea/hooks/useComposerSections"; import { - shouldShowExternalHistoryForkComposer, + shouldShowExternalHistoryContinuationComposer, shouldShowMainChatComposer, } from "./chatViewComposerVisibility"; import { resolveInitialFileChanges } from "./chatViewFileChanges"; +import type { ConversationTargetBinding } from "./conversationTargetSelection"; +import { useConversationSubmitRouter } from "./hooks/conversationSubmit/useConversationSubmitRouter"; import { useBrowserAddToConversationAction } from "./hooks/useBrowserAddToConversationAction"; import { useChatViewAgentOrgSurface } from "./hooks/useChatViewAgentOrgSurface"; import { useChatViewAgentStationDiff } from "./hooks/useChatViewAgentStationDiff"; @@ -86,20 +81,26 @@ import { useChatViewOrgtrackSummary } from "./hooks/useChatViewOrgtrackSummary"; import { useChatViewPipelineClaim } from "./hooks/useChatViewPipelineClaim"; import { useChatViewPlanPillState } from "./hooks/useChatViewPlanPillState"; import { useChatViewScrollToBottom } from "./hooks/useChatViewScrollToBottom"; +import { useConversationTargetBinding } from "./hooks/useConversationTargetBinding"; import { useFollowAgent } from "./hooks/useFollowAgent"; -import type { SubmitOverrideInput } from "./hooks/useInputArea/types"; import { latestCompletedAssistantFingerprint, useWorkItemFollowUpSuggestions, } from "./hooks/useWorkItemFollowUpSuggestions"; -const logger = createLogger("ChatView"); - export type { ChatViewProps } from "./ChatViewTypes"; -const ChatView: React.FC = memo( +type ResolvedChatViewProps = Omit< + ChatViewProps, + "conversationTargetBinding" +> & { + conversationTargetBinding: ConversationTargetBinding | null; +}; + +const ResolvedChatView: React.FC = memo( ({ sessionId, + conversationTargetBinding, displayMode = "full", turnPaginationEnabled = true, position = "right", @@ -107,11 +108,7 @@ const ChatView: React.FC = memo( readOnly = false, secondary = false, chromeTopInset = 0, - onSessionContinuation, }) => { - const { t: tNavigation } = useTranslation("navigation"); - const store = useStore(); - const { openSession } = useSessionView(); const rootRef = useRef(null); const inputBoxRef = useRef(null); const [pinnedHeaderHost, setPinnedHeaderHost] = @@ -133,7 +130,6 @@ const ChatView: React.FC = memo( useTodoSync(isReadOnlySurface ? undefined : sessionId); useFileReviewSync(sessionId, !isReadOnlySurface && !secondary); const currentSession = useAtomValue(sessionByIdAtom(sessionId)); - const pinnedCommentsSession = usePinnedSession(sessionId) ?? null; const hydratedSessionIdsRef = useRef(new Set()); useEffect(() => { if ( @@ -185,75 +181,9 @@ const ChatView: React.FC = memo( enabled: !isReadOnlySurface && !secondary && !isCursorIde && isLiveStatus, }); - // Every imported third-party history is immutable at its source. The - // composer below is still interactive, but submitting it creates an - // ORGII-owned continuation after the shared workspace/account/model - // picker — it never writes back into Codex/Claude/Cursor/etc. - const showInteractArea = useShowInteractArea(); const hasCloudDownloadSurface = useCloudSessionHasDownloadSurface(sessionId); - // Sources whose CLI cannot reopen a session (Cursor IDE, Windsurf, - // Trae, …) are pure read-only replays: no composer, no continuation - // affordance. Only CLI-continuable histories offer the fork composer. - const importedCliResume = getImportedHistoryCliResume(sessionId); - const handleExternalHistoryForkSubmit = useCallback( - async (input: SubmitOverrideInput) => { - if (!isImportedHistory) return false; - try { - // Carry BOTH projection fields (mirrors - // useImportedSessionSubmitOverride): displayText stays the user's - // visible words, agentContent is the dispatched agent input. The - // old `agentContent ?? displayText` collapse persisted the internal - // contract as the user's message. - const newSessionId = await forkExternalHistoryIntoOrgiiSession({ - sourceSessionId: sessionId, - sourceSession: currentSession, - userMessage: input.displayText, - agentMessage: input.agentContent, - imageDataUrls: input.imageDataUrls, - }); - await loadSessions({ forceRefresh: true }); - const continuationSession = store.get(sessionByIdAtom(newSessionId)); - const continuation = { - sessionId: newSessionId, - sessionName: continuationSession?.name, - repoPath: continuationSession?.repoPath, - }; - if (onSessionContinuation) { - onSessionContinuation(continuation); - } else { - openSession( - continuation.sessionId, - continuation.sessionName, - continuation.repoPath - ); - } - } catch (error) { - // InputArea clears a handled override. Restore the exact draft on - // cancel/failure so choosing credentials is never destructive. - store.set(restoreToInputAtom, { - sessionId, - displayContent: input.displayText, - imageDataUrls: input.imageDataUrls, - }); - if (!(error instanceof ForkCancelledError)) { - logger.error("failed to continue imported history", error); - Message.error(tNavigation("collaboration.forkImported.error")); - } - } - return true; - }, - [ - currentSession, - isImportedHistory, - onSessionContinuation, - openSession, - sessionId, - store, - tNavigation, - ] - ); const { showFollowAgent, followAgentLabel, @@ -326,22 +256,20 @@ const ChatView: React.FC = memo( const showCurrentPlanSurface = useAtomValue(showCurrentPlanSurfaceAtom); const hasBlockingDownloadSurface = hasCloudDownloadSurface && transcriptEmpty; - const showExternalHistoryForkComposer = - shouldShowExternalHistoryForkComposer({ + const showExternalHistoryContinuationComposer = + shouldShowExternalHistoryContinuationComposer({ hasBlockingDownloadSurface, isImportedHistory, readOnly, - canResume: Boolean(importedCliResume), }); - const showMainComposer = shouldShowMainChatComposer({ - showInteractArea, - isReadOnlySurface, - hasBlockingDownloadSurface, - }); - const showFloatingComposer = - showMainComposer || showExternalHistoryForkComposer; + const showMainComposer = + shouldShowMainChatComposer({ + showInteractArea, + isReadOnlySurface, + hasBlockingDownloadSurface, + }) || showExternalHistoryContinuationComposer; const { setMeasuredFloatingComposerRef, historyBottomInset } = - useChatViewFloatingComposerInset(showFloatingComposer); + useChatViewFloatingComposerInset(showMainComposer); const gitArtifactStats = useMemo( () => ({ @@ -374,7 +302,7 @@ const ChatView: React.FC = memo( handleAgentOrgMemberSessionJump, handleMainComposerSubmitOverride, cancelQueuedMessage, - enqueueCount, + queueTailKey, handleClearSessionQueue, handleReorderSessionQueue, handleSendNow, @@ -386,9 +314,19 @@ const ChatView: React.FC = memo( groupChatHistoryAction, } = useChatViewAgentOrgSurface({ sessionId, - currentSession, - onSessionContinuation, showCurrentPlanSurface, + conversationRoot: conversationTargetBinding?.root ?? null, + }); + const { + submit: handleConversationSubmit, + retry: handleCanonicalConversationRetry, + resolveDispatch: resolveCanonicalRetryDispatch, + } = useConversationSubmitRouter({ + sessionId, + currentSession, + root: conversationTargetBinding?.root ?? null, + selectedTarget: conversationTargetBinding?.target ?? null, + onSurfaceSubmit: handleMainComposerSubmitOverride, }); // Primary card active-data state (reported up by each card) @@ -425,7 +363,7 @@ const ChatView: React.FC = memo( } = useComposerSections({ sessionId, queueCount: sessionMessageQueue.length, - enqueueCount, + queueTailKey, hasQuestion, hasPermission, hasModeSwitch, @@ -454,8 +392,9 @@ const ChatView: React.FC = memo( // The visible ChatView's session is the authoritative composer target. // Agent-org member views may override it with queueSessionId, but ordinary // imported teammate sessions have no agent-org queue target. Passing null - // there made useMessageDispatch fail before onSubmitOverride could run - // ("no active sessionId"), bypassing the fork-before-send flow entirely. + // there made useMessageDispatch fail before onSubmitOverride could admit + // the turn to the canonical queue ("no active sessionId"), so no writable + // native execution episode could be prepared. const inputAreaSessionId = queueSessionId ?? sessionId; const { suggestions: followUpSuggestions, @@ -508,7 +447,7 @@ const ChatView: React.FC = memo( agentOrgIntervention: agentOrgInterventionSlot, streamRetry, groupChatPausedBottomContent, - onSubmitOverride: handleMainComposerSubmitOverride, + onSubmitOverride: handleConversationSubmit, customMentionOptions: groupChatMentionOptions, queueEditProps, disableStopWhenEmpty: groupChatViewActive, @@ -551,7 +490,7 @@ const ChatView: React.FC = memo( agentOrgInterventionSlot, streamRetry, groupChatPausedBottomContent, - handleMainComposerSubmitOverride, + handleConversationSubmit, groupChatMentionOptions, queueEditProps, followUpSuggestions, @@ -563,92 +502,143 @@ const ChatView: React.FC = memo( // sessionsAtom, but their org tags and push markers are keyed by bare // session id — a session_id-only stub keeps the discussion surface alive // on their local view. Scope-only shares still need the full row and - // stay uncovered here. Rows that WERE resident stay pinned so a sidebar - // roster refresh cannot strip the open conversation's identity fields. + // stay uncovered here. Imported replay rows are retained centrally by the + // session loader, so this surface does not keep a second Session cache. const commentsSession = - pinnedCommentsSession ?? + currentSession ?? (isExternalHistorySession(sessionId) ? ({ session_id: sessionId } as Session) : null); + const commentsTargetOverride = + conversationTargetBinding?.cloudTarget ?? null; return ( -
0 - ? turnPaginationEnabled || groupChatViewActive - ? { paddingTop: chromeTopInset } - : { top: chromeTopInset } - : undefined - } - data-chat-pinned-header-portal-host - /> -
- + {(activeRunnerSessionId) => { + const runnerBindings = resolveConversationRunnerBindings( + sessionId, + activeRunnerSessionId + ); + return ( + <> +
0 + ? turnPaginationEnabled || groupChatViewActive + ? { paddingTop: chromeTopInset } + : { top: chromeTopInset } + : undefined + } + data-chat-pinned-header-portal-host /> -
- - - } - composer={} - /> +
+ +
+ + + + + + ); + }} + ); } ); +ResolvedChatView.displayName = "ResolvedChatView"; + +const ChatViewWithLoadedBinding: React.FC< + Omit +> = memo((props) => { + const conversationTargetBinding = useConversationTargetBinding( + props.sessionId + ); + return ( + + ); +}); + +ChatViewWithLoadedBinding.displayName = "ChatViewWithLoadedBinding"; + +const ChatView: React.FC = memo( + ({ conversationTargetBinding, ...props }) => + conversationTargetBinding === undefined ? ( + + ) : ( + + ) +); + ChatView.displayName = "ChatView"; export default ChatView; diff --git a/src/engines/ChatPanel/ChatViewComposerSection.types.ts b/src/engines/ChatPanel/ChatViewComposerSection.types.ts index 11e7134e8d..65c4654ccd 100644 --- a/src/engines/ChatPanel/ChatViewComposerSection.types.ts +++ b/src/engines/ChatPanel/ChatViewComposerSection.types.ts @@ -34,6 +34,8 @@ interface GroupChatPendingMessageView { export interface ChatViewComposerSectionProps { sessionId: string; inputAreaSessionId: string; + /** Native execution episode controlled by Stop while the source stays visible. */ + controlSessionId?: string | null; showMainComposer: boolean; composerRef: React.Ref; inputBoxRef?: React.Ref; diff --git a/src/engines/ChatPanel/ChatViewHistorySurface.tsx b/src/engines/ChatPanel/ChatViewHistorySurface.tsx index 49f63d9639..0dea593028 100644 --- a/src/engines/ChatPanel/ChatViewHistorySurface.tsx +++ b/src/engines/ChatPanel/ChatViewHistorySurface.tsx @@ -13,13 +13,11 @@ import ChatHistory from "./ChatHistory"; import type { ChatHistoryProps } from "./ChatHistory/ChatHistory.types"; import { GroupChatProvider } from "./ChatHistory/GroupChatView/GroupChatContext"; import { AgentEventsTap } from "./ChatHistory/GroupChatView/useGroupChatMergedEvents"; -import { ConversationStreamProvider } from "./ConversationStreamProvider"; import AgentOrgOverviewPanel from "./InputArea/components/AgentOrgOverviewPanel"; interface ChatViewHistorySurfaceProps { sessionId: string; groupChatViewActive: boolean; - groupChatMergedEvents: SessionEvent[]; groupChatAgents: ReadonlyArray<{ sessionId: string }>; pipelineSessionId: string | null; handleGroupChatTapEvents: (sessionId: string, events: SessionEvent[]) => void; @@ -49,12 +47,18 @@ interface ChatViewHistorySurfaceProps { ChatHistoryProps["onGroupChatViewToggle"] >; isReadOnlySurface: boolean; + onFailedUserIntentRetry: NonNullable< + ChatHistoryProps["onFailedUserIntentRetry"] + >; + resolveFailedUserIntentDispatch: NonNullable< + ChatHistoryProps["resolveFailedUserIntentDispatch"] + >; + planningIndicatorScope: ChatHistoryProps["planningIndicatorScope"]; } export function ChatViewHistorySurface({ sessionId, groupChatViewActive, - groupChatMergedEvents, groupChatAgents, pipelineSessionId, handleGroupChatTapEvents, @@ -79,77 +83,77 @@ export function ChatViewHistorySurface({ groupChatViewAvailable, handleGroupChatViewToggle, isReadOnlySurface, + onFailedUserIntentRetry, + resolveFailedUserIntentDispatch, + planningIndicatorScope, }: ChatViewHistorySurfaceProps) { return ( - { + void retryFailedGroupChatMessage(rowId, editedDisplayText).catch( + (error: unknown) => Message.error(String(error)) + ); + }} > - { - void retryFailedGroupChatMessage(rowId, editedDisplayText).catch( - (error: unknown) => Message.error(String(error)) - ); - }} - > - {groupChatViewActive && ( - - )} - {groupChatViewActive && - groupChatAgents - .filter( - (agent) => - !agent.sessionId.startsWith("agent-org-member-pending:") - ) - .map((agent) => ( - + )} + {groupChatViewActive && + groupChatAgents + .filter( + (agent) => !agent.sessionId.startsWith("agent-org-member-pending:") + ) + .map((agent) => ( + + ))} + + - ))} - - - ) : null - } - onAgentOrgMemberSelect={handleAgentOrgMemberSessionJump} - onAgentOrgRunViewRefresh={refreshAgentOrgRunView} - onScrollNavChange={handleScrollNavChange} - followAgentNav={followAgentNav} - browserAddToConversationNav={browserAddToConversationNav} - displayMode={displayMode} - turnPaginationEnabled={turnPaginationEnabled} - paginationTrailingSlot={paginationTrailingSlot} - pinnedHeaderPortalHost={pinnedHeaderHost} - chromeTopInset={chromeTopInset} - bottomInset={historyBottomInset} - groupChatViewAvailable={groupChatViewAvailable} - groupChatViewActive={groupChatViewActive} - onGroupChatViewToggle={handleGroupChatViewToggle} - /> - - - + ) : null + } + onAgentOrgMemberSelect={handleAgentOrgMemberSessionJump} + onAgentOrgRunViewRefresh={refreshAgentOrgRunView} + onScrollNavChange={handleScrollNavChange} + followAgentNav={followAgentNav} + browserAddToConversationNav={browserAddToConversationNav} + displayMode={displayMode} + turnPaginationEnabled={turnPaginationEnabled} + paginationTrailingSlot={paginationTrailingSlot} + pinnedHeaderPortalHost={pinnedHeaderHost} + chromeTopInset={chromeTopInset} + bottomInset={historyBottomInset} + groupChatViewAvailable={groupChatViewAvailable} + groupChatViewActive={groupChatViewActive} + onGroupChatViewToggle={handleGroupChatViewToggle} + onFailedUserIntentRetry={onFailedUserIntentRetry} + resolveFailedUserIntentDispatch={resolveFailedUserIntentDispatch} + planningIndicatorScope={planningIndicatorScope} + /> + + ); } diff --git a/src/engines/ChatPanel/ChatViewLiveRegion.tsx b/src/engines/ChatPanel/ChatViewLiveRegion.tsx index 9b959ed94f..c7ab40dc20 100644 --- a/src/engines/ChatPanel/ChatViewLiveRegion.tsx +++ b/src/engines/ChatPanel/ChatViewLiveRegion.tsx @@ -1,17 +1,23 @@ import { type ReactNode, memo } from "react"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { SessionCommentsProvider } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; +import { Org2ConversationSenderMetadataProvider } from "@src/features/Org2Cloud/SessionConversation/Org2ConversationSenderMetadataProvider"; +import type { SessionCommentTarget } from "@src/features/Org2Cloud/sessionCommentTarget"; import type { Session } from "@src/store/session"; +import { ConversationStreamProvider } from "./ConversationStreamProvider"; import { usePipelineChatEvents } from "./hooks/usePipelineChatEvents"; interface ChatViewLiveRegionProps { commentsSession: Session | null; + commentsTargetOverride: SessionCommentTarget | null; turnAnchorsVisible: boolean; rootRef: React.RefObject; dataSessionId: string; - transcript: ReactNode; - composer: ReactNode; + conversationSessionId: string; + conversationOverrideEvents: SessionEvent[] | undefined; + children: (activeRunnerSessionId: string | null) => ReactNode; } /** @@ -21,29 +27,43 @@ interface ChatViewLiveRegionProps { */ export const ChatViewLiveRegion = memo(function ChatViewLiveRegion({ commentsSession, + commentsTargetOverride, turnAnchorsVisible, rootRef, dataSessionId, - transcript, - composer, + conversationSessionId, + conversationOverrideEvents, + children, }: ChatViewLiveRegionProps) { const { commentAnchors, transcriptReady } = usePipelineChatEvents(); return ( -
- {transcript} - {composer} -
+ + {(activeRunnerSessionId) => ( +
+ {children(activeRunnerSessionId)} +
+ )} +
+
); }); diff --git a/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx b/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx index 7493c0851a..3ed0aec955 100644 --- a/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx +++ b/src/engines/ChatPanel/ChatViewPostHistoryOverlays.tsx @@ -1,93 +1,32 @@ -/** - * ChatViewPostHistoryOverlays — bottom-of-history overlays stacked above the - * primary chat history surface: the "continue as ORGII session" composer - * shown for imported/external history, and (when that composer isn't - * showing) a standalone scroll-to-bottom affordance for imported history - * views. - */ +/** Standalone history affordances used only when no composer is visible. */ import React from "react"; -import { useTranslation } from "react-i18next"; -import { getImportedHistoryCliResume } from "@src/api/tauri/externalHistory"; -import { COMPOSER_BOTTOM_DOCK_PADDING_CLASS } from "@src/config/composerStackTokens"; import { CHAT_PANEL_WIDTH_TOKENS } from "@src/config/detailPanelTokens"; -import { - CHAT_SESSION_CONTEXT_NONE, - ChatSessionContext, -} from "./ChatSessionContext"; -import InputArea from "./InputArea"; -import type { SubmitOverrideInput } from "./hooks/useInputArea/types"; - interface ChatViewPostHistoryOverlaysProps { - showExternalHistoryForkComposer: boolean; - composerRef: (node: HTMLDivElement | null) => void; - position: "left" | "right"; - onSubmitOverride: (input: SubmitOverrideInput) => Promise; + composerVisible: boolean; externalScrollToBottomButton: React.ReactNode; isImportedHistory: boolean; - /** The viewed history session — Address Comments targets its threads - * even though this composer dispatches into a fork. */ - sessionId?: string; } export function ChatViewPostHistoryOverlays({ - showExternalHistoryForkComposer, - composerRef, - position, - onSubmitOverride, + composerVisible, externalScrollToBottomButton, isImportedHistory, - sessionId, }: ChatViewPostHistoryOverlaysProps) { - const { t: tNavigation } = useTranslation("navigation"); - // The composer only renders for CLI-continuable sources (ChatView gates - // `showExternalHistoryForkComposer` on the same `getImportedHistoryCliResume` - // check), so `cliResume` is always defined whenever this placeholder runs. - const cliResume = getImportedHistoryCliResume(sessionId); - const composerPlaceholder = tNavigation( - "collaboration.continueCli.composerPlaceholder", - { agent: cliResume?.displayName ?? "" } - ); - return ( - <> - {showExternalHistoryForkComposer && ( + isImportedHistory && + !composerVisible && + externalScrollToBottomButton && ( +
-
-
- - - -
+ + {externalScrollToBottomButton} +
- )} - {isImportedHistory && - !showExternalHistoryForkComposer && - externalScrollToBottomButton && ( -
-
- - {externalScrollToBottomButton} - -
-
- )} - +
+ ) ); } diff --git a/src/engines/ChatPanel/ChatViewTypes.ts b/src/engines/ChatPanel/ChatViewTypes.ts index 1f18f7e445..67be003f57 100644 --- a/src/engines/ChatPanel/ChatViewTypes.ts +++ b/src/engines/ChatPanel/ChatViewTypes.ts @@ -3,12 +3,18 @@ * sibling hooks/sub-components can reference them without importing the * full `ChatView` component. */ -import type { SessionContinuation } from "@src/store/session/sessionTabPlacementAtom"; import type { ChatHistoryDisplayMode } from "@src/store/ui/chatPanel/displayPrefsAtoms"; +import type { ConversationTargetBinding } from "./conversationTargetSelection"; + export interface ChatViewProps { /** Session ID to display. Sync bridges and events load for this session. */ sessionId: string; + /** + * Resolved once by a surface that also owns session header actions. When + * omitted, ChatView remains self-contained and resolves the binding itself. + */ + conversationTargetBinding?: ConversationTargetBinding | null; displayMode?: ChatHistoryDisplayMode; turnPaginationEnabled?: boolean; /** Dock side for the containing chat panel, used to place side previews inward. */ @@ -40,10 +46,4 @@ export interface ChatViewProps { * the IDE's current folders. */ secondary?: boolean; - /** - * Retarget the owning tab after an immutable imported history is forked - * into a writable ORGII session. The callback must also claim/navigate the - * new session pipeline for its surface. - */ - onSessionContinuation?: (continuation: SessionContinuation) => void; } diff --git a/src/engines/ChatPanel/ConversationExecutionBindingContext.ts b/src/engines/ChatPanel/ConversationExecutionBindingContext.ts new file mode 100644 index 0000000000..2509455c2f --- /dev/null +++ b/src/engines/ChatPanel/ConversationExecutionBindingContext.ts @@ -0,0 +1,18 @@ +import { createContext, useContext } from "react"; + +import type { ConversationTargetBinding } from "./conversationTargetSelection"; + +/** + * One canonical conversation binding per ChatView surface. + * + * Runtime/model controls are deep composer children, but resolving a binding + * can probe the local workspace and subscribe to durable target memory. Keep + * that work at the ChatView boundary and share the result instead of mounting + * an independent resolver in every consumer. + */ +export const ConversationExecutionBindingContext = + createContext(null); + +export function useConversationExecutionBinding(): ConversationTargetBinding | null { + return useContext(ConversationExecutionBindingContext); +} diff --git a/src/engines/ChatPanel/ConversationStreamProvider.test.ts b/src/engines/ChatPanel/ConversationStreamProvider.test.ts new file mode 100644 index 0000000000..8546a1b703 --- /dev/null +++ b/src/engines/ChatPanel/ConversationStreamProvider.test.ts @@ -0,0 +1,399 @@ +import { createStore } from "jotai"; +import { describe, expect, it, vi } from "vitest"; + +import { + type ConversationRootLocator, + conversationRootKey, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + NATIVE_SOURCE_EVENT_ID_ARG, + nativeSourceEventId, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isVisibleInChat } from "@src/engines/SessionCore/ingestion/visibilityFilters"; +import { buildConversationRunnerOverlay } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay"; +import { + type ActiveMessageDelivery, + messageDeliveryRecordsAtom, +} from "@src/store/ui/messageQueueAtom"; + +import { + conversationActiveDeliveriesAtom, + createLocalExecutionHydrationCoordinator, + projectVisibleLocalExecutionTail, + resolveConversationRunnerBindings, + selectConversationActiveRunners, + shouldHydrateLocalExecutionSnapshot, + shouldIngestConversationRunnerLiveEvents, +} from "./ConversationStreamProvider"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((fulfill) => { + resolve = fulfill; + }); + return { promise, resolve }; +} + +function messageEvent( + id: string, + source: "user" | "assistant", + displayText: string, + createdAt: string +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "root", + createdAt, + functionName: source === "user" ? "user_message" : "assistant_message", + uiCanonical: source === "user" ? "user" : "assistant_message", + actionType: source === "user" ? "raw" : "assistant", + args: {}, + result: { content: displayText }, + source, + displayText, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function root(conversationId: string): ConversationRootLocator { + return { + authority: "org2-cloud", + authorityScope: ["https://cloud.example", "org-1"], + conversationId, + }; +} + +function activeDelivery( + id: string, + conversationId: string, + dispatchIdentityKey = "identity-a" +): ActiveMessageDelivery { + return { + id, + turnIntentId: `turn-${id}`, + sessionId: conversationId, + content: id, + displayContent: id, + conversationDispatch: { + kind: "canonical_conversation", + root: root(conversationId), + target: { cliAgentType: "claude_code" }, + dispatchIdentityKey, + }, + status: "preparing", + priority: "next", + createdAt: "2026-09-04T00:00:00.000Z", + }; +} + +describe("resolveConversationRunnerBindings", () => { + it("keeps the visible transcript on its canonical source while footer and Stop follow the runner", () => { + expect( + resolveConversationRunnerBindings( + "codexapp-canonical-source", + "cliagent-native-runner" + ) + ).toEqual({ + sourceSessionId: "codexapp-canonical-source", + controlSessionId: "cliagent-native-runner", + planningIndicatorScope: { + sessionId: "cliagent-native-runner", + isLive: true, + }, + }); + }); + + it("returns footer and Stop to their ordinary source owners without a runner", () => { + expect( + resolveConversationRunnerBindings("cliagent-ordinary", null) + ).toEqual({ + sourceSessionId: "cliagent-ordinary", + controlSessionId: null, + planningIndicatorScope: null, + }); + }); +}); + +describe("shouldIngestConversationRunnerLiveEvents", () => { + it("mounts the hidden runner ingestion edge for a canonical root", () => { + expect( + shouldIngestConversationRunnerLiveEvents( + "cliagent-hidden", + "imported-session-root" + ) + ).toBe(true); + }); + + it("leaves a visible runner to the primary SessionSync owner", () => { + expect( + shouldIngestConversationRunnerLiveEvents( + "cliagent-visible", + "cliagent-visible" + ) + ).toBe(false); + }); +}); + +describe("conversation delivery lifecycle scoping", () => { + it("does not notify one conversation when an unrelated delivery changes", () => { + const store = createStore(); + const matching = activeDelivery("matching", "root-a"); + const unrelated = activeDelivery("unrelated", "root-b"); + const scopedAtom = conversationActiveDeliveriesAtom({ + cloudRootKey: conversationRootKey(root("root-a")), + cloudIdentityKey: "identity-a", + localRootKey: null, + }); + const listener = vi.fn(); + const unsubscribe = store.sub(scopedAtom, listener); + + store.set(messageDeliveryRecordsAtom, [matching]); + expect(listener).toHaveBeenCalledTimes(1); + expect(store.get(scopedAtom)).toEqual([matching]); + + store.set(messageDeliveryRecordsAtom, [matching, unrelated]); + expect(listener).toHaveBeenCalledTimes(1); + + store.set(messageDeliveryRecordsAtom, [matching, { ...unrelated }]); + expect(listener).toHaveBeenCalledTimes(1); + + store.set(messageDeliveryRecordsAtom, [{ ...matching }, unrelated]); + expect(listener).toHaveBeenCalledTimes(2); + unsubscribe(); + }); + + it("keeps Cloud delivery identity isolation while local roots use their root owner", () => { + const matching = activeDelivery("matching", "root-a", "identity-a"); + const wrongIdentity = activeDelivery( + "wrong-identity", + "root-a", + "identity-b" + ); + const local = activeDelivery("local", "local-root", "identity-b"); + const store = createStore(); + const scopedAtom = conversationActiveDeliveriesAtom({ + cloudRootKey: conversationRootKey(root("root-a")), + cloudIdentityKey: "identity-a", + localRootKey: conversationRootKey(root("local-root")), + }); + store.set(messageDeliveryRecordsAtom, [matching, wrongIdentity, local]); + + expect(store.get(scopedAtom)).toEqual([matching, local]); + }); +}); + +describe("local execution-child hydration lifecycle", () => { + it("hydrates only on first/root-change/settled-delivery boundaries", () => { + expect( + shouldHydrateLocalExecutionSnapshot(null, { + rootKey: "root-a", + activeDeliveryCount: 0, + }) + ).toBe(true); + expect( + shouldHydrateLocalExecutionSnapshot( + { rootKey: "root-a", activeDeliveryCount: 0 }, + { rootKey: "root-a", activeDeliveryCount: 1 } + ) + ).toBe(false); + expect( + shouldHydrateLocalExecutionSnapshot( + { rootKey: "root-a", activeDeliveryCount: 1 }, + { rootKey: "root-a", activeDeliveryCount: 0 } + ) + ).toBe(true); + expect( + shouldHydrateLocalExecutionSnapshot( + { rootKey: "root-a", activeDeliveryCount: 0 }, + { rootKey: "root-b", activeDeliveryCount: 0 } + ) + ).toBe(true); + expect( + shouldHydrateLocalExecutionSnapshot( + { rootKey: "root-a", activeDeliveryCount: 0 }, + { rootKey: null, activeDeliveryCount: 0 } + ) + ).toBe(false); + }); + + it("single-flights hydration bursts and commits only the latest generation", async () => { + const first = deferred(); + const second = deferred(); + const loads: string[] = []; + const commits: Array<[string, string]> = []; + const coordinator = createLocalExecutionHydrationCoordinator( + async (request: string) => { + loads.push(request); + return loads.length === 1 ? first.promise : second.promise; + }, + (result, request) => commits.push([result, request]), + () => undefined + ); + + coordinator.request("old-root"); + coordinator.request("latest-root"); + expect(loads).toEqual(["old-root"]); + + first.resolve("stale-result"); + await first.promise; + await vi.waitFor(() => { + expect(loads).toEqual(["old-root", "latest-root"]); + }); + expect(commits).toEqual([]); + + second.resolve("latest-result"); + await second.promise; + await vi.waitFor(() => { + expect(commits).toEqual([["latest-result", "latest-root"]]); + }); + }); + + it("releases hydration ownership if an error callback throws", async () => { + const hydrate = vi.fn(async (request: string) => { + if (request === "failed-root") throw new Error("read failed"); + return request; + }); + const commit = vi.fn(); + const coordinator = createLocalExecutionHydrationCoordinator( + hydrate, + commit, + () => { + coordinator.request("next-root"); + throw new Error("error subscriber failed"); + } + ); + + coordinator.request("failed-root"); + await vi.waitFor(() => { + expect(commit).toHaveBeenCalledWith("next-root", "next-root"); + }); + coordinator.request("later-root"); + await vi.waitFor(() => { + expect(commit).toHaveBeenCalledWith("later-root", "later-root"); + }); + expect(hydrate).toHaveBeenCalledTimes(3); + }); + + it("routes a local active turn through the same runner overlay as Cloud", () => { + const delivery = { + ...activeDelivery("local-turn", "local-root"), + runnerSessionId: "claude-child", + // Raw provider history contains many rows hidden from chat projection. + runnerEventStartIndex: 36, + }; + const [runner] = selectConversationActiveRunners([delivery], { + cloudRootKey: null, + cloudIdentityKey: null, + localRootKey: conversationRootKey(root("local-root")), + landedTurnIds: new Set(), + }); + const historical = messageEvent( + "historical", + "assistant", + "old answer", + "2026-09-05T00:00:00Z" + ); + const currentUser = messageEvent( + "current-user", + "user", + "continue", + "2026-09-05T00:01:00Z" + ); + const currentAssistant = messageEvent( + "current-assistant", + "assistant", + "working", + "2026-09-05T00:01:01Z" + ); + + expect(runner).toEqual({ + runnerSessionId: "claude-child", + turnId: "turn-local-turn", + eventStartIndex: 36, + }); + if (!runner) throw new Error("expected local active runner"); + expect( + buildConversationRunnerOverlay( + runner, + [ + historical, + { + ...currentUser, + result: { + ...currentUser.result, + turnIntentId: "turn-local-turn", + }, + }, + { + ...historical, + id: "materialized-historical", + chunk_id: "materialized-historical", + args: { + [NATIVE_SOURCE_EVENT_ID_ARG]: nativeSourceEventId(historical), + }, + }, + { + ...currentAssistant, + result: { + ...currentAssistant.result, + turnIntentId: "turn-local-turn", + }, + }, + ], + "local-root" + ).map((event) => [event.id, event.displayText]) + ).toEqual([["runlive-current-assistant", "working"]]); + }); + + it("verifies a child against raw native history before applying chat visibility", () => { + const rawRoot = [ + messageEvent("root-user", "user", "inspect", "2026-09-05T00:00:00Z"), + // Normal chat projection hides this structural native message, but it + // still participates in the provider transcript prefix. + messageEvent("root-hidden", "assistant", " ", "2026-09-05T00:00:01Z"), + messageEvent("root-answer", "assistant", "done", "2026-09-05T00:00:02Z"), + ]; + const suffix = [ + messageEvent("child-user", "user", "continue", "2026-09-05T00:01:00Z"), + messageEvent( + "child-answer", + "assistant", + "continued", + "2026-09-05T00:01:01Z" + ), + ]; + const childEvents = [ + ...rawRoot.map((event) => ({ + ...event, + id: `child-${event.id}`, + chunk_id: `child-${event.id}`, + })), + ...suffix, + ]; + const child = { + session_id: "claude-child", + created_at: "2026-09-05T00:01:00Z", + }; + + expect(rawRoot.filter(isVisibleInChat)).toHaveLength(2); + expect( + projectVisibleLocalExecutionTail( + rawRoot.filter(isVisibleInChat), + [{ child, events: childEvents }], + "root" + ) + ).toEqual([]); + expect( + projectVisibleLocalExecutionTail( + rawRoot, + [{ child, events: childEvents }], + "root" + ).map((event) => event.displayText) + ).toEqual(["continue", "continued"]); + }); +}); diff --git a/src/engines/ChatPanel/ConversationStreamProvider.tsx b/src/engines/ChatPanel/ConversationStreamProvider.tsx index c22d9a81b6..e51d75f46c 100644 --- a/src/engines/ChatPanel/ConversationStreamProvider.tsx +++ b/src/engines/ChatPanel/ConversationStreamProvider.tsx @@ -1,70 +1,368 @@ -import { useAtomValue, useSetAtom } from "jotai"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useAtomValue } from "jotai"; +import { selectAtom } from "jotai/utils"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { resolveConversationViewerState } from "@src/engines/SessionCore/conversations/conversationSenderMetadata"; +import { + type ConversationRootLocator, + conversationRootKey, +} from "@src/engines/SessionCore/conversations/conversationTypes"; +import { + type LocalCanonicalConversationSnapshot, + type LocalExecutionSegment, + loadLocalCanonicalConversationSnapshot, + projectVerifiedLocalExecutionTail, + suppressLandedQueuedUserRows, + suppressLandedRowsOfFailedQueuedTurns, +} from "@src/engines/SessionCore/conversations/localConversationExecutionTail"; import { sessionIdAtom } from "@src/engines/SessionCore/core/atoms/metadata"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { derivePlanDisplayEvents } from "@src/engines/SessionCore/derived/planDisplayEvents"; import { chatEventsForSessionAtomFamily } from "@src/engines/SessionCore/derived/sessionScopedChatEvents"; +import { isVisibleInChat } from "@src/engines/SessionCore/ingestion/visibilityFilters"; +import { useSessionEventIngestion } from "@src/engines/SessionCore/sync/useSessionEventIngestion"; import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; import { - activeConversationRunnersAtom, - collectLandedTurnIds, - selectActiveRunners, -} from "@src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom"; + assembleCanonicalConversationTimeline, + legacyConversationFamilyForTimeline, +} from "@src/features/Org2Cloud/SessionConversation/canonicalConversationTimeline"; import { type ConversationFamilyMember, resolveConversationFamily, - stitchConversationSegments, } from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; import { useConversationPlaneEvents } from "@src/features/Org2Cloud/SessionConversation/conversationPlaneAtom"; -import { ConversationRunnerScopeProvider } from "@src/features/Org2Cloud/SessionConversation/conversationRunnerScope"; -import { mergePlaneIntoTranscript } from "@src/features/Org2Cloud/SessionConversation/conversationTimeline"; import { - buildDiscussionEvents, - mergeConversationEvents, -} from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; + buildConversationRunnerOverlay, + collectLandedTurnIds, + conversationRunnerOverlaysEqual, +} from "@src/features/Org2Cloud/SessionConversation/conversationRunnerOverlay"; +import { mergeConversationEvents } from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; import { useEnsureFamilyLoaded } from "@src/features/Org2Cloud/SessionConversation/useEnsureFamilyLoaded"; import { useMarkDiscussionSeen } from "@src/features/Org2Cloud/SessionConversation/useMarkDiscussionSeen"; -import { usePinnedSession } from "@src/features/Org2Cloud/SessionConversation/usePinnedSession"; -import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; -import { org2CloudRemoteSessionsAtom } from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; -import { findImportedSession } from "@src/features/TeamCollaboration/engine/collabImportIdentity"; +import { + org2CloudAuthAtom, + org2CloudAuthIdentityKey, +} from "@src/features/Org2Cloud/org2CloudAuthAtom"; +import { + org2CloudRemoteSessionsAtom, + remoteSessionsEntryForIdentity, +} from "@src/features/Org2Cloud/org2CloudRemoteSessionsAtom"; +import { + findImportedSession, + normalizeSourceEndpointUrl, +} from "@src/features/TeamCollaboration/engine/collabImportIdentity"; import { getSessionForkedFrom } from "@src/features/TeamCollaboration/forkSession"; +import { createLogger } from "@src/hooks/logger"; import type { RemoteTeammateSessionMetadata } from "@src/store/collaboration/types"; -import { sessionsAtom } from "@src/store/session"; +import { sessionByIdAtom, sessionsAtom } from "@src/store/session"; +import { + type ActiveMessageDelivery, + activeMessageDeliveriesAtom, +} from "@src/store/ui/messageQueueAtom"; import { ChatHistoryOverrideContext } from "./ChatHistoryOverrideContext"; +import { + conversationRootForSession, + conversationSourceFromImportedHistory, +} from "./hooks/useConversationTargetBinding"; + +const EMPTY_DISCUSSION_COMMENTS = [] as const; +const EMPTY_LOCAL_EXECUTION_SEGMENTS: readonly LocalExecutionSegment[] = []; +const log = createLogger("ConversationStreamProvider"); + +interface ConversationDeliveryScope { + cloudRootKey: string | null; + cloudIdentityKey: string | null; + localRootKey: string | null; +} + +function activeDeliveriesEqual( + left: readonly ActiveMessageDelivery[], + right: readonly ActiveMessageDelivery[] +): boolean { + return ( + left.length === right.length && + left.every((delivery, index) => delivery === right[index]) + ); +} + +export function selectConversationActiveDeliveries( + deliveries: readonly ActiveMessageDelivery[], + scope: ConversationDeliveryScope +): ActiveMessageDelivery[] { + return deliveries.filter((delivery) => { + const descriptor = delivery.conversationDispatch; + const rootKey = conversationRootKey(descriptor.root); + if (scope.localRootKey && rootKey === scope.localRootKey) return true; + return Boolean( + scope.cloudRootKey && + scope.cloudIdentityKey && + rootKey === scope.cloudRootKey && + descriptor.dispatchIdentityKey === scope.cloudIdentityKey + ); + }); +} + +export function conversationActiveDeliveriesAtom( + scope: ConversationDeliveryScope +) { + return selectAtom( + activeMessageDeliveriesAtom, + (deliveries) => selectConversationActiveDeliveries(deliveries, scope), + activeDeliveriesEqual + ); +} + +interface LatestHydrationRequest { + generation: number; + value: T; +} + +interface LocalExecutionHydrationCoordinator { + request: (request: TRequest) => void; + invalidate: () => void; + activate: () => void; + deactivate: () => void; +} + +/** + * Runs at most one native-history hydration at a time and coalesces bursts to + * the newest request. Generation checks prevent an old root from committing. + */ +export function createLocalExecutionHydrationCoordinator( + hydrate: (request: TRequest) => Promise, + onCurrent: (result: TResult, request: TRequest) => void, + onError: (error: unknown, request: TRequest) => void +): LocalExecutionHydrationCoordinator { + let generation = 0; + let active = true; + let running = false; + let pending: LatestHydrationRequest | null = null; + + const drain = async () => { + while (active && pending) { + const current = pending; + pending = null; + try { + const result = await hydrate(current.value); + if (active && current.generation === generation) { + onCurrent(result, current.value); + } + } catch (error) { + if (active && current.generation === generation) { + onError(error, current.value); + } + } + } + running = false; + // No await occurs between the loop condition and this assignment, but keep + // the restart guard explicit so future scheduling changes cannot lose work. + if (active && pending) start(); + }; + const start = () => { + if (!active || running || !pending) return; + running = true; + void drain().catch((error: unknown) => { + // A projection/error subscriber can throw too. Do not leave the + // single-flight owner permanently held or lose a newer pending root. + running = false; + log.error("local execution hydration callback failed", error); + if (active && pending) start(); + }); + }; + + return { + request(request) { + generation += 1; + pending = { generation, value: request }; + start(); + }, + invalidate() { + generation += 1; + pending = null; + }, + activate() { + active = true; + start(); + }, + deactivate() { + active = false; + generation += 1; + pending = null; + }, + }; +} + +interface LocalExecutionHydrationRequest { + root: ConversationRootLocator; + rootKey: string; +} + +interface LocalExecutionHydrationSnapshot { + rootKey: string; + snapshot: LocalCanonicalConversationSnapshot; +} + +interface LocalExecutionHydrationTrigger { + rootKey: string | null; + activeDeliveryCount: number; +} + +/** + * Native history is immutable during a queued turn from this projection's + * perspective: the live runner overlay owns in-flight output. Rehydrate when + * the root changes or a delivery leaves (its native suffix has settled), not + * when a delivery starts or the root Session object streams metadata updates. + */ +export function shouldHydrateLocalExecutionSnapshot( + previous: LocalExecutionHydrationTrigger | null, + next: LocalExecutionHydrationTrigger +): boolean { + if (!next.rootKey) return false; + return ( + previous === null || + previous.rootKey !== next.rootKey || + next.activeDeliveryCount < previous.activeDeliveryCount + ); +} + +async function hydrateLocalExecutionSnapshot( + request: LocalExecutionHydrationRequest +): Promise { + return { + rootKey: request.rootKey, + snapshot: await loadLocalCanonicalConversationSnapshot(request.root), + }; +} + +interface ConversationActiveRunner { + runnerSessionId: string; + turnId: string; + eventStartIndex: number; +} + +export function selectConversationActiveRunners( + deliveries: readonly ActiveMessageDelivery[], + scope: ConversationDeliveryScope & { landedTurnIds: ReadonlySet } +): ConversationActiveRunner[] { + return deliveries.flatMap((delivery) => { + const descriptor = delivery.conversationDispatch; + const rootKey = conversationRootKey(descriptor.root); + const isLocal = Boolean( + scope.localRootKey && rootKey === scope.localRootKey + ); + const isCloud = Boolean( + scope.cloudRootKey && + scope.cloudIdentityKey && + rootKey === scope.cloudRootKey && + descriptor.dispatchIdentityKey === scope.cloudIdentityKey + ); + if ( + (!isLocal && !isCloud) || + (isCloud && scope.landedTurnIds.has(delivery.turnIntentId)) || + !delivery.runnerSessionId || + delivery.runnerEventStartIndex === undefined + ) { + return []; + } + return [ + { + runnerSessionId: delivery.runnerSessionId, + turnId: delivery.turnIntentId, + eventStartIndex: delivery.runnerEventStartIndex, + }, + ]; + }); +} + +/** Verify against raw native history, then run the ordinary chat projection. */ +export function projectVisibleLocalExecutionTail( + authoritativeRootEvents: readonly SessionEvent[], + segments: readonly LocalExecutionSegment[], + canonicalSessionId: string +): SessionEvent[] { + return derivePlanDisplayEvents( + projectVerifiedLocalExecutionTail( + authoritativeRootEvents, + segments, + canonicalSessionId + ).filter(isVisibleInChat) + ); +} interface ConversationStreamProviderProps { sessionId: string; /** Pre-merged group-chat stream; takes precedence over conversation merging. */ overrideEvents: SessionEvent[] | undefined; - children: React.ReactNode; + children: (activeRunnerSessionId: string | null) => React.ReactNode; +} + +export function resolveConversationRunnerBindings( + sourceSessionId: string, + activeRunnerSessionId: string | null +): { + sourceSessionId: string; + controlSessionId: string | null; + planningIndicatorScope: { sessionId: string; isLive: true } | null; +} { + return { + sourceSessionId, + controlSessionId: activeRunnerSessionId, + planningIndicatorScope: activeRunnerSessionId + ? { sessionId: activeRunnerSessionId, isLive: true } + : null, + }; +} + +/** The primary SessionSync surface already ingests its own live channel. */ +export function shouldIngestConversationRunnerLiveEvents( + runnerSessionId: string, + pipelineSessionId: string | null +): boolean { + return runnerSessionId !== pipelineSessionId; } interface MemberEventsTapProps { bareSessionId: string; localSessionId: string; + ingestLive?: boolean; onEvents: (bareSessionId: string, events: SessionEvent[]) => void; + onUnmount?: (bareSessionId: string) => void; } /** Invisible per-family-member subscription; the atom self-hydrates on mount. */ function MemberEventsTap({ bareSessionId, localSessionId, + ingestLive = false, onEvents, + onUnmount, }: MemberEventsTapProps): null { + useSessionEventIngestion(ingestLive ? localSessionId : null); const events = useAtomValue(chatEventsForSessionAtomFamily(localSessionId)); React.useEffect(() => { onEvents(bareSessionId, events); }, [bareSessionId, events, onEvents]); + React.useEffect( + () => () => { + onUnmount?.(bareSessionId); + }, + [bareSessionId, onUnmount] + ); return null; } /** - * Feeds ChatHistory the conversation stream: the fork family stitched into - * one transcript (root first, continuations introduced by divider rows), - * with the session's discussion rows (cloud comments) interleaved by - * timestamp. Must render inside `SessionCommentsProvider`. + * Feeds ChatHistory the conversation stream: pre-plane compatibility + * segments plus the canonical Cloud plane and discussion rows. Post-plane + * execution episodes are not subscribed as additional transcript owners. + * Must render inside `SessionCommentsProvider`. */ export function ConversationStreamProvider({ sessionId, @@ -76,20 +374,24 @@ export function ConversationStreamProvider({ chatEventsForSessionAtomFamily(pipelineSessionId ?? sessionId) ); const comments = useSessionCommentsContext(); - const currentSession = usePinnedSession(sessionId); + const currentSession = useAtomValue(sessionByIdAtom(sessionId)); const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); const sessions = useAtomValue(sessionsAtom); const auth = useAtomValue(org2CloudAuthAtom); + const authIdentityKey = auth ? org2CloudAuthIdentityKey(auth) : null; const target = comments?.target ?? null; - const grouped = comments?.grouped ?? null; + const discussionComments = comments?.comments ?? EMPTY_DISCUSSION_COMMENTS; const toSourceEventId = comments?.toSourceEventId ?? null; const anchorBareSessionId = currentSession?.importedFrom?.sourceSessionId ?? sessionId; const family = useMemo(() => { if (!target || overrideEvents) return null; - const rows = remoteEntries[target.orgId]?.rows; + const rows = remoteSessionsEntryForIdentity( + remoteEntries[target.orgId], + authIdentityKey + )?.rows; if (!rows?.length) return null; const resolved = resolveConversationFamily(rows, anchorBareSessionId); if (resolved) return resolved; @@ -142,18 +444,30 @@ export function ConversationStreamProvider({ target, overrideEvents, remoteEntries, + authIdentityKey, anchorBareSessionId, currentSession, auth?.userId, auth?.profile?.displayName, ]); + const plane = useConversationPlaneEvents(target); + const timelineFamily = useMemo( + () => + legacyConversationFamilyForTimeline( + family, + anchorBareSessionId, + plane.events, + plane.historyStartedAt + ), + [anchorBareSessionId, family, plane.events, plane.historyStartedAt] + ); useMarkDiscussionSeen(sessionId, comments, family); const memberTaps = useMemo(() => { - if (!family || !target) return []; + if (!timelineFamily || !target) return []; const taps: { bareSessionId: string; localSessionId: string }[] = []; - for (const member of family) { + for (const member of timelineFamily) { if (member.bareSessionId === anchorBareSessionId) continue; const local = sessions.find( @@ -173,13 +487,23 @@ export function ConversationStreamProvider({ } } return taps; - }, [family, target, sessions, auth?.supabaseUrl, anchorBareSessionId]); + }, [ + timelineFamily, + target, + sessions, + auth?.supabaseUrl, + anchorBareSessionId, + ]); const loadedBareSessionIds = useMemo( () => new Set(memberTaps.map((tap) => tap.bareSessionId)), [memberTaps] ); - useEnsureFamilyLoaded(family, loadedBareSessionIds, anchorBareSessionId); + useEnsureFamilyLoaded( + timelineFamily, + loadedBareSessionIds, + anchorBareSessionId + ); const [eventsByBareId, setEventsByBareId] = useState< ReadonlyMap @@ -195,134 +519,274 @@ export function ConversationStreamProvider({ }, [] ); - - const plane = useConversationPlaneEvents(target); - const viewerUserId = auth?.userId ?? null; + const handleMemberUnmount = useCallback((bareSessionId: string) => { + setEventsByBareId((previous) => { + if (!previous.has(bareSessionId)) return previous; + const next = new Map(previous); + next.delete(bareSessionId); + return next; + }); + }, []); + const viewer = resolveConversationViewerState( + auth?.userId ?? comments?.viewerUserId ?? null, + true + ); // Live overlay for THIS device's in-flight member turns: the runner is a // local session, so its thinking / tool / worked-for events stream in real // time — tap and merge them until the plane carries the turn's terminal // tail, so the sender sees the agent working instead of a dead wait. - const runnerRegistry = useAtomValue(activeConversationRunnersAtom); - const setRunnerRegistry = useSetAtom(activeConversationRunnersAtom); const planeRootId = target?.sessionId ?? null; + const runnerRegistryKey = useMemo(() => { + if (!auth || !authIdentityKey || !target || !planeRootId) return null; + return conversationRootKey({ + authority: "org2-cloud", + authorityScope: [ + normalizeSourceEndpointUrl(auth.supabaseUrl), + target.orgId, + ], + conversationId: planeRootId, + }); + }, [auth, authIdentityKey, planeRootId, target]); + const localRoot = useMemo(() => { + if (target || overrideEvents) return null; + const imported = conversationSourceFromImportedHistory({ + sessionId, + session: currentSession, + })?.root; + const root = + imported ?? + (currentSession ? conversationRootForSession(currentSession) : null); + return root && root.conversationId === sessionId ? root : null; + }, [currentSession, overrideEvents, sessionId, target]); + const localRootKey = localRoot ? conversationRootKey(localRoot) : null; + const localRootRef = useRef(localRoot); + useEffect(() => { + localRootRef.current = localRoot; + }, [localRoot]); + const scopedActiveDeliveriesAtom = useMemo( + () => + conversationActiveDeliveriesAtom({ + cloudRootKey: runnerRegistryKey, + cloudIdentityKey: authIdentityKey, + localRootKey, + }), + [authIdentityKey, localRootKey, runnerRegistryKey] + ); + const activeDeliveries = useAtomValue(scopedActiveDeliveriesAtom); const landedTurnIds = useMemo( () => collectLandedTurnIds(plane.events), [plane.events] ); const activeRunners = useMemo(() => { - if (!planeRootId) return []; - // Drop a runner as soon as its agent tail is on the plane — the - // authoritative rows take over with no double-render. - return selectActiveRunners( - runnerRegistry[planeRootId] ?? [], - landedTurnIds - ); - }, [runnerRegistry, planeRootId, landedTurnIds]); + return selectConversationActiveRunners(activeDeliveries, { + cloudRootKey: runnerRegistryKey, + cloudIdentityKey: authIdentityKey, + localRootKey, + landedTurnIds, + }); + }, [ + activeDeliveries, + authIdentityKey, + landedTurnIds, + localRootKey, + runnerRegistryKey, + ]); + const activeRunnerIds = useMemo( + () => new Set(activeRunners.map((runner) => runner.runnerSessionId)), + [activeRunners] + ); // The in-flight runner drives the chat footer's running/typing indicator // so a member's long turn shows "Thinking…" instead of a frozen screen. - const activeRunnerScope = + const activeRunnerSessionId = activeRunners.length > 0 ? activeRunners[activeRunners.length - 1].runnerSessionId : null; + const localRootDeliveryCount = useMemo( + () => + localRootKey + ? activeDeliveries.filter( + (delivery) => + conversationRootKey(delivery.conversationDispatch.root) === + localRootKey + ).length + : 0, + [activeDeliveries, localRootKey] + ); + const [loadedLocalExecution, setLoadedLocalExecution] = + useState(null); + const localHydrationCoordinatorRef = + useRef | null>( + null + ); + const localHydrationTriggerRef = + useRef(null); useEffect(() => { - if (!planeRootId) return; - const list = runnerRegistry[planeRootId]; - if (!list?.length) return; - const kept = selectActiveRunners(list, landedTurnIds); - if (kept.length === list.length) return; - setRunnerRegistry((current) => { - const next = { ...current }; - if (kept.length === 0) delete next[planeRootId]; - else next[planeRootId] = kept; - return next; + const coordinator = createLocalExecutionHydrationCoordinator< + LocalExecutionHydrationRequest, + LocalExecutionHydrationSnapshot + >( + hydrateLocalExecutionSnapshot, + (next) => { + setLoadedLocalExecution(next); + }, + (error, request) => { + log.warn("local execution hydration could not load children", { + sessionId: request.root.conversationId, + localRootKey: request.rootKey, + error, + }); + } + ); + localHydrationCoordinatorRef.current = coordinator; + coordinator.activate(); + return () => { + coordinator.deactivate(); + if (localHydrationCoordinatorRef.current === coordinator) { + localHydrationCoordinatorRef.current = null; + } + }; + }, []); + useEffect(() => { + const nextTrigger = { + rootKey: localRootKey, + activeDeliveryCount: localRootDeliveryCount, + }; + const shouldHydrate = shouldHydrateLocalExecutionSnapshot( + localHydrationTriggerRef.current, + nextTrigger + ); + localHydrationTriggerRef.current = nextTrigger; + const coordinator = localHydrationCoordinatorRef.current; + if (!coordinator) return; + const currentRoot = localRootRef.current; + if (!currentRoot || !localRootKey) { + coordinator.invalidate(); + return; + } + if (!shouldHydrate) return; + coordinator.request({ + root: currentRoot, + rootKey: localRootKey, }); - }, [planeRootId, runnerRegistry, landedTurnIds, setRunnerRegistry]); - const [runnerEventsById, setRunnerEventsById] = useState< + }, [localRootDeliveryCount, localRootKey]); + const localSnapshot = + localRootKey && loadedLocalExecution?.rootKey === localRootKey + ? loadedLocalExecution.snapshot + : null; + const authoritativeLocalRootEvents = localSnapshot?.rootEvents ?? null; + const localExecutionSegments: readonly LocalExecutionSegment[] = + localSnapshot?.segments ?? EMPTY_LOCAL_EXECUTION_SEGMENTS; + const localTails = useMemo(() => { + if (!localRootKey || !authoritativeLocalRootEvents) return []; + return projectVisibleLocalExecutionTail( + authoritativeLocalRootEvents, + localExecutionSegments, + sessionId + ); + }, [ + authoritativeLocalRootEvents, + localExecutionSegments, + localRootKey, + sessionId, + ]); + const [runnerOverlayById, setRunnerOverlayById] = useState< ReadonlyMap >(() => new Map()); const handleRunnerEvents = useCallback( (runnerSessionId: string, events: SessionEvent[]) => { - setRunnerEventsById((previous) => { - if (previous.get(runnerSessionId) === events) return previous; - const next = new Map(previous); - next.set(runnerSessionId, events); + const runner = activeRunners.find( + (candidate) => candidate.runnerSessionId === runnerSessionId + ); + if (!runner) return; + const overlay = buildConversationRunnerOverlay(runner, events, sessionId); + setRunnerOverlayById((previous) => { + if ( + conversationRunnerOverlaysEqual( + previous.get(runnerSessionId), + overlay + ) + ) { + return previous; + } + const next = new Map( + [...previous].filter(([id]) => activeRunnerIds.has(id)) + ); + // Keep only the current-turn projection. Holding the full native + // transcript here would pin a large imported/reused Session after the + // EventStore subscription is gone. + next.set(runnerSessionId, overlay); return next; }); }, - [] + [activeRunnerIds, activeRunners, sessionId] ); + const handleRunnerUnmount = useCallback((runnerSessionId: string) => { + setRunnerOverlayById((previous) => { + if (!previous.has(runnerSessionId)) return previous; + const next = new Map(previous); + next.delete(runnerSessionId); + return next; + }); + }, []); const value = useMemo((): SessionEvent[] | undefined => { if (overrideEvents) return overrideEvents; - const base = family - ? stitchConversationSegments( - family, - anchorBareSessionId, - chatEvents, - eventsByBareId - ) - : chatEvents; - // 0024 conversation-plane turns (every member's AND the owner's) fold - // onto the transcript by server seq — local twins keep their identity. - const timeline = - plane.events.length > 0 - ? mergePlaneIntoTranscript(base, plane.events, sessionId, viewerUserId) - : base; - // Synthetic rows merged by timestamp: the sender's live runner overlay - // and Team chat discussion. + const timeline = assembleCanonicalConversationTimeline({ + family: timelineFamily, + anchorBareSessionId, + anchorEvents: chatEvents, + eventsByBareSessionId: eventsByBareId, + planeEvents: plane.events, + planeHistoryStartedAt: plane.historyStartedAt, + comments: discussionComments, + streamSessionId: sessionId, + viewer, + ...(toSourceEventId ? { toSourceEventId } : {}), + }); + // The only UI-only addition is the sender's live runner overlay. const synthetic: SessionEvent[] = []; // Live runner overlay (sender-local, pre-tail): show the agent working. - // The runner's own user event carries the injected context prefix, so - // only its non-user tail is overlaid; ids are namespaced so they never - // collide with plane rows, and the whole overlay vanishes once the - // turnId lands on the plane above. + // The canonical optimistic row already owns the visible user message, so + // the overlay contributes only provider output. Its ids are namespaced and + // the whole overlay vanishes once the turnId lands on the plane above. for (const runner of activeRunners) { - const live = runnerEventsById.get(runner.runnerSessionId); - if (!live?.length) continue; - for (const event of live) { - if (event.source === "user") continue; - synthetic.push({ - ...event, - id: `runlive-${event.id}`, - chunk_id: `runlive-${event.id}`, - sessionId, - }); - } + const overlay = runnerOverlayById.get(runner.runnerSessionId); + if (overlay?.length) synthetic.push(...overlay); } - if ( - grouped && - toSourceEventId && - (grouped.byEventId.size > 0 || - grouped.sessionLevel.length > 0 || - grouped.orphaned.length > 0) - ) { - const bySourceId = new Map(); - for (const event of chatEvents) { - const sourceId = toSourceEventId(event.id); - if (!bySourceId.has(sourceId)) bySourceId.set(sourceId, event); - } - synthetic.push(...buildDiscussionEvents(grouped, sessionId, bySourceId)); + if (localTails.length > 0) { + return mergeConversationEvents( + suppressLandedQueuedUserRows(timeline, localTails), + [ + ...synthetic, + ...suppressLandedRowsOfFailedQueuedTurns(timeline, localTails), + ] + ); } if (synthetic.length === 0) { - return family || timeline !== base ? timeline : undefined; + return timelineFamily || + plane.events.length > 0 || + discussionComments.length > 0 + ? timeline + : undefined; } return mergeConversationEvents(timeline, synthetic); }, [ + localTails, overrideEvents, - family, + timelineFamily, anchorBareSessionId, chatEvents, eventsByBareId, sessionId, - viewerUserId, - grouped, + viewer, + discussionComments, toSourceEventId, plane.events, + plane.historyStartedAt, activeRunners, - runnerEventsById, + runnerOverlayById, ]); - return ( <> {memberTaps.map((tap) => ( @@ -331,6 +795,7 @@ export function ConversationStreamProvider({ bareSessionId={tap.bareSessionId} localSessionId={tap.localSessionId} onEvents={handleMemberEvents} + onUnmount={handleMemberUnmount} /> ))} {activeRunners.map((runner) => ( @@ -338,14 +803,17 @@ export function ConversationStreamProvider({ key={`runner-${runner.runnerSessionId}`} bareSessionId={runner.runnerSessionId} localSessionId={runner.runnerSessionId} + ingestLive={shouldIngestConversationRunnerLiveEvents( + runner.runnerSessionId, + pipelineSessionId + )} onEvents={handleRunnerEvents} + onUnmount={handleRunnerUnmount} /> ))} - - - {children} - - + + {children(activeRunnerSessionId)} + ); } diff --git a/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx b/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx index 8ca0f3d492..7c1beb8f16 100644 --- a/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx +++ b/src/engines/ChatPanel/InputArea/components/ContextInfoButton.tsx @@ -20,8 +20,10 @@ import { PILL_CONTROL_HOVER_CLASS, } from "@src/components/CompoundPill/config"; import Textarea from "@src/components/Textarea"; +import { useConversationExecutionBinding } from "@src/engines/ChatPanel/ConversationExecutionBindingContext"; import { manualCompactInFlightSessionAtom, + resolveManualCompactSessionId, useManualCompact, } from "@src/engines/ChatPanel/hooks/useManualCompact"; import { useSessionId } from "@src/engines/SessionCore/hooks/session"; @@ -162,6 +164,7 @@ const ContextInfoButton: React.FC = memo( ({ variant = "toolbar", compact = false }) => { const { t } = useTranslation(); const { sessionId } = useSessionId(); + const executionBinding = useConversationExecutionBinding(); const [housekeeperEnabled] = useSetting("housekeeper.enabled"); const [contextCompactEnabled] = useSetting( "housekeeper.features.contextCompact" @@ -305,7 +308,9 @@ const ContextInfoButton: React.FC = memo( [runManualCompact] ); - const compactDisabled = manualCompacting; + const manualCompactSupported = + resolveManualCompactSessionId(sessionId, executionBinding) !== null; + const compactDisabled = manualCompacting || !manualCompactSupported; const triggerSurfaceClass = panelPos !== null ? PILL_CONTROL_ACTIVE_SURFACE_CLASS @@ -463,6 +468,14 @@ const ContextInfoButton: React.FC = memo( {manualCompactOpen && (
+ {!manualCompactSupported && ( +

+ {t("contextInfo.manualCompactNativeProvider", { + defaultValue: + "Manual compaction here supports built-in Agent sessions. Compact native CLI history in its provider app.", + })} +

+ )}