From 6f3eb802c33ba098072bc11442cba0963c6ec8c8 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:46:43 +0800 Subject: [PATCH 01/11] fix(collaboration): unify human audience routing Route Team Chat and Work Item human mentions through one frontend audience policy, and keep Rust execution classification in parity through shared contract cases. Human and @all Work Item audiences no longer fall through to assigned Agent execution; explicit Agent targets still win mixed audiences, and human threads remain human. --- .../src/work_item_features/discussion.rs | 77 ++++++++++++---- .../src/work_item_features/tests.rs | 87 ++++++++++++++++++ .../useConversationComposer.ts | 16 +++- .../messageAudienceRouting.contract.json | 86 ++++++++++++++++++ .../messageAudienceRouting.test.ts | 35 ++++++++ .../messageAudienceRouting.ts | 89 +++++++++++++++++++ .../WorkItemContent/workItemMentions.ts | 8 +- 7 files changed, 369 insertions(+), 29 deletions(-) create mode 100644 src/features/TeamCollaboration/messageAudienceRouting.contract.json create mode 100644 src/features/TeamCollaboration/messageAudienceRouting.test.ts create mode 100644 src/features/TeamCollaboration/messageAudienceRouting.ts diff --git a/src-tauri/crates/project-management/src/work_item_features/discussion.rs b/src-tauri/crates/project-management/src/work_item_features/discussion.rs index c9490ce7a..10822ee84 100644 --- a/src-tauri/crates/project-management/src/work_item_features/discussion.rs +++ b/src-tauri/crates/project-management/src/work_item_features/discussion.rs @@ -107,26 +107,54 @@ impl RouteDecision { } } -/// Route a mention at the item's configured agent or agent org: resume its -/// latest session when one exists, otherwise start the item. -fn mention_route(mentions: &[MentionTarget], extras: &serde_json::Value) -> Option { - let addressed = mentions.iter().find_map(|mention| match mention { - MentionTarget::Agent { id } => Some(("agent", id.as_str())), - MentionTarget::AgentOrg { id } => Some(("agent_org", id.as_str())), - _ => None, - })?; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum MentionAudience<'a> { + Unaddressed, + Humans, + Agent { id: &'a str }, + AgentOrg { id: &'a str }, +} + +/// Classify identity-stable mentions before applying Work Item fallback rules. +/// Explicit Agent targets win mixed audiences; otherwise any member or `@all` +/// target makes this a human conversation and suppresses the assigned Agent. +pub(super) fn mention_audience(mentions: &[MentionTarget]) -> MentionAudience<'_> { + if let Some(agent) = mentions.iter().find_map(|mention| match mention { + MentionTarget::Agent { id } => Some(MentionAudience::Agent { id }), + MentionTarget::AgentOrg { id } => Some(MentionAudience::AgentOrg { id }), + MentionTarget::Member { .. } | MentionTarget::All => None, + }) { + return agent; + } + if mentions + .iter() + .any(|mention| matches!(mention, MentionTarget::Member { .. } | MentionTarget::All)) + { + MentionAudience::Humans + } else { + MentionAudience::Unaddressed + } +} + +/// Route the normalized mention audience. Human-directed comments deliberately +/// return a silent decision instead of falling through to the assigned Agent. +fn mention_audience_route( + mentions: &[MentionTarget], + extras: &serde_json::Value, +) -> Option { let config = orchestrator_config(extras); - let matches_config = match addressed { - ("agent", id) => { + let matches_config = match mention_audience(mentions) { + MentionAudience::Unaddressed => return None, + MentionAudience::Humans => return Some(RouteDecision::silent("human_addressed")), + MentionAudience::Agent { id } => { config .as_ref() .and_then(|config| config.agent_definition_id.as_deref()) == Some(id) } - ("agent_org", id) => { + MentionAudience::AgentOrg { id } => { config.as_ref().and_then(|config| config.org_id.as_deref()) == Some(id) } - _ => false, }; if !matches_config { return Some(RouteDecision::silent("mention_unroutable")); @@ -162,14 +190,25 @@ fn thread_route(comments: &[CommentEntry], parent_id: &str) -> Option Option { @@ -186,9 +225,9 @@ fn assignee_route(extras: &serde_json::Value) -> Option { } /// The Discussion routing decision: who a comment wakes and why. -/// Precedence: explicit target > typed agent/org mention > reply thread -/// inference > agent assignee > latest linked session. Replies whose -/// thread has no agent participation stay silent (`member_thread`). +/// Precedence: `/note` > explicit session target > typed mention audience > +/// reply thread inference > agent assignee > latest linked session. Replies to +/// an explicitly human-addressed root remain human unless an Agent has joined. pub(super) fn route_comment( content: &str, explicit_target: Option<&str>, @@ -211,7 +250,7 @@ pub(super) fn route_comment( }, ); } - if let Some(decision) = mention_route(mentions, extras) { + if let Some(decision) = mention_audience_route(mentions, extras) { return decision; } if let Some(decision) = parent_id.and_then(|parent| thread_route(comments, parent)) { diff --git a/src-tauri/crates/project-management/src/work_item_features/tests.rs b/src-tauri/crates/project-management/src/work_item_features/tests.rs index 7e4e08a39..257aac5cd 100644 --- a/src-tauri/crates/project-management/src/work_item_features/tests.rs +++ b/src-tauri/crates/project-management/src/work_item_features/tests.rs @@ -86,6 +86,43 @@ fn post_with_mentions( .expect("post Discussion comment") } +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AudienceContractCase { + name: String, + surface: String, + targets: Vec, + expected: AudienceContractExpectation, +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct AudienceContractExpectation { + agent_mode: String, +} + +#[test] +fn work_item_execution_matches_the_shared_audience_contract() { + let contract: Vec = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../src/features/TeamCollaboration/messageAudienceRouting.contract.json" + ))) + .expect("parse shared audience contract"); + + for case in contract + .into_iter() + .filter(|case| case.surface == "work_item_comment") + { + let actual_mode = match discussion::mention_audience(&case.targets) { + discussion::MentionAudience::Unaddressed => "assigned", + discussion::MentionAudience::Humans => "none", + discussion::MentionAudience::Agent { .. } + | discussion::MentionAudience::AgentOrg { .. } => "explicit", + }; + assert_eq!(actual_mode, case.expected.agent_mode, "{}", case.name); + } +} + #[test] fn discussion_comment_and_run_are_atomic_and_threads_reopen_on_reply() { let _sandbox = test_env::sandbox(); @@ -774,6 +811,56 @@ fn standalone_agent_reply_cancels_the_deferred_assignee_escalation() { assert_eq!(outbox_status, "cancelled"); } +#[test] +fn human_audiences_do_not_fall_through_to_the_assigned_agent() { + let _sandbox = test_env::sandbox(); + seed_with_config(false, "builtin:sde"); + + let member = post_with_mentions( + "comment-member", + "<@member-2> can you review this?", + None, + vec![MentionTarget::Member { + id: "member-2".to_string(), + }], + ); + assert_eq!(member.wake_reason, "human_addressed"); + assert!(member.run.is_none()); + + let everyone = post_with_mentions( + "comment-all", + "Everyone should see this.", + None, + vec![MentionTarget::All], + ); + assert_eq!(everyone.wake_reason, "human_addressed"); + assert!(everyone.run.is_none()); + + let thread_reply = post( + "comment-human-thread-reply", + "Following up on the human thread.", + Some("comment-member"), + ); + assert_eq!(thread_reply.wake_reason, "human_thread"); + assert!(thread_reply.run.is_none()); + + let mixed = post_with_mentions( + "comment-mixed", + "<@member-2> and the assigned Agent should both see this.", + None, + vec![ + MentionTarget::Member { + id: "member-2".to_string(), + }, + MentionTarget::Agent { + id: "builtin:sde".to_string(), + }, + ], + ); + assert_eq!(mixed.wake_reason, "mention_start"); + assert!(mixed.run.is_some()); +} + #[test] fn discussion_preview_reports_assignee_start() { let _sandbox = test_env::sandbox(); diff --git a/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts b/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts index e0400af43..ec739084c 100644 --- a/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts +++ b/src/features/Org2Cloud/SessionConversation/useConversationComposer.ts @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { SubmitOverrideInput } from "@src/engines/ChatPanel/hooks/useInputArea/types"; +import { resolveMessageAudience } from "@src/features/TeamCollaboration/messageAudienceRouting"; import { useSessionCommentsContext } from "../SessionComments/SessionCommentsContext"; import { @@ -51,13 +52,20 @@ export function useConversationSubmitOverride( } const body = input.displayText.trim(); if (!body) return true; - const mentionedUserIds = resolveTeamChatMentions( - body, - comments.mentionableMembers + const audience = resolveMessageAudience( + "team_chat", + resolveTeamChatMentions(body, comments.mentionableMembers).map( + (id) => ({ + kind: "member" as const, + id, + }) + ) ); await comments.addComment({ body, - ...(mentionedUserIds.length > 0 ? { mentionedUserIds } : {}), + ...(audience.human.scope === "members" + ? { mentionedUserIds: audience.human.memberIds } + : {}), }); return true; }, diff --git a/src/features/TeamCollaboration/messageAudienceRouting.contract.json b/src/features/TeamCollaboration/messageAudienceRouting.contract.json new file mode 100644 index 000000000..3eeccd228 --- /dev/null +++ b/src/features/TeamCollaboration/messageAudienceRouting.contract.json @@ -0,0 +1,86 @@ +[ + { + "name": "team chat defaults to the human channel", + "surface": "team_chat", + "targets": [], + "expected": { + "humanScope": "channel", + "memberIds": [], + "agentMode": "none" + } + }, + { + "name": "team chat can address explicit members", + "surface": "team_chat", + "targets": [{ "kind": "member", "id": "member-2" }], + "expected": { + "humanScope": "members", + "memberIds": ["member-2"], + "agentMode": "none" + } + }, + { + "name": "team chat never turns an injected agent target into execution", + "surface": "team_chat", + "targets": [{ "kind": "agent", "id": "builtin:sde" }], + "expected": { + "humanScope": "channel", + "memberIds": [], + "agentMode": "none" + } + }, + { + "name": "plain Work Item comments default to the assigned Agent", + "surface": "work_item_comment", + "targets": [], + "expected": { + "humanScope": "none", + "memberIds": [], + "agentMode": "assigned" + } + }, + { + "name": "Work Item member mentions replace the assigned Agent default", + "surface": "work_item_comment", + "targets": [{ "kind": "member", "id": "member-2" }], + "expected": { + "humanScope": "members", + "memberIds": ["member-2"], + "agentMode": "none" + } + }, + { + "name": "Work Item all mentions are human channel audience", + "surface": "work_item_comment", + "targets": [{ "kind": "all" }], + "expected": { + "humanScope": "channel", + "memberIds": [], + "agentMode": "none" + } + }, + { + "name": "Work Item agent mentions explicitly execute that Agent", + "surface": "work_item_comment", + "targets": [{ "kind": "agent", "id": "builtin:sde" }], + "expected": { + "humanScope": "none", + "memberIds": [], + "agentMode": "explicit" + } + }, + { + "name": "mixed Work Item audiences notify humans and execute the explicit Agent", + "surface": "work_item_comment", + "targets": [ + { "kind": "member", "id": "member-2" }, + { "kind": "agent", "id": "builtin:sde" }, + { "kind": "member", "id": "member-2" } + ], + "expected": { + "humanScope": "members", + "memberIds": ["member-2"], + "agentMode": "explicit" + } + } +] diff --git a/src/features/TeamCollaboration/messageAudienceRouting.test.ts b/src/features/TeamCollaboration/messageAudienceRouting.test.ts new file mode 100644 index 000000000..65e477e9a --- /dev/null +++ b/src/features/TeamCollaboration/messageAudienceRouting.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { + type MessageAudienceSurface, + type MessageAudienceTarget, + resolveMessageAudience, +} from "./messageAudienceRouting"; +import contractCases from "./messageAudienceRouting.contract.json"; + +interface AudienceContractCase { + name: string; + surface: MessageAudienceSurface; + targets: MessageAudienceTarget[]; + expected: { + humanScope: "none" | "channel" | "members"; + memberIds: string[]; + agentMode: "none" | "assigned" | "explicit"; + }; +} + +describe("resolveMessageAudience", () => { + for (const contractCase of contractCases as AudienceContractCase[]) { + it(contractCase.name, () => { + const route = resolveMessageAudience( + contractCase.surface, + contractCase.targets + ); + expect({ + humanScope: route.human.scope, + memberIds: route.human.memberIds, + agentMode: route.agent.mode, + }).toEqual(contractCase.expected); + }); + } +}); diff --git a/src/features/TeamCollaboration/messageAudienceRouting.ts b/src/features/TeamCollaboration/messageAudienceRouting.ts new file mode 100644 index 000000000..789021fe2 --- /dev/null +++ b/src/features/TeamCollaboration/messageAudienceRouting.ts @@ -0,0 +1,89 @@ +/** + * Canonical message-audience policy for collaboration composers. + * + * Identity parsing stays at each surface boundary (Team Chat resolves @labels + * against the cloud roster; Work Items decode typed mention refs). Once targets + * are identity-stable, this function is the only frontend owner of the policy: + * Team Chat is always human conversation, while a Work Item comment defaults to + * its assigned Agent unless an explicit human audience replaces that default. + */ + +export type MessageAudienceSurface = "team_chat" | "work_item_comment"; + +export type MessageAudienceTarget = + | { kind: "member"; id: string } + | { kind: "agent"; id: string } + | { kind: "agent_org"; id: string } + | { kind: "all" }; + +export type HumanAudience = + | { scope: "none"; memberIds: [] } + | { scope: "channel"; memberIds: string[] } + | { scope: "members"; memberIds: string[] }; + +export type AgentAudience = + | { mode: "none" } + | { mode: "assigned" } + | { + mode: "explicit"; + target: Extract; + }; + +export interface MessageAudienceRoute { + human: HumanAudience; + agent: AgentAudience; +} + +function uniqueMemberIds(targets: readonly MessageAudienceTarget[]): string[] { + const seen = new Set(); + const memberIds: string[] = []; + for (const target of targets) { + if (target.kind !== "member") continue; + const id = target.id.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + memberIds.push(id); + } + return memberIds; +} + +export function resolveMessageAudience( + surface: MessageAudienceSurface, + targets: readonly MessageAudienceTarget[] +): MessageAudienceRoute { + const memberIds = uniqueMemberIds(targets); + const addressesChannel = targets.some((target) => target.kind === "all"); + + if (surface === "team_chat") { + return { + human: + addressesChannel || memberIds.length === 0 + ? { scope: "channel", memberIds } + : { scope: "members", memberIds }, + agent: { mode: "none" }, + }; + } + + const explicitAgent = targets.find( + ( + target + ): target is Extract< + MessageAudienceTarget, + { kind: "agent" | "agent_org" } + > => target.kind === "agent" || target.kind === "agent_org" + ); + const human: HumanAudience = addressesChannel + ? { scope: "channel", memberIds } + : memberIds.length > 0 + ? { scope: "members", memberIds } + : { scope: "none", memberIds: [] }; + + return { + human, + agent: explicitAgent + ? { mode: "explicit", target: explicitAgent } + : human.scope === "none" + ? { mode: "assigned" } + : { mode: "none" }, + }; +} diff --git a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/workItemMentions.ts b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/workItemMentions.ts index d1562e130..d48592f96 100644 --- a/src/modules/ProjectManager/WorkItems/components/WorkItemContent/workItemMentions.ts +++ b/src/modules/ProjectManager/WorkItems/components/WorkItemContent/workItemMentions.ts @@ -1,4 +1,5 @@ import type { WorkItemMentionTarget } from "@src/api/http/project"; +import { resolveMessageAudience } from "@src/features/TeamCollaboration/messageAudienceRouting"; import type { Person } from "@src/types/core/shared"; /** @@ -82,10 +83,5 @@ export function normalizeWorkItemMentions( export function mentionedMemberIds( mentions: readonly WorkItemMentionTarget[] ): string[] { - return mentions - .filter( - (target): target is { kind: "member"; id: string } => - target.kind === "member" - ) - .map((target) => target.id); + return resolveMessageAudience("work_item_comment", mentions).human.memberIds; } From f043049875210c7948d88a32efd2da19b54fc616 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:59:28 +0800 Subject: [PATCH 02/11] feat(conversations): continue shared transcripts locally --- .../src/work_item_features/discussion.rs | 4 +- .../src/work_item_features/tests.rs | 23 +- src/app/root/E2EBootstrap.tsx | 4 + src/app/root/e2e/helpers/cloud.ts | 24 + src/app/root/e2e/helpers/runtimeDebug.ts | 12 + src/app/root/e2e/helpers/sessions.ts | 20 + src/app/root/e2e/types.ts | 4 + src/config/runtimeInstance.test.ts | 11 + src/config/runtimeInstance.ts | 4 +- src/engines/ChatPanel/ChatView.tsx | 16 +- .../ChatPanel/ChatViewPostHistoryOverlays.tsx | 12 +- .../ChatPanel/ConversationStreamProvider.tsx | 4 +- .../chatViewComposerVisibility.test.ts | 2 - .../ChatPanel/chatViewComposerVisibility.ts | 6 +- .../ChatPanel/externalHistoryFork.test.ts | 88 +- src/engines/ChatPanel/externalHistoryFork.ts | 112 +- .../hooks/useImportedSessionSubmitOverride.ts | 130 +- .../localConversationContinuation.test.ts | 559 ++++++++ .../localConversationContinuation.ts | 760 +++++++++++ .../sync/authoritativeSessionEvents.ts | 52 + .../activeConversationRunnersAtom.test.ts | 24 +- .../activeConversationRunnersAtom.ts | 15 +- .../conversationOwnerPublisher.ts | 4 +- .../conversationPlaneAtom.test.ts | 230 ++++ .../conversationPlaneAtom.ts | 224 +++- .../conversationPlaneEvents.ts | 9 +- .../conversationRunnerScope.tsx | 4 +- .../conversationTimeline.ts | 14 +- .../conversationTurnRunner.ts | 332 ++--- .../useConversationSetupPillBinding.ts | 10 +- .../Org2Cloud/org2CloudRemoteSessionsAtom.ts | 28 +- .../Org2Cloud/useOrg2CloudRealtime.test.ts | 19 + .../Org2Cloud/useOrg2CloudRealtime.ts | 10 + .../ForkSessionSetupDialog/index.tsx | 179 ++- .../modelPreselection.test.ts | 27 +- .../modelPreselection.ts | 25 +- .../engine/collabSessionFork.ts | 24 +- .../TeamCollaboration/forkSetupMemory.test.ts | 63 + .../TeamCollaboration/forkSetupMemory.ts | 31 +- .../forkWorkspaceResolution.ts | 3 + src/i18n/locales/de/navigation.json | 5 +- src/i18n/locales/en/navigation.json | 5 +- src/i18n/locales/es/navigation.json | 5 +- src/i18n/locales/fr/navigation.json | 5 +- src/i18n/locales/ja/navigation.json | 5 +- src/i18n/locales/ko/navigation.json | 5 +- src/i18n/locales/pl/navigation.json | 5 +- src/i18n/locales/pt/navigation.json | 5 +- src/i18n/locales/ru/navigation.json | 5 +- src/i18n/locales/tr/navigation.json | 5 +- src/i18n/locales/vi/navigation.json | 5 +- src/i18n/locales/zh-Hant/navigation.json | 5 +- src/i18n/locales/zh/navigation.json | 5 +- .../cloudSessionsSection.tsx | 5 - .../core/cloud-dual-instance-ui.spec.mjs | 1161 ++++++++++++----- tests/e2e/support/core/dualCloudHarness.mjs | 104 +- tests/e2e/support/core/mockApiAccountSeed.mjs | 67 + tests/e2e/wdio.conf.mjs | 65 +- 58 files changed, 3552 insertions(+), 1037 deletions(-) create mode 100644 src/engines/SessionCore/conversations/localConversationContinuation.test.ts create mode 100644 src/engines/SessionCore/conversations/localConversationContinuation.ts create mode 100644 src/engines/SessionCore/sync/authoritativeSessionEvents.ts create mode 100644 src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.test.ts create mode 100644 src/features/TeamCollaboration/forkSetupMemory.test.ts create mode 100644 tests/e2e/support/core/mockApiAccountSeed.mjs diff --git a/src-tauri/crates/project-management/src/work_item_features/discussion.rs b/src-tauri/crates/project-management/src/work_item_features/discussion.rs index 10822ee84..959bf3296 100644 --- a/src-tauri/crates/project-management/src/work_item_features/discussion.rs +++ b/src-tauri/crates/project-management/src/work_item_features/discussion.rs @@ -145,7 +145,7 @@ fn mention_audience_route( let config = orchestrator_config(extras); let matches_config = match mention_audience(mentions) { MentionAudience::Unaddressed => return None, - MentionAudience::Humans => return Some(RouteDecision::silent("human_addressed")), + MentionAudience::Humans => return Some(RouteDecision::silent("member_addressed")), MentionAudience::Agent { id } => { config .as_ref() @@ -208,7 +208,7 @@ fn thread_route(comments: &[CommentEntry], parent_id: &str) -> Option Option { diff --git a/src-tauri/crates/project-management/src/work_item_features/tests.rs b/src-tauri/crates/project-management/src/work_item_features/tests.rs index 257aac5cd..6efdf3856 100644 --- a/src-tauri/crates/project-management/src/work_item_features/tests.rs +++ b/src-tauri/crates/project-management/src/work_item_features/tests.rs @@ -812,38 +812,19 @@ fn standalone_agent_reply_cancels_the_deferred_assignee_escalation() { } #[test] -fn human_audiences_do_not_fall_through_to_the_assigned_agent() { +fn all_and_mixed_audiences_do_not_fall_through_to_the_assigned_agent() { let _sandbox = test_env::sandbox(); seed_with_config(false, "builtin:sde"); - let member = post_with_mentions( - "comment-member", - "<@member-2> can you review this?", - None, - vec![MentionTarget::Member { - id: "member-2".to_string(), - }], - ); - assert_eq!(member.wake_reason, "human_addressed"); - assert!(member.run.is_none()); - let everyone = post_with_mentions( "comment-all", "Everyone should see this.", None, vec![MentionTarget::All], ); - assert_eq!(everyone.wake_reason, "human_addressed"); + assert_eq!(everyone.wake_reason, "member_addressed"); assert!(everyone.run.is_none()); - let thread_reply = post( - "comment-human-thread-reply", - "Following up on the human thread.", - Some("comment-member"), - ); - assert_eq!(thread_reply.wake_reason, "human_thread"); - assert!(thread_reply.run.is_none()); - let mixed = post_with_mentions( "comment-mixed", "<@member-2> and the assigned Agent should both see this.", diff --git a/src/app/root/E2EBootstrap.tsx b/src/app/root/E2EBootstrap.tsx index 5ed53ae01..2b4782d44 100644 --- a/src/app/root/E2EBootstrap.tsx +++ b/src/app/root/E2EBootstrap.tsx @@ -226,6 +226,7 @@ export const E2EBootstrap: FC = () => { launchSession, getSessionAggregateRow, getSessionAggregateRowFromList, + listSessionChildren, findSessionAggregateByWorkItem, seedChatEvents, seedPersistedCachedSession, @@ -253,6 +254,7 @@ export const E2EBootstrap: FC = () => { } = createSessionHelpers(store); const { + focusAppWindow, debugSessionSecuritySnapshot, debugSessionValidateCommand, debugSessionSubagentSnapshot, @@ -433,6 +435,7 @@ export const E2EBootstrap: FC = () => { reloadSessionList, primeSidebarEntityCache, inspectSidebarPagination, + focusAppWindow, debugSessionSecuritySnapshot, debugSessionValidateCommand, debugSessionSubagentSnapshot, @@ -444,6 +447,7 @@ export const E2EBootstrap: FC = () => { launchSession, getSessionAggregateRow, getSessionAggregateRowFromList, + listSessionChildren, findSessionAggregateByWorkItem, seedChatEvents, seedPersistedCachedSession, diff --git a/src/app/root/e2e/helpers/cloud.ts b/src/app/root/e2e/helpers/cloud.ts index 048a399d4..b60ded1b7 100644 --- a/src/app/root/e2e/helpers/cloud.ts +++ b/src/app/root/e2e/helpers/cloud.ts @@ -27,6 +27,11 @@ import { invalidateProjectCache, projectApi } from "@src/api/http/project"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import { cloudSyncLevelSessionAtom } from "@src/features/Org2Cloud/CloudSyncLevelDialog/useCloudSyncLevelDialog"; +import { + conversationPlaneAtom, + conversationPlaneKey, + conversationPlaneSignalAtom, +} from "@src/features/Org2Cloud/SessionConversation/conversationPlaneAtom"; import { collectAddressableThreads } from "@src/features/Org2Cloud/addressComments"; import { org2CloudSharingFloorAtom } from "@src/features/Org2Cloud/org2CloudAccessSettings"; import { org2CloudAuthAtom } from "@src/features/Org2Cloud/org2CloudAuthAtom"; @@ -502,6 +507,12 @@ export function createCloudHelpers({ store }: CloudHelperDeps) { const addressableThreads = collectAddressableThreads( commentEntry?.comments ?? [] ); + const conversationKey = target + ? conversationPlaneKey(target.orgId, target.sessionId) + : null; + const conversationEntry = conversationKey + ? store.get(conversationPlaneAtom)[conversationKey] + : undefined; return { ok: true, debug: { @@ -518,6 +529,19 @@ export function createCloudHelpers({ store }: CloudHelperDeps) { cloudOrgIds: cloudOrgIdsForSession(tags, opts.sessionId), preferredOrgId, target, + conversationPlane: target + ? { + state: conversationEntry?.state ?? "missing", + lastSeq: conversationEntry?.lastSeq ?? null, + eventCount: conversationEntry?.events.length ?? 0, + eventIds: + conversationEntry?.events.map((event) => event.id) ?? [], + turnIds: + conversationEntry?.events.map((event) => event.turnId) ?? [], + signalVersion: + store.get(conversationPlaneSignalAtom)[target.orgId] ?? 0, + } + : null, comments: commentEntry ? { state: commentEntry.state, diff --git a/src/app/root/e2e/helpers/runtimeDebug.ts b/src/app/root/e2e/helpers/runtimeDebug.ts index dd675a384..b4ec8fb93 100644 --- a/src/app/root/e2e/helpers/runtimeDebug.ts +++ b/src/app/root/e2e/helpers/runtimeDebug.ts @@ -1,9 +1,20 @@ import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { asError } from "../result"; import type { Json, Result } from "../types"; export function createRuntimeDebugHelpers() { + const focusAppWindow = async (): Promise> => { + try { + const currentWindow = getCurrentWindow(); + await currentWindow.setFocus(); + return { ok: true, focused: true }; + } catch (err) { + return asError(err); + } + }; + const debugSessionSecuritySnapshot = async ( sessionId: string ): Promise> => { @@ -171,6 +182,7 @@ export function createRuntimeDebugHelpers() { }; return { + focusAppWindow, debugSessionSecuritySnapshot, debugSessionValidateCommand, debugSessionSubagentSnapshot, diff --git a/src/app/root/e2e/helpers/sessions.ts b/src/app/root/e2e/helpers/sessions.ts index 2fed1c21f..28deb2a7a 100644 --- a/src/app/root/e2e/helpers/sessions.ts +++ b/src/app/root/e2e/helpers/sessions.ts @@ -616,6 +616,25 @@ export function createSessionHelpers(store: E2EStore) { } }; + const listSessionChildren = async ( + parentSessionId: string + ): Promise> => { + try { + if (!parentSessionId) { + return { + ok: false, + error: "listSessionChildren: `parentSessionId` is required", + }; + } + const sessions = await invoke("es_get_child_sessions", { + parentSessionId, + }); + return { ok: true, sessions }; + } catch (err) { + return asError(err); + } + }; + const findSessionAggregateByWorkItem = async ( workItemId: string ): Promise> => { @@ -838,6 +857,7 @@ export function createSessionHelpers(store: E2EStore) { launchSession, getSessionAggregateRow, getSessionAggregateRowFromList, + listSessionChildren, findSessionAggregateByWorkItem, seedSessionContextUsage, seedPersistedCachedSession, diff --git a/src/app/root/e2e/types.ts b/src/app/root/e2e/types.ts index 97457f93b..3f2590982 100644 --- a/src/app/root/e2e/types.ts +++ b/src/app/root/e2e/types.ts @@ -471,6 +471,7 @@ export interface E2EHelpers { ) => Promise>; resetToNewSession: () => Promise<{ ok: true } | Err>; openSession: (sessionId: string) => Promise>; + focusAppWindow: () => Promise>; debugSessionSecuritySnapshot: ( sessionId: string ) => Promise>; @@ -524,6 +525,9 @@ export interface E2EHelpers { getSessionAggregateRowFromList: ( sessionId: string ) => Promise>; + listSessionChildren: ( + parentSessionId: string + ) => Promise>; findSessionAggregateByWorkItem: ( workItemId: string ) => Promise>; diff --git a/src/config/runtimeInstance.test.ts b/src/config/runtimeInstance.test.ts index 24638fb69..d0d5cb5e6 100644 --- a/src/config/runtimeInstance.test.ts +++ b/src/config/runtimeInstance.test.ts @@ -23,6 +23,17 @@ describe("runtimeInstanceProfileForIdentifier", () => { }); }); + it("keeps the WebDriver identifier on the same isolated profile", () => { + expect( + runtimeInstanceProfileForIdentifier("org2ai.org2.e2e.instance2") + ).toEqual({ + instanceId: 2, + ideServerPort: 13_848, + cliProxyPort: 17_889, + authDeepLinkScheme: "orgii-instance2", + }); + }); + it("falls back for malformed and unbounded identifiers", () => { for (const identifier of [ "org2ai.org2.instance1", diff --git a/src/config/runtimeInstance.ts b/src/config/runtimeInstance.ts index a5dcbd6b5..03228f62e 100644 --- a/src/config/runtimeInstance.ts +++ b/src/config/runtimeInstance.ts @@ -16,7 +16,9 @@ const PRIMARY_CLI_PROXY_PORT = 17_888; export function runtimeInstanceProfileForIdentifier( identifier: string ): RuntimeInstanceProfile { - const match = /^org2ai\.org2\.instance(\d+)$/.exec(identifier.trim()); + const match = /^org2ai\.org2\.(?:e2e\.)?instance(\d+)$/.exec( + identifier.trim() + ); const parsedId = match ? Number(match[1]) : 1; const instanceId = Number.isInteger(parsedId) && parsedId >= 2 && parsedId <= 99 diff --git a/src/engines/ChatPanel/ChatView.tsx b/src/engines/ChatPanel/ChatView.tsx index f0432cd5a..e281fac9b 100644 --- a/src/engines/ChatPanel/ChatView.tsx +++ b/src/engines/ChatPanel/ChatView.tsx @@ -33,7 +33,6 @@ import React, { } 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"; @@ -191,17 +190,16 @@ const ChatView: React.FC = memo( }); // 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. + // composer below is still interactive, but submitting it creates a + // target-provider native continuation after the shared local runtime / + // workspace picker — it never writes back into the imported source. 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); + // Every imported transcript can continue in any installed local runtime. + // Sources that also expose an exact same-provider native resume retain + // that separate header action. const handleExternalHistoryForkSubmit = useCallback( async (input: SubmitOverrideInput) => { if (!isImportedHistory) return false; @@ -308,7 +306,6 @@ const ChatView: React.FC = memo( hasBlockingDownloadSurface, isImportedHistory, readOnly, - canResume: Boolean(importedCliResume), }); const showMainComposer = shouldShowMainChatComposer({ showInteractArea, @@ -551,7 +548,6 @@ const ChatView: React.FC = memo( onSubmitOverride={handleExternalHistoryForkSubmit} externalScrollToBottomButton={externalScrollToBottomButton} isImportedHistory={isImportedHistory} - sessionId={sessionId} /> {showMainComposer && ( Promise; 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({ @@ -38,16 +34,10 @@ export function ChatViewPostHistoryOverlays({ onSubmitOverride, 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 ?? "" } + "collaboration.continueCli.composerPlaceholder" ); return ( diff --git a/src/engines/ChatPanel/ConversationStreamProvider.tsx b/src/engines/ChatPanel/ConversationStreamProvider.tsx index 2f29567d0..0fe1de8f0 100644 --- a/src/engines/ChatPanel/ConversationStreamProvider.tsx +++ b/src/engines/ChatPanel/ConversationStreamProvider.tsx @@ -8,6 +8,7 @@ import { activeConversationRunnersAtom, collectLandedTurnIds, selectActiveRunners, + selectConversationRunnerTail, } from "@src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom"; import { type ConversationFamilyMember, @@ -277,8 +278,7 @@ export function ConversationStreamProvider({ 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; + for (const event of selectConversationRunnerTail(runner, live)) { synthetic.push({ ...event, id: `runlive-${event.id}`, diff --git a/src/engines/ChatPanel/chatViewComposerVisibility.test.ts b/src/engines/ChatPanel/chatViewComposerVisibility.test.ts index 26a052ef9..b3007474b 100644 --- a/src/engines/ChatPanel/chatViewComposerVisibility.test.ts +++ b/src/engines/ChatPanel/chatViewComposerVisibility.test.ts @@ -28,7 +28,6 @@ describe("chat view composer visibility", () => { shouldShowExternalHistoryForkComposer({ isImportedHistory: true, readOnly: false, - canResume: true, hasBlockingDownloadSurface: true, }) ).toBe(false); @@ -36,7 +35,6 @@ describe("chat view composer visibility", () => { shouldShowExternalHistoryForkComposer({ isImportedHistory: true, readOnly: false, - canResume: true, hasBlockingDownloadSurface: false, }) ).toBe(true); diff --git a/src/engines/ChatPanel/chatViewComposerVisibility.ts b/src/engines/ChatPanel/chatViewComposerVisibility.ts index 44095e3e6..a371ce734 100644 --- a/src/engines/ChatPanel/chatViewComposerVisibility.ts +++ b/src/engines/ChatPanel/chatViewComposerVisibility.ts @@ -18,15 +18,11 @@ export function shouldShowMainChatComposer({ export function shouldShowExternalHistoryForkComposer({ isImportedHistory, readOnly, - canResume, hasBlockingDownloadSurface, }: { isImportedHistory: boolean; readOnly: boolean; - canResume: boolean; hasBlockingDownloadSurface: boolean; }): boolean { - return ( - !hasBlockingDownloadSurface && isImportedHistory && !readOnly && canResume - ); + return !hasBlockingDownloadSurface && isImportedHistory && !readOnly; } diff --git a/src/engines/ChatPanel/externalHistoryFork.test.ts b/src/engines/ChatPanel/externalHistoryFork.test.ts index 1e3f8be6c..ee44f0102 100644 --- a/src/engines/ChatPanel/externalHistoryFork.test.ts +++ b/src/engines/ChatPanel/externalHistoryFork.test.ts @@ -4,6 +4,8 @@ import { type ImportedHistorySource, getImportedHistorySourceBySessionId, } from "@src/api/tauri/externalHistory"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { SessionService } from "@src/engines/SessionCore/services/SessionService"; import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; @@ -20,6 +22,9 @@ vi.mock("@src/api/tauri/externalHistory", () => ({ vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ SessionService: { create: vi.fn() }, })); +vi.mock("@src/engines/SessionCore/ingestion/rustBridge", () => ({ + processChunksRust: vi.fn(), +})); vi.mock("@src/features/TeamCollaboration/forkSession", () => ({ requestForkSessionSetup: vi.fn(), })); @@ -43,31 +48,45 @@ function chunk( }; } +function event( + id: string, + source: SessionEvent["source"], + text: string, + actionType = source === "user" ? "raw" : "assistant_message" +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "imported", + createdAt: "2026-07-13T00:00:00.000Z", + functionName: source === "user" ? "user_message" : "assistant_message", + uiCanonical: source === "user" ? "user_message" : "agent_message", + actionType, + args: {}, + result: { message: { content: text, role: source }, content: text }, + source, + displayText: text, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + describe("buildExternalHistoryHandoffPrompt", () => { - it("works for every registered source label and excludes private reasoning", () => { + it("keeps the complete visible transcript and excludes private reasoning", () => { + const long = "x".repeat(25_000); const prompt = buildExternalHistoryHandoffPrompt( [ - chunk("u1", "raw", "user_message", { message: "fix the sync" }), - chunk("r1", "reasoning", "thinking", { - content: "private chain of thought", - }), - { - ...chunk("t1", "tool_call", "read_file", { output: "old file" }), - args: { path: "src/sync.ts" }, - }, - chunk("a1", "assistant_message", "assistant_message", { - content: "I found the issue", - }), + event("u1", "user", long), + event("r1", "assistant", "private chain of thought", "reasoning"), + event("a1", "assistant", "I found the issue"), ], - "continue and verify it", - "Claude App" + "continue and verify it" ); - expect(prompt).toContain("imported Claude App history"); - expect(prompt).toContain("User: fix the sync"); - expect(prompt).toContain("[Imported Claude App action]"); - expect(prompt).toContain("Tool: read_file"); - expect(prompt).toContain("Assistant: I found the issue"); + expect(prompt).toContain(long); + expect(prompt).toContain("Assistant:\nI found the issue"); expect(prompt).toContain("continue and verify it"); expect(prompt).not.toContain("private chain of thought"); }); @@ -107,6 +126,9 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { loadFullTranscriptChunks.mockResolvedValue([ chunk("u1", "user_message", "user_message", { message: "old ask" }), ]); + vi.mocked(processChunksRust).mockResolvedValue([ + event("u1", "user", "old ask"), + ]); vi.mocked(SessionService.create).mockResolvedValue({ sessionId: "agentsession-forked", }); @@ -156,7 +178,12 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { sourceTitle: "Imported review", sourceScopeKey: "github.com/org/repo", sourceModel: "gpt-source", + allowCliRuntime: true, }); + expect(processChunksRust).toHaveBeenCalledWith( + expect.any(Array), + "codexapp-source-1" + ); expect(SessionService.create).toHaveBeenCalledTimes(1); expect(SessionService.create).toHaveBeenCalledWith( expect.objectContaining({ @@ -206,6 +233,29 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { ); }); + it("can create the continuation in an installed external CLI", async () => { + vi.mocked(requestForkSessionSetup).mockResolvedValueOnce({ + workspaceRepoPath: "/local/repo", + execution: { + agentDefinitionId: "builtin:sde", + cliAgentType: "claude_code", + }, + }); + + await forkExternalHistoryIntoOrgiiSession({ + sourceSessionId: "codexapp-source-1", + userMessage: "continue in Claude", + }); + + expect(SessionService.create).toHaveBeenCalledWith( + expect.objectContaining({ + cliAgentType: "claude_code", + repoPath: "/local/repo", + task: expect.stringContaining("old ask"), + }) + ); + }); + it("does not load or create anything when the shared setup is cancelled", async () => { vi.mocked(requestForkSessionSetup).mockRejectedValueOnce( new Error("cancelled") diff --git a/src/engines/ChatPanel/externalHistoryFork.ts b/src/engines/ChatPanel/externalHistoryFork.ts index 202c3fc33..1a8faccee 100644 --- a/src/engines/ChatPanel/externalHistoryFork.ts +++ b/src/engines/ChatPanel/externalHistoryFork.ts @@ -1,109 +1,17 @@ import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; +import { buildCanonicalConversationHandoff } from "@src/engines/SessionCore/conversations/localConversationContinuation"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { SessionService } from "@src/engines/SessionCore/services/SessionService"; import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; import type { Session } from "@src/store/session"; -import type { ActivityChunk } from "@src/types/session/session"; - -const MAX_HISTORY_ITEMS = 80; -const MAX_TEXT_LENGTH = 1200; - -function textValue(value: unknown): string | undefined { - if (typeof value === "string") { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : undefined; - } - if (Array.isArray(value)) { - const parts = value.map(textValue).filter(Boolean); - return parts.length > 0 ? parts.join("\n") : undefined; - } - if (value && typeof value === "object") { - const object = value as Record; - return ( - textValue(object.text) ?? - textValue(object.content) ?? - textValue(object.message) ?? - textValue(object.output) ?? - textValue(object.summary) - ); - } - return undefined; -} - -function truncateText(text: string): string { - return text.length > MAX_TEXT_LENGTH - ? `${text.slice(0, MAX_TEXT_LENGTH)}…` - : text; -} - -function summarizeToolChunk( - chunk: ActivityChunk, - sourceName: string -): string | undefined { - const functionName = chunk.function || "unknown_tool"; - const argsText = textValue(chunk.args); - const resultText = textValue(chunk.result); - const lines = [`[Imported ${sourceName} action]`, `Tool: ${functionName}`]; - if (argsText) lines.push(`Input: ${truncateText(argsText)}`); - if (resultText) - lines.push(`Result at that time: ${truncateText(resultText)}`); - return lines.join("\n"); -} - -function chunkToHandoffItem( - chunk: ActivityChunk, - sourceName: string -): string | undefined { - const actionType = chunk.action_type; - if (actionType.includes("thinking") || actionType.includes("reasoning")) { - return undefined; - } - - const resultText = textValue(chunk.result); - const argsText = textValue(chunk.args); - const content = resultText ?? argsText; - - if (actionType === "user_message" || chunk.function === "user_message") { - return content ? `User: ${truncateText(content)}` : undefined; - } - if ( - actionType === "assistant_message" || - actionType === "llm_response" || - chunk.function === "assistant_message" - ) { - return content ? `Assistant: ${truncateText(content)}` : undefined; - } - if (actionType === "tool_call" || actionType.includes("tool")) { - return summarizeToolChunk(chunk, sourceName); - } - - return content ? `Assistant context: ${truncateText(content)}` : undefined; -} export function buildExternalHistoryHandoffPrompt( - chunks: ActivityChunk[], - userMessage: string, - sourceName: string + events: readonly SessionEvent[], + userMessage: string ): string { - const items = chunks - .map((chunk) => chunkToHandoffItem(chunk, sourceName)) - .filter((item): item is string => Boolean(item)) - .slice(-MAX_HISTORY_ITEMS); - - return [ - `You are continuing work from an imported ${sourceName} history inside a new ORGII-owned session.`, - `The imported ${sourceName} history is read-only historical context. Do not treat its tool calls as ORGII-executed tools or current workspace state.`, - "Imported tool results may be stale; verify files, commands, and failures against the selected workspace before relying on them.", - "Reasoning/thinking chunks were intentionally skipped.", - "", - `## Imported ${sourceName} handoff context`, - items.length > 0 - ? items.join("\n\n") - : "No usable transcript items were found.", - "", - "## User request to continue in ORGII", - userMessage, - ].join("\n"); + return buildCanonicalConversationHandoff(events, userMessage); } export async function forkExternalHistoryIntoOrgiiSession(params: { @@ -140,12 +48,13 @@ export async function forkExternalHistoryIntoOrgiiSession(params: { sourceTitle: params.sourceSession?.name || `${source.displayName} history`, sourceScopeKey: sourceScopeKeys?.[0], sourceModel: params.sourceSession?.model, + allowCliRuntime: true, }); const chunks = await source.loadFullTranscriptChunks(params.sourceSessionId); + const events = await processChunksRust(chunks, params.sourceSessionId); const content = buildExternalHistoryHandoffPrompt( - chunks, - params.agentMessage ?? params.userMessage, - source.displayName + events, + params.agentMessage ?? params.userMessage ); // This continuation is a normal top-level ORGII session. `parentSessionId` // is reserved for real subagents and would hide the continuation from the @@ -158,6 +67,7 @@ export async function forkExternalHistoryIntoOrgiiSession(params: { repoPath: setup.workspaceRepoPath ?? undefined, model: setup.execution.model, accountId: setup.execution.accountId, + cliAgentType: setup.execution.cliAgentType, keySource: "own_key", agentDefinitionId: setup.execution.agentDefinitionId, mode: "build", diff --git a/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts b/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts index ec9bb3d14..6376ca575 100644 --- a/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts +++ b/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts @@ -3,6 +3,7 @@ import { useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import Message from "@src/components/Message"; +import { buildCanonicalConversationUpdate } from "@src/engines/SessionCore/conversations/localConversationContinuation"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; @@ -18,18 +19,11 @@ import { conversationPlaneAtom, conversationPlaneKey, conversationPlaneSignalAtom, + ensureConversationPlaneEntry, } from "@src/features/Org2Cloud/SessionConversation/conversationPlaneAtom"; import { buildConversationPlaneStreamEvents } from "@src/features/Org2Cloud/SessionConversation/conversationPlaneEvents"; import { mergePlaneIntoTranscript } from "@src/features/Org2Cloud/SessionConversation/conversationTimeline"; -import { - buildRunnerPrompt, - renderConversationContext, - runConversationTurn, -} from "@src/features/Org2Cloud/SessionConversation/conversationTurnRunner"; -import { - org2CloudAccessSettingsAtom, - withCloudSessionMode, -} from "@src/features/Org2Cloud/org2CloudAccessSettings"; +import { runConversationTurn } from "@src/features/Org2Cloud/SessionConversation/conversationTurnRunner"; import { commitRefreshedAuth, org2CloudAuthAtom, @@ -42,7 +36,6 @@ import type { ForkImportedErrorKind } from "@src/features/TeamCollaboration/useF import { useForkImportedSession } from "@src/features/TeamCollaboration/useForkImportedSession"; import { createLogger } from "@src/hooks/logger"; import { useSessionView } from "@src/hooks/ui/tabs/useSessionView"; -import { COLLAB_SESSION_ACCESS_MODE } from "@src/store/collaboration/types"; import type { Session } from "@src/store/session"; import { sessionsAtom } from "@src/store/session"; import { restoreToInputAtom } from "@src/store/session/cliSessionStatusAtom"; @@ -154,14 +147,13 @@ export function useImportedSessionSubmitOverride({ ); // CONVERSATION PLANE (0024): once the backend supports the multi-writer - // turn plane, implicit sends stop forking entirely — a member's turn runs - // in an invisible one-shot local session and publishes to the plane; the - // owner's sends keep their own session but inject the plane delta as - // context. The fork/tip paths below remain ONLY as the pre-0024 fallback. + // turn plane, implicit sends stop forking visible Team rows. A member's + // active device continues a durable local child Session and publishes the + // resulting turn to the canonical plane. The fork/tip paths below remain + // only as the pre-0024 fallback. const setAuth = useSetAtom(org2CloudAuthAtom); const planeEntries = useAtomValue(conversationPlaneAtom); const setPlaneSignal = useSetAtom(conversationPlaneSignalAtom); - const setAccessSettings = useSetAtom(org2CloudAccessSettingsAtom); const setActiveRunners = useSetAtom(activeConversationRunnersAtom); const conversationRootId = useMemo(() => { if (lineage) return lineage.rootSessionId ?? lineage.sourceSessionId; @@ -242,11 +234,49 @@ export function useImportedSessionSubmitOverride({ return useCallback( async (input: SubmitOverrideInput): Promise => { - const planeReady = planeInfo?.entry.state === "ready"; + let activePlaneInfo = planeInfo; + // The transcript fetch and the first submit can race on a freshly + // imported conversation. Resolve that capability/fetch decision here + // too: a capable backend must not silently create a legacy visible fork + // just because its plane entry was still idle/loading at click time. + if ( + familyOrgId && + conversationRootId && + activePlaneInfo?.entry.state !== "unsupported" + ) { + if (!auth) { + restorePendingDraft(input, sessionId); + return true; + } + const key = conversationPlaneKey(familyOrgId, conversationRootId); + const store = getInstrumentedStore(); + try { + const entry = await ensureConversationPlaneEntry({ + auth, + orgId: familyOrgId, + rootSessionId: conversationRootId, + getEntry: () => store.get(conversationPlaneAtom)[key], + setEntries: (update) => store.set(conversationPlaneAtom, update), + setAuth: (update) => store.set(org2CloudAuthAtom, update), + }); + activePlaneInfo = { + orgId: familyOrgId, + rootId: conversationRootId, + entry, + }; + } catch (error) { + logger.error("conversation plane readiness failed", error); + restorePendingDraft(input, sessionId); + Message.error(t("collaboration.forkImported.sendFailed")); + return true; + } + } + const planeReady = activePlaneInfo?.entry.state === "ready"; // (a) Member send on a plane-capable backend: publish the message to - // the conversation immediately, run the turn in an invisible one-shot - // local session, stream the agent tail back to the plane. No fork. - if (planeReady && planeInfo && !viewerOwnsRoot) { + // the conversation immediately, continue the sender's durable local + // execution Session, then stream the agent tail back to the plane. + if (planeReady && activePlaneInfo && !viewerOwnsRoot) { + const readyPlaneInfo = activePlaneInfo; if (forkSubmitInFlightRef.current) { restorePendingDraft(input, sessionId); return true; @@ -259,12 +289,12 @@ export function useImportedSessionSubmitOverride({ commitRefreshedAuth(setAuth, auth, freshAuth); const rootLocal = sessions.find( - (candidate) => candidate.session_id === planeInfo.rootId + (candidate) => candidate.session_id === readyPlaneInfo.rootId ) ?? findImportedSession( sessions, - planeInfo.orgId, - planeInfo.rootId, + readyPlaneInfo.orgId, + readyPlaneInfo.rootId, auth.supabaseUrl ); const rootEvents = rootLocal @@ -274,7 +304,7 @@ export function useImportedSessionSubmitOverride({ : []; const timeline = mergePlaneIntoTranscript( rootEvents, - planeInfo.entry.events, + readyPlaneInfo.entry.events, sessionId, auth.userId ); @@ -283,7 +313,8 @@ export function useImportedSessionSubmitOverride({ // workspace-requiring agent cannot launch at all. const rootRow = familyOrgId ? remoteEntries[familyOrgId]?.rows?.find( - (candidate) => candidate.sourceSessionId === planeInfo.rootId + (candidate) => + candidate.sourceSessionId === readyPlaneInfo.rootId ) : undefined; let publishResolve!: () => void; @@ -296,22 +327,22 @@ export function useImportedSessionSubmitOverride({ if (!runnerSessionId) return; liveRunnerSessionId = null; setActiveRunners((current) => { - const list = current[planeInfo.rootId]; + const list = current[readyPlaneInfo.rootId]; if (!list) return current; const kept = list.filter( (runner) => runner.runnerSessionId !== runnerSessionId ); if (kept.length === list.length) return current; const next = { ...current }; - if (kept.length === 0) delete next[planeInfo.rootId]; - else next[planeInfo.rootId] = kept; + if (kept.length === 0) delete next[readyPlaneInfo.rootId]; + else next[readyPlaneInfo.rootId] = kept; return next; }); }; const turnPromise = runConversationTurn({ getAccessToken, - orgId: planeInfo.orgId, - rootSessionId: planeInfo.rootId, + orgId: readyPlaneInfo.orgId, + rootSessionId: readyPlaneInfo.rootId, conversationTitle: currentSession?.name ?? rootLocal?.name ?? "Conversation", displayText: input.displayText, @@ -320,31 +351,25 @@ export function useImportedSessionSubmitOverride({ timeline, sourceScopeKey: rootRow?.repoScopeKey, sourceModel: currentSession?.model ?? rootRow?.model, - onRunnerReady: (runnerSessionId, turnId) => { - // Plumbing session: never sync it to the cloud as a session. - setAccessSettings((current) => - withCloudSessionMode( - current, - planeInfo.orgId, - runnerSessionId, - COLLAB_SESSION_ACCESS_MODE.OFF - ) - ); - // Overlay the runner's LIVE events (thinking / tools / worked-for) + onRunnerReady: (runnerSessionId, turnId, eventStartIndex) => { + // Overlay the local execution's LIVE events (thinking / tools / worked-for) // into the conversation until the plane carries this turn's // agent tail — or the turn settles without one. liveRunnerSessionId = runnerSessionId; setActiveRunners((current) => { - const list = current[planeInfo.rootId] ?? []; + const list = current[readyPlaneInfo.rootId] ?? []; return { ...current, - [planeInfo.rootId]: [...list, { runnerSessionId, turnId }], + [readyPlaneInfo.rootId]: [ + ...list, + { runnerSessionId, turnId, eventStartIndex }, + ], }; }); }, onUserMessagePublished: publishResolve, onPushed: () => - bumpConversationPlaneSignal(setPlaneSignal, planeInfo.orgId), + bumpConversationPlaneSignal(setPlaneSignal, readyPlaneInfo.orgId), }); // The composer unblocks as soon as the user's words are on the // plane; the agent tail continues in the background. @@ -374,22 +399,21 @@ export function useImportedSessionSubmitOverride({ // context prefix — the owner's own turns are already its history), // and the turn is PUBLISHED to the plane under a turnId exactly like // a member turn, so every turn of the conversation has a seq. - if (planeReady && planeInfo && viewerOwnsRoot) { + if (planeReady && activePlaneInfo && viewerOwnsRoot) { + const readyPlaneInfo = activePlaneInfo; // Group-chat routing owns its own sends. if (await onFallbackSubmit(input)) return true; if (!auth) return false; const freshAuth = await ensureFreshSession(auth); if (!freshAuth) return false; commitRefreshedAuth(setAuth, auth, freshAuth); - const othersRows = planeInfo.entry.events.filter( + const othersRows = readyPlaneInfo.entry.events.filter( (row) => row.authorUserId !== auth.userId ); const agentContent = othersRows.length > 0 - ? buildRunnerPrompt( - renderConversationContext( - buildConversationPlaneStreamEvents(othersRows, sessionId) - ), + ? buildCanonicalConversationUpdate( + buildConversationPlaneStreamEvents(othersRows, sessionId), input.agentContent ?? input.displayText ) : input.agentContent; @@ -413,13 +437,13 @@ export function useImportedSessionSubmitOverride({ } void publishOwnerTurn({ getAccessToken, - orgId: planeInfo.orgId, - rootSessionId: planeInfo.rootId, + orgId: readyPlaneInfo.orgId, + rootSessionId: readyPlaneInfo.rootId, sessionId, turnIntentId, displayText: input.displayText, onPushed: () => - bumpConversationPlaneSignal(setPlaneSignal, planeInfo.orgId), + bumpConversationPlaneSignal(setPlaneSignal, readyPlaneInfo.orgId), }).catch((error: unknown) => { logger.warn("owner turn publish failed", error); }); @@ -533,6 +557,7 @@ export function useImportedSessionSubmitOverride({ currentSession?.importedFrom, currentSession?.name, currentSession?.model, + conversationRootId, familyOrgId, forkImportedSession, getAccessToken, @@ -545,7 +570,6 @@ export function useImportedSessionSubmitOverride({ restorePendingDraft, sessionId, sessions, - setAccessSettings, setActiveRunners, setAuth, setPlaneSignal, diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.test.ts b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts new file mode 100644 index 000000000..867cf6d61 --- /dev/null +++ b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts @@ -0,0 +1,559 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + CONVERSATION_TURN_ID_ARG, + continueLocalConversation, + conversationExecutionParentId, + conversationPrefixHash, + localConversationQueueSizeForTests, + readDurableConversationTerminal, + renderCanonicalConversation, + withLocalConversationQueue, +} from "./localConversationContinuation"; + +const mocks = vi.hoisted(() => ({ + getAgentSession: vi.fn(), + cliStatus: vi.fn(), + invokeTauri: vi.fn(), + create: vi.fn(), + sendMessage: vi.fn(), + loadEvents: vi.fn(), + getTerminal: vi.fn(), + markTerminal: vi.fn(), +})); + +vi.mock("@src/api/tauri/agent", () => ({ + getSession: mocks.getAgentSession, +})); +vi.mock("@src/api/tauri/rpc", () => ({ + rpc: { cli: { status: mocks.cliStatus } }, +})); +vi.mock("@src/util/platform/tauri/init", () => ({ + invokeTauri: mocks.invokeTauri, +})); +vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ + SessionService: { + create: mocks.create, + sendMessage: mocks.sendMessage, + }, +})); +vi.mock("@src/engines/SessionCore/sync/authoritativeSessionEvents", () => ({ + loadAuthoritativeSessionEvents: mocks.loadEvents, +})); +vi.mock("@src/engines/SessionCore/control/turnLifecycle", async () => { + const { atom } = await import("jotai"); + return { + beginTurnDispatch: vi.fn(() => 3), + confirmTurnRunning: vi.fn(), + getLastTurnTerminal: mocks.getTerminal, + markTurnTerminal: mocks.markTerminal, + toTurnTerminalStatus: (status: string) => + status === "failed" || status === "error" || status === "timeout" + ? "failed" + : status === "cancelled" || status === "abandoned" + ? "cancelled" + : "completed", + turnLifecycleSignalAtom: atom(0), + }; +}); +vi.mock("@src/util/core/state/instrumentedStore", () => ({ + getInstrumentedStore: () => ({ sub: vi.fn(() => () => undefined) }), +})); + +function event( + id: string, + source: SessionEvent["source"], + text: string, + options: { actionType?: string; turnId?: string } = {} +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "root", + createdAt: `2026-08-26T00:00:0${id.length}.000Z`, + functionName: source === "user" ? "user_message" : "assistant", + uiCanonical: source === "user" ? "user_message" : "agent_message", + actionType: options.actionType ?? (source === "user" ? "raw" : "assistant"), + args: options.turnId ? { [CONVERSATION_TURN_ID_ARG]: options.turnId } : {}, + result: { + message: { content: text, role: source }, + content: text, + }, + source, + displayText: text, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function localUser(sessionId: string, content: string): SessionEvent { + return { ...event(`user-${sessionId}`, "user", content), sessionId }; +} + +const root = { + authority: "org2-cloud", + authorityScope: ["org-1"], + conversationId: "root-1", +}; + +const target = { + agentDefinitionId: "builtin:sde", + accountId: "account-1", + model: "model-1", + workspaceRepoPath: "/repo", +}; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.invokeTauri.mockResolvedValue([]); + mocks.create.mockResolvedValue({ sessionId: "agentsession-child" }); + mocks.sendMessage.mockResolvedValue(undefined); + mocks.getTerminal.mockReturnValue({ + generation: 3, + status: "completed", + at: Date.now() + 1_000, + }); +}); + +describe("local conversation continuation", () => { + it("uses a provider-neutral, non-secret durable parent identity", () => { + expect(conversationExecutionParentId(root)).toBe( + '["org2-conversation",1,"org2-cloud",["org-1"],"root-1"]' + ); + expect( + conversationExecutionParentId({ ...root, authority: "local-session" }) + ).not.toBe(conversationExecutionParentId(root)); + }); + + it("renders the complete visible transcript and omits private reasoning", () => { + const long = "x".repeat(25_000); + const rendered = renderCanonicalConversation([ + event("u", "user", long), + event("r", "assistant", "private", { actionType: "reasoning" }), + event("a", "assistant", "answer"), + ]); + expect(rendered).toContain(long); + expect(rendered).toContain("answer"); + expect(rendered).not.toContain("private"); + }); + + it("invalidates a prefix cursor when canonical content changes", () => { + const before = [event("u", "user", "one"), event("a", "assistant", "two")]; + const changed = [event("u", "user", "edited"), before[1]]; + expect(conversationPrefixHash(before)).not.toBe( + conversationPrefixHash(changed) + ); + }); + + it("serializes one conversation and releases the bounded queue entry", async () => { + const order: string[] = []; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const first = withLocalConversationQueue("same", async () => { + order.push("first:start"); + await gate; + order.push("first:end"); + }); + const second = withLocalConversationQueue("same", async () => { + order.push("second"); + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(order).toEqual(["first:start"]); + release(); + await Promise.all([first, second]); + expect(order).toEqual(["first:start", "first:end", "second"]); + expect(localConversationQueueSizeForTests()).toBe(0); + }); + + it("observes a hidden native child's durable terminal without a mounted channel", async () => { + mocks.getTerminal.mockReturnValue(null); + const childEvents = [ + localUser("agentsession-child", "bootstrap"), + { + ...event("answer-hidden", "assistant", "background answer"), + sessionId: "agentsession-child", + }, + ]; + mocks.create.mockResolvedValue({ sessionId: "agentsession-child" }); + mocks.getAgentSession.mockResolvedValue({ + sessionId: "agentsession-child", + status: "completed", + createdAt: "2026-08-26T00:00:00.000Z", + updatedAt: "2026-08-26T00:00:01.000Z", + }); + mocks.loadEvents.mockResolvedValue({ + events: childEvents, + source: "native_store", + }); + + await expect( + readDurableConversationTerminal("agentsession-child") + ).resolves.toBe("completed"); + + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline: [], + displayText: "run in the hidden child", + target, + turnIntentId: "turn-hidden", + }) + ).resolves.toMatchObject({ + sessionId: "agentsession-child", + terminalStatus: "completed", + agentTail: [ + expect.objectContaining({ displayText: "background answer" }), + ], + }); + }); + + it("creates one durable child Session, then resumes it with only canonical delta", async () => { + const base = [event("root-user", "user", "initial question")]; + let childEvents: SessionEvent[] = []; + mocks.loadEvents.mockImplementation(async () => ({ + events: childEvents, + source: "event_store", + })); + mocks.create.mockImplementation(async ({ task }) => { + childEvents = [ + localUser("agentsession-child", task), + { + ...event("answer-1", "assistant", "first answer"), + sessionId: "agentsession-child", + }, + ]; + return { sessionId: "agentsession-child" }; + }); + + const first = await continueLocalConversation({ + root, + title: "Shared", + timeline: base, + displayText: "first request", + target, + turnIntentId: "turn-1", + }); + expect(first.created).toBe(true); + expect(first.agentTail.map((item) => item.displayText)).toEqual([ + "first answer", + ]); + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionId: conversationExecutionParentId(root), + accountId: "account-1", + model: "model-1", + }) + ); + + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-child", + updatedAt: "2026-08-26T01:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + sessionId: "agentsession-child", + status: "completed", + createdAt: "2026-08-26T00:00:00.000Z", + updatedAt: "2026-08-26T01:00:00.000Z", + workspacePath: "/repo", + agentDefinitionId: "builtin:sde", + accountId: "account-1", + model: "model-1", + }); + const secondTimeline = [ + ...base, + event("plane-u1", "user", "first request", { turnId: "turn-1" }), + event("plane-a1", "assistant", "first answer", { turnId: "turn-1" }), + event("teammate", "user", "new teammate context", { turnId: "other" }), + ]; + mocks.sendMessage.mockImplementation(async ({ content }) => { + childEvents = [ + ...childEvents, + localUser("agentsession-child-2", content), + { + ...event("answer-2", "assistant", "second answer"), + sessionId: "agentsession-child", + }, + ]; + }); + + const second = await continueLocalConversation({ + root, + title: "Shared", + timeline: secondTimeline, + displayText: "second request", + target, + turnIntentId: "turn-2", + }); + expect(second.created).toBe(false); + const sent = mocks.sendMessage.mock.calls[0]?.[0]?.content as string; + expect(sent).toContain("new teammate context"); + expect(sent).not.toContain("first answer"); + expect(sent).toContain("second request"); + expect(second.agentTail.map((item) => item.displayText)).toEqual([ + "second answer", + ]); + expect(mocks.create).toHaveBeenCalledTimes(1); + + const thirdTimeline = [ + ...secondTimeline, + event("plane-u2", "user", "second request", { turnId: "turn-2" }), + event("plane-a2", "assistant", "second answer", { turnId: "turn-2" }), + ]; + mocks.sendMessage.mockRejectedValueOnce(new Error("native id vanished")); + mocks.create.mockImplementationOnce(async ({ task }) => { + childEvents = [ + localUser("agentsession-child-2", task), + { + ...event("answer-3", "assistant", "fresh fallback answer"), + sessionId: "agentsession-child-2", + }, + ]; + return { sessionId: "agentsession-child-2" }; + }); + + const fallback = await continueLocalConversation({ + root, + title: "Shared", + timeline: thirdTimeline, + displayText: "third request", + target, + turnIntentId: "turn-3", + }); + expect(fallback).toMatchObject({ + sessionId: "agentsession-child-2", + created: true, + }); + expect(fallback.agentTail.map((item) => item.displayText)).toEqual([ + "fresh fallback answer", + ]); + expect(mocks.markTerminal).toHaveBeenCalledWith( + "agentsession-child", + "failed", + { generation: 3 } + ); + expect(mocks.create).toHaveBeenCalledTimes(2); + }); + + it("uses the same durable continuation path for an installed external CLI", async () => { + const cliTarget = { + ...target, + cliAgentType: "codex", + }; + const base = [event("root-user", "user", "portable history")]; + let childEvents: SessionEvent[] = []; + mocks.loadEvents.mockImplementation(async () => ({ + events: childEvents, + source: "native_cli_store", + })); + mocks.create.mockImplementationOnce(async ({ task }) => { + childEvents = [ + localUser("cliagent-child", task), + { + ...event("cli-answer-1", "assistant", "cli first answer"), + sessionId: "cliagent-child", + }, + ]; + return { sessionId: "cliagent-child" }; + }); + + await continueLocalConversation({ + root, + title: "Portable", + timeline: base, + displayText: "first cli request", + target: cliTarget, + turnIntentId: "cli-turn-1", + }); + expect(mocks.create).toHaveBeenCalledWith( + expect.objectContaining({ + cliAgentType: "codex", + parentSessionId: conversationExecutionParentId(root), + }) + ); + + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "cliagent-child", + updatedAt: "2026-08-26T02:00:00.000Z", + }, + ]); + mocks.cliStatus.mockResolvedValue({ + sessionId: "cliagent-child", + status: "completed", + updatedAt: "2026-08-26T02:00:00.000Z", + cliAgentType: "codex", + accountId: "account-1", + model: "model-1", + worktreePath: "/repo", + agentDefinitionId: "builtin:sde", + }); + mocks.sendMessage.mockImplementationOnce(async ({ content }) => { + childEvents = [ + ...childEvents, + localUser("cliagent-child", content), + { + ...event("cli-answer-2", "assistant", "cli resumed answer"), + sessionId: "cliagent-child", + }, + ]; + }); + const timeline = [ + ...base, + event("cli-plane-u1", "user", "first cli request", { + turnId: "cli-turn-1", + }), + event("cli-plane-a1", "assistant", "cli first answer", { + turnId: "cli-turn-1", + }), + event("remote-u", "user", "new remote context", { + turnId: "remote-turn", + }), + ]; + + const resumed = await continueLocalConversation({ + root, + title: "Portable", + timeline, + displayText: "second cli request", + target: cliTarget, + turnIntentId: "cli-turn-2", + }); + + expect(resumed).toMatchObject({ + sessionId: "cliagent-child", + created: false, + }); + const sent = mocks.sendMessage.mock.calls[0]?.[0]?.content as string; + expect(sent).toContain("new remote context"); + expect(sent).not.toContain("cli first answer"); + expect(sent).toContain("second cli request"); + expect(mocks.create).toHaveBeenCalledTimes(1); + }); + + it("rolls to a fresh episode when the target changes or the prior child failed", async () => { + const firstTimeline = [event("root-user", "user", "history")]; + let childEvents: SessionEvent[] = []; + mocks.loadEvents.mockImplementation(async () => ({ + events: childEvents, + source: "native_store", + })); + mocks.create.mockImplementation(async ({ task, model }) => { + const sessionId = + model === "model-2" ? "agentsession-model-2" : "agentsession-model-1"; + childEvents = [ + localUser(sessionId, task), + { + ...event(`answer-${model}`, "assistant", `answer from ${model}`), + sessionId, + }, + ]; + return { sessionId }; + }); + + await continueLocalConversation({ + root, + title: "Shared", + timeline: firstTimeline, + displayText: "first request", + target, + turnIntentId: "turn-model-1", + }); + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-model-1", + updatedAt: "2026-08-26T03:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + sessionId: "agentsession-model-1", + status: "completed", + createdAt: "2026-08-26T00:00:00.000Z", + updatedAt: "2026-08-26T03:00:00.000Z", + workspacePath: "/repo", + agentDefinitionId: "builtin:sde", + accountId: "account-1", + model: "model-1", + }); + const afterFirst = [ + ...firstTimeline, + event("plane-model-u1", "user", "first request", { + turnId: "turn-model-1", + }), + event("plane-model-a1", "assistant", "answer from model-1", { + turnId: "turn-model-1", + }), + ]; + + const targetRoll = await continueLocalConversation({ + root, + title: "Shared", + timeline: afterFirst, + displayText: "switch target", + target: { ...target, model: "model-2" }, + turnIntentId: "turn-model-2", + }); + expect(targetRoll).toMatchObject({ + sessionId: "agentsession-model-2", + created: true, + }); + expect(mocks.sendMessage).not.toHaveBeenCalled(); + + mocks.invokeTauri.mockResolvedValue([ + { + sessionId: "agentsession-model-2", + updatedAt: "2026-08-26T04:00:00.000Z", + }, + ]); + mocks.getAgentSession.mockResolvedValue({ + sessionId: "agentsession-model-2", + status: "failed", + createdAt: "2026-08-26T00:00:00.000Z", + updatedAt: "2026-08-26T04:00:00.000Z", + workspacePath: "/repo", + agentDefinitionId: "builtin:sde", + accountId: "account-1", + model: "model-2", + }); + mocks.create.mockImplementationOnce(async ({ task }) => { + childEvents = [ + localUser("agentsession-after-failure", task), + { + ...event("answer-after-failure", "assistant", "fresh recovery"), + sessionId: "agentsession-after-failure", + }, + ]; + return { sessionId: "agentsession-after-failure" }; + }); + const afterFailedTurn = [ + ...afterFirst, + event("plane-model-u2", "user", "switch target", { + turnId: "turn-model-2", + }), + event("plane-model-a2", "assistant", "failed", { + turnId: "turn-model-2", + }), + ]; + + const failureRoll = await continueLocalConversation({ + root, + title: "Shared", + timeline: afterFailedTurn, + displayText: "recover after failure", + target: { ...target, model: "model-2" }, + turnIntentId: "turn-after-failure", + }); + expect(failureRoll).toMatchObject({ + sessionId: "agentsession-after-failure", + created: true, + }); + }); +}); diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.ts b/src/engines/SessionCore/conversations/localConversationContinuation.ts new file mode 100644 index 000000000..b938ba535 --- /dev/null +++ b/src/engines/SessionCore/conversations/localConversationContinuation.ts @@ -0,0 +1,760 @@ +/** + * Provider-neutral local continuation for one canonical conversation. + * + * The canonical transcript can come from Cloud, an imported session, or a + * normal local Session. Execution always happens on this device with the + * caller's selected local runtime/account/workspace. A normal persisted + * Session is the continuation record: `parentSessionId` groups its hidden + * execution episodes under a deterministic conversation parent, so no + * localStorage runner registry or parallel continuation database is needed. + */ +import { getSession as getAgentSession } from "@src/api/tauri/agent"; +import { rpc } from "@src/api/tauri/rpc"; +import { + type TurnTerminalStatus, + beginTurnDispatch, + confirmTurnRunning, + getLastTurnTerminal, + markTurnTerminal, + toTurnTerminalStatus, + turnLifecycleSignalAtom, +} from "@src/engines/SessionCore/control/turnLifecycle"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { loadAuthoritativeSessionEvents } from "@src/engines/SessionCore/sync/authoritativeSessionEvents"; +import { isTerminalStatus } from "@src/types/session/session"; +import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; +import { invokeTauri } from "@src/util/platform/tauri/init"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +const TURN_DEADLINE_MS = 15 * 60_000; +const TRANSCRIPT_SETTLE_MS = 5_000; +const TRANSCRIPT_SETTLE_POLL_MS = 100; +const TERMINAL_POLL_MS = 100; +const CURSOR_PREFIX = " void | Promise; + onSessionReady?: ( + sessionId: string, + /** Authoritative native-event prefix that predates this turn. */ + eventStartIndex: number + ) => void | Promise; +} + +export interface ContinueLocalConversationResult { + sessionId: string; + created: boolean; + terminalStatus: TurnTerminalStatus; + agentTail: SessionEvent[]; +} + +interface ChildSessionView { + sessionId: string; + updatedAt: string; +} + +interface ConversationCursor { + prefixCount: number; + prefixHash: string; + localTurnIntentId: string; +} + +interface ExecutionCandidate { + sessionId: string; + updatedAt: string; +} + +function requireIdentityPart(label: string, value: string): string { + const normalized = value.trim(); + if (!normalized) throw new Error(`conversation ${label} is required`); + if (normalized.length > 2_048) { + throw new Error(`conversation ${label} is too long`); + } + return normalized; +} + +/** Durable grouping id stored directly on normal native/CLI Session rows. */ +export function conversationExecutionParentId( + locator: ConversationRootLocator +): string { + if (locator.authorityScope.length > 16) { + throw new Error("conversation authority scope has too many parts"); + } + return JSON.stringify([ + "org2-conversation", + 1, + requireIdentityPart("authority", locator.authority), + locator.authorityScope.map((part, index) => + requireIdentityPart(`authority scope ${index}`, part) + ), + requireIdentityPart("id", locator.conversationId), + ]); +} + +function eventTurnId(event: SessionEvent): string | null { + const value = event.args?.[CONVERSATION_TURN_ID_ARG]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function rawEventText(event: SessionEvent): string { + const result = event.result as Record | undefined; + const message = result?.message as Record | undefined; + const candidates = [ + message?.content, + result?.content, + result?.observation, + event.displayText, + ]; + return ( + candidates.find((value): value is string => typeof value === "string") ?? "" + ); +} + +function senderLabel(event: SessionEvent): string { + const sender = event.args?.conversationSender as + | { displayName?: unknown } + | undefined; + if (typeof sender?.displayName === "string" && sender.displayName.trim()) { + return sender.displayName.trim(); + } + if (event.source === "user") return "User"; + if (event.source === "assistant") return "Assistant"; + return "System"; +} + +function isTransferableEvent(event: SessionEvent): boolean { + const action = event.actionType.toLowerCase(); + const fn = event.functionName.toLowerCase(); + // Provider-private/signed reasoning is not portable conversation state. + if ( + action.includes("thinking") || + action.includes("reasoning") || + fn.includes("thinking") || + fn.includes("reasoning") + ) { + return false; + } + return Boolean(event.displayText.trim() || rawEventText(event).trim()); +} + +function stableEventProjection(event: SessionEvent): string { + return JSON.stringify([ + event.id, + event.createdAt, + event.source, + event.functionName, + event.actionType, + event.displayText, + rawEventText(event), + eventTurnId(event), + ]); +} + +/** Two independent 32-bit FNV lanes; correctness fence, not a signature. */ +export function conversationPrefixHash( + events: readonly SessionEvent[], + count = events.length +): string { + let left = 0x811c9dc5; + let right = 0x9e3779b9; + const limit = Math.min(Math.max(0, count), events.length); + for (let index = 0; index < limit; index += 1) { + const text = stableEventProjection(events[index]); + for (let offset = 0; offset < text.length; offset += 1) { + const code = text.charCodeAt(offset); + left = Math.imul(left ^ code, 0x01000193) >>> 0; + right = Math.imul(right ^ (code + offset), 0x85ebca6b) >>> 0; + } + } + return `${left.toString(16).padStart(8, "0")}${right + .toString(16) + .padStart(8, "0")}`; +} + +function encodeCursor(cursor: ConversationCursor): string { + const payload = encodeURIComponent(JSON.stringify(cursor)); + return `${CURSOR_PREFIX}value="${payload}"${CURSOR_SUFFIX}`; +} + +function cursorsIn(events: readonly SessionEvent[]): ConversationCursor[] { + const cursors: ConversationCursor[] = []; + for (const event of events) { + if (event.source !== "user") continue; + const text = rawEventText(event); + let from = 0; + for (;;) { + const start = text.indexOf(CURSOR_PREFIX, from); + if (start < 0) break; + const end = text.indexOf(CURSOR_SUFFIX, start + CURSOR_PREFIX.length); + if (end < 0) break; + const tag = text.slice(start + CURSOR_PREFIX.length, end); + const match = /(?:^|\s)value="([^"]+)"/.exec(tag); + from = end + CURSOR_SUFFIX.length; + if (!match) continue; + try { + const parsed = JSON.parse( + decodeURIComponent(match[1]) + ) as Partial; + if ( + Number.isSafeInteger(parsed.prefixCount) && + (parsed.prefixCount ?? -1) >= 0 && + typeof parsed.prefixHash === "string" && + parsed.prefixHash.length > 0 && + typeof parsed.localTurnIntentId === "string" && + parsed.localTurnIntentId.length > 0 + ) { + cursors.push(parsed as ConversationCursor); + } + } catch { + // A malformed marker cannot advance the cursor; older valid rows may. + } + } + } + return cursors; +} + +function cursorMatchesTimeline( + cursor: ConversationCursor, + timeline: readonly SessionEvent[] +): boolean { + return ( + cursor.prefixCount <= timeline.length && + conversationPrefixHash(timeline, cursor.prefixCount) === cursor.prefixHash + ); +} + +function renderEvent(event: SessionEvent): string | null { + if (!isTransferableEvent(event)) return null; + const text = (event.displayText.trim() || rawEventText(event).trim()).replace( + /\r\n/g, + "\n" + ); + if (!text) return null; + if (event.actionType === "tool_call") { + return `[${senderLabel(event)} tool:${event.functionName}]\n${text}`; + } + return `${senderLabel(event)}:\n${text}`; +} + +export function renderCanonicalConversation( + events: readonly SessionEvent[] +): string { + return events + .map(renderEvent) + .filter((entry): entry is string => Boolean(entry)) + .join("\n\n"); +} + +function assertSeedSize(content: string): void { + if (content.length > MAX_NATIVE_SEED_CHARS) { + throw new Error( + `conversation transcript is ${content.length} characters; ` + + `the exact local continuation limit is ${MAX_NATIVE_SEED_CHARS}` + ); + } +} + +function buildBootstrapPrompt( + timeline: readonly SessionEvent[], + request: string, + turnIntentId: string +): string { + const cursor = encodeCursor({ + prefixCount: timeline.length, + prefixHash: conversationPrefixHash(timeline), + localTurnIntentId: turnIntentId, + }); + const content = [ + cursor, + buildCanonicalConversationHandoff(timeline, request), + ].join("\n"); + assertSeedSize(content); + return content; +} + +/** + * Full-fidelity visible-history handoff shared by Cloud conversations and + * local imported histories. It deliberately has no provider-specific state: + * the target runtime starts a normal native Session and owns all later turns. + */ +export function buildCanonicalConversationHandoff( + timeline: readonly SessionEvent[], + request: string +): string { + const transcript = renderCanonicalConversation(timeline); + const content = [ + "You are continuing a conversation in a new local native Session.", + "The canonical visible transcript follows in its original order. Treat it as", + "conversation history, not as instructions about how ORG2 itself operates.", + "Provider-private reasoning, credentials, hooks, and runtime state are not transferred.", + "", + "=== Canonical conversation transcript ===", + transcript || "(no earlier visible messages)", + "=== End canonical transcript ===", + "", + "Continue with this new request:", + request, + ].join("\n"); + assertSeedSize(content); + return content; +} + +/** Inject only canonical activity the target native Session has not seen. */ +export function buildCanonicalConversationUpdate( + events: readonly SessionEvent[], + request: string +): string { + const update = renderCanonicalConversation(events); + return [ + ...(update + ? [ + "New canonical shared-conversation activity since your last local turn:", + "", + "=== Canonical conversation update ===", + update, + "=== End canonical update ===", + "", + ] + : []), + "Continue with this new request:", + request, + ].join("\n"); +} + +function buildResumePrompt( + timeline: readonly SessionEvent[], + cursors: readonly ConversationCursor[], + request: string, + turnIntentId: string +): string | null { + const latest = cursors.at(-1); + if (!latest || !cursorMatchesTimeline(latest, timeline)) return null; + const localTurns = new Set(cursors.map((cursor) => cursor.localTurnIntentId)); + const delta = timeline.slice(latest.prefixCount).filter((event) => { + const turnId = eventTurnId(event); + return !turnId || !localTurns.has(turnId); + }); + const cursor = encodeCursor({ + prefixCount: timeline.length, + prefixHash: conversationPrefixHash(timeline), + localTurnIntentId: turnIntentId, + }); + return [cursor, buildCanonicalConversationUpdate(delta, request)].join("\n"); +} + +async function listExecutionChildren( + parentSessionId: string +): Promise { + const children = await invokeTauri( + "es_get_child_sessions", + { parentSessionId } + ); + return children + .filter( + (child) => + typeof child.sessionId === "string" && child.sessionId.length > 0 + ) + .map((child) => ({ + sessionId: child.sessionId, + updatedAt: child.updatedAt, + })) + .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); +} + +function sameOptional(left: unknown, right: string | undefined): boolean { + return ( + (typeof left === "string" && left.length > 0 ? left : undefined) === right + ); +} + +function hasFailedTerminal(status: unknown): boolean { + return ( + typeof status === "string" && toTurnTerminalStatus(status) === "failed" + ); +} + +async function candidateMatchesTarget( + sessionId: string, + target: LocalConversationTarget +): Promise { + if (isCliSession(sessionId)) { + if (!target.cliAgentType) return false; + const row = (await rpc.cli.status({ sessionId })) as Record< + string, + unknown + > | null; + if (!row) return false; + if (hasFailedTerminal(row.status)) return false; + const workspace = + (typeof row.worktreePath === "string" && row.worktreePath) || + (typeof row.repoPath === "string" && row.repoPath) || + undefined; + return ( + row.cliAgentType === target.cliAgentType && + sameOptional(row.accountId, target.accountId) && + sameOptional(row.model, target.model) && + sameOptional(workspace, target.workspaceRepoPath ?? undefined) && + sameOptional(row.agentDefinitionId, target.agentDefinitionId) + ); + } + if (target.cliAgentType) return false; + const row = await getAgentSession(sessionId); + if (!row) return false; + if (hasFailedTerminal(row.status)) return false; + return ( + sameOptional(row.workspacePath, target.workspaceRepoPath ?? undefined) && + sameOptional(row.accountId, target.accountId) && + sameOptional(row.model, target.model) && + sameOptional(row.agentDefinitionId, target.agentDefinitionId) + ); +} + +async function findCompatibleExecution( + parentSessionId: string, + target: LocalConversationTarget, + timeline: readonly SessionEvent[] +): Promise<{ + sessionId: string; + updatedAt: string; + events: SessionEvent[]; + cursors: ConversationCursor[]; +} | null> { + const children = await listExecutionChildren(parentSessionId); + for (const child of children) { + if (!(await candidateMatchesTarget(child.sessionId, target))) continue; + try { + const { events } = await loadAuthoritativeSessionEvents(child.sessionId); + const cursors = cursorsIn(events); + const latest = cursors.at(-1); + if (latest && cursorMatchesTimeline(latest, timeline)) { + return { + sessionId: child.sessionId, + updatedAt: child.updatedAt, + events, + cursors, + }; + } + } catch { + // A missing/corrupt native transcript is not resumable. Try an older + // compatible episode before creating a fresh one. + } + } + return null; +} + +export async function readDurableConversationTerminal( + sessionId: string, + baselineUpdatedAt?: string +): Promise { + const row = isCliSession(sessionId) + ? await rpc.cli.status({ sessionId }) + : await getAgentSession(sessionId); + if (!row || !isTerminalStatus(row.status)) return null; + // A reusable Session is normally terminal before the new dispatch. Its + // timestamp must advance before that terminal can close this generation. + if (baselineUpdatedAt && row.updatedAt === baselineUpdatedAt) return null; + return toTurnTerminalStatus(row.status); +} + +async function waitForTurnTerminal( + sessionId: string, + minimumGeneration: number, + startedAt: number, + deadlineMs: number, + baselineUpdatedAt?: string +): Promise { + const store = getInstrumentedStore(); + const lifecycleTerminal = (): TurnTerminalStatus | null => { + const terminal = getLastTurnTerminal(sessionId); + return terminal && + terminal.generation >= minimumGeneration && + terminal.at >= startedAt + ? terminal.status + : null; + }; + const immediate = lifecycleTerminal(); + if (immediate) return immediate; + return new Promise((resolve, reject) => { + const remaining = deadlineMs - Date.now(); + if (remaining <= 0) { + reject(new Error("conversation turn timed out")); + return; + } + let settled = false; + let checking = false; + let pollTimer: ReturnType | null = null; + let unsubscribe: (() => void) | null = null; + const cleanup = () => { + if (pollTimer !== null) clearTimeout(pollTimer); + unsubscribe?.(); + }; + const timeout = setTimeout(() => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("conversation turn timed out")); + }, remaining); + const finish = (status: TurnTerminalStatus) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + cleanup(); + resolve(status); + }; + const check = async () => { + if (settled || checking) return; + if (pollTimer !== null) { + clearTimeout(pollTimer); + pollTimer = null; + } + const lifecycle = lifecycleTerminal(); + if (lifecycle) { + finish(lifecycle); + return; + } + checking = true; + try { + const durable = await readDurableConversationTerminal( + sessionId, + baselineUpdatedAt + ).catch(() => null); + if (durable) { + finish(durable); + return; + } + } finally { + checking = false; + } + if (!settled) { + pollTimer = setTimeout(() => void check(), TERMINAL_POLL_MS); + } + }; + unsubscribe = store.sub(turnLifecycleSignalAtom, () => void check()); + void check(); + }); +} + +function sameEventPrefix( + before: readonly SessionEvent[], + after: readonly SessionEvent[] +): boolean { + return ( + before.length <= after.length && + before.every((event, index) => event.id === after[index]?.id) + ); +} + +function sliceTurnTail( + before: readonly SessionEvent[], + after: readonly SessionEvent[], + turnIntentId: string +): SessionEvent[] | null { + let appended: readonly SessionEvent[]; + if (sameEventPrefix(before, after)) { + appended = after.slice(before.length); + } else { + const anchor = after.findIndex( + (event) => + event.source === "user" && + cursorsIn([event]).some( + (cursor) => cursor.localTurnIntentId === turnIntentId + ) + ); + if (anchor < 0) return null; + appended = after.slice(anchor + 1); + } + return appended.filter((event) => event.source !== "user"); +} + +async function loadSettledTail( + sessionId: string, + before: readonly SessionEvent[], + turnIntentId: string, + deadlineMs: number +): Promise { + const settleDeadline = Math.min( + deadlineMs, + Date.now() + TRANSCRIPT_SETTLE_MS + ); + for (;;) { + const { events } = await loadAuthoritativeSessionEvents(sessionId); + const tail = sliceTurnTail(before, events, turnIntentId); + if (tail) return tail; + if (Date.now() >= settleDeadline) { + throw new Error( + `conversation turn ${turnIntentId} is missing its native transcript anchor` + ); + } + await new Promise((resolve) => + setTimeout(resolve, TRANSCRIPT_SETTLE_POLL_MS) + ); + } +} + +const queues = new Map>(); + +/** Bounded per-conversation serialization; entries disappear when settled. */ +export async function withLocalConversationQueue( + key: string, + run: () => Promise +): Promise { + const previous = queues.get(key) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(run); + queues.set(key, next); + try { + return await next; + } finally { + if (queues.get(key) === next) queues.delete(key); + } +} + +export function localConversationQueueSizeForTests(): number { + return queues.size; +} + +export async function continueLocalConversation( + params: ContinueLocalConversationParams +): Promise { + const parentSessionId = conversationExecutionParentId(params.root); + return withLocalConversationQueue(parentSessionId, async () => { + const request = params.agentContent ?? params.displayText; + const compatible = await findCompatibleExecution( + parentSessionId, + params.target, + params.timeline + ); + const resumeContent = compatible + ? buildResumePrompt( + params.timeline, + compatible.cursors, + request, + params.turnIntentId + ) + : null; + await params.beforeDispatch?.(); + const deadlineMs = Date.now() + TURN_DEADLINE_MS; + const startedAt = Date.now(); + + if (compatible && resumeContent) { + const generation = beginTurnDispatch(compatible.sessionId); + let resumeAccepted = false; + try { + await SessionService.sendMessage({ + sessionId: compatible.sessionId, + content: resumeContent, + displayText: params.displayText, + model: params.target.model, + accountId: params.target.accountId, + mode: "build", + imageDataUrls: params.imageDataUrls, + clientMessageId: `conversation-turn:${params.turnIntentId}`, + turnIntentId: params.turnIntentId, + turnIntentSource: "user_submit", + directUserIntent: true, + }); + confirmTurnRunning(compatible.sessionId); + await params.onSessionReady?.( + compatible.sessionId, + compatible.events.length + ); + resumeAccepted = true; + } catch { + markTurnTerminal(compatible.sessionId, "failed", { generation }); + // A stale provider-native id, deleted transcript, or broken local + // runtime invalidates only this execution episode. Keep the same + // canonical turn and fall through to a fresh native Session. + } + if (resumeAccepted) { + const terminalStatus = await waitForTurnTerminal( + compatible.sessionId, + generation, + startedAt, + deadlineMs, + compatible.updatedAt + ); + const agentTail = await loadSettledTail( + compatible.sessionId, + compatible.events, + params.turnIntentId, + deadlineMs + ); + return { + sessionId: compatible.sessionId, + created: false, + terminalStatus, + agentTail, + }; + } + } + + const bootstrap = buildBootstrapPrompt( + params.timeline, + request, + params.turnIntentId + ); + const created = await SessionService.create({ + task: bootstrap, + imageDataUrls: params.imageDataUrls, + name: params.title, + repoPath: params.target.workspaceRepoPath ?? undefined, + model: params.target.model, + accountId: params.target.accountId, + cliAgentType: params.target.cliAgentType, + keySource: "own_key", + agentDefinitionId: params.target.agentDefinitionId, + parentSessionId, + mode: "build", + }); + await params.onSessionReady?.(created.sessionId, 0); + const terminalStatus = await waitForTurnTerminal( + created.sessionId, + 1, + startedAt, + deadlineMs + ); + const agentTail = await loadSettledTail( + created.sessionId, + [], + params.turnIntentId, + deadlineMs + ); + return { + sessionId: created.sessionId, + created: true, + terminalStatus, + agentTail, + }; + }); +} diff --git a/src/engines/SessionCore/sync/authoritativeSessionEvents.ts b/src/engines/SessionCore/sync/authoritativeSessionEvents.ts new file mode 100644 index 000000000..29c2feb1f --- /dev/null +++ b/src/engines/SessionCore/sync/authoritativeSessionEvents.ts @@ -0,0 +1,52 @@ +/** + * Canonical full-history read for one managed local Session. + * + * Rust-native sessions persist normalized events in EventStore. Managed CLI + * sessions may instead use the provider's native transcript as their source + * of truth, so an optimistic EventStore row is never enough to prove that a + * CLI turn has finished persisting. Keep that provider distinction here; + * conversation continuation and cloud sync must not each invent it again. + */ +import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +import { loadCliHistory } from "./adapters/cli/cliHistory"; + +export interface AuthoritativeSessionEvents { + events: SessionEvent[]; + /** Stable EventStore revision when EventStore was authoritative. */ + localContentRevision?: number; + source: "event_store" | "cli_history"; +} + +export async function loadAuthoritativeSessionEvents( + sessionId: string, + signal: AbortSignal = new AbortController().signal +): Promise { + if (isCliSession(sessionId)) { + return { + events: await loadCliHistory(sessionId, signal), + source: "cli_history", + }; + } + + const revisionBefore = + await eventStoreProxy.getPersistedEventRevision(sessionId); + const events = await eventStoreProxy.getPersistedEvents(sessionId); + const revisionAfter = + await eventStoreProxy.getPersistedEventRevision(sessionId); + const localContentRevision = + revisionBefore && + revisionAfter && + revisionBefore.revision === revisionAfter.revision && + revisionAfter.eventCount === events.length + ? revisionAfter.revision + : undefined; + + return { + events, + localContentRevision, + source: "event_store", + }; +} diff --git a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts index 216304f9d..183ee42b9 100644 --- a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts +++ b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + import { collectLandedTurnIds, selectActiveRunners, + selectConversationRunnerTail, } from "./activeConversationRunnersAtom"; const row = (turnId: string, source: "user" | "assistant" | "system") => ({ @@ -31,8 +34,8 @@ describe("collectLandedTurnIds", () => { describe("selectActiveRunners", () => { const runners = [ - { runnerSessionId: "r1", turnId: "t1" }, - { runnerSessionId: "r2", turnId: "t2" }, + { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 8 }, + { runnerSessionId: "r2", turnId: "t2", eventStartIndex: 0 }, ]; it("keeps a runner while only its user row is on the plane", () => { @@ -49,3 +52,20 @@ describe("selectActiveRunners", () => { expect(selectActiveRunners(runners, landed)).toEqual([runners[1]]); }); }); + +describe("selectConversationRunnerTail", () => { + it("windows a reused native session to the current non-user tail", () => { + const events = [ + { id: "old-agent", source: "assistant" }, + { id: "current-user", source: "user" }, + { id: "current-tool", source: "system" }, + { id: "current-agent", source: "assistant" }, + ] as unknown as SessionEvent[]; + expect( + selectConversationRunnerTail( + { runnerSessionId: "r1", turnId: "t1", eventStartIndex: 1 }, + events + ).map((event) => event.id) + ).toEqual(["current-tool", "current-agent"]); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts index 50cd3eb86..a91b8a9cd 100644 --- a/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts +++ b/src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom.ts @@ -1,7 +1,8 @@ /** * Live overlay registry for in-flight member turns. * - * A member's send runs the turn in an invisible one-shot local runner and + * A member's send runs the turn in an invisible durable local execution + * Session and * only publishes the agent tail to the plane at terminal — so without this, * even the SENDER stares at their own message with no thinking, no tools, * no "Agent worked for Ns" until the whole turn lands at once. @@ -25,6 +26,8 @@ export interface ActiveConversationRunner { runnerSessionId: string; /** The turnId the tail is pushed under — the plane-landed drop signal. */ turnId: string; + /** Native-event prefix from earlier turns; never overlay it again. */ + eventStartIndex: number; } /** plane rootSessionId → this device's in-flight member runners. */ @@ -51,3 +54,13 @@ export function selectActiveRunners( ): ActiveConversationRunner[] { return runners.filter((runner) => !landedTurnIds.has(runner.turnId)); } + +/** Current-turn native tail only; prior turns and the injected user row stay hidden. */ +export function selectConversationRunnerTail( + runner: ActiveConversationRunner, + events: readonly SessionEvent[] +): SessionEvent[] { + return events + .slice(Math.max(0, runner.eventStartIndex)) + .filter((event) => event.source !== "user"); +} diff --git a/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts b/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts index 5121cfe7c..90a2ecfff 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher.ts @@ -1,8 +1,8 @@ /** * Owner publisher — the owner's half of "every turn is on the plane". * - * A member's turn reaches the plane through its one-shot runner; the - * owner's turn runs in the owner's own session and used to reach other + * A member's turn reaches the plane through its durable local execution + * Session; the owner's turn runs in the owner's own session and used to reach other * clients only through the session replay (slow, and ordered by sender * clock against the plane). This publishes the owner's turn to the plane * under a turnId exactly like a member turn — the user row as soon as the diff --git a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.test.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.test.ts new file mode 100644 index 000000000..ff7ef0579 --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.test.ts @@ -0,0 +1,230 @@ +// @vitest-environment jsdom +import { Provider, createStore } from "jotai"; +import { act, createElement } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { type SmokeRoot, createSmokeRoot } from "@src/test/reactSmokeHarness"; + +import { org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import type { Org2CloudAuthState } from "../org2CloudAuthAtom"; +import { getCloudCapabilitiesConfirmed } from "../org2CloudCapabilities"; +import { ensureFreshSession } from "../org2CloudClient"; +import { listConversationEvents } from "../org2CloudConversationEventsClient"; +import { + type ConversationPlaneEntry, + __CONVERSATION_PLANE_INTERNALS, + conversationPlaneAtom, + ensureConversationPlaneEntry, + useConversationPlaneEvents, +} from "./conversationPlaneAtom"; + +vi.mock("../org2CloudCapabilities", () => ({ + getCloudCapabilitiesConfirmed: vi.fn(), +})); +vi.mock("../org2CloudClient", () => ({ + ensureFreshSession: vi.fn(), +})); +vi.mock("../org2CloudConversationEventsClient", () => ({ + listConversationEvents: vi.fn(), +})); + +const ensureFreshSessionMock = vi.mocked(ensureFreshSession); +const capabilitiesMock = vi.mocked(getCloudCapabilitiesConfirmed); +const listEventsMock = vi.mocked(listConversationEvents); + +const AUTH: Org2CloudAuthState = { + kind: "org2_cloud", + supabaseUrl: "https://cloud.example.test", + supabaseAnonKey: "anon", + userId: "user-1", + accessToken: "jwt-1", + refreshToken: "refresh-1", + expiresAt: 4_000_000_000, +}; + +function createHarness() { + let entries: Record = {}; + let auth: Org2CloudAuthState | null = AUTH; + return { + params: { + auth: AUTH, + orgId: "org-1", + rootSessionId: "root-1", + getEntry: () => entries["org-1:root-1"], + setEntries: ( + update: ( + current: Record + ) => Record + ) => { + entries = update(entries); + }, + setAuth: ( + update: ( + current: Org2CloudAuthState | null + ) => Org2CloudAuthState | null + ) => { + auth = update(auth); + }, + }, + entry: () => entries["org-1:root-1"], + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + __CONVERSATION_PLANE_INTERNALS.reset(); + ensureFreshSessionMock.mockResolvedValue(AUTH); +}); + +describe("ensureConversationPlaneEntry", () => { + it("waits for the first capable-plane fetch and returns the ready entry", async () => { + capabilitiesMock.mockResolvedValue({ + capabilities: { conversationEvents: true } as never, + confirmed: true, + }); + listEventsMock.mockResolvedValue({ + events: [ + { + id: "event-1", + rootSessionId: "root-1", + authorUserId: "user-1", + turnId: "turn-1", + seq: 1, + event: { id: "native-1" }, + createdAt: "2026-08-26T00:00:00.000Z", + } as never, + ], + hasMore: false, + }); + const harness = createHarness(); + + await expect( + ensureConversationPlaneEntry(harness.params) + ).resolves.toMatchObject({ state: "ready", lastSeq: 1 }); + expect(harness.entry()).toMatchObject({ state: "ready", lastSeq: 1 }); + expect(listEventsMock).toHaveBeenCalledWith("jwt-1", { + orgId: "org-1", + rootSessionId: "root-1", + afterSeq: 0, + }); + }); + + it("coalesces the mounted fetch and a racing first submit", async () => { + capabilitiesMock.mockResolvedValue({ + capabilities: { conversationEvents: true } as never, + confirmed: true, + }); + let resolveList!: (value: { events: []; hasMore: false }) => void; + listEventsMock.mockReturnValue( + new Promise((resolve) => { + resolveList = resolve; + }) + ); + const harness = createHarness(); + + const mountedFetch = ensureConversationPlaneEntry(harness.params); + const firstSubmit = ensureConversationPlaneEntry(harness.params); + expect(firstSubmit).toBe(mountedFetch); + resolveList({ events: [], hasMore: false }); + + await expect(Promise.all([mountedFetch, firstSubmit])).resolves.toEqual([ + { state: "ready", events: [], lastSeq: 0 }, + { state: "ready", events: [], lastSeq: 0 }, + ]); + expect(ensureFreshSessionMock).toHaveBeenCalledTimes(1); + expect(listEventsMock).toHaveBeenCalledTimes(1); + }); + + it("uses the legacy fork path only after a confirmed unsupported result", async () => { + capabilitiesMock.mockResolvedValue({ + capabilities: { conversationEvents: false } as never, + confirmed: true, + }); + const harness = createHarness(); + + await expect( + ensureConversationPlaneEntry(harness.params) + ).resolves.toMatchObject({ state: "unsupported" }); + expect(harness.entry()).toMatchObject({ state: "unsupported" }); + expect(listEventsMock).not.toHaveBeenCalled(); + }); + + it("fails closed instead of forking when the capability probe is unknown", async () => { + capabilitiesMock.mockResolvedValue({ + capabilities: { conversationEvents: false } as never, + confirmed: false, + }); + const harness = createHarness(); + + await expect(ensureConversationPlaneEntry(harness.params)).rejects.toThrow( + "capability probe was unconfirmed" + ); + expect(harness.entry()).toMatchObject({ state: "error" }); + expect(listEventsMock).not.toHaveBeenCalled(); + }); +}); + +describe("useConversationPlaneEvents foreground recovery", () => { + let root: SmokeRoot; + + beforeEach(() => { + root = createSmokeRoot(); + }); + + it("incrementally catches up when its native window regains focus", async () => { + capabilitiesMock.mockResolvedValue({ + capabilities: { conversationEvents: true } as never, + confirmed: true, + }); + listEventsMock + .mockResolvedValueOnce({ events: [], hasMore: false }) + .mockResolvedValueOnce({ + events: [ + { + id: "event-after-focus", + rootSessionId: "root-1", + authorUserId: "user-2", + turnId: "turn-2", + seq: 1, + event: { id: "native-after-focus" }, + createdAt: "2026-08-26T00:00:01.000Z", + } as never, + ], + hasMore: false, + }); + const focus = vi.spyOn(document, "hasFocus").mockReturnValue(true); + const store = createStore(); + store.set(org2CloudAuthAtom, AUTH); + const Harness = () => { + useConversationPlaneEvents({ + orgId: "org-1", + sessionId: "root-1", + } as never); + return null; + }; + + await root.render( + createElement(Provider, { store }, createElement(Harness)) + ); + await vi.waitFor(() => expect(listEventsMock).toHaveBeenCalledTimes(1)); + + await act(async () => window.dispatchEvent(new Event("focus"))); + + await vi.waitFor(() => expect(listEventsMock).toHaveBeenCalledTimes(2)); + expect(listEventsMock).toHaveBeenLastCalledWith("jwt-1", { + orgId: "org-1", + rootSessionId: "root-1", + afterSeq: 0, + }); + expect(store.get(conversationPlaneAtom)["org-1:root-1"]).toMatchObject({ + state: "ready", + lastSeq: 1, + }); + + window.dispatchEvent(new Event("focus")); + expect(listEventsMock).toHaveBeenCalledTimes(2); + + focus.mockRestore(); + await root.unmount(); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts index a161e8487..a99303530 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneAtom.ts @@ -5,17 +5,19 @@ * every entry "unsupported" and the fork-wire fallback stays in charge. */ import { atom, useAtomValue, useSetAtom, useStore } from "jotai"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { createLogger } from "@src/hooks/logger"; import { commitRefreshedAuth, org2CloudAuthAtom } from "../org2CloudAuthAtom"; +import type { Org2CloudAuthState } from "../org2CloudAuthAtom"; import { getCloudCapabilitiesConfirmed } from "../org2CloudCapabilities"; import { ensureFreshSession } from "../org2CloudClient"; import { type CloudConversationEvent, listConversationEvents, } from "../org2CloudConversationEventsClient"; +import { REALTIME_SIGNAL_COALESCE_MS } from "../org2CloudRealtimeSignalCoalescer"; import type { SessionCommentTarget } from "../sessionCommentTarget"; const log = createLogger("ConversationPlane"); @@ -54,7 +56,24 @@ export const conversationPlaneAtom = atom< /** orgId → monotonically increasing signal counter (realtime bump). */ export const conversationPlaneSignalAtom = atom>({}); -const inFlightByKey = new Set(); +type ConversationPlaneEntries = Record; +type SetConversationPlaneEntries = ( + update: (current: ConversationPlaneEntries) => ConversationPlaneEntries +) => void; +type SetCloudAuth = ( + update: (current: Org2CloudAuthState | null) => Org2CloudAuthState | null +) => void; + +interface RefreshConversationPlaneParams { + auth: Org2CloudAuthState; + orgId: string; + rootSessionId: string; + getEntry: () => ConversationPlaneEntry | undefined; + setEntries: SetConversationPlaneEntries; + setAuth: SetCloudAuth; +} + +const inFlightByKey = new Map>(); function mergePlaneEvents( previous: ConversationPlaneEntry, @@ -75,6 +94,90 @@ function mergePlaneEvents( }; } +/** + * One authoritative loader shared by the mounted transcript and the submit + * boundary. A capable backend must never race through the legacy visible-fork + * path merely because its first plane fetch is still in flight. + */ +export function refreshConversationPlaneEntry( + params: RefreshConversationPlaneParams +): Promise { + const key = conversationPlaneKey(params.orgId, params.rootSessionId); + const existing = inFlightByKey.get(key); + if (existing) return existing; + + const load = (async (): Promise => { + const before = params.getEntry() ?? EMPTY_ENTRY; + if (before.state !== "ready") { + params.setEntries((current) => ({ + ...current, + [key]: { ...before, state: "loading" }, + })); + } + try { + const fresh = await ensureFreshSession(params.auth); + if (!fresh) throw new Error("cloud auth refresh failed"); + commitRefreshedAuth(params.setAuth, params.auth, fresh); + const probe = await getCloudCapabilitiesConfirmed(fresh.accessToken); + if (!probe.capabilities.conversationEvents) { + if (!probe.confirmed) { + throw new Error( + "conversation plane capability probe was unconfirmed" + ); + } + const unsupported = { ...EMPTY_ENTRY, state: "unsupported" } as const; + params.setEntries((current) => ({ + ...current, + [key]: unsupported, + })); + return unsupported; + } + + let resolved = params.getEntry() ?? before; + let afterSeq = resolved.lastSeq; + for (;;) { + const page = await listConversationEvents(fresh.accessToken, { + orgId: params.orgId, + rootSessionId: params.rootSessionId, + afterSeq, + }); + params.setEntries((current) => { + const previous = current[key] ?? EMPTY_ENTRY; + resolved = mergePlaneEvents(previous, page.events); + return { ...current, [key]: resolved }; + }); + if (!page.hasMore || page.events.length === 0) break; + afterSeq = page.events[page.events.length - 1].seq; + } + return resolved; + } catch (error) { + params.setEntries((current) => { + const previous = current[key] ?? EMPTY_ENTRY; + if (previous.state === "ready") return current; + return { ...current, [key]: { ...previous, state: "error" } }; + }); + throw error; + } + })(); + inFlightByKey.set(key, load); + const clearInFlight = () => { + if (inFlightByKey.get(key) === load) inFlightByKey.delete(key); + }; + void load.then(clearInFlight, clearInFlight); + return load; +} + +/** Wait for the first capability/fetch decision; ready entries need no work. */ +export function ensureConversationPlaneEntry( + params: RefreshConversationPlaneParams +): Promise { + const current = params.getEntry(); + if (current?.state === "ready" || current?.state === "unsupported") { + return Promise.resolve(current); + } + return refreshConversationPlaneEntry(params); +} + /** * Keeps the plane entry for the given conversation target fetched and * incrementally fresh. Refetches whenever the org's signal counter bumps @@ -90,6 +193,7 @@ export function useConversationPlaneEvents( const entries = useAtomValue(conversationPlaneAtom); const setEntries = useSetAtom(conversationPlaneAtom); const signals = useAtomValue(conversationPlaneSignalAtom); + const lastForegroundRecoverAtRef = useRef(0); const targetOrgId = target?.orgId; const targetSessionId = target?.sessionId; const signal = targetOrgId ? (signals[targetOrgId] ?? 0) : 0; @@ -102,53 +206,17 @@ export function useConversationPlaneEvents( useEffect(() => { if (!targetOrgId || !targetSessionId || !key || !auth) return; const currentEntry = store.get(conversationPlaneAtom)[key] ?? EMPTY_ENTRY; - const entryState = currentEntry.state; - if (entryState === "unsupported") return; - if (inFlightByKey.has(key)) return; - inFlightByKey.add(key); - void (async () => { - try { - const fresh = await ensureFreshSession(auth); - if (!fresh) return; - commitRefreshedAuth(setAuth, auth, fresh); - const probe = await getCloudCapabilitiesConfirmed(fresh.accessToken); - if (!probe.capabilities.conversationEvents) { - if (probe.confirmed) { - setEntries((current) => ({ - ...current, - [key]: { ...EMPTY_ENTRY, state: "unsupported" }, - })); - } - return; - } - let afterSeq = currentEntry.lastSeq; - for (;;) { - const page = await listConversationEvents(fresh.accessToken, { - orgId: targetOrgId, - rootSessionId: targetSessionId, - afterSeq, - }); - setEntries((current) => { - const previous = current[key] ?? EMPTY_ENTRY; - return { - ...current, - [key]: mergePlaneEvents(previous, page.events), - }; - }); - if (!page.hasMore || page.events.length === 0) break; - afterSeq = page.events[page.events.length - 1].seq; - } - } catch (error) { - log.warn(`conversation plane fetch failed for ${key}`, error); - setEntries((current) => { - const previous = current[key] ?? EMPTY_ENTRY; - if (previous.state === "ready") return current; - return { ...current, [key]: { ...previous, state: "error" } }; - }); - } finally { - inFlightByKey.delete(key); - } - })(); + if (currentEntry.state === "unsupported") return; + void refreshConversationPlaneEntry({ + auth, + orgId: targetOrgId, + rootSessionId: targetSessionId, + getEntry: () => store.get(conversationPlaneAtom)[key], + setEntries, + setAuth, + }).catch((error: unknown) => { + log.warn(`conversation plane fetch failed for ${key}`, error); + }); }, [ targetOrgId, targetSessionId, @@ -160,9 +228,67 @@ export function useConversationPlaneEvents( store, ]); + // A short foreground switch does not release the shared Realtime socket + // (the lease intentionally has a blur grace), so it cannot rely on a new + // SUBSCRIBED edge to recover an at-most-once broadcast. Match the other + // Cloud planes: on actual foreground regain, run one cooldown-bounded + // incremental pull from this conversation's durable seq cursor. + useEffect(() => { + if ( + !targetOrgId || + !targetSessionId || + !key || + !auth || + typeof window === "undefined" || + typeof document === "undefined" + ) { + return undefined; + } + const recover = () => { + if (document.visibilityState === "hidden") return; + if (typeof document.hasFocus === "function" && !document.hasFocus()) { + return; + } + if ( + Date.now() - lastForegroundRecoverAtRef.current < + REALTIME_SIGNAL_COALESCE_MS + ) { + return; + } + // A native foreground transition can emit both `focus` and + // `visibilitychange`; collapse only that duplicate pair. Unlike the + // 30-second full-list cooldown used by heavier Cloud planes, each + // distinct app switch must advance this cheap `after_seq` cursor. + lastForegroundRecoverAtRef.current = Date.now(); + void refreshConversationPlaneEntry({ + auth, + orgId: targetOrgId, + rootSessionId: targetSessionId, + getEntry: () => store.get(conversationPlaneAtom)[key], + setEntries, + setAuth, + }).catch((error: unknown) => { + log.warn( + `conversation plane foreground recovery failed for ${key}`, + error + ); + }); + }; + window.addEventListener("focus", recover); + document.addEventListener("visibilitychange", recover); + return () => { + window.removeEventListener("focus", recover); + document.removeEventListener("visibilitychange", recover); + }; + }, [targetOrgId, targetSessionId, key, auth, setAuth, setEntries, store]); + return entry; } +export const __CONVERSATION_PLANE_INTERNALS = { + reset: () => inFlightByKey.clear(), +}; + /** Bump helper for realtime dispatch and local pushes. */ export function bumpConversationPlaneSignal( set: ( diff --git a/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts b/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts index 77da3fec8..e798eb772 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationPlaneEvents.ts @@ -1,3 +1,4 @@ +import { CONVERSATION_TURN_ID_ARG } from "@src/engines/SessionCore/conversations/localConversationContinuation"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; @@ -33,8 +34,12 @@ export function buildConversationPlaneStreamEvents( createdAt: inner.createdAt || row.createdAt, args: inner.source === "user" - ? { ...inner.args, [CONVERSATION_SENDER_ARG]: stamp } - : inner.args, + ? { + ...inner.args, + [CONVERSATION_SENDER_ARG]: stamp, + [CONVERSATION_TURN_ID_ARG]: row.turnId, + } + : { ...inner.args, [CONVERSATION_TURN_ID_ARG]: row.turnId }, }; return stamped; }); diff --git a/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx b/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx index a68844a0b..2ed7ca0a0 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx +++ b/src/features/Org2Cloud/SessionConversation/conversationRunnerScope.tsx @@ -1,8 +1,8 @@ /** * The live runner scope for a mounted conversation surface. * - * A member's turn runs in an invisible one-shot local runner, so the mounted - * imported session stays idle — its planning indicator and streaming-delta + * A member's turn runs in an invisible durable local execution Session, so + * the mounted imported session stays idle — its planning indicator and streaming-delta * footer never light up, and a long turn looks frozen (no "Thinking…", no * activity) until the tail lands. The conversation stream publishes the * in-flight runner's sessionId here; the chat footer reads it and scopes its diff --git a/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts b/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts index bf4df7379..de28d9ecb 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTimeline.ts @@ -9,6 +9,7 @@ * imported replay copy of it) keeps its local identity and takes the plane's * position; local events that predate the plane keep the timestamp merge. */ +import { CONVERSATION_TURN_ID_ARG } from "@src/engines/SessionCore/conversations/localConversationContinuation"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import type { CloudConversationEvent } from "../org2CloudConversationEventsClient"; @@ -41,9 +42,10 @@ function timestampMs(value: string | undefined): number { return Number.isFinite(ms) ? ms : 0; } -function stampSender( +function stampPlaneMetadata( event: SessionEvent, - row: CloudConversationEvent + row: CloudConversationEvent, + includeSender: boolean ): SessionEvent { const stamp: ConversationSenderStamp = { userId: row.authorUserId, @@ -51,7 +53,11 @@ function stampSender( }; return { ...event, - args: { ...event.args, [CONVERSATION_SENDER_ARG]: stamp }, + args: { + ...event.args, + ...(includeSender ? { [CONVERSATION_SENDER_ARG]: stamp } : {}), + [CONVERSATION_TURN_ID_ARG]: row.turnId, + }, }; } @@ -89,7 +95,7 @@ export function mergePlaneIntoTranscript( claimed.add(twin); event = row.event.source === "user" && row.authorUserId !== viewerUserId - ? stampSender(twin, row) + ? stampPlaneMetadata(twin, row, true) : twin; } else { event = planeStream[index]; diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts index 02f341a31..1f4bc477f 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts @@ -1,33 +1,17 @@ /** - * Conversation turn runner — the write half of the 0024 conversation-events - * plane (design: docs/conversation-events-plane-design-2026-08-21.md). + * Cloud-plane adapter for the provider-neutral local continuation core. * - * When a member chats in a conversation they do not own, the turn executes - * in a LOCAL, invisible one-shot runner session on their machine - * (sender-runs / sender-pays) and the resulting events are pushed — - * author-stamped — to the shared plane. No fork, no transcript copy, no new - * sidebar entity. - * - * ONE-SHOT per turn: `SessionService.create` is the only dispatch primitive - * proven headless (Routine/work-item background runs ride it), so every - * turn gets a fresh runner with the full bounded conversation context - * injected (the external-history handoff pattern) — never a dispatch into - * an unmounted surface. Runner sessions are plumbing: the caller forces - * their cloud sync OFF, and `collectConversationRunnerSessionIds` hides - * them from My Sessions. - * - * Push order is Slack-shaped: the user's message row goes out FIRST (every - * client sees it instantly), the agent tail follows under the same turnId - * when the local run completes. + * Cloud stores and orders canonical events; it never executes an Agent and + * never receives a local credential. The current local app selects one of its + * own runtimes, continues a normal persisted child Session, and publishes only + * that turn's normalized tail back to the shared plane. */ import Message from "@src/components/Message"; import { - getLastTurnTerminal, - turnLifecycleSignalAtom, -} from "@src/engines/SessionCore/control/turnLifecycle"; -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; + CONVERSATION_TURN_ID_ARG, + continueLocalConversation, +} from "@src/engines/SessionCore/conversations/localConversationContinuation"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { SessionService } from "@src/engines/SessionCore/services/SessionService"; import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; import { @@ -37,7 +21,6 @@ import { } from "@src/features/TeamCollaboration/forkSetupMemory"; import { createLogger } from "@src/hooks/logger"; import i18n from "@src/i18n"; -import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { boundConversationEventForPush, @@ -47,147 +30,26 @@ import { const log = createLogger("ConversationTurnRunner"); -const RUNNER_REGISTRY_KEY = "orgii:conversation-runners-v1"; -const TURN_DEADLINE_MS = 15 * 60_000; -const CONTEXT_MAX_ENTRIES = 60; -const CONTEXT_MAX_ENTRY_CHARS = 600; -const CONTEXT_MAX_TOTAL_CHARS = 18_000; - -interface RunnerRegistryEntry { - /** Every one-shot runner this device created for the conversation. */ - runnerSessionIds: string[]; - updatedAt: string; -} - -type RunnerRegistry = Record; - -function registryKey(orgId: string, rootSessionId: string): string { - return `${orgId}:${rootSessionId}`; -} - -function readRegistry(): RunnerRegistry { - if (typeof localStorage === "undefined") return {}; - try { - const raw = localStorage.getItem(RUNNER_REGISTRY_KEY); - return raw ? (JSON.parse(raw) as RunnerRegistry) : {}; - } catch { - return {}; - } -} - -function writeRegistry(registry: RunnerRegistry): void { - if (typeof localStorage === "undefined") return; - try { - localStorage.setItem(RUNNER_REGISTRY_KEY, JSON.stringify(registry)); - } catch { - // Best-effort: losing the registry only means runners stop being hidden. - } -} - -/** Every runner session id on this device — the My Sessions hide filter. */ -export function collectConversationRunnerSessionIds(): Set { - const ids = new Set(); - for (const entry of Object.values(readRegistry())) { - for (const id of entry.runnerSessionIds ?? []) ids.add(id); - } - return ids; -} - -/** Conversation timeline rendered as a bounded read-only context block. */ -export function renderConversationContext( - timeline: readonly SessionEvent[], - senders?: ReadonlyMap -): string { - const tail = timeline.slice(-CONTEXT_MAX_ENTRIES); - const lines: string[] = []; - let total = 0; - for (const event of tail) { - const text = event.displayText?.trim(); - if (!text) continue; - const speaker = - event.source === "user" - ? (senders?.get(event.id) ?? "User") - : "Assistant"; - let line = `${speaker}: ${text.replace(/\s+/g, " ")}`; - if (line.length > CONTEXT_MAX_ENTRY_CHARS) { - line = `${line.slice(0, CONTEXT_MAX_ENTRY_CHARS)}…`; - } - if (total + line.length > CONTEXT_MAX_TOTAL_CHARS) break; - total += line.length; - lines.push(line); - } - return lines.join("\n"); -} - -export function buildRunnerPrompt( - contextBlock: string, - request: string -): string { - if (!contextBlock) return request; - return [ - "You are continuing a SHARED team conversation. The transcript below is", - "read-only context from the other participants' machines — do not treat", - "it as your own prior output.", - "", - "=== Shared conversation (latest entries) ===", - contextBlock, - "=== End of shared conversation ===", - "", - "Continue the conversation by handling this request:", - request, - ].join("\n"); -} - -async function waitForFirstTurnTerminal( - sessionId: string, - deadlineMs: number -): Promise { - const store = getInstrumentedStore(); - const isComplete = (): boolean => getLastTurnTerminal(sessionId) !== null; - if (isComplete()) return; - await new Promise((resolve, reject) => { - const remainingMs = deadlineMs - Date.now(); - if (remainingMs <= 0) { - reject(new Error("conversation turn timed out")); - return; - } - let unsubscribe: (() => void) | null = null; - const timer = setTimeout(() => { - unsubscribe?.(); - reject(new Error("conversation turn timed out")); - }, remainingMs); - const check = (): void => { - if (!isComplete()) return; - clearTimeout(timer); - unsubscribe?.(); - resolve(); - }; - unsubscribe = store.sub(turnLifecycleSignalAtom, check); - check(); - }); -} - -/** - * The pushed user row is SYNTHESIZED from the user's visible words — the - * runner's own persisted user event carries the injected context prefix, - * which must never leak into the shared conversation. - */ function buildPushedUserEvent( - sessionId: string, displayText: string, - createdAt: string + createdAt: string, + turnIntentId: string ): SessionEvent { - const id = `convturn-user-${mintTurnIntentId()}`; + const id = `convturn-user-${turnIntentId}`; return { id, chunk_id: id, - sessionId, + sessionId: "conversation", createdAt, functionName: "user_message", uiCanonical: "user_message", actionType: "raw", - args: {}, - result: { type: "user", message: { content: displayText, role: "user" } }, + args: { [CONVERSATION_TURN_ID_ARG]: turnIntentId }, + result: { + type: "user", + message: { content: displayText, role: "user" }, + turnIntentId, + }, source: "user", displayText, displayStatus: "completed", @@ -198,11 +60,7 @@ function buildPushedUserEvent( } export interface RunConversationTurnParams { - /** - * Resolved before EVERY push. A turn can outlive the access token that - * was valid at dispatch (a 10-minute tool-heavy turn did, live), so the - * tail push must never reuse a token captured at the start. - */ + /** Resolved separately for every push; long turns may outlive a JWT. */ getAccessToken: () => Promise; orgId: string; rootSessionId: string; @@ -210,46 +68,31 @@ export interface RunConversationTurnParams { displayText: string; agentContent?: string; imageDataUrls?: string[]; - /** Merged conversation timeline for the read-only context prefix. */ + /** Canonical merged transcript immediately before this turn. */ timeline: readonly SessionEvent[]; sourceScopeKey?: string; sourceModel?: string; - /** - * Called as soon as the one-shot runner session id is known, with the - * turnId the tail will be pushed under. The caller overlays the runner's - * LIVE events until the plane carries this turnId. - */ - onRunnerReady?: (runnerSessionId: string, turnId: string) => void; - /** - * Fires after push #1 (the user's message row) lands on the plane — the - * composer unblocks here; the agent tail streams in later under the same - * turnId. - */ + turnIntentId?: string; + onRunnerReady?: ( + sessionId: string, + turnId: string, + eventStartIndex: number + ) => void; onUserMessagePublished?: () => void; - /** Fires after each successful push (signal-bump hook). */ onPushed?: () => void; } export interface RunConversationTurnResult { runnerSessionId: string; pushedEventCount: number; + turnIntentId: string; } export async function runConversationTurn( params: RunConversationTurnParams ): Promise { - const key = registryKey(params.orgId, params.rootSessionId); - const contextBlock = renderConversationContext(params.timeline); - const request = params.agentContent ?? params.displayText; - const deadlineMs = Date.now() + TURN_DEADLINE_MS; + const turnIntentId = params.turnIntentId ?? mintTurnIntentId(); const dispatchIso = new Date().toISOString(); - const turnId = crypto.randomUUID(); - - // The execution setup must exist BEFORE the user's words go public — a - // cancelled setup dialog cancels the whole send. Per-repo-scope memory - // keeps this silent after the first confirmation (the forkTeammateSession - // idiom): dialog once, remember, reuse with a toast; a failed remembered - // launch clears the memory and re-prompts exactly once below. const remembered = loadForkSetupMemory(params.sourceScopeKey); let usedRememberedSetup = Boolean(remembered); let setup = @@ -258,93 +101,102 @@ export async function runConversationTurn( sourceTitle: params.conversationTitle, sourceScopeKey: params.sourceScopeKey, sourceModel: params.sourceModel, + allowCliRuntime: true, })); if (!remembered) saveForkSetupMemory(params.sourceScopeKey, setup); - await pushConversationEvents(await params.getAccessToken(), { - orgId: params.orgId, - rootSessionId: params.rootSessionId, - turnId, - events: [ - boundConversationEventForPush( - buildPushedUserEvent("conversation", params.displayText, dispatchIso) - ), - ], - }); - params.onPushed?.(); - params.onUserMessagePublished?.(); - - const createRunner = () => - SessionService.create({ - task: buildRunnerPrompt(contextBlock, request), + let userPublished = false; + const beforeDispatch = async () => { + if (userPublished) return; + await pushConversationEvents(await params.getAccessToken(), { + orgId: params.orgId, + rootSessionId: params.rootSessionId, + turnId: turnIntentId, + events: [ + boundConversationEventForPush( + buildPushedUserEvent(params.displayText, dispatchIso, turnIntentId) + ), + ], + }); + userPublished = true; + params.onPushed?.(); + params.onUserMessagePublished?.(); + }; + + const execute = () => + continueLocalConversation({ + root: { + authority: "org2-cloud", + authorityScope: [params.orgId], + conversationId: params.rootSessionId, + }, + title: params.conversationTitle, + timeline: params.timeline, + displayText: params.displayText, + agentContent: params.agentContent, imageDataUrls: params.imageDataUrls, - name: params.conversationTitle, - repoPath: setup.workspaceRepoPath ?? undefined, - model: setup.execution.model, - accountId: setup.execution.accountId, - keySource: "own_key", - agentDefinitionId: setup.execution.agentDefinitionId, - mode: "build", + target: { + agentDefinitionId: setup.execution.agentDefinitionId, + cliAgentType: setup.execution.cliAgentType, + accountId: setup.execution.accountId, + model: setup.execution.model, + workspaceRepoPath: setup.workspaceRepoPath, + }, + turnIntentId, + beforeDispatch, + onSessionReady: (sessionId, eventStartIndex) => + params.onRunnerReady?.(sessionId, turnIntentId, eventStartIndex), }); - let created; + + let result; try { - created = await createRunner(); + result = await execute(); } catch (error) { if (!usedRememberedSetup) throw error; - // The remembered setup went stale (checkout moved, account or model - // removed). Drop it and fall back to the dialog once. - log.warn("remembered runner setup failed; re-prompting", error); + // A removed checkout/account/runtime invalidates only the remembered + // local preference. The already-published user row remains the same + // stable turn; retrying does not duplicate it. + log.warn("remembered conversation setup failed; re-prompting", error); clearForkSetupMemory(params.sourceScopeKey); setup = await requestForkSessionSetup({ sourceTitle: params.conversationTitle, sourceScopeKey: params.sourceScopeKey, sourceModel: params.sourceModel, + allowCliRuntime: true, }); saveForkSetupMemory(params.sourceScopeKey, setup); usedRememberedSetup = false; - created = await createRunner(); + result = await execute(); } + if (usedRememberedSetup) { Message.info( i18n.t("navigation:collaboration.session.forkSetupReused", { - model: setup.execution.model ?? setup.execution.agentDefinitionId, + model: + setup.execution.cliAgentType ?? + setup.execution.model ?? + setup.execution.agentDefinitionId, }) ); } - const runnerSessionId = created.sessionId; - const registry = readRegistry(); - const entry = registry[key]; - writeRegistry({ - ...registry, - [key]: { - runnerSessionIds: [...(entry?.runnerSessionIds ?? []), runnerSessionId], - updatedAt: dispatchIso, - }, - }); - params.onRunnerReady?.(runnerSessionId, turnId); - await waitForFirstTurnTerminal(runnerSessionId, deadlineMs); - - const persisted = await eventStoreProxy - .getPersistedEvents(runnerSessionId) - .catch(() => [] as SessionEvent[]); - // The runner's own user event carries the injected context prefix (never - // pushed — the clean user row already went out in push #1); the agent and - // tool tail is the shared payload. - const agentTail = persisted - .filter((event) => event.source !== "user") - .map(boundConversationEventForPush); + const agentTail = result.agentTail.map(boundConversationEventForPush); if (agentTail.length > 0) { await pushConversationEventsChunked(await params.getAccessToken(), { orgId: params.orgId, rootSessionId: params.rootSessionId, - turnId, + turnId: turnIntentId, events: agentTail, }); params.onPushed?.(); } log.info( - `pushed conversation turn ${turnId}: 1 + ${agentTail.length} event(s) to ${key}` + `continued ${params.orgId}:${params.rootSessionId} in ${result.sessionId}; ` + + `pushed 1 + ${agentTail.length} event(s)` ); - return { runnerSessionId, pushedEventCount: 1 + agentTail.length }; + return { + runnerSessionId: result.sessionId, + pushedEventCount: 1 + agentTail.length, + turnIntentId, + }; } diff --git a/src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding.ts b/src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding.ts index 4d648122a..be8d2b059 100644 --- a/src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding.ts +++ b/src/features/Org2Cloud/SessionConversation/useConversationSetupPillBinding.ts @@ -78,7 +78,7 @@ export function useConversationSetupPillBinding( if (!importedFrom) return null; void memoryVersion; const remembered = loadForkSetupMemory(scopeKey); - if (!remembered) return null; + if (!remembered || remembered.execution.cliAgentType) return null; return { keySource: KEY_SOURCE.OWN, model: remembered.execution.model, @@ -90,13 +90,15 @@ export function useConversationSetupPillBinding( (config: AdvancedConfig): boolean => { if (isHostedKey(config.keySource) || !config.model) return false; const current = loadForkSetupMemory(scopeKey); - if (!current) return false; + if (!current || current.execution.cliAgentType) return false; + const accountId = config.selectedAccountId ?? current.execution.accountId; + if (!accountId) return false; saveForkSetupMemory(scopeKey, { ...current, execution: { - ...current.execution, + agentDefinitionId: current.execution.agentDefinitionId, model: config.model, - accountId: config.selectedAccountId ?? current.execution.accountId, + accountId, }, }); return true; diff --git a/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.ts b/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.ts index e9d3fa83a..87f16fa83 100644 --- a/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.ts +++ b/src/features/Org2Cloud/org2CloudRemoteSessionsAtom.ts @@ -27,7 +27,6 @@ import { org2CloudAuthIdentityKey, } from "./org2CloudAuthAtom"; import { ensureFreshSession } from "./org2CloudClient"; -import { FOCUS_REFRESH_COOLDOWN_MS } from "./org2CloudRealtimeRecovery"; import { listOrgSessions } from "./org2CloudSyncClient"; const log = createLogger("Org2CloudRemoteSessions"); @@ -488,12 +487,12 @@ export function useCloudOrgRemoteSessions( requestState, ]); - // A foreground transition is an explicit recovery boundary: the Realtime - // lease was released while unfocused/hidden, so replace the listing once - // after focus returns. This also gives a timed-out initial RPC a deterministic - // user-driven retry without introducing a timer loop. Flap-cooled: the - // recovery is a FULL paged listing, so alt-tab bursts pay for one. - const lastFocusRecoverAtRef = useRef(0); + // A foreground transition is an explicit recovery boundary. The Realtime + // lease may remain alive through its blur grace, but at-most-once signals + // can still be missed; replace the listing once after every real away → + // foreground transition. Transition state (rather than a time cooldown) + // deduplicates the focus + visibility pair without delaying revocations. + const needsForegroundRecoveryRef = useRef(false); useEffect(() => { if ( !orgId || @@ -504,11 +503,15 @@ export function useCloudOrgRemoteSessions( ) { return undefined; } + const markAway = () => { + needsForegroundRecoveryRef.current = true; + }; const recover = () => { if ( typeof document !== "undefined" && document.visibilityState === "hidden" ) { + markAway(); return; } if ( @@ -518,19 +521,18 @@ export function useCloudOrgRemoteSessions( ) { return; } - if (requestState.inFlightKeys.has(`${authIdentityKey}|${orgId}`)) return; - if ( - Date.now() - lastFocusRecoverAtRef.current < - FOCUS_REFRESH_COOLDOWN_MS - ) { + if (!needsForegroundRecoveryRef.current) return; + if (requestState.inFlightKeys.has(`${authIdentityKey}|${orgId}`)) { return; } - lastFocusRecoverAtRef.current = Date.now(); + needsForegroundRecoveryRef.current = false; void fetchOrgSessions(orgId, { full: true }); }; + window.addEventListener("blur", markAway); window.addEventListener("focus", recover); document.addEventListener("visibilitychange", recover); return () => { + window.removeEventListener("blur", markAway); window.removeEventListener("focus", recover); document.removeEventListener("visibilitychange", recover); }; diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts index 1cc87b04f..5c102036b 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.test.ts @@ -8,6 +8,7 @@ import { activeSessionIdAtom } from "@src/store/session/viewAtom"; import { chatPanelSelectedCloudOrgAtom } from "@src/store/ui/chatPanelAtom"; import { type SmokeRoot, createSmokeRoot } from "@src/test/reactSmokeHarness"; +import { conversationPlaneSignalAtom } from "./SessionConversation/conversationPlaneAtom"; import { org2CloudAuthAtom } from "./org2CloudAuthAtom"; import { type Org2CloudOrg, @@ -378,4 +379,22 @@ describe("useOrg2CloudRealtime lifecycle", () => { expect(connection.presences[0]?.handle.leave).toHaveBeenCalledOnce(); expect(vi.getTimerCount()).toBe(baselineTimerCount); }); + + it("invalidates the canonical conversation plane on every visible subscribed edge", async () => { + await mount(); + const connection = connections[0]!; + const signalSubscription = subscription( + connection, + "org_change_signals", + "org_id=eq.org-a" + ); + const before = store.get(conversationPlaneSignalAtom)["org-a"] ?? 0; + + act(() => signalSubscription.options.onStatus?.(true)); + const afterFull = store.get(conversationPlaneSignalAtom)["org-a"] ?? 0; + expect(afterFull).toBe(before + 1); + + act(() => signalSubscription.options.onStatus?.(true)); + expect(store.get(conversationPlaneSignalAtom)["org-a"]).toBe(afterFull + 1); + }); }); diff --git a/src/features/Org2Cloud/useOrg2CloudRealtime.ts b/src/features/Org2Cloud/useOrg2CloudRealtime.ts index ee3e4a8ad..9436ab920 100644 --- a/src/features/Org2Cloud/useOrg2CloudRealtime.ts +++ b/src/features/Org2Cloud/useOrg2CloudRealtime.ts @@ -708,6 +708,11 @@ export function useOrg2CloudRealtime(): void { bumpOrgCommentsSignal(orgId); bumpChannelsVersion(orgId); bumpChannelMessagesVersion(orgId); + // Conversation-plane broadcasts are at-most-once too. If this + // client yielded the realtime lease while another app window was + // focused, the canonical transcript must catch up on the next + // subscribe edge just like sessions, comments, and Team Chat. + bumpConversationPlaneVersion(orgId); return; } orgFullRecoveryAtRef.current.set(orgId, Date.now()); @@ -733,6 +738,10 @@ export function useOrg2CloudRealtime(): void { // Messages posted/edited/deleted during the gap arrive through the // channel's own `p_since` delta, which already carries tombstones. bumpChannelMessagesVersion(orgId); + // Pull every mounted canonical conversation through its bounded + // `after_seq` cursor. This is an invalidation only; no transcript or + // merge semantics are duplicated in realtime. + bumpConversationPlaneVersion(orgId); }, [ armCoarseSignalSafetyNet, @@ -741,6 +750,7 @@ export function useOrg2CloudRealtime(): void { bumpActiveSessionCommentsSignal, bumpChannelsVersion, bumpChannelMessagesVersion, + bumpConversationPlaneVersion, refreshEntitlementForOrg, ] ); diff --git a/src/features/TeamCollaboration/components/ForkSessionSetupDialog/index.tsx b/src/features/TeamCollaboration/components/ForkSessionSetupDialog/index.tsx index 69bbd52aa..99c1d3f76 100644 --- a/src/features/TeamCollaboration/components/ForkSessionSetupDialog/index.tsx +++ b/src/features/TeamCollaboration/components/ForkSessionSetupDialog/index.tsx @@ -3,9 +3,11 @@ import { atom, useAtom } from "jotai"; import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { CliAgentTypeSchema } from "@src/api/tauri/rpc/schemas/validation"; import Button from "@src/components/Button"; import Select from "@src/components/Select"; import type { SelectOption } from "@src/components/Select"; +import { getCliTransportLabel } from "@src/config/cliAgents"; import { accountHasModel, accountModelIds, @@ -13,6 +15,7 @@ import { } from "@src/hooks/models/useModelAccountLookup"; import { useAgentDefinitions } from "@src/modules/MainApp/AgentOrgs/hooks/useAgentDefinitions"; import type { AgentDefinition } from "@src/modules/MainApp/AgentOrgs/types"; +import { useCliAgents } from "@src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents"; import useSharedRepoList from "@src/scaffold/GlobalSpotlight/hooks/data/useSharedRepoList"; import type { RepoItem } from "@src/scaffold/GlobalSpotlight/types"; @@ -25,7 +28,10 @@ import { primeShareableScopeKey, subscribeShareableScopeKeys, } from "../../repoScopeResolver"; -import { resolveForkModelPreselection } from "./modelPreselection"; +import { + resolveForkAgentPreselection, + resolveForkModelPreselection, +} from "./modelPreselection"; export interface ForkSessionSetupSelection { workspaceRepoPath: string | null; @@ -38,6 +44,7 @@ export interface ForkSessionSetupRequest { sourceModel?: string; sourceAgentDisplayName?: string; sourceAgentDefinitionId?: string; + allowCliRuntime?: boolean; resolve: (selection: ForkSessionSetupSelection | null) => void; } @@ -66,14 +73,6 @@ function agentDisplayLabel(agent: AgentDefinition): string { : agent.name; } -function agentPrefersModel( - agent: AgentDefinition, - model: string | undefined -): boolean { - if (!model) return false; - return agent.selectedModelId === model; -} - const ForkSessionSetupForm: React.FC = ({ request, resolve, @@ -81,6 +80,9 @@ const ForkSessionSetupForm: React.FC = ({ const { t } = useTranslation("navigation"); const { accounts } = useModelAccountLookup(); const { builtInAgents, agents: customAgents } = useAgentDefinitions(); + const { agents: cliAgents } = useCliAgents({ + enabled: request.allowCliRuntime === true, + }); const allAgents = useMemo( () => [...builtInAgents, ...customAgents], [builtInAgents, customAgents] @@ -92,6 +94,7 @@ const ForkSessionSetupForm: React.FC = ({ const [chosenAccountId, setChosenAccountId] = useState(""); const [chosenModel, setChosenModel] = useState(""); const [chosenAgentDefinitionId, setChosenAgentDefinitionId] = useState(""); + const [chosenRuntime, setChosenRuntime] = useState("native"); const [workspaceRepoPath, setWorkspaceRepoPath] = useState( null ); @@ -123,18 +126,15 @@ const ForkSessionSetupForm: React.FC = ({ : undefined) ?? runnableAccounts[0], [sourceModel, runnableAccounts] ); - const preferredAgent = useMemo(() => { - const sourceAgent = sourceAgentDefinitionId - ? allAgents.find((agent) => agent.id === sourceAgentDefinitionId) - : undefined; - if (sourceAgent) return sourceAgent; - if (sourceModel) { - return allAgents.find((agent) => agentPrefersModel(agent, sourceModel)); - } - return ( - allAgents.find((agent) => agent.id === "builtin:sde") ?? allAgents[0] - ); - }, [allAgents, sourceAgentDefinitionId, sourceModel]); + const preferredAgent = useMemo( + () => + resolveForkAgentPreselection( + allAgents, + sourceAgentDefinitionId, + sourceModel + ), + [allAgents, sourceAgentDefinitionId, sourceModel] + ); const selectedAgent = useMemo( () => allAgents.find( @@ -170,6 +170,42 @@ const ForkSessionSetupForm: React.FC = ({ })), [allAgents] ); + const runnableCliAgents = useMemo( + () => + cliAgents.flatMap((agent) => { + const parsed = CliAgentTypeSchema.safeParse(agent.name); + return agent.installed && agent.supportsGui && parsed.success + ? [{ agent, cliAgentType: parsed.data }] + : []; + }), + [cliAgents] + ); + const runtimeOptions = useMemo( + () => [ + { + value: "native", + label: t("collaboration.session.forkSetupRuntimeNative"), + }, + ...runnableCliAgents.map(({ agent, cliAgentType }) => ({ + value: `cli:${cliAgentType}`, + label: `${agent.displayName} · ${t( + "collaboration.session.forkSetupRuntimeCli" + )} (${getCliTransportLabel(cliAgentType)})`, + triggerLabel: agent.displayName, + })), + ], + [runnableCliAgents, t] + ); + const selectedCliAgent = useMemo(() => { + if (!chosenRuntime.startsWith("cli:")) return null; + const parsed = CliAgentTypeSchema.safeParse(chosenRuntime.slice(4)); + if (!parsed.success) return null; + return ( + runnableCliAgents.find( + (candidate) => candidate.cliAgentType === parsed.data + ) ?? null + ); + }, [chosenRuntime, runnableCliAgents]); const modelOptions = useMemo(() => { if (!selectedAccount) return []; return accountModelIds(selectedAccount) @@ -203,9 +239,15 @@ const ForkSessionSetupForm: React.FC = ({ ? normalizeRepoScopeKey(request.sourceScopeKey) : null; const workspaceRequired = Boolean(targetKey); + const nativeExecutionReady = + Boolean(selectedAccount && accountId && model) && + Boolean(selectedAccount && accountHasModel(selectedAccount, model)); + const executionReady = selectedCliAgent + ? true + : chosenRuntime === "native" && nativeExecutionReady; const canContinue = - Boolean(selectedAccount && accountId && model && selectedAgent) && - Boolean(selectedAccount && accountHasModel(selectedAccount, model)) && + Boolean(selectedAgent) && + executionReady && (!workspaceRequired || Boolean(workspaceRepoPath)); useEffect(() => { @@ -237,11 +279,16 @@ const ForkSessionSetupForm: React.FC = ({ if (!canContinue || !selectedAgent) return; resolve({ workspaceRepoPath, - execution: { - agentDefinitionId: selectedAgent.id, - accountId, - model, - }, + execution: selectedCliAgent + ? { + agentDefinitionId: selectedAgent.id, + cliAgentType: selectedCliAgent.cliAgentType, + } + : { + agentDefinitionId: selectedAgent.id, + accountId, + model, + }, }); }; @@ -317,7 +364,11 @@ const ForkSessionSetupForm: React.FC = ({ )} -
+
- + ) : null} + {!selectedCliAgent ? ( + <> + + + ) : null}
diff --git a/src/features/TeamCollaboration/components/ForkSessionSetupDialog/modelPreselection.test.ts b/src/features/TeamCollaboration/components/ForkSessionSetupDialog/modelPreselection.test.ts index 9869fe5a5..402ed9c3f 100644 --- a/src/features/TeamCollaboration/components/ForkSessionSetupDialog/modelPreselection.test.ts +++ b/src/features/TeamCollaboration/components/ForkSessionSetupDialog/modelPreselection.test.ts @@ -1,6 +1,31 @@ import { describe, expect, it } from "vitest"; -import { resolveForkModelPreselection } from "./modelPreselection"; +import { + resolveForkAgentPreselection, + resolveForkModelPreselection, +} from "./modelPreselection"; + +describe("resolveForkAgentPreselection", () => { + const agents = [ + { id: "builtin:base", selectedModelId: "claude-opus-5" }, + { id: "builtin:sde", selectedModelId: "gpt-5.2-codex" }, + ]; + + it("uses the source agent, then a source-model match", () => { + expect( + resolveForkAgentPreselection(agents, "builtin:base", "gpt-5.2-codex")?.id + ).toBe("builtin:base"); + expect( + resolveForkAgentPreselection(agents, undefined, "claude-opus-5")?.id + ).toBe("builtin:base"); + }); + + it("falls back to SDE when the imported model matches no local agent", () => { + expect( + resolveForkAgentPreselection(agents, undefined, "gpt-4o-mini")?.id + ).toBe("builtin:sde"); + }); +}); describe("resolveForkModelPreselection", () => { it("prefers the source session's model over the fallback agent's model", () => { diff --git a/src/features/TeamCollaboration/components/ForkSessionSetupDialog/modelPreselection.ts b/src/features/TeamCollaboration/components/ForkSessionSetupDialog/modelPreselection.ts index c180d4e91..637d248a8 100644 --- a/src/features/TeamCollaboration/components/ForkSessionSetupDialog/modelPreselection.ts +++ b/src/features/TeamCollaboration/components/ForkSessionSetupDialog/modelPreselection.ts @@ -1,5 +1,5 @@ /** - * Model preselection for the fork-and-continue setup dialog. + * Agent/model preselection for the fork-and-continue setup dialog. * * Priority question: when the fork's execution agent was NOT explicitly * pinned (no user choice, no source agent hint — the local external-history @@ -10,6 +10,29 @@ * pick or a collab-fork agent hint) keeps its own configured model first — * choosing an agent IS choosing its setup. */ +export interface ForkAgentPreselectionCandidate { + id: string; + selectedModelId?: string; +} + +export function resolveForkAgentPreselection< + T extends ForkAgentPreselectionCandidate, +>( + agents: readonly T[], + sourceAgentDefinitionId: string | undefined, + sourceModel: string | undefined +): T | undefined { + const sourceAgent = sourceAgentDefinitionId + ? agents.find((agent) => agent.id === sourceAgentDefinitionId) + : undefined; + if (sourceAgent) return sourceAgent; + const modelAgent = sourceModel + ? agents.find((agent) => agent.selectedModelId === sourceModel) + : undefined; + if (modelAgent) return modelAgent; + return agents.find((agent) => agent.id === "builtin:sde") ?? agents[0]; +} + export interface ForkModelPreselectionInput { /** Model the user explicitly picked in the dialog ("" = none). */ chosenModel: string; diff --git a/src/features/TeamCollaboration/engine/collabSessionFork.ts b/src/features/TeamCollaboration/engine/collabSessionFork.ts index a53fd5a29..1fd6d5aea 100644 --- a/src/features/TeamCollaboration/engine/collabSessionFork.ts +++ b/src/features/TeamCollaboration/engine/collabSessionFork.ts @@ -116,11 +116,20 @@ export interface ForkSessionResult { modelFallback?: { inheritedModel: string; fallbackModel?: string }; } -export interface ForkExecutionSelection { - agentDefinitionId: string; - accountId: string; - model: string; -} +export type ForkExecutionSelection = + | { + agentDefinitionId: string; + cliAgentType?: never; + accountId: string; + model: string; + } + | { + agentDefinitionId: string; + /** Managed External CLI runtime authenticated on this device. */ + cliAgentType: string; + accountId?: never; + model?: never; + }; export interface ForkSessionOptions extends RemoteSessionFetchOptions { /** Explicit local credentials/model chosen by the member continuing it. */ @@ -252,7 +261,10 @@ export async function forkSession( } if ( options.execution && - (localKeys === null || + (options.execution.cliAgentType || + !options.execution.accountId || + !options.execution.model || + localKeys === null || !isModelRunnableWithAccount( options.execution.accountId, options.execution.model, diff --git a/src/features/TeamCollaboration/forkSetupMemory.test.ts b/src/features/TeamCollaboration/forkSetupMemory.test.ts new file mode 100644 index 000000000..948908549 --- /dev/null +++ b/src/features/TeamCollaboration/forkSetupMemory.test.ts @@ -0,0 +1,63 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { loadForkSetupMemory, saveForkSetupMemory } from "./forkSetupMemory"; + +const STORAGE_KEY = "orgii:fork-setup-memory-v1"; + +beforeEach(() => { + window.localStorage.clear(); +}); + +describe("fork setup memory", () => { + it("round-trips native and managed CLI execution choices", () => { + saveForkSetupMemory("repo-native", { + workspaceRepoPath: "/native", + execution: { + agentDefinitionId: "builtin:sde", + accountId: "openai", + model: "gpt-test", + }, + }); + saveForkSetupMemory("repo-cli", { + workspaceRepoPath: "/cli", + execution: { + agentDefinitionId: "builtin:sde", + cliAgentType: "claude_code", + }, + }); + + expect(loadForkSetupMemory("repo-native")).toEqual({ + workspaceRepoPath: "/native", + execution: { + agentDefinitionId: "builtin:sde", + accountId: "openai", + model: "gpt-test", + }, + }); + expect(loadForkSetupMemory("repo-cli")).toEqual({ + workspaceRepoPath: "/cli", + execution: { + agentDefinitionId: "builtin:sde", + cliAgentType: "claude_code", + }, + }); + }); + + it("rejects incomplete persisted execution records", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + repo: { + workspaceRepoPath: "/repo", + execution: { + agentDefinitionId: "builtin:sde", + accountId: "openai", + }, + savedAt: "2026-08-26T00:00:00.000Z", + }, + }) + ); + + expect(loadForkSetupMemory("repo")).toBeNull(); + }); +}); diff --git a/src/features/TeamCollaboration/forkSetupMemory.ts b/src/features/TeamCollaboration/forkSetupMemory.ts index f4e25a12a..688b33c43 100644 --- a/src/features/TeamCollaboration/forkSetupMemory.ts +++ b/src/features/TeamCollaboration/forkSetupMemory.ts @@ -53,10 +53,37 @@ export function loadForkSetupMemory( repoScopeKey: string | null | undefined ): ForkSessionSetupSelection | null { const entry = readAll()[memoryKey(repoScopeKey)]; - if (!entry?.execution?.agentDefinitionId) return null; + const execution = entry?.execution as + | Record + | null + | undefined; + if ( + !execution || + typeof execution.agentDefinitionId !== "string" || + !execution.agentDefinitionId + ) { + return null; + } + const parsedExecution = + typeof execution.cliAgentType === "string" && execution.cliAgentType + ? { + agentDefinitionId: execution.agentDefinitionId, + cliAgentType: execution.cliAgentType, + } + : typeof execution.accountId === "string" && + execution.accountId && + typeof execution.model === "string" && + execution.model + ? { + agentDefinitionId: execution.agentDefinitionId, + accountId: execution.accountId, + model: execution.model, + } + : null; + if (!parsedExecution) return null; return { workspaceRepoPath: entry.workspaceRepoPath, - execution: entry.execution, + execution: parsedExecution, }; } diff --git a/src/features/TeamCollaboration/forkWorkspaceResolution.ts b/src/features/TeamCollaboration/forkWorkspaceResolution.ts index 855db6fb6..82503c68f 100644 --- a/src/features/TeamCollaboration/forkWorkspaceResolution.ts +++ b/src/features/TeamCollaboration/forkWorkspaceResolution.ts @@ -133,6 +133,8 @@ export interface ForkSessionSetupSource { sourceModel?: string; sourceAgentDisplayName?: string; sourceAgentDefinitionId?: string; + /** Offer installed managed external CLIs as local continuation runtimes. */ + allowCliRuntime?: boolean; } /** @@ -166,6 +168,7 @@ export async function requestForkSessionSetup( sourceModel: source.sourceModel, sourceAgentDisplayName: source.sourceAgentDisplayName, sourceAgentDefinitionId: source.sourceAgentDefinitionId, + allowCliRuntime: source.allowCliRuntime, resolve, }); } diff --git a/src/i18n/locales/de/navigation.json b/src/i18n/locales/de/navigation.json index 8a29b08da..2943c8f00 100644 --- a/src/i18n/locales/de/navigation.json +++ b/src/i18n/locales/de/navigation.json @@ -395,7 +395,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/en/navigation.json b/src/i18n/locales/en/navigation.json index 9f87bd209..ebe083926 100644 --- a/src/i18n/locales/en/navigation.json +++ b/src/i18n/locales/en/navigation.json @@ -251,7 +251,10 @@ "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", - "forkSetupReused": "Continuing with your last setup · {{model}}" + "forkSetupReused": "Continuing with your last setup · {{model}}", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/es/navigation.json b/src/i18n/locales/es/navigation.json index 53a9f0ef8..20b711282 100644 --- a/src/i18n/locales/es/navigation.json +++ b/src/i18n/locales/es/navigation.json @@ -395,7 +395,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/fr/navigation.json b/src/i18n/locales/fr/navigation.json index 60e1c14fa..1c8b211ca 100644 --- a/src/i18n/locales/fr/navigation.json +++ b/src/i18n/locales/fr/navigation.json @@ -395,7 +395,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/ja/navigation.json b/src/i18n/locales/ja/navigation.json index c9e1b237a..943d3eee1 100644 --- a/src/i18n/locales/ja/navigation.json +++ b/src/i18n/locales/ja/navigation.json @@ -393,7 +393,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/ko/navigation.json b/src/i18n/locales/ko/navigation.json index 895467263..eab6d5a7f 100644 --- a/src/i18n/locales/ko/navigation.json +++ b/src/i18n/locales/ko/navigation.json @@ -393,7 +393,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/pl/navigation.json b/src/i18n/locales/pl/navigation.json index 2e8f271d1..6e6eda072 100644 --- a/src/i18n/locales/pl/navigation.json +++ b/src/i18n/locales/pl/navigation.json @@ -393,7 +393,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/pt/navigation.json b/src/i18n/locales/pt/navigation.json index 5b1c14f6a..fba925cb7 100644 --- a/src/i18n/locales/pt/navigation.json +++ b/src/i18n/locales/pt/navigation.json @@ -395,7 +395,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/ru/navigation.json b/src/i18n/locales/ru/navigation.json index 71632ab9a..c7797d93c 100644 --- a/src/i18n/locales/ru/navigation.json +++ b/src/i18n/locales/ru/navigation.json @@ -393,7 +393,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/tr/navigation.json b/src/i18n/locales/tr/navigation.json index 6c41c6446..161f699b8 100644 --- a/src/i18n/locales/tr/navigation.json +++ b/src/i18n/locales/tr/navigation.json @@ -393,7 +393,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/vi/navigation.json b/src/i18n/locales/vi/navigation.json index bcff7799b..fb64bd62d 100644 --- a/src/i18n/locales/vi/navigation.json +++ b/src/i18n/locales/vi/navigation.json @@ -393,7 +393,10 @@ "forkSnapshotIncomplete": "The cloud snapshot is incomplete. Ask the owner to finish syncing, then retry.", "forkAgentUnavailable": "Choose a local agent before forking.", "forkBackendRegistrationFailed": "The session history was copied, but the runnable fork could not be registered. Retry.", - "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry." + "forkReplayUnavailable": "Full replay is not available for this session. Ask the owner to share the replay, then retry.", + "forkSetupRuntime": "Runtime", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/zh-Hant/navigation.json b/src/i18n/locales/zh-Hant/navigation.json index f2482a7f4..c9e421835 100644 --- a/src/i18n/locales/zh-Hant/navigation.json +++ b/src/i18n/locales/zh-Hant/navigation.json @@ -485,7 +485,10 @@ "forkSnapshotIncomplete": "雲端快照不完整。請讓擁有者完成同步後重試。", "forkAgentUnavailable": "請先選擇本機 Agent 再分叉。", "forkBackendRegistrationFailed": "歷史已複製,但可執行的分叉工作階段註冊失敗。請重試。", - "forkReplayUnavailable": "此工作階段尚無完整回放。請讓擁有者共享完整回放後重試。" + "forkReplayUnavailable": "此工作階段尚無完整回放。請讓擁有者共享完整回放後重試。", + "forkSetupRuntime": "執行方式", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "Loading repos…", diff --git a/src/i18n/locales/zh/navigation.json b/src/i18n/locales/zh/navigation.json index 9054efa86..6286e9fce 100644 --- a/src/i18n/locales/zh/navigation.json +++ b/src/i18n/locales/zh/navigation.json @@ -486,7 +486,10 @@ "forkAgentUnavailable": "请先选择本机 Agent 再分叉。", "forkBackendRegistrationFailed": "历史已复制,但可运行的分叉会话注册失败。请重试。", "forkReplayUnavailable": "此会话尚无完整回放。请让所有者共享完整回放后重试。", - "forkSetupReused": "已按上次配置接续 · {{model}}" + "forkSetupReused": "已按上次配置接续 · {{model}}", + "forkSetupRuntime": "执行方式", + "forkSetupRuntimeNative": "Native Agent", + "forkSetupRuntimeCli": "External CLI" }, "repoPicker": { "loading": "正在加载 repo…", diff --git a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx index e1920652c..e4b016687 100644 --- a/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx +++ b/src/scaffold/NavigationSidebar/connectors/WorkstationSidebarConnector/cloudSessionsSection.tsx @@ -34,7 +34,6 @@ import { useTranslation } from "react-i18next"; import { deleteSession as deleteLocalSession } from "@src/api/tauri/agent"; import { deleteOrgtrackCollaborationSession } from "@src/api/tauri/lineage"; import Message from "@src/components/Message"; -import { collectConversationRunnerSessionIds } from "@src/features/Org2Cloud/SessionConversation/conversationTurnRunner"; import { hiddenRemoteSessionKey, readHiddenRemoteSessionIds, @@ -241,10 +240,6 @@ export function useCloudSessionsSection({ )) { excluded.add(sessionId); } - // One-shot conversation runners are execution plumbing, never sessions. - for (const sessionId of collectConversationRunnerSessionIds()) { - excluded.add(sessionId); - } return excluded; }, [orgId, sessions, rows, selfUserId]); diff --git a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs index ac791e237..b3ebb81a9 100644 --- a/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs +++ b/tests/e2e/specs/core/cloud-dual-instance-ui.spec.mjs @@ -56,6 +56,8 @@ import { const CLOUD_INVITE_LINK_PREFIX = "https://invite.org2.dev/#invite="; const TEAM_NAME = `Dual-instance Team ${RUN_ID}`; +const PRIMARY_INSTANCE_MEMBER_NAME = "Neonforge"; +const SECONDARY_INSTANCE_MEMBER_NAME = "VantaNode"; const RENAMED_TEAM_NAME = `Renamed dual team ${RUN_ID}`; let sessionId = `dual-instance-session-${RUN_ID}`; const SESSION_TITLE = `Dual instance restricted share ${RUN_ID}`; @@ -67,7 +69,11 @@ const EDITED_COMMENT_BODY = `@agent dual-instance edited task ${RUN_ID}`; const EDITED_COMMENT_BRIEF = EDITED_COMMENT_BODY.slice("@agent ".length); const REPLY_BODY = `Owner reply from the other instance ${RUN_ID}`; const TEAM_INBOX_MENTION_BODY = `Team Inbox mention ${RUN_ID}`; -const SEND_BODY = `Continue this work from the matching workspace ${RUN_ID}`; +const FIRST_CONTINUATION_BODY = `VantaNode continuation one ${RUN_ID}`; +const SECOND_CONTINUATION_BODY = `VantaNode continuation two ${RUN_ID}`; +const THIRD_CONTINUATION_BODY = `VantaNode continuation after cold restart ${RUN_ID}`; +const PRIMARY_PLANE_DELTA = `Neonforge plane delta ${RUN_ID}`; +const COLD_RESTART_PLANE_DELTA = `Neonforge delta while VantaNode is offline ${RUN_ID}`; const PROJECT_NAME = `Dual cloud project ${RUN_ID}`; const PROJECT_SLUG = PROJECT_NAME.toLowerCase() .replace(/[^a-z0-9]+/g, "-") @@ -89,7 +95,7 @@ let repoScopeKey = EXPECTED_REPO_NETWORK_SCOPE; const SECONDARY_E2E_REPO_PATH = process.env.E2E_SECONDARY_REPO_PATH ?? E2E_REPO_PATH; const E2E_PROVIDER_MODE = process.env.E2E_PROVIDER_MODE ?? "mock"; -const FORK_E2E_MODEL = "gpt-4o-mini"; +const CONTINUATION_ONLY = process.env.E2E_CLOUD_DUAL_CONTINUATION_ONLY === "1"; let sourceTurnAnchorEventId = null; let secondaryImportedTurnToggleSelector = null; let secondaryImportedSessionId = null; @@ -241,27 +247,86 @@ async function completeForkSetupOn(client, label, options = {}) { '[data-testid^="fork-setup-workspace-"]', `${label} matching workspace` ); - await client.waitUntil( - async () => - executeOn( - client, - ` + if (options.expectRuntime) { + await waitForRenderedOn( + client, + '[data-testid="fork-setup-runtime"]', + `${label} runtime selector`, + CLOUD_FETCH_TIMEOUT_MS + ); + } + try { + await client.waitUntil( + async () => + executeOn( + client, + ` const agent = document.querySelector('[data-testid="fork-setup-agent"]'); const account = document.querySelector('[data-testid="fork-setup-account"]'); const model = document.querySelector('[data-testid="fork-setup-model"]'); + const runtime = document.querySelector('[data-testid="fork-setup-runtime"]'); const submit = document.querySelector('[data-testid="fork-session-setup-submit"]'); return !!agent?.querySelector('.select-value')?.textContent?.trim() && !!account?.querySelector('.select-value')?.textContent?.trim() && !!model?.querySelector('.select-value')?.textContent?.trim() && + (!runtime || !!runtime.querySelector('.select-value')?.textContent?.trim()) && !!submit && !submit.disabled; ` + ), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: `${label} agent/account/model defaults never became runnable`, + } + ); + } catch (error) { + const [accountsResult, defsResult, dom] = await Promise.all([ + invokeOn(client, "listAccounts"), + invokeOn(client, "listAgentDefs"), + executeOn( + client, + ` + const inspect = (testId) => { + const node = document.querySelector('[data-testid="' + testId + '"]'); + return node ? { + value: node.querySelector('.select-value')?.textContent?.trim() ?? null, + text: node.textContent?.trim() ?? '', + disabled: node.disabled ?? null, + } : null; + }; + const submit = document.querySelector('[data-testid="fork-session-setup-submit"]'); + return { + agent: inspect('fork-setup-agent'), + runtime: inspect('fork-setup-runtime'), + account: inspect('fork-setup-account'), + model: inspect('fork-setup-model'), + submitDisabled: submit?.disabled ?? null, + workspaceCount: document.querySelectorAll('[data-testid^="fork-setup-workspace-"]').length, + }; + ` ), - { - timeout: CLOUD_FETCH_TIMEOUT_MS, - interval: 250, - timeoutMsg: `${label} agent/account/model defaults never became runnable`, - } - ); + ]); + const safeAccounts = (accountsResult?.accounts ?? []).map((account) => ({ + id: account.id, + name: account.name, + agentType: account.agent_type, + enabled: account.enabled, + healthStatus: account.health_status, + hasApiKey: account.has_api_key, + hasSessionToken: account.has_session_token, + supportsRustAgents: account.supports_rust_agents, + availableModels: account.available_models, + enabledModels: account.enabled_models, + })); + const defs = (defsResult?.defs ?? []).map((definition) => ({ + id: definition.id, + name: definition.name, + builtIn: definition.builtIn, + })); + throw new Error( + `${error instanceof Error ? error.message : String(error)}; diagnostic=${JSON.stringify({ dom, accounts: safeAccounts, defs })}` + ); + } if (options.agentName) { await clickRenderedOn( client, @@ -343,6 +408,90 @@ async function callProjectsRpc(envConfig, user, functionName, body) { return text ? JSON.parse(text) : null; } +async function readCloudCapabilities(envConfig, user) { + return callProjectsRpc(envConfig, user, "get_cloud_capabilities", {}); +} + +async function listConversationPlane(envConfig, user, orgId, rootSessionId) { + const payload = await callProjectsRpc( + envConfig, + user, + "cloud_list_conversation_events", + { + p_org_id: orgId, + p_root_session_id: rootSessionId, + p_after_seq: 0, + p_limit: 500, + } + ); + return payload?.events ?? []; +} + +async function listCloudOrgSessions(envConfig, user, orgId) { + const payload = await callProjectsRpc( + envConfig, + user, + "cloud_list_org_sessions", + { + p_org_id: orgId, + since: null, + p_limit: 200, + p_cursor_updated_at: null, + p_cursor_session_id: null, + } + ); + return payload?.sessions ?? []; +} + +async function waitForConversationTurn( + client, + envConfig, + user, + orgId, + rootSessionId, + authorUserId, + body, + label +) { + let userRow = null; + let turnRows = []; + await client.waitUntil( + async () => { + const rows = await listConversationPlane( + envConfig, + user, + orgId, + rootSessionId + ); + userRow = rows.find( + (row) => + row.authorUserId === authorUserId && + row.event?.source === "user" && + row.event?.displayText === body + ); + if (!userRow?.turnId) return false; + turnRows = rows.filter((row) => row.turnId === userRow.turnId); + return turnRows.some((row) => row.event?.source === "assistant"); + }, + { + timeout: 180_000, + interval: 500, + timeoutMsg: `${label} never reached the canonical plane with an Agent tail`, + } + ); + return { userRow, turnRows }; +} + +function conversationExecutionParentId(orgId, rootSessionId) { + return JSON.stringify([ + "org2-conversation", + 1, + "org2-cloud", + [orgId], + rootSessionId, + ]); +} + async function waitForE2EOn(client) { await client.waitUntil( async () => @@ -703,9 +852,7 @@ async function createInviteFromOwner(previousLink = "") { `return document.querySelector('[data-testid="cloud-org-invite-link"]')?.textContent?.trim() ?? '';` )) ?? "" ); - return ( - link.startsWith(CLOUD_INVITE_LINK_PREFIX) && link !== previousLink - ); + return link.startsWith(CLOUD_INVITE_LINK_PREFIX) && link !== previousLink; }, { timeout: CLOUD_FETCH_TIMEOUT_MS, @@ -1064,14 +1211,14 @@ describe("Cloud collaboration with two independent rendered app instances", func const ownerResult = await provisionCloudUser( env, "dual-owner", - "Dual Owner" + PRIMARY_INSTANCE_MEMBER_NAME ); if (!ownerResult.ok) throw new Error(ownerResult.reason); owner = ownerResult.user; const teammateResult = await provisionCloudUser( env, "dual-teammate", - "Dual Teammate" + SECONDARY_INSTANCE_MEMBER_NAME ); if (!teammateResult.ok) { await cleanupCloudUser(env, owner); @@ -1090,7 +1237,7 @@ describe("Cloud collaboration with two independent rendered app instances", func accessToken: owner.accessToken, refreshToken: owner.refreshToken, expiresAt: owner.expiresAt, - displayName: "Dual Owner", + displayName: PRIMARY_INSTANCE_MEMBER_NAME, }), "seed primary owner auth" ); @@ -1102,7 +1249,12 @@ describe("Cloud collaboration with two independent rendered app instances", func "clear secondary cloud auth" ); await applyCloudEndpointOn(second.client, env); - await seedAuthOn(second.client, env, teammate, "Dual Teammate"); + await seedAuthOn( + second.client, + env, + teammate, + SECONDARY_INSTANCE_MEMBER_NAME + ); const [ownerOrgs, teammateOrgs] = await Promise.all([ (async () => { @@ -1243,7 +1395,9 @@ describe("Cloud collaboration with two independent rendered app instances", func return document.querySelector('[data-testid="cloud-org-invite-link"]')?.textContent?.trim() ?? ''; `); if (!String(inviteLink).startsWith(CLOUD_INVITE_LINK_PREFIX)) { - throw new Error("rendered team invite is not a valid invite handoff link"); + throw new Error( + "rendered team invite is not a valid invite handoff link" + ); } unwrapOn( @@ -1287,13 +1441,20 @@ describe("Cloud collaboration with two independent rendered app instances", func } ); - // The owner's already-open panel must receive roster invalidation live. + // The primary deliberately releases its Realtime lease while the second + // native window is focused. Refocus the already-open owner panel; the + // subscribe edge must compensate for the roster event missed in the + // background without a reload or polling loop. + await browser.$('[data-testid="cloud-org-members"]').click(); + + // The owner's already-open panel must recover the joined teammate live. try { await browser.waitUntil( async () => - execJS( - `return document.querySelectorAll('[data-testid="cloud-org-member-row"]').length >= 2;` - ), + execJS(` + return Array.from(document.querySelectorAll('[data-testid="cloud-org-member-row"]')) + .some((row) => row.getAttribute('data-member-id') === ${JSON.stringify(teammate.userId)}); + `), { timeout: CLOUD_FETCH_TIMEOUT_MS, interval: 500, @@ -1337,14 +1498,12 @@ describe("Cloud collaboration with two independent rendered app instances", func "secondary ensure shared repository" ); if (E2E_PROVIDER_MODE === "mock") { - unwrapOn( - await invokeOn(second.client, "addAccount", { - openaiApiKey: "sk-orgii-rendered-e2e-not-sent", - model: FORK_E2E_MODEL, - accountName: `Cloud fork rendered E2E ${RUN_ID}`, - }), - "secondary seed rendered mock fork account" - ); + const secondaryAccount = await getSecondaryForkAccount(second.client); + if (secondaryAccount.name !== second.seededAccountName) { + throw new Error( + `Second app selected ${secondaryAccount.name ?? secondaryAccount.id}, expected isolated ${second.seededAccountName}` + ); + } } else { const secondaryAccount = await getSecondaryForkAccount(second.client); if (secondaryAccount.name !== second.seededAccountName) { @@ -1353,6 +1512,10 @@ describe("Cloud collaboration with two independent rendered app instances", func ); } } + unwrapOn( + await invokeOn(second.client, "refreshAgentDefs"), + "secondary hydrate local Agent definitions" + ); await openCloudOrgPanelFromSidebar(teamOrgId); await selectPrimaryCloudOrgManagementTab( @@ -1729,6 +1892,11 @@ describe("Cloud collaboration with two independent rendered app instances", func ); } + // The focused continuation protocol deliberately stops at the canonical + // Team Session boundary. Blame, comments, presence, and explicit forks + // remain covered by their existing full-suite path below. + if (CONTINUATION_ONLY) return; + // Full-replay authorization is also the authorization boundary for Team // Session Blame. The imported transcript must be projected locally with // the owner's identity; no second cloud provenance database is involved. @@ -1760,7 +1928,7 @@ describe("Cloud collaboration with two independent rendered app instances", func collaborationHistory.collaborationOrigin?.sessionRowId !== `${teamOrgId}:${owner.userId}:${sessionId}` || collaborationHistory.collaborationOrigin?.ownerDisplayName !== - "Dual Owner" || + PRIMARY_INSTANCE_MEMBER_NAME || collaborationHistory.actionCounts?.read !== 1 ) { throw new Error( @@ -1837,7 +2005,7 @@ describe("Cloud collaboration with two independent rendered app instances", func "return document.querySelector(arguments[0])?.textContent ?? '';", [teamBlameSelector] ); - if (!String(blameText).includes("@Dual Owner")) { + if (!String(blameText).includes(`@${PRIMARY_INSTANCE_MEMBER_NAME}`)) { throw new Error(`Team Session Blame lost owner identity: ${blameText}`); } await clickRenderedOn( @@ -1922,7 +2090,7 @@ describe("Cloud collaboration with two independent rendered app instances", func "return document.querySelector(arguments[0])?.getAttribute('aria-label') ?? '';", [ownerViewerChip] ); - if (!String(viewerLabel).includes("Dual Owner")) { + if (!String(viewerLabel).includes(PRIMARY_INSTANCE_MEMBER_NAME)) { throw new Error(`viewer chip did not identify the owner: ${viewerLabel}`); } const viewerLivesInPublishedHeader = await executeOn( @@ -2454,378 +2622,673 @@ describe("Cloud collaboration with two independent rendered app instances", func } }); - it("D. syncs comment CRUD/status, intercepts send into a same-remote fork, and revokes directed access live", async function () { + it("D. syncs comment CRUD/status, continues one canonical conversation locally, and revokes directed access live", async function () { this.timeout(360_000); - // C ends on a writable fork. Re-open the remote row to return to its - // imported replay before exercising edit/status and intercept-send. - await clickRenderedOn( - second.client, - remoteRowSelector, - "secondary reopen imported replay" - ); - await waitForRenderedOn( - second.client, - '[data-testid="session-fork-button"]', - "secondary imported replay fork action", - CLOUD_FETCH_TIMEOUT_MS - ); - await clickRenderedOn( - second.client, - secondaryImportedTurnToggleSelector, - "secondary reopen turn comment panel" - ); - await waitForRenderedOn( - second.client, - '[data-testid="session-comment-row"]', - "secondary existing comment thread", - CLOUD_FETCH_TIMEOUT_MS - ); + if (!CONTINUATION_ONLY) { + // C ends on a writable fork. Re-open the remote row to return to its + // imported replay before exercising edit/status and intercept-send. + await clickRenderedOn( + second.client, + remoteRowSelector, + "secondary reopen imported replay" + ); + await waitForRenderedOn( + second.client, + '[data-testid="session-fork-button"]', + "secondary imported replay fork action", + CLOUD_FETCH_TIMEOUT_MS + ); + await clickRenderedOn( + second.client, + secondaryImportedTurnToggleSelector, + "secondary reopen turn comment panel" + ); + await waitForRenderedOn( + second.client, + '[data-testid="session-comment-row"]', + "secondary existing comment thread", + CLOUD_FETCH_TIMEOUT_MS + ); - await clickRenderedOn( - second.client, - '[data-testid="session-comment-edit"]', - "secondary edit own comment" - ); - await typeRenderedOn( - second.client, - '[data-testid="session-comment-row"] textarea', - EDITED_COMMENT_BODY, - "secondary edited comment body" - ); - await clickRenderedOn( - second.client, - '[data-testid="session-comment-edit-save"]', - "secondary save edited comment" - ); - try { - await browser.waitUntil( - async () => - visibleTextIncludesOn( - browser, - '[data-testid="session-comment-row"]', - EDITED_COMMENT_BRIEF - ), - { - timeout: CLOUD_FETCH_TIMEOUT_MS, - interval: 250, - timeoutMsg: "owner did not receive the teammate comment edit live", - } + await clickRenderedOn( + second.client, + '[data-testid="session-comment-edit"]', + "secondary edit own comment" ); - } catch (error) { - const [ownerDebug, teammateDebug] = await Promise.all([ - invokeE2E("cloudInspectDebugState", { sessionId }), - invokeOn(second.client, "cloudInspectDebugState", { - sessionId, - }), - ]); - throw new Error( - `${error instanceof Error ? error.message : String(error)}\n` + - `owner comment state: ${JSON.stringify(ownerDebug)}\n` + - `teammate comment state: ${JSON.stringify(teammateDebug)}` + await typeRenderedOn( + second.client, + '[data-testid="session-comment-row"] textarea', + EDITED_COMMENT_BODY, + "secondary edited comment body" ); - } + await clickRenderedOn( + second.client, + '[data-testid="session-comment-edit-save"]', + "secondary save edited comment" + ); + try { + await browser.waitUntil( + async () => + visibleTextIncludesOn( + browser, + '[data-testid="session-comment-row"]', + EDITED_COMMENT_BRIEF + ), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: "owner did not receive the teammate comment edit live", + } + ); + } catch (error) { + const [ownerDebug, teammateDebug] = await Promise.all([ + invokeE2E("cloudInspectDebugState", { sessionId }), + invokeOn(second.client, "cloudInspectDebugState", { + sessionId, + }), + ]); + throw new Error( + `${error instanceof Error ? error.message : String(error)}\n` + + `owner comment state: ${JSON.stringify(ownerDebug)}\n` + + `teammate comment state: ${JSON.stringify(teammateDebug)}` + ); + } - const ownerPermissions = await execJS(` + const ownerPermissions = await execJS(` const row = document.querySelector('[data-testid="session-comment-row"]'); return { edit: !!row?.querySelector('[data-testid="session-comment-edit"]'), delete: !!row?.querySelector('[data-testid="session-comment-delete"]'), }; `); - if (ownerPermissions.edit || !ownerPermissions.delete) { - throw new Error( - `comment permission UI is wrong for owner/admin viewing teammate content: ${JSON.stringify(ownerPermissions)}` - ); - } + if (ownerPermissions.edit || !ownerPermissions.delete) { + throw new Error( + `comment permission UI is wrong for owner/admin viewing teammate content: ${JSON.stringify(ownerPermissions)}` + ); + } - await clickRendered( - '[data-testid="session-comment-reply"]', - "owner open reply composer" - ); - await typeRendered( - '[data-testid="session-comment-reply-composer"] textarea', - REPLY_BODY, - "owner reply body" - ); - await browser.waitUntil( - async () => - execJS(` + await clickRendered( + '[data-testid="session-comment-reply"]', + "owner open reply composer" + ); + await typeRendered( + '[data-testid="session-comment-reply-composer"] textarea', + REPLY_BODY, + "owner reply body" + ); + await browser.waitUntil( + async () => + execJS(` const button = document.querySelector('[data-testid="session-comment-reply-composer-submit"]'); return !!button && !button.disabled; `), - { - timeout: 30_000, - interval: 250, - timeoutMsg: "owner reply submit never enabled", - } - ); - await clickRendered( - '[data-testid="session-comment-reply-composer-submit"]', - "owner submit reply" - ); - await browser.waitUntil( - async () => - execJS( - `return Array.from(document.querySelectorAll('[data-testid="session-comment-row"]')).some((row) => row.textContent?.includes(${JSON.stringify(REPLY_BODY)}));` - ), - { - timeout: CLOUD_FETCH_TIMEOUT_MS, - interval: 250, - timeoutMsg: "owner reply RPC did not update the owner UI", - } - ); - try { - await second.client.waitUntil( + { + timeout: 30_000, + interval: 250, + timeoutMsg: "owner reply submit never enabled", + } + ); + await clickRendered( + '[data-testid="session-comment-reply-composer-submit"]', + "owner submit reply" + ); + await browser.waitUntil( async () => - executeOn( - second.client, - ` - return Array.from(document.querySelectorAll('[data-testid="session-comment-row"]')) - .some((row) => row.textContent?.includes(arguments[0])); - `, - [REPLY_BODY] + execJS( + `return Array.from(document.querySelectorAll('[data-testid="session-comment-row"]')).some((row) => row.textContent?.includes(${JSON.stringify(REPLY_BODY)}));` ), { timeout: CLOUD_FETCH_TIMEOUT_MS, interval: 250, - timeoutMsg: "secondary did not receive the owner reply live", + timeoutMsg: "owner reply RPC did not update the owner UI", } ); - } catch (error) { - const secondaryActive = unwrapOn( - await invokeOn(second.client, "getActiveSessionId"), - "secondary active session diagnostic" - ).sessionId; - const [primaryDebug, secondaryDebug, secondaryCommentText] = - await Promise.all([ - invokeE2E("cloudInspectDebugState", { sessionId }), - invokeOn(second.client, "cloudInspectDebugState", { - sessionId: secondaryActive ?? sessionId, - }), - executeOn( - second.client, - `return Array.from(document.querySelectorAll('[data-testid="session-comment-row"]')).map((row) => row.textContent ?? '');` - ), - ]); - throw new Error( - `${error instanceof Error ? error.message : String(error)}; primary=${JSON.stringify(primaryDebug)}; secondary=${JSON.stringify(secondaryDebug)}; secondaryRows=${JSON.stringify(secondaryCommentText)}` - ); - } + try { + await second.client.waitUntil( + async () => + executeOn( + second.client, + ` + return Array.from(document.querySelectorAll('[data-testid="session-comment-row"]')) + .some((row) => row.textContent?.includes(arguments[0])); + `, + [REPLY_BODY] + ), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 250, + timeoutMsg: "secondary did not receive the owner reply live", + } + ); + } catch (error) { + const secondaryActive = unwrapOn( + await invokeOn(second.client, "getActiveSessionId"), + "secondary active session diagnostic" + ).sessionId; + const [primaryDebug, secondaryDebug, secondaryCommentText] = + await Promise.all([ + invokeE2E("cloudInspectDebugState", { sessionId }), + invokeOn(second.client, "cloudInspectDebugState", { + sessionId: secondaryActive ?? sessionId, + }), + executeOn( + second.client, + `return Array.from(document.querySelectorAll('[data-testid="session-comment-row"]')).map((row) => row.textContent ?? '');` + ), + ]); + throw new Error( + `${error instanceof Error ? error.message : String(error)}; primary=${JSON.stringify(primaryDebug)}; secondary=${JSON.stringify(secondaryDebug)}; secondaryRows=${JSON.stringify(secondaryCommentText)}` + ); + } - await clickRenderedOn( - second.client, - '[data-testid="session-comment-status-resolved"]', - "secondary resolve comment thread" - ); - await waitForRenderedOn( - second.client, - '[data-testid="session-comment-resolved-toggle"]', - "secondary local resolved-thread toggle", - CLOUD_FETCH_TIMEOUT_MS - ); - await waitForRendered( - '[data-testid="session-comment-resolved-toggle"]', - "owner realtime resolved-thread toggle", - CLOUD_FETCH_TIMEOUT_MS - ); - await clickRenderedOn( - second.client, - '[data-testid="session-comment-resolved-toggle"]', - "secondary expand resolved threads" - ); - await waitForRenderedOn( - second.client, - '[data-testid="session-comment-resolved-marker"]', - "secondary local resolved marker", - CLOUD_FETCH_TIMEOUT_MS - ); - await clickRendered( - '[data-testid="session-comment-resolved-toggle"]', - "owner expand resolved threads" - ); - await waitForRendered( - '[data-testid="session-comment-resolved-marker"]', - "owner realtime resolved marker", - CLOUD_FETCH_TIMEOUT_MS - ); + await clickRenderedOn( + second.client, + '[data-testid="session-comment-status-resolved"]', + "secondary resolve comment thread" + ); + await waitForRenderedOn( + second.client, + '[data-testid="session-comment-resolved-toggle"]', + "secondary local resolved-thread toggle", + CLOUD_FETCH_TIMEOUT_MS + ); + await waitForRendered( + '[data-testid="session-comment-resolved-toggle"]', + "owner realtime resolved-thread toggle", + CLOUD_FETCH_TIMEOUT_MS + ); + await clickRenderedOn( + second.client, + '[data-testid="session-comment-resolved-toggle"]', + "secondary expand resolved threads" + ); + await waitForRenderedOn( + second.client, + '[data-testid="session-comment-resolved-marker"]', + "secondary local resolved marker", + CLOUD_FETCH_TIMEOUT_MS + ); + await clickRendered( + '[data-testid="session-comment-resolved-toggle"]', + "owner expand resolved threads" + ); + await waitForRendered( + '[data-testid="session-comment-resolved-marker"]', + "owner realtime resolved marker", + CLOUD_FETCH_TIMEOUT_MS + ); + } - // Imported composer submit must go straight to the actionable setup - // dialog. Cancelling is silent and restores the captured draft. + const capabilities = await readCloudCapabilities(env, teammate); + if (capabilities?.conversationEventsIdempotency !== true) { + throw new Error( + "Cloud backend lacks conversationEventsIdempotency; canonical continuation cannot be verified" + ); + } + + // The rendered imported-session composer is the production continuation + // entry point. Its first send asks VantaNode which LOCAL runtime/workspace + // to use, then appends one canonical user row and the resulting Agent tail. + unwrapOn( + await invokeOn(second.client, "focusAppWindow"), + "focus VantaNode before first continuation" + ); await typeContentEditableOn( second.client, '[data-testid="chat-input"] [contenteditable="true"]', - SEND_BODY, - "secondary imported-session composer" + FIRST_CONTINUATION_BODY, + "VantaNode first canonical continuation" ); await clickRenderedOn( second.client, '[data-testid="chat-send-button"]', - "secondary imported-session send" + "VantaNode first canonical continuation send" ); await waitForRenderedOn( second.client, '[data-testid="fork-session-setup"]', - "send-triggered fork setup", + "VantaNode local continuation setup", CLOUD_FETCH_TIMEOUT_MS ); - await pressEscapeOn(second.client); - await waitForGoneOn( + await completeForkSetupOn(second.client, "VantaNode continuation", { + expectRuntime: true, + }); + const firstPlane = await waitForConversationTurn( second.client, - '[data-testid="fork-session-setup"]', - "cancelled fork setup" + env, + teammate, + teamOrgId, + sessionId, + teammate.userId, + FIRST_CONTINUATION_BODY, + "VantaNode first continuation" + ); + if ( + firstPlane.userRow?.authorDisplayName !== SECONDARY_INSTANCE_MEMBER_NAME + ) { + throw new Error( + `first continuation lost VantaNode attribution: ${JSON.stringify(firstPlane)}` + ); + } + + const executionParentId = conversationExecutionParentId( + teamOrgId, + sessionId ); + let firstChildren = []; await second.client.waitUntil( - async () => - executeOn( - second.client, - ` - return Array.from(document.querySelectorAll('[data-testid="chat-input"] [contenteditable="true"]')) - .some((editor) => editor.textContent?.includes(arguments[0])); - `, - [SEND_BODY] - ), + async () => { + firstChildren = unwrapOn( + await invokeOn( + second.client, + "listSessionChildren", + executionParentId + ), + "list VantaNode local continuation children" + ).sessions; + return ( + firstChildren.length === 1 && Boolean(firstChildren[0]?.sessionId) + ); + }, { - timeout: 30_000, - interval: 250, - timeoutMsg: "cancelled fork did not restore the captured draft", + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 500, + timeoutMsg: + "VantaNode did not persist exactly one local execution child", } ); - - await clickRenderedOn( + const runnerSessionId = firstChildren[0].sessionId; + const firstTranscript = unwrapOn( + await invokeOn(second.client, "readSdeTranscript", runnerSessionId), + "read VantaNode first native continuation transcript" + ).result; + if ( + firstTranscript?.ok !== true || + !Array.isArray(firstTranscript.messages) + ) { + throw new Error( + `VantaNode first native transcript unavailable: ${JSON.stringify(firstTranscript)}` + ); + } + const hiddenRunnerRendered = await executeOn( second.client, - '[data-testid="chat-send-button"]', - "secondary retry imported-session send" + `return !!document.querySelector(arguments[0]);`, + [`[data-testid="sidebar-session-item-${runnerSessionId}"]`] ); - await completeForkSetupOn(second.client, "secondary send-triggered fork"); - await waitForRenderedOn( + const visibleForkCreated = await executeOn( second.client, - '[data-testid="session-forked-from-chip"]', - "send-created writable fork", - CLOUD_FETCH_TIMEOUT_MS + `return !!document.querySelector('[data-testid="session-forked-from-chip"]');` + ); + if (hiddenRunnerRendered || visibleForkCreated) { + throw new Error( + `local execution leaked into the visible Session model: ${JSON.stringify({ runnerSessionId, hiddenRunnerRendered, visibleForkCreated })}` + ); + } + + // Neonforge now contributes a real owner-side UI turn. VantaNode must + // render it, then include it as the only remote delta when the same local + // native child resumes for the second VantaNode turn. + unwrap( + await invokeE2E("focusAppWindow"), + "focus Neonforge before owner continuation" + ); + unwrap( + await invokeE2E("openSession", sessionId), + "open Neonforge canonical root before owner turn" + ); + await typeContentEditableOn( + browser, + '[data-testid="chat-input"] [contenteditable="true"]', + PRIMARY_PLANE_DELTA, + "Neonforge canonical continuation" + ); + await clickRendered( + '[data-testid="chat-send-button"]', + "Neonforge canonical continuation send" + ); + const ownerPlane = await waitForConversationTurn( + browser, + env, + owner, + teamOrgId, + sessionId, + owner.userId, + PRIMARY_PLANE_DELTA, + "Neonforge owner continuation" ); + if ( + ownerPlane.userRow?.authorDisplayName !== PRIMARY_INSTANCE_MEMBER_NAME + ) { + throw new Error( + `owner continuation lost Neonforge attribution: ${JSON.stringify(ownerPlane)}` + ); + } await second.client.waitUntil( async () => executeOn( second.client, - `return (document.body.textContent ?? '').includes(arguments[0]);`, - [SEND_BODY] + `return document.querySelector('[data-testid="chat-message-list"]')?.textContent?.includes(arguments[0]) === true;`, + [PRIMARY_PLANE_DELTA] ), { timeout: CLOUD_FETCH_TIMEOUT_MS, - interval: 250, - timeoutMsg: "captured first message was lost after forking", + interval: 500, + timeoutMsg: "VantaNode did not render Neonforge's canonical turn", } ); + const primaryAssistantCountBeforeSecond = await execJS(` + return document.querySelectorAll('[data-testid="chat-message-assistant"]').length; + `); - const sendForkActive = unwrapOn( - await invokeOn(second.client, "getActiveSessionId"), - "secondary send-created fork identity" + unwrapOn( + await invokeOn(second.client, "focusAppWindow"), + "focus VantaNode before native resume" ); - await second.client.waitUntil( - async () => { - const state = unwrapOn( - await invokeOn(second.client, "inspectChatState"), - "secondary send-created fork history" - ); - return ( - state.activeSessionId === sendForkActive.sessionId && - (state.chatEvents ?? []).some( - (event) => event.displayText === SESSION_TITLE - ) && - (state.chatEvents ?? []).some( - (event) => event.displayText === "Inherited answer 2" - ) && - (state.chatEvents ?? []).some( - (event) => event.displayText === SEND_BODY - ) - ); - }, - { - timeout: CLOUD_FETCH_TIMEOUT_MS, - interval: 250, - timeoutMsg: - "sending the first fork message replaced its inherited transcript", - } + await typeContentEditableOn( + second.client, + '[data-testid="chat-input"] [contenteditable="true"]', + SECOND_CONTINUATION_BODY, + "VantaNode second canonical continuation" ); await clickRenderedOn( second.client, - '[data-testid="session-forked-from-chip"]', - "secondary open send-created fork parent" + '[data-testid="chat-send-button"]', + "VantaNode second canonical continuation send" + ); + const secondPlane = await waitForConversationTurn( + second.client, + env, + teammate, + teamOrgId, + sessionId, + teammate.userId, + SECOND_CONTINUATION_BODY, + "VantaNode resumed continuation" + ); + const setupDialogStillOpen = await executeOn( + second.client, + `return !!document.querySelector('[data-testid="fork-session-setup"]');` + ); + if (setupDialogStillOpen) { + throw new Error("VantaNode second turn unexpectedly reopened setup"); + } + const secondChildren = unwrapOn( + await invokeOn(second.client, "listSessionChildren", executionParentId), + "list VantaNode children after native resume" + ).sessions; + if ( + secondChildren.length !== 1 || + secondChildren[0]?.sessionId !== runnerSessionId + ) { + throw new Error( + `second VantaNode turn did not reuse one native child: ${JSON.stringify({ runnerSessionId, secondChildren })}` + ); + } + const secondTranscript = unwrapOn( + await invokeOn(second.client, "readSdeTranscript", runnerSessionId), + "read VantaNode resumed native transcript" + ).result; + if ( + secondTranscript?.ok !== true || + !Array.isArray(secondTranscript.messages) || + secondTranscript.messages.length <= firstTranscript.messages.length + ) { + throw new Error( + `VantaNode native transcript did not append: ${JSON.stringify({ firstTranscript, secondTranscript })}` + ); + } + const resumedUserMessage = [...secondTranscript.messages] + .reverse() + .find((message) => message.role === "user"); + const resumedUserText = JSON.stringify(resumedUserMessage ?? {}); + if ( + !resumedUserText.includes(PRIMARY_PLANE_DELTA) || + !resumedUserText.includes(SECOND_CONTINUATION_BODY) || + resumedUserText.includes(FIRST_CONTINUATION_BODY) + ) { + throw new Error( + `native resume did not inject only the canonical delta: ${resumedUserText}` + ); + } + + const secondAssistantText = String( + secondPlane.turnRows.find((row) => row.event?.source === "assistant") + ?.event?.displayText ?? "" + ).trim(); + if (!secondAssistantText) { + throw new Error( + `second canonical turn has no readable Agent tail: ${JSON.stringify(secondPlane)}` + ); + } + // Refocus the actual native window (not only a DOM node). The canonical + // plane's foreground recovery must pull the durable seq delta even when + // the 45-second blur grace kept the Realtime socket alive and therefore + // produced no new SUBSCRIBED edge. + unwrap( + await invokeE2E("focusAppWindow"), + "refocus Neonforge for canonical-plane recovery" + ); + try { + await browser.waitUntil( + async () => + execJS(` + const list = document.querySelector('[data-testid="chat-message-list"]'); + const assistants = Array.from(list?.querySelectorAll('[data-testid="chat-message-assistant"]') ?? []); + return (list?.textContent ?? '').includes(${JSON.stringify(SECOND_CONTINUATION_BODY)}) && + assistants.length > ${JSON.stringify(primaryAssistantCountBeforeSecond)} && + (assistants[assistants.length - 1]?.textContent ?? '').trim().length > 0; + `), + { + timeout: CLOUD_FETCH_TIMEOUT_MS, + interval: 500, + timeoutMsg: + "Neonforge did not render VantaNode's latest user row and Agent tail verbatim", + } + ); + } catch (error) { + const [debug, presence, rawServerRows, rendered] = await Promise.all([ + invokeE2E("cloudInspectDebugState", { sessionId }), + invokeE2E("cloudInspectPresence"), + listConversationPlane(env, owner, teamOrgId, sessionId), + execJS(` + return { + text: (document.querySelector('[data-testid="chat-message-list"]')?.textContent ?? '').slice(-4000), + visibilityState: document.visibilityState, + hasFocus: document.hasFocus(), + }; + `), + ]); + const serverRows = rawServerRows.map((row) => ({ + id: row.id, + seq: row.seq, + turnId: row.turnId, + authorUserId: row.authorUserId, + source: row.event?.source, + displayText: String(row.event?.displayText ?? "").slice(0, 160), + })); + throw new Error( + `${error instanceof Error ? error.message : String(error)}; ` + + `debug=${JSON.stringify(debug)}; presence=${JSON.stringify(presence)}; ` + + `serverRows=${JSON.stringify(serverRows)}; rendered=${JSON.stringify(rendered)}` + ); + } + + console.info( + `[cloud-dual-e2e] canonical continuation evidence ${JSON.stringify({ + primary: PRIMARY_INSTANCE_MEMBER_NAME, + secondary: SECONDARY_INSTANCE_MEMBER_NAME, + rootSessionId: sessionId, + runnerSessionId, + firstTurnId: firstPlane.userRow?.turnId, + ownerTurnId: ownerPlane.userRow?.turnId, + secondTurnId: secondPlane.userRow?.turnId, + firstPlaneRows: firstPlane.turnRows.length, + ownerPlaneRows: ownerPlane.turnRows.length, + secondPlaneRows: secondPlane.turnRows.length, + })}` + ); + + // Kill the VantaNode app process while preserving its native ORGII_HOME + // and WebKit storage. Neonforge appends a canonical turn while that + // device is offline; after a true cold boot, VantaNode must hydrate the + // plane from Cloud and discover the same persisted native child by its + // deterministic conversation parent — no continuation registry exists. + await second.stopApp(); + unwrap( + await invokeE2E("focusAppWindow"), + "focus Neonforge while VantaNode is offline" + ); + await typeContentEditableOn( + browser, + '[data-testid="chat-input"] [contenteditable="true"]', + COLD_RESTART_PLANE_DELTA, + "Neonforge offline-device delta" + ); + await clickRendered( + '[data-testid="chat-send-button"]', + "Neonforge offline-device delta send" + ); + const coldOwnerPlane = await waitForConversationTurn( + browser, + env, + owner, + teamOrgId, + sessionId, + owner.userId, + COLD_RESTART_PLANE_DELTA, + "Neonforge turn while VantaNode is offline" + ); + + await second.restartApp(); + await waitForCloudOrgsOn(second.client, CLOUD_FETCH_TIMEOUT_MS); + unwrapOn( + await invokeOn(second.client, "reloadSessionList"), + "reload VantaNode sessions after cold boot" + ); + unwrapOn( + await invokeOn(second.client, "openSession", secondaryImportedSessionId), + "reopen VantaNode imported Team Session after cold boot" + ); + await waitForRenderedOn( + second.client, + '[data-testid="chat-input"] [contenteditable="true"]', + "VantaNode composer after cold boot", + CLOUD_FETCH_TIMEOUT_MS + ); + unwrapOn( + await invokeOn(second.client, "focusAppWindow"), + "focus VantaNode after cold boot" ); await second.client.waitUntil( - async () => { - const state = unwrapOn( - await invokeOn(second.client, "inspectChatState"), - "secondary send-created fork parent" - ); - const header = await executeOn( + async () => + executeOn( second.client, - `return { - forkAction: !!document.querySelector('[data-testid="session-fork-button"]'), - forkProvenance: !!document.querySelector('[data-testid="session-forked-from-chip"]'), - };` - ); - return ( - state.activeSessionId === secondaryImportedSessionId && - header.forkAction && - !header.forkProvenance - ); - }, + `return document.querySelector('[data-testid="chat-message-list"]')?.textContent?.includes(arguments[0]) === true;`, + [COLD_RESTART_PLANE_DELTA] + ), { timeout: CLOUD_FETCH_TIMEOUT_MS, - interval: 250, + interval: 500, timeoutMsg: - "secondary send-created fork parent did not become the active imported replay", + "VantaNode cold boot did not hydrate Neonforge's offline delta", } ); - const parentAfterForkSend = unwrapOn( - await invokeOn(second.client, "inspectChatState"), - "secondary parent after fork send" - ); + const coldBootChildren = unwrapOn( + await invokeOn(second.client, "listSessionChildren", executionParentId), + "list VantaNode continuation children after cold boot" + ).sessions; if ( - !(parentAfterForkSend.chatEvents ?? []).some( - (event) => event.displayText === SESSION_TITLE - ) || - !(parentAfterForkSend.chatEvents ?? []).some( - (event) => event.displayText === "Inherited answer 2" - ) + coldBootChildren.length !== 1 || + coldBootChildren[0]?.sessionId !== runnerSessionId ) { throw new Error( - `opening the parent after a fork message lost its source transcript: ${JSON.stringify(parentAfterForkSend.chatEvents ?? [])}` + `cold boot lost the persisted native child: ${JSON.stringify({ runnerSessionId, coldBootChildren })}` ); } - unwrapOn( - await invokeOn(second.client, "openSession", sendForkActive.sessionId), - "secondary reopen send-created fork" + + await typeContentEditableOn( + second.client, + '[data-testid="chat-input"] [contenteditable="true"]', + THIRD_CONTINUATION_BODY, + "VantaNode post-restart continuation" ); - await waitForRenderedOn( + await clickRenderedOn( second.client, - '[data-testid="session-forked-from-chip"]', - "secondary send-created fork reopened", - CLOUD_FETCH_TIMEOUT_MS + '[data-testid="chat-send-button"]', + "VantaNode post-restart continuation send" ); - const reopenedSendFork = unwrapOn( - await invokeOn(second.client, "inspectChatState"), - "secondary reopened send-created fork history" + const thirdPlane = await waitForConversationTurn( + second.client, + env, + teammate, + teamOrgId, + sessionId, + teammate.userId, + THIRD_CONTINUATION_BODY, + "VantaNode post-restart continuation" ); + const coldSetupDialogOpen = await executeOn( + second.client, + `return !!document.querySelector('[data-testid="fork-session-setup"]');` + ); + if (coldSetupDialogOpen) { + throw new Error("VantaNode cold resume unexpectedly reopened setup"); + } + const thirdChildren = unwrapOn( + await invokeOn(second.client, "listSessionChildren", executionParentId), + "list VantaNode children after cold native resume" + ).sessions; if ( - reopenedSendFork.activeSessionId !== sendForkActive.sessionId || - !(reopenedSendFork.chatEvents ?? []).some( - (event) => event.displayText === SESSION_TITLE - ) || - !(reopenedSendFork.chatEvents ?? []).some( - (event) => event.displayText === "Inherited answer 2" - ) || - !(reopenedSendFork.chatEvents ?? []).some( - (event) => event.displayText === SEND_BODY - ) + thirdChildren.length !== 1 || + thirdChildren[0]?.sessionId !== runnerSessionId + ) { + throw new Error( + `cold native resume did not reuse one child: ${JSON.stringify({ runnerSessionId, thirdChildren })}` + ); + } + const thirdTranscript = unwrapOn( + await invokeOn(second.client, "readSdeTranscript", runnerSessionId), + "read VantaNode cold-resumed native transcript" + ).result; + if ( + thirdTranscript?.ok !== true || + !Array.isArray(thirdTranscript.messages) || + thirdTranscript.messages.length <= secondTranscript.messages.length ) { throw new Error( - `reopening the fork after its first message lost inherited or new history: ${JSON.stringify(reopenedSendFork.chatEvents ?? [])}` + `cold native transcript did not append: ${JSON.stringify({ secondTranscript, thirdTranscript })}` ); } + const coldResumedUserText = JSON.stringify( + [...thirdTranscript.messages] + .reverse() + .find((message) => message.role === "user") ?? {} + ); + if ( + !coldResumedUserText.includes(COLD_RESTART_PLANE_DELTA) || + !coldResumedUserText.includes(THIRD_CONTINUATION_BODY) || + coldResumedUserText.includes(FIRST_CONTINUATION_BODY) || + coldResumedUserText.includes(SECOND_CONTINUATION_BODY) + ) { + throw new Error( + `cold native resume did not inject only the offline canonical delta: ${coldResumedUserText}` + ); + } + console.info( + `[cloud-dual-e2e] cold continuation evidence ${JSON.stringify({ + orgiiHome: second.orgiiHome, + runnerSessionId, + offlineOwnerTurnId: coldOwnerPlane.userRow?.turnId, + postRestartTurnId: thirdPlane.userRow?.turnId, + thirdPlaneRows: thirdPlane.turnRows.length, + })}` + ); + unwrap( + await invokeE2E("focusAppWindow"), + "focus Neonforge before directed revoke" + ); await clickRendered( '[data-testid="chat-panel-header-more-button"]', "owner more menu before revoke" @@ -2838,17 +3301,49 @@ describe("Cloud collaboration with two independent rendered app instances", func `[data-testid="cloud-session-share-directed-revoke-${teammate.userId}"]`, "owner revoke directed grant" ); - await waitForGoneOn( - second.client, - remoteRowSelector, - "secondary restricted row after revoke", + await waitForGone( + `[data-testid="cloud-session-share-directed-revoke-${teammate.userId}"]`, + "owner directed grant after revoke commit", CLOUD_FETCH_TIMEOUT_MS ); - await waitForRenderedOn( - second.client, - '[data-testid="session-forked-from-chip"]', - "secondary local fork retained after revoke" + unwrapOn( + await invokeOn(second.client, "focusAppWindow"), + "focus VantaNode for revoke recovery" ); + try { + await waitForGoneOn( + second.client, + remoteRowSelector, + "secondary restricted row after revoke", + CLOUD_FETCH_TIMEOUT_MS + ); + } catch (error) { + const [debug, serverRows, rendered] = await Promise.all([ + invokeOn(second.client, "cloudInspectDebugState", { sessionId }), + listCloudOrgSessions(env, teammate, teamOrgId), + executeOn( + second.client, + `return Array.from(document.querySelectorAll('[data-testid^="sidebar-cloud-session-item-"]')).map((row) => ({ id: row.getAttribute('data-testid'), text: row.textContent ?? '' }));` + ), + ]); + throw new Error( + `${error instanceof Error ? error.message : String(error)}; ` + + `debug=${JSON.stringify(debug)}; serverRows=${JSON.stringify(serverRows)}; ` + + `rendered=${JSON.stringify(rendered)}` + ); + } + const retainedChildren = unwrapOn( + await invokeOn(second.client, "listSessionChildren", executionParentId), + "read retained local continuation after cloud revoke" + ).sessions; + if ( + retainedChildren.length !== 1 || + retainedChildren[0]?.sessionId !== runnerSessionId + ) { + throw new Error( + `cloud revoke damaged VantaNode's local native continuation: ${JSON.stringify(retainedChildren)}` + ); + } }); it("E. imports a one-shot link through the rendered Import flow and rejects it after revocation", async function () { @@ -3371,7 +3866,7 @@ describe("Cloud collaboration with two independent rendered app instances", func second.client, ` const button = document.querySelector('[data-testid="work-item-start-agent-button"]'); - return !!button && button.disabled && button.textContent.includes('Dual Owner'); + return !!button && button.disabled && button.textContent.includes(${JSON.stringify(PRIMARY_INSTANCE_MEMBER_NAME)}); ` ), { @@ -3416,7 +3911,7 @@ describe("Cloud collaboration with two independent rendered app instances", func second.client, ` const button = document.querySelector('[data-testid="work-item-start-agent-button"]'); - return !!button && !button.textContent.includes('Dual Owner'); + return !!button && !button.textContent.includes(${JSON.stringify(PRIMARY_INSTANCE_MEMBER_NAME)}); ` ), { diff --git a/tests/e2e/support/core/dualCloudHarness.mjs b/tests/e2e/support/core/dualCloudHarness.mjs index a35447036..cb43a771e 100644 --- a/tests/e2e/support/core/dualCloudHarness.mjs +++ b/tests/e2e/support/core/dualCloudHarness.mjs @@ -15,6 +15,8 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { remote } from "webdriverio"; +import { seedMockApiAccount } from "./mockApiAccountSeed.mjs"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "..", "..", "..", ".."); const tauriConfigPath = resolve(repoRoot, "src-tauri/tauri.conf.json"); @@ -25,11 +27,11 @@ const SECONDARY_WEBDRIVER_PORT = Number.parseInt( 10 ); const SECONDARY_IDE_PORT = Number.parseInt( - process.env.E2E_SECONDARY_IDE_SERVER_PORT ?? "24847", + process.env.E2E_SECONDARY_IDE_SERVER_PORT ?? "13848", 10 ); const SECONDARY_CLI_PROXY_PORT = Number.parseInt( - process.env.E2E_SECONDARY_CLI_PROXY_PORT ?? "28889", + process.env.E2E_SECONDARY_CLI_PROXY_PORT ?? "17889", 10 ); @@ -152,6 +154,19 @@ async function assertPortsFree(ports) { } } +async function waitForPortsFree(ports, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const occupied = []; + for (const port of ports) { + if (await canConnect(port)) occupied.push(port); + } + if (occupied.length === 0) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(`secondary runtime ports did not close: ${ports.join(", ")}`); +} + function secondaryTauriConfig(originalConfig) { const config = JSON.parse(originalConfig); const frontendPort = process.env.E2E_FRONTEND_PORT ?? "1998"; @@ -450,8 +465,20 @@ export async function startSecondCloudInstance() { ]); const tempRoot = mkdtempSync(join(tmpdir(), "orgii-e2e-instance2-")); + const retainEvidence = process.env.E2E_RETAIN_DUAL_EVIDENCE === "1"; const orgiiHome = join(tempRoot, "home"); const externalHistoryHome = join(orgiiHome, "external-history-home"); + const seededMockAccount = + process.env.E2E_PROVIDER_MODE === "mock" + ? seedMockApiAccount(orgiiHome, { + accountId: "e2e2a027", + accountName: "VantaNode E2E Mock", + // Debug builds route this prefix to the deterministic in-process + // provider. A generic OpenAI model would send the fixture key to + // the public endpoint and spend the test in 401 retry backoff. + model: "e2e-fake-provider-cloud-dual-continuation", + }) + : null; const seededAccount = seedSecondaryRealAccount(orgiiHome); const binary = buildSecondaryBinary(tempRoot); const driverProcess = spawn( @@ -476,9 +503,8 @@ export async function startSecondCloudInstance() { ); let client = null; - try { - await waitForPort(SECONDARY_WEBDRIVER_PORT); - client = await remote({ + const connectApp = async (resetFixtureMemory) => { + const nextClient = await remote({ hostname: "127.0.0.1", port: SECONDARY_WEBDRIVER_PORT, path: "/", @@ -493,14 +519,29 @@ export async function startSecondCloudInstance() { "tauri:options": { binary }, }, }); - await client.setTimeout({ script: 420_000 }); - await client.waitUntil( + await nextClient.setTimeout({ script: 420_000 }); + await nextClient.waitUntil( async () => { try { await executeOn( - client, - "window.localStorage.setItem(arguments[0], arguments[1]); return true;", - ["orgii:e2eBaseUrl", `http://127.0.0.1:${SECONDARY_IDE_PORT}`] + nextClient, + ` + // ORGII_HOME isolates native files, but macOS WebKit persists + // localStorage by the fixed E2E bundle id across test runs. + // A newly-created device fixture must not inherit a previous + // run's runtime/account choice. A cold restart of this SAME + // fixture intentionally keeps that choice and auth state. + if (arguments[2]) { + window.localStorage.removeItem('orgii:fork-setup-memory-v1'); + } + window.localStorage.setItem(arguments[0], arguments[1]); + return true; + `, + [ + "orgii:e2eBaseUrl", + `http://127.0.0.1:${SECONDARY_IDE_PORT}`, + resetFixtureMemory, + ] ); return true; } catch { @@ -513,21 +554,48 @@ export async function startSecondCloudInstance() { timeoutMsg: "secondary app never exposed its WebView", } ); + client = nextClient; + return nextClient; + }; + const stopApp = async () => { + const current = client; + client = null; + await current?.deleteSession(); + await waitForPortsFree([SECONDARY_IDE_PORT, SECONDARY_CLI_PROXY_PORT]); + }; + try { + await waitForPort(SECONDARY_WEBDRIVER_PORT); + await connectApp(true); return { - client, + get client() { + if (!client) throw new Error("secondary app is not running"); + return client; + }, ideServerPort: SECONDARY_IDE_PORT, orgiiHome, - seededAccountName: seededAccount?.accountName ?? null, + seededAccountName: + seededAccount?.accountName ?? seededMockAccount?.accountName ?? null, + stopApp, + async restartApp() { + if (client) await stopApp(); + return connectApp(false); + }, async stop() { try { - await client?.deleteSession(); + if (client) await stopApp(); } finally { driverProcess.kill("SIGTERM"); try { mergeSecondaryRealAccount(seededAccount); } finally { - rmSync(tempRoot, { force: true, recursive: true }); + if (retainEvidence) { + console.info( + `[dual-cloud-e2e] retained VantaNode evidence at ${tempRoot}` + ); + } else { + rmSync(tempRoot, { force: true, recursive: true }); + } } } }, @@ -537,7 +605,13 @@ export async function startSecondCloudInstance() { await client?.deleteSession(); } catch {} driverProcess.kill("SIGTERM"); - rmSync(tempRoot, { force: true, recursive: true }); + if (retainEvidence) { + console.info( + `[dual-cloud-e2e] retained failed VantaNode evidence at ${tempRoot}` + ); + } else { + rmSync(tempRoot, { force: true, recursive: true }); + } throw error; } } diff --git a/tests/e2e/support/core/mockApiAccountSeed.mjs b/tests/e2e/support/core/mockApiAccountSeed.mjs new file mode 100644 index 000000000..18440ffc9 --- /dev/null +++ b/tests/e2e/support/core/mockApiAccountSeed.mjs @@ -0,0 +1,67 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** Seed one fake local API account before an isolated app boots. */ +export function seedMockApiAccount( + targetHome, + { + accountId = "e2ea0272", + accountName = "E2E Agent Org Mock", + model = "e2e-fake-provider-agent-org-ui", + } = {} +) { + if (!targetHome) return null; + const now = new Date().toISOString(); + const targetPath = join(targetHome, "credentials.json"); + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync( + targetPath, + `${JSON.stringify( + { + credentials: { + [accountId]: { + account_metadata: {}, + agent_type: "openai_api", + api_key: "e2e-fake-provider-key", + auth_method: "api_key", + available_models: [model], + base_url: null, + created_at: now, + default_variants: [], + description: null, + enabled: true, + enabled_models: [model], + env_vars: {}, + has_local_key: true, + health_status: "unknown", + id: accountId, + is_listed: false, + last_oauth_refresh_failed_at: null, + last_upstream_error_type: null, + last_upstream_status: null, + last_validated_at: null, + last_validation_error: null, + listing_id: null, + model_aliases: [], + model_variants: [], + name: accountName, + oauth_refresh_failure_count: 0, + protocol: null, + quota_info: null, + rate_limit_reset_at: null, + session_token: null, + temporary_unavailable_reason: null, + temporary_unavailable_until: null, + updated_at: now, + }, + }, + updated_at: now, + version: "2.0", + }, + null, + 2 + )}\n`, + "utf8" + ); + return { accountId, accountName, targetPath }; +} diff --git a/tests/e2e/wdio.conf.mjs b/tests/e2e/wdio.conf.mjs index 14b50e5b2..0dc2ee2b7 100644 --- a/tests/e2e/wdio.conf.mjs +++ b/tests/e2e/wdio.conf.mjs @@ -13,6 +13,8 @@ import { homedir, tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { seedMockApiAccount } from "./support/core/mockApiAccountSeed.mjs"; + const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "..", ".."); const appBinary = resolve(repoRoot, "src-tauri/target/debug/org2"); @@ -189,63 +191,6 @@ function seedOrgiiHomeForParallel(sourceHome, targetHome) { } } -function seedMockApiAccount(targetHome) { - if (providerMode !== "mock" || !targetHome) return; - const accountId = "e2ea0272"; - const accountName = "E2E Agent Org Mock"; - const model = "e2e-fake-provider-agent-org-ui"; - const now = new Date().toISOString(); - writeFileSync( - join(targetHome, "credentials.json"), - `${JSON.stringify( - { - credentials: { - [accountId]: { - account_metadata: {}, - agent_type: "openai_api", - api_key: "e2e-fake-provider-key", - auth_method: "api_key", - available_models: [model], - base_url: null, - created_at: now, - default_variants: [], - description: null, - enabled: true, - enabled_models: [model], - env_vars: {}, - has_local_key: true, - health_status: "unknown", - id: accountId, - is_listed: false, - last_oauth_refresh_failed_at: null, - last_upstream_error_type: null, - last_upstream_status: null, - last_validated_at: null, - last_validation_error: null, - listing_id: null, - model_aliases: [], - model_variants: [], - name: accountName, - oauth_refresh_failure_count: 0, - protocol: null, - quota_info: null, - rate_limit_reset_at: null, - session_token: null, - temporary_unavailable_reason: null, - temporary_unavailable_until: null, - updated_at: now, - }, - }, - updated_at: now, - version: "2.0", - }, - null, - 2 - )}\n`, - "utf8" - ); -} - // E2E hit-testing (elementFromPoint vs getBoundingClientRect) assumes // zoom=1. The user's seeded settings may carry general.uiScale != 100, // which WebKit renders via CSS zoom and breaks coordinate math in specs @@ -269,7 +214,7 @@ function resetDerivedProjectDatabaseForIsolatedRun(targetHome) { if (orgiiHome) { seedOrgiiHomeForParallel(sourceOrgiiHome, orgiiHome); - seedMockApiAccount(orgiiHome); + if (providerMode === "mock") seedMockApiAccount(orgiiHome); normalizeUiScaleForIsolatedRun(orgiiHome); resetDerivedProjectDatabaseForIsolatedRun(orgiiHome); process.env.ORGII_HOME = orgiiHome; @@ -355,7 +300,8 @@ function claudeCodeImportFixtureRoundLines(startRound, roundCount, baseMs) { } function ensureClaudeCodeImportFixtureTranscript() { - const fixturePath = claudeCodeImportFixtureTranscriptPath(externalHistoryHome); + const fixturePath = + claudeCodeImportFixtureTranscriptPath(externalHistoryHome); mkdirSync(dirname(fixturePath), { recursive: true }); // A few minutes in the past so every seeded round timestamp is safely // before "now" once the app actually reads this file. @@ -769,6 +715,7 @@ export const config = { mochaOpts: { ui: "bdd", timeout: mochaTimeoutMs, + grep: process.env.E2E_MOCHA_GREP || undefined, }, waitforTimeout: 30_000, connectionRetryTimeout: connectionRetryTimeoutMs, From 749d1af3dea67b27fc9bd59fa3a574797277aa69 Mon Sep 17 00:00:00 2001 From: Neonforge <48338160+Neonforge98@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:32:57 +0800 Subject: [PATCH 03/11] feat(conversations): materialize provider-native transcripts --- .../ProviderNativeConversationContinuation.md | 112 ++ ...ersation-events-plane-design-2026-08-21.md | 239 +++-- .../ForkSessionSetupDialog.md | 20 + .../src/sources/claude_code/history.rs | 4 +- .../src/sources/claude_code/history/replay.rs | 2 +- .../sources/codex/app/transcript/parser.rs | 36 +- .../src/sources/codex/app_tests.rs | 33 + src-tauri/src/agent_sessions/cli/mod.rs | 1 + .../agent_sessions/cli/native_materializer.rs | 965 ++++++++++++++++++ .../cli/parsers/codex_app_server.rs | 12 +- .../cli/session_runner/env_setup.rs | 4 +- .../agent_sessions/cli/session_runner/mod.rs | 2 +- src-tauri/src/commands/handler_list.inc | 2 + .../ChatPanel/externalHistoryFork.test.ts | 72 +- src/engines/ChatPanel/externalHistoryFork.ts | 60 +- .../hooks/useImportedSessionSubmitOverride.ts | 103 +- .../localConversationContinuation.test.ts | 541 +++------- .../localConversationContinuation.ts | 400 ++------ .../nativeConversationMaterializer.test.ts | 221 ++++ .../nativeConversationMaterializer.ts | 273 +++++ .../SessionCore/services/SessionService.ts | 38 +- .../sync/authoritativeSessionEvents.test.ts | 58 ++ .../sync/authoritativeSessionEvents.ts | 39 +- .../conversationTurnRunner.test.ts | 27 + .../conversationTurnRunner.ts | 17 +- .../discussionEvents.test.ts | 8 + .../org2CloudConversationEventsClient.test.ts | 46 + .../org2CloudConversationEventsClient.ts | 20 +- .../ForkSessionSetupDialog/index.tsx | 137 ++- .../engine/collabSessionFork.ts | 4 +- .../TeamCollaboration/forkHandoffPrompt.ts | 136 --- .../TeamCollaboration/forkRelayRegistry.ts | 19 +- .../TeamCollaboration/forkSession.test.ts | 208 +--- src/features/TeamCollaboration/forkSession.ts | 47 +- .../TeamCollaboration/forkSetupMemory.test.ts | 2 + .../TeamCollaboration/forkSetupMemory.ts | 8 +- 36 files changed, 2513 insertions(+), 1403 deletions(-) create mode 100644 docs/architecture-audit-2026-08-26/ProviderNativeConversationContinuation.md create mode 100644 docs/frontend-ui-audit-2026-08-26/ForkSessionSetupDialog.md create mode 100644 src-tauri/src/agent_sessions/cli/native_materializer.rs create mode 100644 src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts create mode 100644 src/engines/SessionCore/conversations/nativeConversationMaterializer.ts create mode 100644 src/engines/SessionCore/sync/authoritativeSessionEvents.test.ts create mode 100644 src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts create mode 100644 src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts delete mode 100644 src/features/TeamCollaboration/forkHandoffPrompt.ts diff --git a/docs/architecture-audit-2026-08-26/ProviderNativeConversationContinuation.md b/docs/architecture-audit-2026-08-26/ProviderNativeConversationContinuation.md new file mode 100644 index 000000000..1a0c78bb0 --- /dev/null +++ b/docs/architecture-audit-2026-08-26/ProviderNativeConversationContinuation.md @@ -0,0 +1,112 @@ +# Provider-native conversation continuation audit + +Scope: imported My Sessions, Team Session conversation-plane execution, Native +Agent/Claude/Codex materialization, setup selection, native resume, and the +Work Item/Team Chat boundary. + +## Acceptance criteria + +- [x] No transcript-to-user-prompt or transcript-to-preamble path remains. +- [x] One portable message/tool projection is shared by imported and Cloud + conversation entry points. +- [x] Every offered target has a native writer, existing reader, and strict + native resume identity. +- [x] The target transcript is read back and compared before the first turn. +- [x] Runtime, account, model, Agent, workspace, and transcript matching are + symmetric for episode reuse. +- [x] Cloud never receives a credential and never executes an Agent. +- [x] Agent-facing content and user images remain separate from the UI display + projection; oversized Cloud events fail instead of truncating. +- [x] Work Item does not own a continuation implementation. +- [x] TypeScript and Rust compilation checks pass; focused behavioral tests pass. + +## Ten-layer audit + +| Layer | Element | Verdict | Reason | Suggested change | +| ----------------------- | ------------------------------------------ | ---------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| 1. Compilation | TS/Rust boundary | keep with reason | `pnpm typecheck` and `cargo check -p org2 --lib` pass. | None. | +| 2. Dead code/dedup | handoff prompt and cursor helpers | fix | Prompt bootstrap duplicated provider context and violated native resume semantics. | Deleted; all entry points use `nativeConversationMaterializer`. | +| 3. Naming | conversation, episode, native session | keep with reason | Canonical conversation is the visible aggregate; episode is a local provider run; native id belongs to the provider. | Keep these terms in docs and code comments. | +| 4. Semantic overloading | `parentSessionId` | keep with reason | It already expresses durable child ownership and avoids a parallel continuation registry. | Do not add another execution table. | +| 5. Defaults | unsupported CLI / resume failure | fix | A default fresh thread would silently lose history. | Capability allowlist and strict Codex `thread/resume`; fail closed. | +| 6. Core leakage | Cloud/Work Item inside continuation core | fix | The core previously carried source-specific prompt/cursor behavior. | Core accepts only locator, canonical events, and local target; Cloud is an adapter. | +| 7. State/finality | reusable terminal child | keep with reason | Existing turn lifecycle generation and durable status prevent stale-terminal completion. | Continue to reject failed children. | +| 8. Wire/serialization | portable IR -> Claude/Codex JSONL | fix | Native files must preserve role/tool pairing, images, ids, and account scope. | Structured Tauri payload, bounded size, atomic JSONL, reader round-trip. | +| 9. Entry parity | imported history / Team fork / Cloud plane | fix | Three entry points previously bootstrapped differently. | All now materialize through the same native boundary before sending. | +| 10. Resolver symmetry | target fingerprint and CLI account | fix | A CLI runtime without its compatible account could bind the wrong native store. | Reuse registry-backed native/CLI account compatibility and match every target field. | + +## Entry-point matrix + +| Entry point | Canonical read | Native materialize | Reader verify | New-turn dispatch | Visible result | +| ------------------------- | ------------------------------ | ----------------------------------- | --------------------------- | -------------------- | --------------------------- | +| Imported My Session | registered full-history reader | shared materializer | authoritative target reader | normal `sendMessage` | ordinary continued Session | +| Team Session conversation | base + Cloud plane | shared materializer on hidden child | authoritative target reader | normal `sendMessage` | canonical root conversation | +| Explicit legacy Team fork | persisted inherited events | shared materializer on fork Session | authoritative target reader | later normal send | explicit fork Session | + +## Resolver matrix + +| Field | Setup selection | Persisted Session | Reuse comparison | Native binding | +| ---------------- | ---------------------------- | --------------------- | ----------------- | -------------------------------- | +| Runtime/provider | yes | yes | yes | yes | +| Agent definition | yes | yes | yes | runtime-owned | +| Account | compatible local account | yes | yes | account-scoped native id/profile | +| Model | account model | yes | yes | normal runner resolution | +| Workspace | verified local checkout/none | yes | yes | native transcript cwd | +| Transcript | canonical events | provider-native store | semantic equality | existing reader | + +## Removed duplication + +- Deleted the Team fork handoff prompt and SessionService first-send wrapper. +- Deleted canonical-delta prompt rendering, cursor hashing, and bootstrap prompt + construction from local continuation. +- Reused existing Session rows, parent/child grouping, CLI account ledger, + imported-history readers, turn lifecycle, and setup dialog. +- Kept Team Chat audience routing and Work Item classification outside the + provider continuation core. + +## Residual constraints + +- Portable conversation fidelity is user/assistant/tool history and + attachments, not provider-private reasoning or process memory. +- The current Cloud event wire rejects an individual event over 64 KiB. It does + not silently truncate a transcript that may later be materialized natively. +- Claude Code and Codex are the only verified External CLI targets in this + change. Other imported providers remain valid sources and become targets only + after adding a native writer plus round-trip and resume tests. + +## Omnigent comparison + +Audit reference: `xhluca/session-migrate` / Omnigent commit +`658fb8bd4d383ff705a4eb9229c0ba3525a1b8f4`. + +What ORG2 intentionally learned from it: + +- Claude and Codex need actual provider files, not a rendered prompt. In + particular, a synthetic Codex rollout needs `session_meta`, `turn_context`, + model-facing `response_item` records, and UI-facing `event_msg` mirrors. +- Native target support is a capability matrix, not an inference from a source + label. A target is selectable only when write, authoritative read, and strict + resume all exist. + +Where ORG2 is stronger for the product's Team/My Session model: + +- ORG2 already has one canonical, author-attributed, ordered collaboration + plane. Provider episodes materialize from that plane and publish their tail + back into it; they are not the user-facing conversation identity. +- Different members and devices may use different local provider accounts and + runtimes while the UI remains on one Team Session. Credentials never need to + move to a server-side runner. +- The same portable projection accepts every registered imported-history + reader as a source, and every native writer is verified through the reader + the normal app actually uses. +- ORG2 fails closed. Omnigent still documents text-preamble carry paths for + Cursor and OpenCode; ORG2 does not advertise either target until it can write + and resume their real native history. + +Where Omnigent is currently ahead: + +- It has implemented more concrete native rebuild targets (including Pi, + Hermes, and Qwen). ORG2's verified External CLI target set in this change is + deliberately narrower: Claude Code and Codex. Extending it means adding an + adapter and its real reader/round-trip/resume proof, not weakening the + invariant. diff --git a/docs/conversation-events-plane-design-2026-08-21.md b/docs/conversation-events-plane-design-2026-08-21.md index 2d0dec58d..8c1a37601 100644 --- a/docs/conversation-events-plane-design-2026-08-21.md +++ b/docs/conversation-events-plane-design-2026-08-21.md @@ -1,102 +1,137 @@ -# Conversation Events Plane — the real fix for "it's just one session" - -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. - -## Model - -- 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). - -## Cloud (migration 0024_conversation_events.sql) - -- 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. - -## Client (ORGII) - -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). - -## Explicitly deferred - -- 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). +# Canonical conversation plane and provider-native continuation + +Updated 2026-08-26. + +## Product invariant + +One Team/My Session remains one visible conversation. A provider run is an +execution episode behind that conversation, not a second user-facing thread. + +Cloud stores and orders the canonical visible `SessionEvent` stream. It does +not run an Agent and never receives a provider key, account credential, model +secret, or local workspace. The member who submits a turn runs it on that +member's active ORG2 device with an explicitly selected local runtime, account, +model, Agent definition, and checkout. + +## Native continuation invariant + +History is never rendered into a user prompt or provider preamble. + +```text +canonical SessionEvent transcript + -> portable message/tool records + -> target provider's native transcript store + -> target provider's native session id + -> normal native resume with only the new user turn +``` + +The portable conversation contract contains ordered user messages, assistant +messages, tool calls, tool-call ids, JSON arguments, tool results, attachments, +and timestamps. Provider-private reasoning, credentials, policy prompts, and +opaque runtime memory are not portable conversation records. + +Every target adapter must implement all three capabilities: + +1. write provider-native role/tool records; +2. expose them through ORG2's existing authoritative reader; +3. resume the exact native session id without a fresh-thread fallback. + +After writing, ORG2 reads the target back through the normal reader and compares +the portable transcript semantically. A mismatch aborts before dispatch. A +provider with no verified writer/reader/resume contract is not offered in the +continuation picker. + +Current targets are: + +| Target | Native write | Authoritative read | Resume identity | +| ----------------- | ------------------------------------------- | ------------------------------ | -------------------------- | +| ORG2 Native Agent | existing `seed_session_with_messages` store | existing Agent message reader | ORG2 Session id | +| Claude Code | account-scoped Claude project JSONL | existing Claude history reader | Claude native session UUID | +| Codex CLI | account-scoped Codex rollout JSONL | existing Codex rollout reader | Codex thread UUID | + +Any registered imported-history reader can be a source. Target support is +deliberately capability-gated; source breadth must never imply a lossy target +fallback. + +## Conversation execution + +A canonical conversation is keyed by `(authority, authority scope, +conversation id)`. For ORG2 Cloud the key is `(org id, root session id)`. +Ordinary persisted child Sessions use this deterministic parent identity, so +the existing Session aggregate is also the durable execution-episode ledger. +There is no continuation-specific database, localStorage runner registry, +checkpoint payload, frozen transcript, or E2EE copy. + +For each turn: + +1. Read the complete canonical transcript immediately before the new turn. +2. Find the newest healthy child whose runtime, Agent, account, model, workspace, + and provider-native transcript all match. +3. If one matches, send only the new turn through normal `sendMessage`/native + resume. +4. Otherwise create an idle child, materialize the complete canonical transcript + natively, round-trip verify it, bind its native session id, then send only the + new turn. +5. Publish the normalized user row and this episode's new agent tail to the + canonical Cloud plane. + +Turns are serialized per canonical conversation in one app process. A native +resume error fails closed; it never silently starts a context-free provider +thread. A later explicit retry may create a new verified episode. + +The visible conversation folds base Session events, Cloud-plane events, plain +Team Chat user messages, and live local episode overlays into one timeline. +Provider-specific child Sessions stay hidden from ordinary session lists. + +## Team Chat and Work Item boundaries + +Plain Team Chat is human conversation. Its unanchored comments are projected as +ordinary attributed user messages in the same visible transcript, so later +Agent turns receive them as native user history. `@human` and `@all` share the +same audience-routing policy with Work Item comments and create human +notifications; they do not by themselves choose a runtime. + +Work Item remains outside the continuation core. Its comment classifier decides +whether a comment is human-only, assigned-Agent, explicit-Agent, or mixed. If a +future Work Item path executes against a Session conversation, it must call the +same thin conversation adapter; it must not add a Work Item runner, transcript +store, or prompt bridge. + +## Cloud plane + +Migration `0024_conversation_events.sql` supplies the existing append-only +`cloud_conversation_events` plane, server sequence, counters, capability gate, +and realtime signal. The native continuation work adds no Cloud execution +infrastructure and no new Cloud persistence format. Cloud sees only normalized +conversation rows and ordinary Session sharing data. + +The current Cloud event RPC caps one event at 64 KiB. Because the plane is a +native-resume source, ORG2 rejects an event above that limit instead of +truncating it and silently changing future model context. A future large-event +transport must preserve and reassemble the exact event before this guard can be +relaxed. + +## Failure and compatibility rules + +- Missing local device/account/workspace: no run; the canonical conversation is + still readable. +- Unsupported target writer: hidden or rejected before Session creation. +- Materialization or reader mismatch: delete the new local episode and do not + dispatch. +- Oversized Cloud event: fail the publish; never replace it with truncated + native history. +- Native resume failure: mark/fail the episode; never fall back to `thread/start` + in the same turn. +- Runtime/account/model/workspace change or transcript divergence: create a new + hidden episode from the canonical transcript. +- Legacy `handoffPending` storage is parse-only compatibility data and has no + execution effect. + +## Explicit non-goals + +- Cloud-hosted Agent execution or borrowed user credentials. +- Prompt/preamble transcript replay. +- Provider-private chain-of-thought or opaque tool-process migration. +- A second checkpoint/encryption/manifest system for already shared Team + Session data. +- Work Item-specific continuation persistence. diff --git a/docs/frontend-ui-audit-2026-08-26/ForkSessionSetupDialog.md b/docs/frontend-ui-audit-2026-08-26/ForkSessionSetupDialog.md new file mode 100644 index 000000000..e636ef796 --- /dev/null +++ b/docs/frontend-ui-audit-2026-08-26/ForkSessionSetupDialog.md @@ -0,0 +1,20 @@ +# Fork Session setup runtime selection UI audit + +The documented `frontend-ui-audit` skill is unavailable in this workspace. This +manual fallback reviews the changed dialog for design-system reuse, +accessibility, localization, and duplicated visual patterns. + +| Line / file | Element | Verdict | Reason | Suggested change | +| ---------------------------------- | ----------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `ForkSessionSetupDialog/index.tsx` | Runtime selector | keep with reason | Reuses the existing shared `Select`; unsupported native writers are omitted instead of shown as a late error. | None. | +| `ForkSessionSetupDialog/index.tsx` | Account selector | fix (resolved) | A generic CLI-account pool could pair Claude/Codex with an incompatible account. | Reused the registry-backed CLI compatibility resolver for the selected runtime. | +| `ForkSessionSetupDialog/index.tsx` | Runtime switch state | fix (resolved) | Account/model state from the previous runtime could remain selected after switching provider. | Clear account/model overrides whenever the runtime changes. | +| `ForkSessionSetupDialog/index.tsx` | Native Agent account list | fix (resolved) | Local filtering duplicated the canonical Agent Registry compatibility rule. | Reused `getRustCompatibleAccounts`. | +| `ForkSessionSetupDialog/index.tsx` | Buttons and selection controls | keep with reason | Existing `Button`, `Select`, native buttons, visible labels, disabled Continue state, and focus behavior remain unchanged. | None. | +| `ForkSessionSetupDialog/index.tsx` | Styling and layout | keep with reason | No new arbitrary size/color token or parallel dialog pattern was introduced. | None. | +| Navigation locale keys | Existing runtime/account/model copy | keep with reason | The change reuses existing localized labels and adds no raw user-facing copy. | None. | + +Verdict totals: **3 fix (resolved)**, **4 keep with reason**, **0 abstract**. + +No multi-file visual sweep candidate was found. Runtime capability is a data +contract; the presentation remains the established setup-dialog pattern. 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 8bb862a40..3b781eef6 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 @@ -38,7 +38,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 +74,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/replay.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/replay.rs index 390ab3416..88aeab68c 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 @@ -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> { 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 b2f4af087..06910be5c 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 @@ -134,16 +134,32 @@ 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; + // 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. + let duplicate_context_item = collector.current.last().is_some_and(|chunk| { + chunk.function == imported_history::FUNCTION_ASSISTANT + && chunk.created_at == created_at + && chunk + .result + .get("observation") + .or_else(|| chunk.result.get("content")) + .and_then(Value::as_str) + == Some(message) + }); + if !duplicate_context_item { + collector + .current + .push(imported_history::assistant_message_chunk( + session_id, + CODEX_PROVIDER_SLUG, + sequence, + &created_at, + message, + )); + sequence += 1; + } } } "message" => { 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 08a3494f6..853a600c8 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,39 @@ 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.000Z","type":"event_msg","payload":{"type":"agent_message","message":"one answer","phase":"final_answer","memory_citation":null}} +"#; + 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(), 1); + assert_eq!( + assistant[0] + .result + .get("observation") + .or_else(|| assistant[0].result.get("content")) + .and_then(Value::as_str), + Some("one 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!( diff --git a/src-tauri/src/agent_sessions/cli/mod.rs b/src-tauri/src/agent_sessions/cli/mod.rs index e975925c9..948d4fb8a 100644 --- a/src-tauri/src/agent_sessions/cli/mod.rs +++ b/src-tauri/src/agent_sessions/cli/mod.rs @@ -15,6 +15,7 @@ pub mod agent_core_bridge; pub mod commands; pub mod hook_approvals; pub mod launch_profile_store; +pub mod native_materializer; pub mod native_transcript; pub mod parsers; pub mod persistence; 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 000000000..97ca810d6 --- /dev/null +++ b/src-tauri/src/agent_sessions/cli/native_materializer.rs @@ -0,0 +1,965 @@ +//! 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::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use chrono::{Datelike, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use super::native_transcript::TRANSCRIPT_SOURCE_NATIVE; +use super::persistence; + +const MAX_ITEMS: usize = 100_000; +const MAX_SERIALIZED_BYTES: usize = 64 * 1024 * 1024; + +#[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, + }, + ToolCall { + id: String, + call_id: String, + name: String, + arguments: String, + created_at: String, + }, + ToolResult { + id: String, + call_id: String, + name: String, + output: String, + created_at: String, + }, +} + +impl NativeConversationItem { + fn id(&self) -> &str { + match self { + Self::Message { id, .. } | Self::ToolCall { id, .. } | Self::ToolResult { id, .. } => { + id + } + } + } + + fn created_at(&self) -> &str { + match self { + Self::Message { created_at, .. } + | Self::ToolCall { created_at, .. } + | Self::ToolResult { created_at, .. } => created_at, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeMaterializationReceipt { + native_session_id: String, + item_count: usize, +} + +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() + )); + } + let encoded = serde_json::to_vec(items) + .map_err(|err| format!("serialize native transcript input: {err}"))?; + if encoded.len() > MAX_SERIALIZED_BYTES { + return Err(format!( + "native transcript is {} bytes; limit is {MAX_SERIALIZED_BYTES}", + encoded.len() + )); + } + for item in items { + if item.id().trim().is_empty() { + return Err("native transcript item id is required".to_string()); + } + match item { + NativeConversationItem::Message { 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( + "assistant historical images cannot be transferred losslessly to this native target" + .to_string(), + ); + } + for image in images { + if !image.starts_with("data:image/") { + return Err( + "historical images must be embedded data URLs for exact native transfer" + .to_string(), + ); + } + } + } + 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()); + } + 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()); + } + } + } + } + Ok(()) +} + +fn atomic_jsonl(path: &Path, records: &[Value]) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| format!("native transcript path has no parent: {}", path.display()))?; + fs::create_dir_all(parent) + .map_err(|err| format!("create native transcript dir {}: {err}", parent.display()))?; + let tmp = path.with_extension(format!("jsonl.tmp-{}", Uuid::new_v4().simple())); + let result = (|| -> Result<(), String> { + let mut file = fs::File::create(&tmp) + .map_err(|err| format!("create native transcript {}: {err}", tmp.display()))?; + for record in records { + serde_json::to_writer(&mut file, record) + .map_err(|err| format!("write native transcript {}: {err}", tmp.display()))?; + file.write_all(b"\n") + .map_err(|err| format!("write native transcript {}: {err}", tmp.display()))?; + } + file.sync_all() + .map_err(|err| format!("sync native transcript {}: {err}", tmp.display()))?; + fs::rename(&tmp, path).map_err(|err| { + format!( + "commit native transcript {} -> {}: {err}", + tmp.display(), + path.display() + ) + })?; + Ok(()) + })(); + if result.is_err() { + let _ = fs::remove_file(&tmp); + } + result +} + +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_messages(items: &[NativeConversationItem]) -> Vec { + items + .iter() + .map(|item| match item { + NativeConversationItem::Message { + role, text, images, .. + } => { + if role == "user" && !images.is_empty() { + let mut content = vec![json!({"type": "text", "text": text})]; + content.extend( + images + .iter() + .map(|image| json!({"type": "image_url", "image_url": {"url": image}})), + ); + json!({"role": role, "content": content}) + } else { + json!({"role": role, "content": text}) + } + } + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => json!({ + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": arguments} + }] + }), + NativeConversationItem::ToolResult { + call_id, + name, + output, + .. + } => json!({ + "role": "tool", + "tool_call_id": call_id, + "name": name, + "content": output + }), + }) + .collect() +} + +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_records( + native_id: &str, + cwd: &Path, + items: &[NativeConversationItem], +) -> Result, String> { + let mut records = Vec::with_capacity(items.len()); + let mut parent_uuid: Option = None; + for item in items { + 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, .. + } => ( + "user".to_string(), + json!({ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": call_id, + "content": output + }] + }), + Some(json!({"toolUseResult": output})), + ), + }; + 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, + "orgiiMaterialization": true + }); + 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 codex_model_provider(profile: &Path, account_id: &str) -> Result { + let config = fs::read_to_string(profile.join("config.toml")).unwrap_or_default(); + if let Some(provider) = config.lines().map(str::trim).find_map(|line| { + let value = line.strip_prefix("model_provider")?.trim(); + let value = value.strip_prefix('=')?.trim(); + let value = value.strip_prefix('"')?.strip_suffix('"')?; + (!value.is_empty()).then(|| value.to_string()) + }) { + return Ok(provider); + } + let key = key_vault::key_store::KEY_SERVICE + .get_key_by_id(account_id) + .ok_or_else(|| format!("Codex account {account_id} is no longer available"))?; + Ok( + if super::session_runner::env_setup::codex_needs_compatible_profile(&key) { + super::session_runner::env_setup::CODEX_COMPATIBLE_PROVIDER_ID.to_string() + } else { + "openai".to_string() + }, + ) +} + +fn codex_records( + native_id: &str, + cwd: &Path, + model_provider: &str, + items: &[NativeConversationItem], +) -> Vec { + let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let mut records = vec![json!({ + "timestamp": now, + "type": "session_meta", + "payload": { + "id": native_id, + "timestamp": now, + "cwd": cwd, + "originator": "orgii", + "cli_version": "0.0.0", + "model_provider": model_provider + } + })]; + let mut current_turn_id: Option = None; + let mut emitted_turn_id: Option = None; + for item in items { + if matches!(item, NativeConversationItem::Message { role, .. } if role == "user") { + current_turn_id = Some(format!( + "turn_{}", + stable_uuid("orgii-codex-turn", native_id, item.id()).replace('-', "") + )); + } + let turn_id = current_turn_id + .get_or_insert_with(|| { + format!( + "turn_{}", + stable_uuid("orgii-codex-turn", native_id, "history").replace('-', "") + ) + }) + .clone(); + if emitted_turn_id.as_ref() != Some(&turn_id) { + records.push(json!({ + "timestamp": item.created_at(), + "type": "turn_context", + "payload": { + "turn_id": turn_id, + "cwd": cwd, + "approval_policy": "on-request", + "sandbox_policy": {"type": "workspace-write"} + } + })); + emitted_turn_id = Some(turn_id); + } + let payload = 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})), + ); + } + json!({"type": "message", "id": id, "role": role, "content": content}) + } + NativeConversationItem::ToolCall { + call_id, + name, + arguments, + .. + } => json!({ + "type": "function_call", + "name": name, + "arguments": arguments, + "call_id": call_id + }), + NativeConversationItem::ToolResult { + call_id, output, .. + } => json!({ + "type": "function_call_output", + "call_id": call_id, + "output": output + }), + }; + records.push(json!({ + "timestamp": item.created_at(), + "type": "response_item", + "payload": payload + })); + if let NativeConversationItem::Message { + role, text, images, .. + } = item + { + let event_payload = if role == "user" { + json!({ + "type": "user_message", + "message": text, + "images": images, + "local_images": [], + "text_elements": [] + }) + } else { + json!({ + "type": "agent_message", + "message": text, + "phase": "final_answer", + "memory_citation": null + }) + }; + records.push(json!({ + "timestamp": item.created_at(), + "type": "event_msg", + "payload": event_payload + })); + } + } + records +} + +fn execution_cwd(session: &persistence::CodeSession) -> Result { + let value = session + .worktree_path + .as_deref() + .or(session.repo_path.as_deref()) + .filter(|value| !value.trim().is_empty()); + match value { + Some(value) => Ok(PathBuf::from(value)), + None => std::env::current_dir().map_err(|err| format!("resolve execution cwd: {err}")), + } +} + +fn find_codex_materialization(root: &Path, native_id: &str) -> Option { + 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 = fs::read_dir(directory).ok()?; + for entry in entries.flatten() { + visited += 1; + if visited > MAX_ITEMS { + return None; + } + let path = entry.path(); + if path.is_dir() { + pending.push(path); + } else if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(&suffix)) + { + return Some(path); + } + } + } + None +} + +fn has_orgii_materialization_marker(path: &Path, agent: &str) -> bool { + let Ok(contents) = fs::read_to_string(path) else { + return false; + }; + let Some(first_line) = contents.lines().next() else { + return false; + }; + let Ok(record) = serde_json::from_str::(first_line) else { + return false; + }; + match agent { + "claude_code" => record["orgiiMaterialization"] == true, + "codex" => record["type"] == "session_meta" && record["payload"]["originator"] == "orgii", + _ => false, + } +} + +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()) + .ok_or_else(|| "native CLI materialization has no account binding".to_string())?; + let bound = persistence::get_cli_session_id_for_account(session_id, Some(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 path = match agent { + "claude_code" => app_paths::claude_code_cli_profile_dir(account_id) + .join("projects") + .join(sanitize_claude_project_name(&execution_cwd(&session)?)) + .join(format!("{native_id}.jsonl")), + "codex" => find_codex_materialization( + &app_paths::codex_cli_profile_dir(account_id).join("sessions"), + native_id, + ) + .ok_or_else(|| format!("materialized Codex transcript {native_id} was not found"))?, + _ => return Ok(false), + }; + if !has_orgii_materialization_marker(&path, agent) { + return Err(format!( + "refusing to remove unmarked provider transcript {}", + path.display() + )); + } + fs::remove_file(&path) + .map_err(|err| format!("remove native materialization {}: {err}", path.display()))?; + persistence::clear_cli_resume_state(session_id, "native_materialization_rollback") + .map_err(|err| format!("clear native materialization binding: {err}"))?; + Ok(true) +} + +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()) + .ok_or_else(|| { + "native CLI materialization requires an explicit local account".to_string() + })?; + let cwd = execution_cwd(&session)?; + let native_id = Uuid::new_v4().to_string(); + let agent = session.cli_agent_type.as_deref().unwrap_or_default(); + let path = match agent { + "claude_code" => { + let profile = app_paths::claude_code_cli_profile_dir(account_id); + let path = profile + .join("projects") + .join(sanitize_claude_project_name(&cwd)) + .join(format!("{native_id}.jsonl")); + atomic_jsonl(&path, &claude_records(&native_id, &cwd, items)?)?; + path + } + "codex" => { + let profile = app_paths::codex_cli_profile_dir(account_id); + let today = Utc::now(); + let path = profile + .join("sessions") + .join(format!("{:04}", today.year())) + .join(format!("{:02}", today.month())) + .join(format!("{:02}", today.day())) + .join(format!( + "rollout-{}-{native_id}.jsonl", + today.format("%Y-%m-%dT%H-%M-%S") + )); + let provider = codex_model_provider(&profile, account_id)?; + atomic_jsonl(&path, &codex_records(&native_id, &cwd, &provider, items))?; + path + } + other => { + return Err(format!( + "CLI target {other:?} cannot write a provider-native role/tool transcript" + )) + } + }; + // The managed replay path intentionally resolves through the established + // imported-history cache. Refresh that same reader index now so the + // caller can round-trip the just-written transcript before dispatch. + let register_result = (|| -> Result<(), String> { + let mut conn = database::db::get_connection() + .map_err(|err| format!("open native transcript reader cache: {err}"))?; + match agent { + "claude_code" => { + orgtrack_core::sources::claude_code::history::list_claude_code_history_sessions_paginated( + &mut conn, 1, 0, + ) + .map_err(|err| format!("index materialized Claude transcript: {err}"))?; + } + "codex" => { + orgtrack_core::sources::codex::app::list_codex_app_sessions_paginated( + &mut conn, 1, 0, + ) + .map_err(|err| format!("index materialized Codex transcript: {err}"))?; + } + _ => unreachable!("unsupported targets returned above"), + } + let bound = persistence::update_cli_session_id_for_account( + session_id, + Some(account_id), + &native_id, + ) + .map_err(|err| format!("bind native transcript {native_id} to {session_id}: {err}"))?; + if !bound { + return Err(format!( + "bind native transcript {native_id}: target session {session_id} disappeared" + )); + } + Ok(()) + })(); + if let Err(error) = register_result { + let _ = fs::remove_file(&path); + return Err(error); + } + tracing::info!( + session_id, + native_session_id = native_id, + target = agent, + path = %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"))?; + agent_core::session::persistence::seed_session_with_messages( + session_id, + &native_agent_messages(items), + ) + .map_err(|err| format!("seed native Agent transcript {session_id}: {err}"))?; + Ok(NativeMaterializationReceipt { + native_session_id: session_id.to_string(), + item_count: items.len(), + }) +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn materialize_native_conversation( + session_id: String, + items: Vec, +) -> Result { + validate_items(&items)?; + tokio::task::spawn_blocking(move || { + if session_id.starts_with(core_types::session::CLI_SESSION_PREFIX) { + materialize_cli(&session_id, &items) + } else { + materialize_native_agent(&session_id, &items) + } + }) + .await + .map_err(|err| format!("native materialization task failed: {err}"))? +} + +#[tauri::command(rename_all = "camelCase")] +pub async fn discard_native_conversation_materialization( + session_id: String, + native_session_id: String, +) -> Result { + tokio::task::spawn_blocking(move || { + discard_cli_materialization(&session_id, &native_session_id) + }) + .await + .map_err(|err| format!("native materialization rollback task failed: {err}"))? +} + +#[cfg(test)] +mod tests { + use super::*; + + fn message() -> NativeConversationItem { + NativeConversationItem::Message { + id: "u1".to_string(), + role: "user".to_string(), + text: "hello".to_string(), + images: Vec::new(), + created_at: "2026-08-26T00:00:00Z".to_string(), + } + } + + fn assistant_message() -> NativeConversationItem { + NativeConversationItem::Message { + id: "a1".to_string(), + role: "assistant".to_string(), + text: "done".to_string(), + images: Vec::new(), + created_at: "2026-08-26T00:00:03Z".to_string(), + } + } + + #[test] + fn claude_materialization_is_native_role_history() { + let records = claude_records( + "00000000-0000-4000-8000-000000000001", + Path::new("/repo"), + &[message()], + ) + .expect("claude records"); + assert_eq!(records[0]["type"], "user"); + assert_eq!(records[0]["message"]["role"], "user"); + assert_eq!(records[0]["message"]["content"], "hello"); + assert_eq!(records[0]["orgiiMaterialization"], true); + let assistant = claude_records( + "00000000-0000-4000-8000-000000000001", + Path::new("/repo"), + &[assistant_message()], + ) + .expect("claude assistant records"); + assert_eq!(assistant[0]["message"]["content"][0]["type"], "text"); + assert_eq!(assistant[0]["message"]["content"][0]["text"], "done"); + } + + #[test] + fn claude_materialization_round_trips_through_the_existing_reader() { + let native_id = "00000000-0000-4000-8000-000000000001"; + let items = vec![ + message(), + NativeConversationItem::ToolCall { + id: "tool-1:call".to_string(), + call_id: "call-1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"/repo/README.md"}"#.to_string(), + created_at: "2026-08-26T00:00:01Z".to_string(), + }, + NativeConversationItem::ToolResult { + id: "tool-1:result".to_string(), + call_id: "call-1".to_string(), + name: "read_file".to_string(), + output: "contents".to_string(), + created_at: "2026-08-26T00:00:02Z".to_string(), + }, + assistant_message(), + ]; + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-claude-roundtrip-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let path = temp_dir.join(format!("{native_id}.jsonl")); + atomic_jsonl( + &path, + &claude_records(native_id, Path::new("/repo"), &items) + .expect("build native Claude transcript"), + ) + .expect("write native Claude transcript"); + + let chunks = + orgtrack_core::sources::claude_code::history::load_claude_code_history_from_path( + "claudecodeapp-native-roundtrip", + &path, + ) + .expect("read native Claude transcript"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .count(), + 1 + ); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "assistant") + .count(), + 1 + ); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("tool call"); + assert_eq!(tool.args["path"], "/repo/README.md"); + assert_eq!(tool.result["output"], "contents"); + + std::fs::remove_dir_all(&temp_dir).expect("remove temp dir"); + } + + #[test] + fn codex_materialization_has_context_and_visible_mirror() { + let records = codex_records( + "00000000-0000-4000-8000-000000000001", + Path::new("/repo"), + "openai", + &[message()], + ); + assert_eq!(records[0]["type"], "session_meta"); + assert!(records + .iter() + .any(|record| record["type"] == "response_item")); + assert!(records.iter().any(|record| record["type"] == "event_msg")); + } + + #[test] + fn codex_materialization_round_trips_through_the_existing_reader() { + let native_id = "00000000-0000-4000-8000-000000000001"; + let items = vec![ + message(), + NativeConversationItem::ToolCall { + id: "tool-1:call".to_string(), + call_id: "call-1".to_string(), + name: "read_file".to_string(), + arguments: r#"{"path":"/repo/README.md"}"#.to_string(), + created_at: "2026-08-26T00:00:01Z".to_string(), + }, + NativeConversationItem::ToolResult { + id: "tool-1:result".to_string(), + call_id: "call-1".to_string(), + name: "read_file".to_string(), + output: "contents".to_string(), + created_at: "2026-08-26T00:00:02Z".to_string(), + }, + assistant_message(), + ]; + let temp_dir = std::env::temp_dir().join(format!( + "orgii-native-codex-roundtrip-{}-{}", + std::process::id(), + Uuid::new_v4().simple() + )); + let path = temp_dir.join("rollout-native.jsonl"); + atomic_jsonl( + &path, + &codex_records(native_id, Path::new("/repo"), "openai", &items), + ) + .expect("write native Codex transcript"); + + let chunks = orgtrack_core::sources::codex::app::load_codex_app_from_path( + "codexapp-native-roundtrip", + &path, + ) + .expect("read native Codex transcript"); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "user_message") + .count(), + 1 + ); + assert_eq!( + chunks + .iter() + .filter(|chunk| chunk.function == "assistant") + .count(), + 1 + ); + let tool = chunks + .iter() + .find(|chunk| chunk.action_type == "tool_call") + .expect("tool call"); + assert_eq!(tool.args["path"], "/repo/README.md"); + assert_eq!(tool.result["output"], "contents"); + assert_eq!( + codex_records(native_id, Path::new("/repo"), "openai", &items) + .iter() + .filter(|record| record["type"] == "turn_context") + .count(), + 1, + "one user turn, including its tools and assistant answer, shares one context id" + ); + + std::fs::remove_dir_all(&temp_dir).expect("remove temp dir"); + } + + #[test] + fn unsupported_historical_image_fails_closed() { + let mut item = message(); + if let NativeConversationItem::Message { images, .. } = &mut item { + images.push("/tmp/image.png".to_string()); + } + assert!(validate_items(&[item]).is_err()); + } + + #[test] + fn unsupported_assistant_image_fails_closed() { + let mut item = assistant_message(); + if let NativeConversationItem::Message { images, .. } = &mut item { + images.push("data:image/png;base64,AAAA".to_string()); + } + assert!(validate_items(&[item]).is_err()); + } +} 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 b7fdf431a..e99475d45 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 @@ -24,7 +24,8 @@ //! 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. +//! 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"}}`. //! - `turn/interrupt` `{threadId, turnId}` → `{}`. @@ -895,7 +896,7 @@ pub async fn run_app_server_turn( } rpc_notify(&mut stdin, "initialized").await?; - // ── Step 2: thread/resume (with fallback) or thread/start ── + // ── Step 2: strict thread/resume or explicit fresh thread/start ── let (approval_policy, sandbox) = thread_permission_params(mode); let mut thread_params = serde_json::json!({ "cwd": &turn.working_dir, @@ -924,12 +925,7 @@ pub async fn run_app_server_turn( .await? { Ok(result) => thread_result = Some(result), - Err(err) => { - tracing::warn!( - "[CodexAppServer] thread/resume failed ({}); starting fresh thread", - err - ); - } + Err(err) => return Err(format!("app-server thread/resume error: {err}")), } } let thread_result = match thread_result { 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 4cb49f421..d745e15d1 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 @@ -22,7 +22,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] = &[ @@ -288,7 +288,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/mod.rs b/src-tauri/src/agent_sessions/cli/session_runner/mod.rs index 0a97dc487..034150bfc 100644 --- a/src-tauri/src/agent_sessions/cli/session_runner/mod.rs +++ b/src-tauri/src/agent_sessions/cli/session_runner/mod.rs @@ -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; diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 5f380d7f1..90f614050 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -502,6 +502,8 @@ api::websocket_handler::subscribe_session_events, api::websocket_handler::unsubscribe_session_events, // 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::discard_native_conversation_materialization, agent_sessions::cli::commands::cli_agent_run, agent_sessions::cli::commands::cli_agent_message, agent_sessions::cli::commands::cli_agent_approval_response, diff --git a/src/engines/ChatPanel/externalHistoryFork.test.ts b/src/engines/ChatPanel/externalHistoryFork.test.ts index ee44f0102..3de910467 100644 --- a/src/engines/ChatPanel/externalHistoryFork.test.ts +++ b/src/engines/ChatPanel/externalHistoryFork.test.ts @@ -4,6 +4,7 @@ import { type ImportedHistorySource, getImportedHistorySourceBySessionId, } from "@src/api/tauri/externalHistory"; +import { materializeNativeConversation } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { SessionService } from "@src/engines/SessionCore/services/SessionService"; @@ -11,17 +12,21 @@ import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSes import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; import type { ActivityChunk } from "@src/types/session/session"; -import { - buildExternalHistoryHandoffPrompt, - forkExternalHistoryIntoOrgiiSession, -} from "./externalHistoryFork"; +import { forkExternalHistoryIntoOrgiiSession } from "./externalHistoryFork"; vi.mock("@src/api/tauri/externalHistory", () => ({ getImportedHistorySourceBySessionId: vi.fn(), })); vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ - SessionService: { create: vi.fn() }, + SessionService: { create: vi.fn(), sendMessage: vi.fn() }, })); +vi.mock( + "@src/engines/SessionCore/conversations/nativeConversationMaterializer", + () => ({ + materializeNativeConversation: vi.fn(), + discardNativeConversationSession: vi.fn(), + }) +); vi.mock("@src/engines/SessionCore/ingestion/rustBridge", () => ({ processChunksRust: vi.fn(), })); @@ -73,25 +78,6 @@ function event( } as SessionEvent; } -describe("buildExternalHistoryHandoffPrompt", () => { - it("keeps the complete visible transcript and excludes private reasoning", () => { - const long = "x".repeat(25_000); - const prompt = buildExternalHistoryHandoffPrompt( - [ - event("u1", "user", long), - event("r1", "assistant", "private chain of thought", "reasoning"), - event("a1", "assistant", "I found the issue"), - ], - "continue and verify it" - ); - - expect(prompt).toContain(long); - expect(prompt).toContain("Assistant:\nI found the issue"); - expect(prompt).toContain("continue and verify it"); - expect(prompt).not.toContain("private chain of thought"); - }); -}); - describe("forkExternalHistoryIntoOrgiiSession", () => { const loadFullTranscriptChunks = vi.fn(); const source: ImportedHistorySource = { @@ -132,6 +118,10 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { vi.mocked(SessionService.create).mockResolvedValue({ sessionId: "agentsession-forked", }); + vi.mocked(materializeNativeConversation).mockResolvedValue({ + events: [event("u1", "user", "old ask")], + receipt: { nativeSessionId: "agentsession-forked", itemCount: 1 }, + }); }); it("uses the shared setup before loading history, then creates one writable ORGII continuation", async () => { @@ -187,7 +177,6 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { expect(SessionService.create).toHaveBeenCalledTimes(1); expect(SessionService.create).toHaveBeenCalledWith( expect.objectContaining({ - imageDataUrls: ["data:image/png;base64,abc"], name: "Continue Imported review", repoPath: "/local/repo", model: "gpt-test", @@ -195,7 +184,19 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { keySource: "own_key", agentDefinitionId: "custom:security-auditor", mode: "build", - task: expect.stringContaining("continue and run tests"), + task: "", + }) + ); + expect(materializeNativeConversation).toHaveBeenCalledWith({ + sessionId: "agentsession-forked", + timeline: [expect.objectContaining({ id: "u1" })], + }); + expect(SessionService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "agentsession-forked", + content: "continue and run tests", + displayText: "continue and run tests", + imageDataUrls: ["data:image/png;base64,abc"], }) ); expect( @@ -213,11 +214,12 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { agentMessage: contract, }); - const task = vi.mocked(SessionService.create).mock.calls[0]?.[0]?.task; - // The handoff prompt embeds the AGENT copy as the continuation request — - // never the raw pill serialization the display copy carries. - expect(task).toContain("render_inline_canvas exactly once"); - expect(task).not.toContain("[skill:/canvas]"); + expect(SessionService.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining("render_inline_canvas exactly once"), + displayText: "canvas [skill:/canvas] build a coffee order UI", + }) + ); }); it("falls back to the display copy when no agent projection exists", async () => { @@ -226,9 +228,9 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { userMessage: "continue and run tests", }); - expect(SessionService.create).toHaveBeenCalledWith( + expect(SessionService.sendMessage).toHaveBeenCalledWith( expect.objectContaining({ - task: expect.stringContaining("continue and run tests"), + content: "continue and run tests", }) ); }); @@ -239,6 +241,7 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { execution: { agentDefinitionId: "builtin:sde", cliAgentType: "claude_code", + accountId: "claude-account", }, }); @@ -250,8 +253,9 @@ describe("forkExternalHistoryIntoOrgiiSession", () => { expect(SessionService.create).toHaveBeenCalledWith( expect.objectContaining({ cliAgentType: "claude_code", + accountId: "claude-account", repoPath: "/local/repo", - task: expect.stringContaining("old ask"), + task: "", }) ); }); diff --git a/src/engines/ChatPanel/externalHistoryFork.ts b/src/engines/ChatPanel/externalHistoryFork.ts index 1a8faccee..52064be55 100644 --- a/src/engines/ChatPanel/externalHistoryFork.ts +++ b/src/engines/ChatPanel/externalHistoryFork.ts @@ -1,19 +1,15 @@ import { getImportedHistorySourceBySessionId } from "@src/api/tauri/externalHistory"; -import { buildCanonicalConversationHandoff } from "@src/engines/SessionCore/conversations/localConversationContinuation"; -import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { + discardNativeConversationSession, + materializeNativeConversation, +} from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import { processChunksRust } from "@src/engines/SessionCore/ingestion/rustBridge"; import { SessionService } from "@src/engines/SessionCore/services/SessionService"; +import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import { requestForkSessionSetup } from "@src/features/TeamCollaboration/forkSession"; import { resolveShareableScopeKeys } from "@src/features/TeamCollaboration/repoScopeResolver"; import type { Session } from "@src/store/session"; -export function buildExternalHistoryHandoffPrompt( - events: readonly SessionEvent[], - userMessage: string -): string { - return buildCanonicalConversationHandoff(events, userMessage); -} - export async function forkExternalHistoryIntoOrgiiSession(params: { sourceSessionId: string; sourceSession?: Session; @@ -21,11 +17,9 @@ export async function forkExternalHistoryIntoOrgiiSession(params: { userMessage: string; /** * Agent-facing projection of `userMessage` (skill pills expanded, canvas - * contract, base64-free). When present it is what the model must receive - * as the continuation request; `userMessage` remains the display copy. - * `session_launch` only carries a single content field, so the handoff - * prompt embeds the agent projection — a fully split visible message would - * need backend support. + * contract, base64-free). This is only the new turn; imported history is + * materialized as provider-native role/tool records before it is sent. + * `userMessage` remains the display copy. */ agentMessage?: string; imageDataUrls?: string[]; @@ -41,7 +35,7 @@ export async function forkExternalHistoryIntoOrgiiSession(params: { const sourceScopeKeys = sourceRepoPath ? await resolveShareableScopeKeys(sourceRepoPath) : null; - // Prompt before loading the potentially large source transcript. The user + // Choose execution before loading the potentially large source transcript. The user // chooses this machine's real checkout and credentials; an imported model // label is only a preference hint, never an execution fallback. const setup = await requestForkSessionSetup({ @@ -52,17 +46,8 @@ export async function forkExternalHistoryIntoOrgiiSession(params: { }); const chunks = await source.loadFullTranscriptChunks(params.sourceSessionId); const events = await processChunksRust(chunks, params.sourceSessionId); - const content = buildExternalHistoryHandoffPrompt( - events, - params.agentMessage ?? params.userMessage - ); - // This continuation is a normal top-level ORGII session. `parentSessionId` - // is reserved for real subagents and would hide the continuation from the - // primary session list after a reload. The handoff prompt carries the - // external source context without changing the new session's hierarchy. const result = await SessionService.create({ - task: content, - imageDataUrls: params.imageDataUrls, + task: "", name: `Continue ${params.sourceSession?.name || `${source.displayName} history`}`, repoPath: setup.workspaceRepoPath ?? undefined, model: setup.execution.model, @@ -72,5 +57,30 @@ export async function forkExternalHistoryIntoOrgiiSession(params: { agentDefinitionId: setup.execution.agentDefinitionId, mode: "build", }); + try { + await materializeNativeConversation({ + sessionId: result.sessionId, + timeline: events, + }); + const turnIntentId = mintTurnIntentId(); + await SessionService.sendMessage({ + sessionId: result.sessionId, + content: params.agentMessage ?? params.userMessage, + displayText: params.userMessage, + imageDataUrls: params.imageDataUrls, + model: setup.execution.model, + accountId: setup.execution.accountId, + mode: "build", + clientMessageId: `native-import:${turnIntentId}`, + turnIntentId, + turnIntentSource: "user_submit", + directUserIntent: true, + }); + } catch (error) { + await discardNativeConversationSession(result.sessionId).catch( + () => undefined + ); + throw error; + } return result.sessionId; } diff --git a/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts b/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts index 6376ca575..13e7f0164 100644 --- a/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts +++ b/src/engines/ChatPanel/hooks/useImportedSessionSubmitOverride.ts @@ -3,17 +3,15 @@ import { useCallback, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; import Message from "@src/components/Message"; -import { buildCanonicalConversationUpdate } from "@src/engines/SessionCore/conversations/localConversationContinuation"; import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; -import { mintTurnIntentId } from "@src/engines/SessionCore/sync/adapters/shared/eventFactories"; import { waitForSessionChannelReady } from "@src/engines/SessionCore/sync/useSessionChannel"; +import { useSessionCommentsContext } from "@src/features/Org2Cloud/SessionComments/SessionCommentsContext"; import { activeConversationRunnersAtom } from "@src/features/Org2Cloud/SessionConversation/activeConversationRunnersAtom"; import { type ConversationFamilyMember, resolveConversationFamily, } from "@src/features/Org2Cloud/SessionConversation/continuationEvents"; -import { publishOwnerTurn } from "@src/features/Org2Cloud/SessionConversation/conversationOwnerPublisher"; import { bumpConversationPlaneSignal, conversationPlaneAtom, @@ -21,9 +19,12 @@ import { conversationPlaneSignalAtom, ensureConversationPlaneEntry, } from "@src/features/Org2Cloud/SessionConversation/conversationPlaneAtom"; -import { buildConversationPlaneStreamEvents } from "@src/features/Org2Cloud/SessionConversation/conversationPlaneEvents"; import { mergePlaneIntoTranscript } from "@src/features/Org2Cloud/SessionConversation/conversationTimeline"; import { runConversationTurn } from "@src/features/Org2Cloud/SessionConversation/conversationTurnRunner"; +import { + buildDiscussionEvents, + mergeConversationEvents, +} from "@src/features/Org2Cloud/SessionConversation/discussionEvents"; import { commitRefreshedAuth, org2CloudAuthAtom, @@ -91,6 +92,7 @@ export function useImportedSessionSubmitOverride({ const remoteEntries = useAtomValue(org2CloudRemoteSessionsAtom); const sessions = useAtomValue(sessionsAtom); const auth = useAtomValue(org2CloudAuthAtom); + const comments = useSessionCommentsContext(); // TIP-FOLLOW: a conversation continues at its NEWEST family member no // matter which member's surface the send comes from. Without this, a send @@ -272,10 +274,12 @@ export function useImportedSessionSubmitOverride({ } } const planeReady = activePlaneInfo?.entry.state === "ready"; - // (a) Member send on a plane-capable backend: publish the message to - // the conversation immediately, continue the sender's durable local - // execution Session, then stream the agent tail back to the plane. - if (planeReady && activePlaneInfo && !viewerOwnsRoot) { + // Every author, including the root owner, uses the same native + // materialization path. The visible root remains the canonical surface; + // a provider-specific execution episode is only an implementation + // detail. Group-chat routing still gets first refusal on the owner row. + if (planeReady && activePlaneInfo) { + if (viewerOwnsRoot && (await onFallbackSubmit(input))) return true; const readyPlaneInfo = activePlaneInfo; if (forkSubmitInFlightRef.current) { restorePendingDraft(input, sessionId); @@ -302,12 +306,36 @@ export function useImportedSessionSubmitOverride({ .getPersistedEvents(rootLocal.session_id) .catch(() => [] as SessionEvent[]) : []; - const timeline = mergePlaneIntoTranscript( + const planeTimeline = mergePlaneIntoTranscript( rootEvents, readyPlaneInfo.entry.events, sessionId, auth.userId ); + // Plain Team Chat comments are first-class attributed user messages + // in the visible transcript. Feed that same projection into native + // materialization; anchored review cards and agent reports remain + // system presentation rows and are ignored by the portable contract. + const grouped = comments?.grouped; + const toSourceEventId = comments?.toSourceEventId; + let timeline = planeTimeline; + if ( + grouped && + toSourceEventId && + (grouped.byEventId.size > 0 || + grouped.sessionLevel.length > 0 || + grouped.orphaned.length > 0) + ) { + const bySourceId = new Map(); + for (const event of planeTimeline) { + const sourceId = toSourceEventId(event.id); + if (!bySourceId.has(sourceId)) bySourceId.set(sourceId, event); + } + timeline = mergeConversationEvents( + planeTimeline, + buildDiscussionEvents(grouped, sessionId, bySourceId) + ); + } // The root row's repo scope keys the setup memory AND resolves the // runner's local checkout — without it the dialog reappears and a // workspace-requiring agent cannot launch at all. @@ -393,62 +421,6 @@ export function useImportedSessionSubmitOverride({ forkSubmitInFlightRef.current = false; } } - // (b) Owner send on a plane-capable backend: the owner's own session - // stays the execution surface, the agent SEES the members' turns (the - // plane rows of other authors ride the agent copy as a read-only - // context prefix — the owner's own turns are already its history), - // and the turn is PUBLISHED to the plane under a turnId exactly like - // a member turn, so every turn of the conversation has a seq. - if (planeReady && activePlaneInfo && viewerOwnsRoot) { - const readyPlaneInfo = activePlaneInfo; - // Group-chat routing owns its own sends. - if (await onFallbackSubmit(input)) return true; - if (!auth) return false; - const freshAuth = await ensureFreshSession(auth); - if (!freshAuth) return false; - commitRefreshedAuth(setAuth, auth, freshAuth); - const othersRows = readyPlaneInfo.entry.events.filter( - (row) => row.authorUserId !== auth.userId - ); - const agentContent = - othersRows.length > 0 - ? buildCanonicalConversationUpdate( - buildConversationPlaneStreamEvents(othersRows, sessionId), - input.agentContent ?? input.displayText - ) - : input.agentContent; - const turnIntentId = mintTurnIntentId(); - try { - await submitIntoForkedSession({ - sessionId, - displayContent: input.displayText, - agentContent, - imageDataUrls: input.imageDataUrls, - turnIntentId, - applyStopSubmitGuards: true, - dedupeDirectSubmit: true, - clearUserInitiatedCancelOnQueue: true, - }); - } catch (error) { - logger.error("owner conversation send failed", error); - restorePendingDraft(input, sessionId); - Message.error(t("collaboration.forkImported.sendFailed")); - return true; - } - void publishOwnerTurn({ - getAccessToken, - orgId: readyPlaneInfo.orgId, - rootSessionId: readyPlaneInfo.rootId, - sessionId, - turnIntentId, - displayText: input.displayText, - onPushed: () => - bumpConversationPlaneSignal(setPlaneSignal, readyPlaneInfo.orgId), - }).catch((error: unknown) => { - logger.warn("owner turn publish failed", error); - }); - return true; - } // The tip already lives here as a writable session (typically the // viewer's own earlier continuation): no new fork — the send goes // straight into it, and the surface follows. This is what keeps a @@ -558,6 +530,7 @@ export function useImportedSessionSubmitOverride({ currentSession?.name, currentSession?.model, conversationRootId, + comments, familyOrgId, forkImportedSession, getAccessToken, diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.test.ts b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts index 867cf6d61..96657d6cb 100644 --- a/src/engines/SessionCore/conversations/localConversationContinuation.test.ts +++ b/src/engines/SessionCore/conversations/localConversationContinuation.test.ts @@ -6,10 +6,8 @@ import { CONVERSATION_TURN_ID_ARG, continueLocalConversation, conversationExecutionParentId, - conversationPrefixHash, localConversationQueueSizeForTests, readDurableConversationTerminal, - renderCanonicalConversation, withLocalConversationQueue, } from "./localConversationContinuation"; @@ -20,13 +18,13 @@ const mocks = vi.hoisted(() => ({ create: vi.fn(), sendMessage: vi.fn(), loadEvents: vi.fn(), + materialize: vi.fn(), + discard: vi.fn(), getTerminal: vi.fn(), markTerminal: vi.fn(), })); -vi.mock("@src/api/tauri/agent", () => ({ - getSession: mocks.getAgentSession, -})); +vi.mock("@src/api/tauri/agent", () => ({ getSession: mocks.getAgentSession })); vi.mock("@src/api/tauri/rpc", () => ({ rpc: { cli: { status: mocks.cliStatus } }, })); @@ -34,14 +32,18 @@ vi.mock("@src/util/platform/tauri/init", () => ({ invokeTauri: mocks.invokeTauri, })); vi.mock("@src/engines/SessionCore/services/SessionService", () => ({ - SessionService: { - create: mocks.create, - sendMessage: mocks.sendMessage, - }, + SessionService: { create: mocks.create, sendMessage: mocks.sendMessage }, })); vi.mock("@src/engines/SessionCore/sync/authoritativeSessionEvents", () => ({ loadAuthoritativeSessionEvents: mocks.loadEvents, })); +vi.mock("./nativeConversationMaterializer", async (importOriginal) => ({ + ...(await importOriginal< + typeof import("./nativeConversationMaterializer") + >()), + materializeNativeConversation: mocks.materialize, + discardNativeConversationSession: mocks.discard, +})); vi.mock("@src/engines/SessionCore/control/turnLifecycle", async () => { const { atom } = await import("jotai"); return { @@ -66,21 +68,18 @@ function event( id: string, source: SessionEvent["source"], text: string, - options: { actionType?: string; turnId?: string } = {} + options: { turnId?: string; sessionId?: string } = {} ): SessionEvent { return { id, chunk_id: id, - sessionId: "root", - createdAt: `2026-08-26T00:00:0${id.length}.000Z`, + sessionId: options.sessionId ?? "root", + createdAt: "2026-08-26T00:00:00.000Z", functionName: source === "user" ? "user_message" : "assistant", uiCanonical: source === "user" ? "user_message" : "agent_message", - actionType: options.actionType ?? (source === "user" ? "raw" : "assistant"), + actionType: source === "user" ? "raw" : "assistant", args: options.turnId ? { [CONVERSATION_TURN_ID_ARG]: options.turnId } : {}, - result: { - message: { content: text, role: source }, - content: text, - }, + result: { message: { content: text, role: source }, content: text }, source, displayText: text, displayStatus: "completed", @@ -90,16 +89,11 @@ function event( } as SessionEvent; } -function localUser(sessionId: string, content: string): SessionEvent { - return { ...event(`user-${sessionId}`, "user", content), sessionId }; -} - const root = { authority: "org2-cloud", authorityScope: ["org-1"], conversationId: "root-1", }; - const target = { agentDefinitionId: "builtin:sde", accountId: "account-1", @@ -107,11 +101,43 @@ const target = { workspaceRepoPath: "/repo", }; +let childEvents: SessionEvent[] = []; + beforeEach(() => { vi.clearAllMocks(); + childEvents = []; mocks.invokeTauri.mockResolvedValue([]); mocks.create.mockResolvedValue({ sessionId: "agentsession-child" }); - mocks.sendMessage.mockResolvedValue(undefined); + mocks.loadEvents.mockImplementation(async () => ({ + events: childEvents, + source: "native_store", + })); + mocks.materialize.mockImplementation(async ({ sessionId, timeline }) => { + childEvents = (timeline as SessionEvent[]).map((item) => ({ + ...item, + sessionId, + })); + return { + events: childEvents, + receipt: { nativeSessionId: sessionId, itemCount: childEvents.length }, + }; + }); + mocks.discard.mockResolvedValue(undefined); + mocks.sendMessage.mockImplementation( + async ({ sessionId, displayText, turnIntentId }) => { + childEvents = [ + ...childEvents, + event(`user-${turnIntentId}`, "user", displayText, { + sessionId, + turnId: turnIntentId, + }), + event(`answer-${turnIntentId}`, "assistant", "native answer", { + sessionId, + turnId: turnIntentId, + }), + ]; + } + ); mocks.getTerminal.mockReturnValue({ generation: 3, status: "completed", @@ -119,34 +145,11 @@ beforeEach(() => { }); }); -describe("local conversation continuation", () => { +describe("local native conversation continuation", () => { it("uses a provider-neutral, non-secret durable parent identity", () => { expect(conversationExecutionParentId(root)).toBe( '["org2-conversation",1,"org2-cloud",["org-1"],"root-1"]' ); - expect( - conversationExecutionParentId({ ...root, authority: "local-session" }) - ).not.toBe(conversationExecutionParentId(root)); - }); - - it("renders the complete visible transcript and omits private reasoning", () => { - const long = "x".repeat(25_000); - const rendered = renderCanonicalConversation([ - event("u", "user", long), - event("r", "assistant", "private", { actionType: "reasoning" }), - event("a", "assistant", "answer"), - ]); - expect(rendered).toContain(long); - expect(rendered).toContain("answer"); - expect(rendered).not.toContain("private"); - }); - - it("invalidates a prefix cursor when canonical content changes", () => { - const before = [event("u", "user", "one"), event("a", "assistant", "two")]; - const changed = [event("u", "user", "edited"), before[1]]; - expect(conversationPrefixHash(before)).not.toBe( - conversationPrefixHash(changed) - ); }); it("serializes one conversation and releases the bounded queue entry", async () => { @@ -164,396 +167,188 @@ describe("local conversation continuation", () => { order.push("second"); }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(order).toEqual(["first:start"]); release(); await Promise.all([first, second]); expect(order).toEqual(["first:start", "first:end", "second"]); expect(localConversationQueueSizeForTests()).toBe(0); }); - it("observes a hidden native child's durable terminal without a mounted channel", async () => { - mocks.getTerminal.mockReturnValue(null); - const childEvents = [ - localUser("agentsession-child", "bootstrap"), - { - ...event("answer-hidden", "assistant", "background answer"), - sessionId: "agentsession-child", - }, + it("materializes native history, then sends only the new request", async () => { + const timeline = [ + event("u1", "user", "original question"), + event("a1", "assistant", "original answer"), ]; - mocks.create.mockResolvedValue({ sessionId: "agentsession-child" }); - mocks.getAgentSession.mockResolvedValue({ - sessionId: "agentsession-child", - status: "completed", - createdAt: "2026-08-26T00:00:00.000Z", - updatedAt: "2026-08-26T00:00:01.000Z", - }); - mocks.loadEvents.mockResolvedValue({ - events: childEvents, - source: "native_store", - }); - - await expect( - readDurableConversationTerminal("agentsession-child") - ).resolves.toBe("completed"); - - await expect( - continueLocalConversation({ - root, - title: "Shared", - timeline: [], - displayText: "run in the hidden child", - target, - turnIntentId: "turn-hidden", - }) - ).resolves.toMatchObject({ - sessionId: "agentsession-child", - terminalStatus: "completed", - agentTail: [ - expect.objectContaining({ displayText: "background answer" }), - ], - }); - }); - - it("creates one durable child Session, then resumes it with only canonical delta", async () => { - const base = [event("root-user", "user", "initial question")]; - let childEvents: SessionEvent[] = []; - mocks.loadEvents.mockImplementation(async () => ({ - events: childEvents, - source: "event_store", - })); - mocks.create.mockImplementation(async ({ task }) => { - childEvents = [ - localUser("agentsession-child", task), - { - ...event("answer-1", "assistant", "first answer"), - sessionId: "agentsession-child", - }, - ]; - return { sessionId: "agentsession-child" }; - }); - - const first = await continueLocalConversation({ + const result = await continueLocalConversation({ root, title: "Shared", - timeline: base, - displayText: "first request", + timeline, + displayText: "new request", target, turnIntentId: "turn-1", }); - expect(first.created).toBe(true); - expect(first.agentTail.map((item) => item.displayText)).toEqual([ - "first answer", - ]); + expect(mocks.create).toHaveBeenCalledWith( expect.objectContaining({ + task: "", parentSessionId: conversationExecutionParentId(root), - accountId: "account-1", - model: "model-1", }) ); + expect(mocks.materialize).toHaveBeenCalledWith({ + sessionId: "agentsession-child", + timeline, + }); + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: "new request", + displayText: "new request", + }) + ); + expect(result).toMatchObject({ + sessionId: "agentsession-child", + created: true, + agentTail: [expect.objectContaining({ displayText: "native answer" })], + }); + }); + it("resumes an exact native transcript without rematerializing it", async () => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline.map((item) => ({ + ...item, + sessionId: "agentsession-existing", + })); mocks.invokeTauri.mockResolvedValue([ { - sessionId: "agentsession-child", + sessionId: "agentsession-existing", updatedAt: "2026-08-26T01:00:00.000Z", }, ]); mocks.getAgentSession.mockResolvedValue({ - sessionId: "agentsession-child", status: "completed", - createdAt: "2026-08-26T00:00:00.000Z", updatedAt: "2026-08-26T01:00:00.000Z", workspacePath: "/repo", - agentDefinitionId: "builtin:sde", accountId: "account-1", model: "model-1", - }); - const secondTimeline = [ - ...base, - event("plane-u1", "user", "first request", { turnId: "turn-1" }), - event("plane-a1", "assistant", "first answer", { turnId: "turn-1" }), - event("teammate", "user", "new teammate context", { turnId: "other" }), - ]; - mocks.sendMessage.mockImplementation(async ({ content }) => { - childEvents = [ - ...childEvents, - localUser("agentsession-child-2", content), - { - ...event("answer-2", "assistant", "second answer"), - sessionId: "agentsession-child", - }, - ]; + agentDefinitionId: "builtin:sde", }); - const second = await continueLocalConversation({ + const result = await continueLocalConversation({ root, title: "Shared", - timeline: secondTimeline, - displayText: "second request", + timeline, + displayText: "resume natively", target, turnIntentId: "turn-2", }); - expect(second.created).toBe(false); - const sent = mocks.sendMessage.mock.calls[0]?.[0]?.content as string; - expect(sent).toContain("new teammate context"); - expect(sent).not.toContain("first answer"); - expect(sent).toContain("second request"); - expect(second.agentTail.map((item) => item.displayText)).toEqual([ - "second answer", - ]); - expect(mocks.create).toHaveBeenCalledTimes(1); - const thirdTimeline = [ - ...secondTimeline, - event("plane-u2", "user", "second request", { turnId: "turn-2" }), - event("plane-a2", "assistant", "second answer", { turnId: "turn-2" }), - ]; - mocks.sendMessage.mockRejectedValueOnce(new Error("native id vanished")); - mocks.create.mockImplementationOnce(async ({ task }) => { - childEvents = [ - localUser("agentsession-child-2", task), - { - ...event("answer-3", "assistant", "fresh fallback answer"), - sessionId: "agentsession-child-2", - }, - ]; - return { sessionId: "agentsession-child-2" }; - }); - - const fallback = await continueLocalConversation({ - root, - title: "Shared", - timeline: thirdTimeline, - displayText: "third request", - target, - turnIntentId: "turn-3", - }); - expect(fallback).toMatchObject({ - sessionId: "agentsession-child-2", - created: true, - }); - expect(fallback.agentTail.map((item) => item.displayText)).toEqual([ - "fresh fallback answer", - ]); - expect(mocks.markTerminal).toHaveBeenCalledWith( - "agentsession-child", - "failed", - { generation: 3 } + expect(result.created).toBe(false); + expect(mocks.create).not.toHaveBeenCalled(); + expect(mocks.materialize).not.toHaveBeenCalled(); + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ content: "resume natively" }) ); - expect(mocks.create).toHaveBeenCalledTimes(2); }); - it("uses the same durable continuation path for an installed external CLI", async () => { - const cliTarget = { - ...target, - cliAgentType: "codex", - }; - const base = [event("root-user", "user", "portable history")]; - let childEvents: SessionEvent[] = []; - mocks.loadEvents.mockImplementation(async () => ({ - events: childEvents, - source: "native_cli_store", - })); - mocks.create.mockImplementationOnce(async ({ task }) => { - childEvents = [ - localUser("cliagent-child", task), - { - ...event("cli-answer-1", "assistant", "cli first answer"), - sessionId: "cliagent-child", - }, - ]; - return { sessionId: "cliagent-child" }; - }); - - await continueLocalConversation({ - root, - title: "Portable", - timeline: base, - displayText: "first cli request", - target: cliTarget, - turnIntentId: "cli-turn-1", - }); - expect(mocks.create).toHaveBeenCalledWith( - expect.objectContaining({ - cliAgentType: "codex", - parentSessionId: conversationExecutionParentId(root), - }) - ); - + it("creates a new native episode when shared history diverged", async () => { + childEvents = [ + event("old", "user", "old", { sessionId: "agentsession-old" }), + ]; mocks.invokeTauri.mockResolvedValue([ - { - sessionId: "cliagent-child", - updatedAt: "2026-08-26T02:00:00.000Z", - }, + { sessionId: "agentsession-old", updatedAt: "2026-08-26T01:00:00Z" }, ]); - mocks.cliStatus.mockResolvedValue({ - sessionId: "cliagent-child", + mocks.getAgentSession.mockResolvedValue({ status: "completed", - updatedAt: "2026-08-26T02:00:00.000Z", - cliAgentType: "codex", + updatedAt: "2026-08-26T01:00:00Z", + workspacePath: "/repo", accountId: "account-1", model: "model-1", - worktreePath: "/repo", agentDefinitionId: "builtin:sde", }); - mocks.sendMessage.mockImplementationOnce(async ({ content }) => { - childEvents = [ - ...childEvents, - localUser("cliagent-child", content), - { - ...event("cli-answer-2", "assistant", "cli resumed answer"), - sessionId: "cliagent-child", - }, - ]; - }); - const timeline = [ - ...base, - event("cli-plane-u1", "user", "first cli request", { - turnId: "cli-turn-1", - }), - event("cli-plane-a1", "assistant", "cli first answer", { - turnId: "cli-turn-1", - }), - event("remote-u", "user", "new remote context", { - turnId: "remote-turn", - }), - ]; + const timeline = [event("new", "user", "teammate added context")]; - const resumed = await continueLocalConversation({ + const result = await continueLocalConversation({ root, - title: "Portable", + title: "Shared", timeline, - displayText: "second cli request", - target: cliTarget, - turnIntentId: "cli-turn-2", + displayText: "continue", + target, + turnIntentId: "turn-3", }); - - expect(resumed).toMatchObject({ - sessionId: "cliagent-child", - created: false, + expect(result.created).toBe(true); + expect(mocks.materialize).toHaveBeenCalledWith({ + sessionId: "agentsession-child", + timeline, }); - const sent = mocks.sendMessage.mock.calls[0]?.[0]?.content as string; - expect(sent).toContain("new remote context"); - expect(sent).not.toContain("cli first answer"); - expect(sent).toContain("second cli request"); - expect(mocks.create).toHaveBeenCalledTimes(1); }); - it("rolls to a fresh episode when the target changes or the prior child failed", async () => { - const firstTimeline = [event("root-user", "user", "history")]; - let childEvents: SessionEvent[] = []; - mocks.loadEvents.mockImplementation(async () => ({ - events: childEvents, - source: "native_store", - })); - mocks.create.mockImplementation(async ({ task, model }) => { - const sessionId = - model === "model-2" ? "agentsession-model-2" : "agentsession-model-1"; - childEvents = [ - localUser(sessionId, task), - { - ...event(`answer-${model}`, "assistant", `answer from ${model}`), - sessionId, - }, - ]; - return { sessionId }; - }); - - await continueLocalConversation({ - root, - title: "Shared", - timeline: firstTimeline, - displayText: "first request", - target, - turnIntentId: "turn-model-1", - }); + it("fails closed when native resume fails", async () => { + const timeline = [event("u1", "user", "same native history")]; + childEvents = timeline; mocks.invokeTauri.mockResolvedValue([ - { - sessionId: "agentsession-model-1", - updatedAt: "2026-08-26T03:00:00.000Z", - }, + { sessionId: "agentsession-existing", updatedAt: "2026-08-26T01:00:00Z" }, ]); mocks.getAgentSession.mockResolvedValue({ - sessionId: "agentsession-model-1", status: "completed", - createdAt: "2026-08-26T00:00:00.000Z", - updatedAt: "2026-08-26T03:00:00.000Z", + updatedAt: "2026-08-26T01:00:00Z", workspacePath: "/repo", - agentDefinitionId: "builtin:sde", accountId: "account-1", model: "model-1", + agentDefinitionId: "builtin:sde", }); - const afterFirst = [ - ...firstTimeline, - event("plane-model-u1", "user", "first request", { - turnId: "turn-model-1", - }), - event("plane-model-a1", "assistant", "answer from model-1", { - turnId: "turn-model-1", - }), - ]; + mocks.sendMessage.mockRejectedValueOnce(new Error("native id vanished")); - const targetRoll = await continueLocalConversation({ - root, - title: "Shared", - timeline: afterFirst, - displayText: "switch target", - target: { ...target, model: "model-2" }, - turnIntentId: "turn-model-2", - }); - expect(targetRoll).toMatchObject({ - sessionId: "agentsession-model-2", - created: true, - }); - expect(mocks.sendMessage).not.toHaveBeenCalled(); + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline, + displayText: "continue", + target, + turnIntentId: "turn-4", + }) + ).rejects.toThrow("native id vanished"); + expect(mocks.create).not.toHaveBeenCalled(); + }); - mocks.invokeTauri.mockResolvedValue([ - { - sessionId: "agentsession-model-2", - updatedAt: "2026-08-26T04:00:00.000Z", - }, - ]); - mocks.getAgentSession.mockResolvedValue({ - sessionId: "agentsession-model-2", - status: "failed", - createdAt: "2026-08-26T00:00:00.000Z", - updatedAt: "2026-08-26T04:00:00.000Z", - workspacePath: "/repo", - agentDefinitionId: "builtin:sde", - accountId: "account-1", - model: "model-2", - }); - mocks.create.mockImplementationOnce(async ({ task }) => { - childEvents = [ - localUser("agentsession-after-failure", task), - { - ...event("answer-after-failure", "assistant", "fresh recovery"), - sessionId: "agentsession-after-failure", - }, - ]; - return { sessionId: "agentsession-after-failure" }; - }); - const afterFailedTurn = [ - ...afterFirst, - event("plane-model-u2", "user", "switch target", { - turnId: "turn-model-2", - }), - event("plane-model-a2", "assistant", "failed", { - turnId: "turn-model-2", - }), - ]; + it("marks a fresh native episode failed when its first resume send is rejected", async () => { + mocks.sendMessage.mockRejectedValueOnce( + new Error("provider rejected native id") + ); - const failureRoll = await continueLocalConversation({ - root, - title: "Shared", - timeline: afterFailedTurn, - displayText: "recover after failure", - target: { ...target, model: "model-2" }, - turnIntentId: "turn-after-failure", - }); - expect(failureRoll).toMatchObject({ - sessionId: "agentsession-after-failure", - created: true, - }); + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline: [event("u1", "user", "native history")], + displayText: "continue", + target, + turnIntentId: "turn-fresh-failure", + }) + ).rejects.toThrow("provider rejected native id"); + expect(mocks.markTerminal).toHaveBeenCalledWith( + "agentsession-child", + "failed", + { generation: 3 } + ); + }); + + it("rejects a CLI target without a native writer contract", async () => { + await expect( + continueLocalConversation({ + root, + title: "Shared", + timeline: [], + displayText: "continue", + target: { ...target, cliAgentType: "cursor_cli" }, + turnIntentId: "turn-5", + }) + ).rejects.toThrow("cannot materialize"); + }); + + it("observes a hidden child's durable terminal without a mounted channel", async () => { + mocks.getTerminal.mockReturnValue(null); + mocks.getAgentSession.mockResolvedValue({ status: "completed" }); + await expect( + readDurableConversationTerminal("agentsession-child") + ).resolves.toBe("completed"); }); }); diff --git a/src/engines/SessionCore/conversations/localConversationContinuation.ts b/src/engines/SessionCore/conversations/localConversationContinuation.ts index b938ba535..30d299e24 100644 --- a/src/engines/SessionCore/conversations/localConversationContinuation.ts +++ b/src/engines/SessionCore/conversations/localConversationContinuation.ts @@ -27,13 +27,18 @@ import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; import { invokeTauri } from "@src/util/platform/tauri/init"; import { isCliSession } from "@src/util/session/sessionDispatch"; +import { + discardNativeConversationSession, + materializeNativeConversation, + nativeConversationItemsEqual, + projectNativeConversationItems, + supportsNativeConversationTarget, +} from "./nativeConversationMaterializer"; + const TURN_DEADLINE_MS = 15 * 60_000; const TRANSCRIPT_SETTLE_MS = 5_000; const TRANSCRIPT_SETTLE_POLL_MS = 100; const TERMINAL_POLL_MS = 100; -const CURSOR_PREFIX = " 0 ? value : null; } -function rawEventText(event: SessionEvent): string { - const result = event.result as Record | undefined; - const message = result?.message as Record | undefined; - const candidates = [ - message?.content, - result?.content, - result?.observation, - event.displayText, - ]; - return ( - candidates.find((value): value is string => typeof value === "string") ?? "" - ); -} - -function senderLabel(event: SessionEvent): string { - const sender = event.args?.conversationSender as - | { displayName?: unknown } - | undefined; - if (typeof sender?.displayName === "string" && sender.displayName.trim()) { - return sender.displayName.trim(); - } - if (event.source === "user") return "User"; - if (event.source === "assistant") return "Assistant"; - return "System"; -} - -function isTransferableEvent(event: SessionEvent): boolean { - const action = event.actionType.toLowerCase(); - const fn = event.functionName.toLowerCase(); - // Provider-private/signed reasoning is not portable conversation state. - if ( - action.includes("thinking") || - action.includes("reasoning") || - fn.includes("thinking") || - fn.includes("reasoning") - ) { - return false; - } - return Boolean(event.displayText.trim() || rawEventText(event).trim()); -} - -function stableEventProjection(event: SessionEvent): string { - return JSON.stringify([ - event.id, - event.createdAt, - event.source, - event.functionName, - event.actionType, - event.displayText, - rawEventText(event), - eventTurnId(event), - ]); -} - -/** Two independent 32-bit FNV lanes; correctness fence, not a signature. */ -export function conversationPrefixHash( - events: readonly SessionEvent[], - count = events.length -): string { - let left = 0x811c9dc5; - let right = 0x9e3779b9; - const limit = Math.min(Math.max(0, count), events.length); - for (let index = 0; index < limit; index += 1) { - const text = stableEventProjection(events[index]); - for (let offset = 0; offset < text.length; offset += 1) { - const code = text.charCodeAt(offset); - left = Math.imul(left ^ code, 0x01000193) >>> 0; - right = Math.imul(right ^ (code + offset), 0x85ebca6b) >>> 0; - } - } - return `${left.toString(16).padStart(8, "0")}${right - .toString(16) - .padStart(8, "0")}`; -} - -function encodeCursor(cursor: ConversationCursor): string { - const payload = encodeURIComponent(JSON.stringify(cursor)); - return `${CURSOR_PREFIX}value="${payload}"${CURSOR_SUFFIX}`; -} - -function cursorsIn(events: readonly SessionEvent[]): ConversationCursor[] { - const cursors: ConversationCursor[] = []; - for (const event of events) { - if (event.source !== "user") continue; - const text = rawEventText(event); - let from = 0; - for (;;) { - const start = text.indexOf(CURSOR_PREFIX, from); - if (start < 0) break; - const end = text.indexOf(CURSOR_SUFFIX, start + CURSOR_PREFIX.length); - if (end < 0) break; - const tag = text.slice(start + CURSOR_PREFIX.length, end); - const match = /(?:^|\s)value="([^"]+)"/.exec(tag); - from = end + CURSOR_SUFFIX.length; - if (!match) continue; - try { - const parsed = JSON.parse( - decodeURIComponent(match[1]) - ) as Partial; - if ( - Number.isSafeInteger(parsed.prefixCount) && - (parsed.prefixCount ?? -1) >= 0 && - typeof parsed.prefixHash === "string" && - parsed.prefixHash.length > 0 && - typeof parsed.localTurnIntentId === "string" && - parsed.localTurnIntentId.length > 0 - ) { - cursors.push(parsed as ConversationCursor); - } - } catch { - // A malformed marker cannot advance the cursor; older valid rows may. - } - } - } - return cursors; -} - -function cursorMatchesTimeline( - cursor: ConversationCursor, - timeline: readonly SessionEvent[] -): boolean { - return ( - cursor.prefixCount <= timeline.length && - conversationPrefixHash(timeline, cursor.prefixCount) === cursor.prefixHash - ); -} - -function renderEvent(event: SessionEvent): string | null { - if (!isTransferableEvent(event)) return null; - const text = (event.displayText.trim() || rawEventText(event).trim()).replace( - /\r\n/g, - "\n" - ); - if (!text) return null; - if (event.actionType === "tool_call") { - return `[${senderLabel(event)} tool:${event.functionName}]\n${text}`; - } - return `${senderLabel(event)}:\n${text}`; -} - -export function renderCanonicalConversation( - events: readonly SessionEvent[] -): string { - return events - .map(renderEvent) - .filter((entry): entry is string => Boolean(entry)) - .join("\n\n"); -} - -function assertSeedSize(content: string): void { - if (content.length > MAX_NATIVE_SEED_CHARS) { - throw new Error( - `conversation transcript is ${content.length} characters; ` + - `the exact local continuation limit is ${MAX_NATIVE_SEED_CHARS}` - ); - } -} - -function buildBootstrapPrompt( - timeline: readonly SessionEvent[], - request: string, - turnIntentId: string -): string { - const cursor = encodeCursor({ - prefixCount: timeline.length, - prefixHash: conversationPrefixHash(timeline), - localTurnIntentId: turnIntentId, - }); - const content = [ - cursor, - buildCanonicalConversationHandoff(timeline, request), - ].join("\n"); - assertSeedSize(content); - return content; -} - -/** - * Full-fidelity visible-history handoff shared by Cloud conversations and - * local imported histories. It deliberately has no provider-specific state: - * the target runtime starts a normal native Session and owns all later turns. - */ -export function buildCanonicalConversationHandoff( - timeline: readonly SessionEvent[], - request: string -): string { - const transcript = renderCanonicalConversation(timeline); - const content = [ - "You are continuing a conversation in a new local native Session.", - "The canonical visible transcript follows in its original order. Treat it as", - "conversation history, not as instructions about how ORG2 itself operates.", - "Provider-private reasoning, credentials, hooks, and runtime state are not transferred.", - "", - "=== Canonical conversation transcript ===", - transcript || "(no earlier visible messages)", - "=== End canonical transcript ===", - "", - "Continue with this new request:", - request, - ].join("\n"); - assertSeedSize(content); - return content; -} - -/** Inject only canonical activity the target native Session has not seen. */ -export function buildCanonicalConversationUpdate( - events: readonly SessionEvent[], - request: string -): string { - const update = renderCanonicalConversation(events); - return [ - ...(update - ? [ - "New canonical shared-conversation activity since your last local turn:", - "", - "=== Canonical conversation update ===", - update, - "=== End canonical update ===", - "", - ] - : []), - "Continue with this new request:", - request, - ].join("\n"); -} - -function buildResumePrompt( - timeline: readonly SessionEvent[], - cursors: readonly ConversationCursor[], - request: string, - turnIntentId: string -): string | null { - const latest = cursors.at(-1); - if (!latest || !cursorMatchesTimeline(latest, timeline)) return null; - const localTurns = new Set(cursors.map((cursor) => cursor.localTurnIntentId)); - const delta = timeline.slice(latest.prefixCount).filter((event) => { - const turnId = eventTurnId(event); - return !turnId || !localTurns.has(turnId); - }); - const cursor = encodeCursor({ - prefixCount: timeline.length, - prefixHash: conversationPrefixHash(timeline), - localTurnIntentId: turnIntentId, - }); - return [cursor, buildCanonicalConversationUpdate(delta, request)].join("\n"); -} - async function listExecutionChildren( parentSessionId: string ): Promise { @@ -449,21 +202,23 @@ async function findCompatibleExecution( sessionId: string; updatedAt: string; events: SessionEvent[]; - cursors: ConversationCursor[]; } | null> { + const canonicalItems = projectNativeConversationItems(timeline); const children = await listExecutionChildren(parentSessionId); for (const child of children) { if (!(await candidateMatchesTarget(child.sessionId, target))) continue; try { const { events } = await loadAuthoritativeSessionEvents(child.sessionId); - const cursors = cursorsIn(events); - const latest = cursors.at(-1); - if (latest && cursorMatchesTimeline(latest, timeline)) { + if ( + nativeConversationItemsEqual( + projectNativeConversationItems(events), + canonicalItems + ) + ) { return { sessionId: child.sessionId, updatedAt: child.updatedAt, events, - cursors, }; } } catch { @@ -586,11 +341,7 @@ function sliceTurnTail( appended = after.slice(before.length); } else { const anchor = after.findIndex( - (event) => - event.source === "user" && - cursorsIn([event]).some( - (cursor) => cursor.localTurnIntentId === turnIntentId - ) + (event) => event.source === "user" && eventTurnId(event) === turnIntentId ); if (anchor < 0) return null; appended = after.slice(anchor + 1); @@ -647,6 +398,11 @@ export function localConversationQueueSizeForTests(): number { export async function continueLocalConversation( params: ContinueLocalConversationParams ): Promise { + if (!supportsNativeConversationTarget(params.target)) { + throw new Error( + `target ${params.target.cliAgentType ?? "native"} cannot materialize a provider-native role/tool transcript` + ); + } const parentSessionId = conversationExecutionParentId(params.root); return withLocalConversationQueue(parentSessionId, async () => { const request = params.agentContent ?? params.displayText; @@ -655,25 +411,16 @@ export async function continueLocalConversation( params.target, params.timeline ); - const resumeContent = compatible - ? buildResumePrompt( - params.timeline, - compatible.cursors, - request, - params.turnIntentId - ) - : null; await params.beforeDispatch?.(); const deadlineMs = Date.now() + TURN_DEADLINE_MS; const startedAt = Date.now(); - if (compatible && resumeContent) { + if (compatible) { const generation = beginTurnDispatch(compatible.sessionId); - let resumeAccepted = false; try { await SessionService.sendMessage({ sessionId: compatible.sessionId, - content: resumeContent, + content: request, displayText: params.displayText, model: params.target.model, accountId: params.target.accountId, @@ -689,44 +436,35 @@ export async function continueLocalConversation( compatible.sessionId, compatible.events.length ); - resumeAccepted = true; - } catch { + } catch (error) { markTurnTerminal(compatible.sessionId, "failed", { generation }); - // A stale provider-native id, deleted transcript, or broken local - // runtime invalidates only this execution episode. Keep the same - // canonical turn and fall through to a fresh native Session. - } - if (resumeAccepted) { - const terminalStatus = await waitForTurnTerminal( - compatible.sessionId, - generation, - startedAt, - deadlineMs, - compatible.updatedAt - ); - const agentTail = await loadSettledTail( - compatible.sessionId, - compatible.events, - params.turnIntentId, - deadlineMs - ); - return { - sessionId: compatible.sessionId, - created: false, - terminalStatus, - agentTail, - }; + // A native-resume failure must never silently start a context-free + // thread. The caller can retry, which will materialize a new episode. + throw error; } + const terminalStatus = await waitForTurnTerminal( + compatible.sessionId, + generation, + startedAt, + deadlineMs, + compatible.updatedAt + ); + const agentTail = await loadSettledTail( + compatible.sessionId, + compatible.events, + params.turnIntentId, + deadlineMs + ); + return { + sessionId: compatible.sessionId, + created: false, + terminalStatus, + agentTail, + }; } - const bootstrap = buildBootstrapPrompt( - params.timeline, - request, - params.turnIntentId - ); const created = await SessionService.create({ - task: bootstrap, - imageDataUrls: params.imageDataUrls, + task: "", name: params.title, repoPath: params.target.workspaceRepoPath ?? undefined, model: params.target.model, @@ -737,16 +475,54 @@ export async function continueLocalConversation( parentSessionId, mode: "build", }); - await params.onSessionReady?.(created.sessionId, 0); + let materialized; + try { + materialized = await materializeNativeConversation({ + sessionId: created.sessionId, + timeline: params.timeline, + }); + } catch (error) { + await discardNativeConversationSession(created.sessionId).catch( + () => undefined + ); + throw error; + } + const generation = beginTurnDispatch(created.sessionId); + try { + await SessionService.sendMessage({ + sessionId: created.sessionId, + content: request, + displayText: params.displayText, + model: params.target.model, + accountId: params.target.accountId, + mode: "build", + imageDataUrls: params.imageDataUrls, + clientMessageId: `conversation-turn:${params.turnIntentId}`, + turnIntentId: params.turnIntentId, + turnIntentSource: "user_submit", + directUserIntent: true, + }); + confirmTurnRunning(created.sessionId); + await params.onSessionReady?.( + created.sessionId, + materialized.events.length + ); + } catch (error) { + markTurnTerminal(created.sessionId, "failed", { generation }); + await discardNativeConversationSession(created.sessionId).catch( + () => undefined + ); + throw error; + } const terminalStatus = await waitForTurnTerminal( created.sessionId, - 1, + generation, startedAt, deadlineMs ); const agentTail = await loadSettledTail( created.sessionId, - [], + materialized.events, params.turnIntentId, deadlineMs ); diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts new file mode 100644 index 000000000..3a55fb288 --- /dev/null +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.test.ts @@ -0,0 +1,221 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + materializeNativeConversation, + nativeConversationItemsEqual, + projectNativeConversationItems, + supportsNativeConversationTarget, +} from "./nativeConversationMaterializer"; + +const mocks = vi.hoisted(() => ({ + invokeTauri: vi.fn(), + loadEvents: vi.fn(), +})); + +vi.mock("@src/util/platform/tauri/init", () => ({ + invokeTauri: mocks.invokeTauri, +})); +vi.mock("@src/engines/SessionCore/sync/authoritativeSessionEvents", () => ({ + loadAuthoritativeSessionEvents: mocks.loadEvents, +})); + +function message( + id: string, + source: "user" | "assistant", + text: string +): SessionEvent { + return { + id, + chunk_id: id, + sessionId: "source", + createdAt: "2026-08-26T00:00:00.000Z", + functionName: source === "user" ? "user_message" : "assistant_message", + uiCanonical: source === "user" ? "user_message" : "agent_message", + actionType: "raw", + args: {}, + result: { message: { role: source, content: text }, content: text }, + source, + displayText: text, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +function tool(): SessionEvent { + return { + id: "tool-1", + chunk_id: "tool-1", + sessionId: "source", + createdAt: "2026-08-26T00:00:01.000Z", + functionName: "read_file", + uiCanonical: "tool_call", + actionType: "tool_call", + callId: "call-1", + args: { + path: "/repo/README.md", + nested: { second: 2, first: 1 }, + conversationTurnId: "internal-turn", + conversationSender: { displayName: "Ada" }, + __orgiiPrivate: true, + }, + result: {}, + source: "assistant", + displayText: "", + displayStatus: "completed", + displayVariant: "tool_call", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("native conversation materialization", () => { + it("projects roles and paired tools without rendering history into a prompt", () => { + const items = projectNativeConversationItems([ + message("u1", "user", "inspect it"), + tool(), + message("a1", "assistant", "done"), + ]); + + expect(items).toEqual([ + expect.objectContaining({ + kind: "message", + role: "user", + text: "inspect it", + }), + expect.objectContaining({ + kind: "tool_call", + callId: "call-1", + name: "read_file", + }), + expect.objectContaining({ + kind: "tool_result", + callId: "call-1", + output: "", + }), + expect.objectContaining({ + kind: "message", + role: "assistant", + text: "done", + }), + ]); + const args = JSON.parse( + (items[1] as Extract<(typeof items)[number], { kind: "tool_call" }>) + .arguments + ); + expect(args).toEqual({ + path: "/repo/README.md", + nested: { second: 2, first: 1 }, + }); + }); + + it("compares JSON tool arguments semantically rather than by object key order", () => { + const left = projectNativeConversationItems([tool()]); + const right = structuredClone(left); + if (right[0]?.kind === "tool_call") { + right[0].arguments = + '{"nested":{"first":1,"second":2},"path":"/repo/README.md"}'; + } + expect(nativeConversationItemsEqual(left, right)).toBe(true); + }); + + it("supports native Agent plus verified Claude/Codex writers only", () => { + expect(supportsNativeConversationTarget({})).toBe(true); + expect( + supportsNativeConversationTarget({ cliAgentType: "claude_code" }) + ).toBe(true); + expect(supportsNativeConversationTarget({ cliAgentType: "codex" })).toBe( + true + ); + expect( + supportsNativeConversationTarget({ cliAgentType: "cursor_cli" }) + ).toBe(false); + }); + + it("requires the target's authoritative reader to return the same native transcript", async () => { + const timeline = [message("u1", "user", "hello")]; + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 1, + }); + mocks.loadEvents.mockResolvedValue({ + events: timeline, + source: "native_store", + }); + + await expect( + materializeNativeConversation({ + sessionId: "agentsession-target", + timeline, + }) + ).resolves.toMatchObject({ + receipt: { nativeSessionId: "native-1", itemCount: 1 }, + }); + expect(mocks.invokeTauri).toHaveBeenCalledWith( + "materialize_native_conversation", + expect.objectContaining({ sessionId: "agentsession-target" }) + ); + }); + + it("leaves an empty target fresh instead of inventing an unresumable native id", async () => { + await expect( + materializeNativeConversation({ + sessionId: "cli-session-empty", + timeline: [], + }) + ).resolves.toEqual({ + events: [], + receipt: { nativeSessionId: "", itemCount: 0 }, + }); + expect(mocks.invokeTauri).not.toHaveBeenCalled(); + expect(mocks.loadEvents).not.toHaveBeenCalled(); + }); + + it("fails closed when the provider reader does not round-trip the write", async () => { + mocks.invokeTauri.mockResolvedValue({ + nativeSessionId: "native-1", + itemCount: 1, + }); + mocks.loadEvents.mockResolvedValue({ + events: [message("a1", "assistant", "different")], + source: "native_store", + }); + + await expect( + materializeNativeConversation({ + sessionId: "agentsession-target", + timeline: [message("u1", "user", "hello")], + }) + ).rejects.toThrow("round-trip verification failed"); + }); + + it("removes a failed CLI materialization without touching other native history", async () => { + mocks.invokeTauri.mockResolvedValueOnce({ + nativeSessionId: "native-1", + itemCount: 1, + }); + mocks.loadEvents.mockResolvedValue({ + events: [message("a1", "assistant", "different")], + source: "cli_history", + }); + + await expect( + materializeNativeConversation({ + sessionId: "cliagent-target", + timeline: [message("u1", "user", "hello")], + }) + ).rejects.toThrow("round-trip verification failed"); + expect(mocks.invokeTauri).toHaveBeenNthCalledWith( + 2, + "discard_native_conversation_materialization", + { sessionId: "cliagent-target", nativeSessionId: "native-1" } + ); + }); +}); diff --git a/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts new file mode 100644 index 000000000..2c2da4639 --- /dev/null +++ b/src/engines/SessionCore/conversations/nativeConversationMaterializer.ts @@ -0,0 +1,273 @@ +import { deleteSession } from "@src/api/tauri/agent"; +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; +import { loadAuthoritativeSessionEvents } from "@src/engines/SessionCore/sync/authoritativeSessionEvents"; +import { removeSession } from "@src/store/session/sessionAtom/mutations"; +import { isStoreInitialized } from "@src/util/core/state/instrumentedStore"; +import { invokeTauri } from "@src/util/platform/tauri/init"; +import { isCliSession } from "@src/util/session/sessionDispatch"; + +import type { LocalConversationTarget } from "./localConversationContinuation"; + +export const NATIVE_MATERIALIZATION_CLI_TARGETS = [ + "claude_code", + "codex", +] as const; + +export type NativeMaterializationCliTarget = + (typeof NATIVE_MATERIALIZATION_CLI_TARGETS)[number]; + +export type NativeConversationItem = + | { + kind: "message"; + id: string; + role: "user" | "assistant"; + text: string; + images: string[]; + createdAt: string; + } + | { + kind: "tool_call"; + id: string; + callId: string; + name: string; + arguments: string; + createdAt: string; + } + | { + kind: "tool_result"; + id: string; + callId: string; + name: string; + output: string; + createdAt: string; + }; + +export interface NativeMaterializationReceipt { + nativeSessionId: string; + itemCount: number; +} + +function eventText(event: SessionEvent): string { + const result = event.result as Record | undefined; + const message = result?.message as Record | undefined; + for (const candidate of [ + message?.content, + result?.content, + result?.observation, + result?.output, + event.displayText, + ]) { + if (typeof candidate === "string") return candidate; + } + return ""; +} + +function eventImages(event: SessionEvent): string[] { + const images = (event.result as Record | undefined)?.images; + if (!Array.isArray(images)) return []; + return images.filter( + (image): image is string => typeof image === "string" && image.length > 0 + ); +} + +function transferableToolArgs(event: SessionEvent): Record { + return Object.fromEntries( + Object.entries(event.args ?? {}).filter( + ([key]) => + key !== "conversationTurnId" && + key !== "conversationSender" && + !key.startsWith("__orgii") + ) + ); +} + +function isPrivateProviderEvent(event: SessionEvent): boolean { + const action = event.actionType.toLowerCase(); + const fn = event.functionName.toLowerCase(); + return ( + action.includes("thinking") || + action.includes("reasoning") || + fn.includes("thinking") || + fn.includes("reasoning") + ); +} + +function isToolEvent(event: SessionEvent): boolean { + return ( + event.actionType === "tool_call" || + event.displayVariant === "tool_call" || + Boolean(event.callId && event.functionName) + ); +} + +function hasToolResult(event: SessionEvent): boolean { + return event.displayStatus !== "running" && event.displayStatus !== "pending"; +} + +/** + * Lossless portable conversation plane. It preserves roles and tool pairing; + * it never renders history into a prompt. Provider-private reasoning and + * system policy are intentionally outside the portable contract. + */ +export function projectNativeConversationItems( + events: readonly SessionEvent[] +): NativeConversationItem[] { + const items: NativeConversationItem[] = []; + for (const event of events) { + if (event.isDelta || isPrivateProviderEvent(event)) continue; + if (isToolEvent(event)) { + const callId = event.callId?.trim() || `call-${event.id}`; + const name = event.functionName.trim(); + if (!name) { + throw new Error(`native transcript tool event ${event.id} has no name`); + } + items.push({ + kind: "tool_call", + id: `${event.id}:call`, + callId, + name, + arguments: JSON.stringify(transferableToolArgs(event)), + createdAt: event.createdAt, + }); + if (hasToolResult(event)) { + items.push({ + kind: "tool_result", + id: `${event.id}:result`, + callId, + name, + output: eventText(event), + createdAt: event.createdAt, + }); + } + continue; + } + if (event.source !== "user" && event.source !== "assistant") continue; + const text = eventText(event); + const images = eventImages(event); + if (!text && images.length === 0) continue; + items.push({ + kind: "message", + id: event.id, + role: event.source, + text, + images, + createdAt: event.createdAt, + }); + } + return items; +} + +function semanticItem(item: NativeConversationItem): unknown { + switch (item.kind) { + case "message": + return [item.kind, item.role, item.text, item.images]; + case "tool_call": + return [ + item.kind, + item.callId, + item.name, + canonicalJson(JSON.parse(item.arguments) as unknown), + ]; + case "tool_result": + return [item.kind, item.callId, item.name, item.output]; + } +} + +function canonicalJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalJson); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalJson(item)]) + ); + } + return value; +} + +export function nativeConversationItemsEqual( + left: readonly NativeConversationItem[], + right: readonly NativeConversationItem[] +): boolean { + return ( + left.length === right.length && + left.every( + (item, index) => + JSON.stringify(semanticItem(item)) === + JSON.stringify(semanticItem(right[index])) + ) + ); +} + +export function supportsNativeConversationTarget( + target: Pick +): boolean { + return ( + !target.cliAgentType || + NATIVE_MATERIALIZATION_CLI_TARGETS.includes( + target.cliAgentType as NativeMaterializationCliTarget + ) + ); +} + +/** Roll back only a just-created execution episode after pre-dispatch failure. */ +export async function discardNativeConversationSession( + sessionId: string +): Promise { + try { + if (isCliSession(sessionId)) { + await invokeTauri("cli_agent_delete", { sessionId }); + } else { + await deleteSession(sessionId); + } + } finally { + if (isStoreInitialized()) removeSession(sessionId); + } +} + +export async function materializeNativeConversation(params: { + sessionId: string; + timeline: readonly SessionEvent[]; +}): Promise<{ events: SessionEvent[]; receipt: NativeMaterializationReceipt }> { + const items = projectNativeConversationItems(params.timeline); + if (params.timeline.length > 0 && items.length === 0) { + throw new Error( + "conversation has no portable native role/tool transcript to materialize" + ); + } + // With no history there is nothing to migrate. Leave the fresh target + // unbound so its normal first send creates the provider-native session. + if (items.length === 0) { + return { + events: [], + receipt: { nativeSessionId: "", itemCount: 0 }, + }; + } + const receipt = await invokeTauri( + "materialize_native_conversation", + { sessionId: params.sessionId, items } + ); + try { + if (receipt.itemCount !== items.length) { + throw new Error( + `native materializer wrote ${receipt.itemCount} of ${items.length} items` + ); + } + const { events } = await loadAuthoritativeSessionEvents(params.sessionId); + const roundTripped = projectNativeConversationItems(events); + if (!nativeConversationItemsEqual(items, roundTripped)) { + throw new Error( + "native transcript round-trip verification failed; the target session was not started" + ); + } + return { events, receipt }; + } catch (error) { + if (isCliSession(params.sessionId)) { + await invokeTauri("discard_native_conversation_materialization", { + sessionId: params.sessionId, + nativeSessionId: receipt.nativeSessionId, + }).catch(() => undefined); + } + throw error; + } +} diff --git a/src/engines/SessionCore/services/SessionService.ts b/src/engines/SessionCore/services/SessionService.ts index 763a16260..bbfe97053 100644 --- a/src/engines/SessionCore/services/SessionService.ts +++ b/src/engines/SessionCore/services/SessionService.ts @@ -27,10 +27,6 @@ import { import { rpc } from "@src/api/tauri/rpc"; import { ROUTES } from "@src/config/routes"; import { getAdapterForSession } from "@src/engines/SessionCore/sync/types"; -import { - buildPendingForkHandoff, - markForkHandoffConsumed, -} from "@src/features/TeamCollaboration/forkSession"; import { createLogger } from "@src/hooks/logger"; import { collectAdeContext } from "@src/services/context/collectors"; import { @@ -320,38 +316,11 @@ export const SessionService = { ); } - // Fork relay (design §16.11): the FIRST real message sent to a forked - // session carries a bounded digest of the inherited teammate history, - // because the agent's LLM context is rebuilt from `agent_messages` — - // which a fork starts without. `displayText` keeps the user's own words - // in the transcript; the marker is consumed only after the send - // succeeds, so a failed send retries with the handoff intact. No-op for - // every non-forked session (durable one-shot marker, armed at fork time). - let effectiveContent = content; - let effectiveDisplayText = displayText; - let forkHandoffArmed = false; - if (!isResume) { - try { - const forkHandoff = await buildPendingForkHandoff(sessionId, content); - if (forkHandoff) { - effectiveContent = forkHandoff.content; - effectiveDisplayText = displayText ?? forkHandoff.displayText; - forkHandoffArmed = true; - } - } catch (handoffError) { - // Handoff assembly must never block a send — the fork still works, - // just without inherited context on this turn. - logger.warn( - `Fork handoff assembly failed for ${sessionId}: ${String(handoffError)}` - ); - } - } - try { await adapter.sendMessage({ sessionId, - content: effectiveContent, - displayText: effectiveDisplayText, + content, + displayText, model: model || undefined, accountId: accountId || undefined, mode: mode || undefined, @@ -364,9 +333,6 @@ export const SessionService = { adeContext, sessionRepoPath: sessionRow?.repoPath ?? null, }); - if (forkHandoffArmed) { - markForkHandoffConsumed(sessionId); - } // Float the row to the top of "today" in the sidebar without // waiting for the next session list refresh. The backend will // emit its own fresh `updated_at` on the next `loadSessions`, diff --git a/src/engines/SessionCore/sync/authoritativeSessionEvents.test.ts b/src/engines/SessionCore/sync/authoritativeSessionEvents.test.ts new file mode 100644 index 000000000..dee909663 --- /dev/null +++ b/src/engines/SessionCore/sync/authoritativeSessionEvents.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionEvent } from "../core/types"; +import { loadAuthoritativeSessionEvents } from "./authoritativeSessionEvents"; + +const mocks = vi.hoisted(() => ({ + loadAgentHistory: vi.fn(), + loadCliHistory: vi.fn(), + getAdapterForSession: vi.fn(), +})); + +vi.mock("./adapters/cli/cliHistory", () => ({ + loadCliHistory: mocks.loadCliHistory, +})); + +vi.mock("./types", () => ({ + getAdapterForSession: mocks.getAdapterForSession, +})); + +const EVENT = { id: "event-1" } as SessionEvent; + +describe("loadAuthoritativeSessionEvents", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getAdapterForSession.mockReturnValue({ + category: "agent", + loadHistory: mocks.loadAgentHistory, + }); + }); + + it("reads a native Agent through its persisted native-message adapter", async () => { + mocks.loadAgentHistory.mockResolvedValue([EVENT]); + + await expect( + loadAuthoritativeSessionEvents("agentsession-native") + ).resolves.toEqual({ events: [EVENT], source: "agent_history" }); + expect(mocks.loadAgentHistory).toHaveBeenCalledOnce(); + expect(mocks.loadCliHistory).not.toHaveBeenCalled(); + }); + + it("reads a managed CLI through its provider transcript adapter", async () => { + mocks.loadCliHistory.mockResolvedValue([EVENT]); + + await expect( + loadAuthoritativeSessionEvents("cliagent-native") + ).resolves.toEqual({ events: [EVENT], source: "cli_history" }); + expect(mocks.loadCliHistory).toHaveBeenCalledOnce(); + expect(mocks.getAdapterForSession).not.toHaveBeenCalled(); + }); + + it("fails closed without an authoritative native reader", async () => { + mocks.getAdapterForSession.mockReturnValue(undefined); + + await expect( + loadAuthoritativeSessionEvents("agentsession-native") + ).rejects.toThrow("No authoritative native Agent history reader"); + }); +}); diff --git a/src/engines/SessionCore/sync/authoritativeSessionEvents.ts b/src/engines/SessionCore/sync/authoritativeSessionEvents.ts index 29c2feb1f..c53b34e9a 100644 --- a/src/engines/SessionCore/sync/authoritativeSessionEvents.ts +++ b/src/engines/SessionCore/sync/authoritativeSessionEvents.ts @@ -1,23 +1,20 @@ /** * Canonical full-history read for one managed local Session. * - * Rust-native sessions persist normalized events in EventStore. Managed CLI - * sessions may instead use the provider's native transcript as their source - * of truth, so an optimistic EventStore row is never enough to prove that a - * CLI turn has finished persisting. Keep that provider distinction here; - * conversation continuation and cloud sync must not each invent it again. + * Both Rust-native and managed CLI sessions are read through their established + * native-history adapters. EventStore is a render/cache projection and can be + * empty immediately after a transcript is seeded, so it cannot prove that a + * provider-native materialization round-tripped. */ -import { eventStoreProxy } from "@src/engines/SessionCore/core/store/EventStoreProxy"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import { isCliSession } from "@src/util/session/sessionDispatch"; import { loadCliHistory } from "./adapters/cli/cliHistory"; +import { getAdapterForSession } from "./types"; export interface AuthoritativeSessionEvents { events: SessionEvent[]; - /** Stable EventStore revision when EventStore was authoritative. */ - localContentRevision?: number; - source: "event_store" | "cli_history"; + source: "agent_history" | "cli_history"; } export async function loadAuthoritativeSessionEvents( @@ -31,22 +28,14 @@ export async function loadAuthoritativeSessionEvents( }; } - const revisionBefore = - await eventStoreProxy.getPersistedEventRevision(sessionId); - const events = await eventStoreProxy.getPersistedEvents(sessionId); - const revisionAfter = - await eventStoreProxy.getPersistedEventRevision(sessionId); - const localContentRevision = - revisionBefore && - revisionAfter && - revisionBefore.revision === revisionAfter.revision && - revisionAfter.eventCount === events.length - ? revisionAfter.revision - : undefined; - + const adapter = getAdapterForSession(sessionId); + if (!adapter || adapter.category !== "agent") { + throw new Error( + `No authoritative native Agent history reader is registered for ${sessionId}` + ); + } return { - events, - localContentRevision, - source: "event_store", + events: await adapter.loadHistory(sessionId, signal), + source: "agent_history", }; } diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts new file mode 100644 index 000000000..15c7ad94a --- /dev/null +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { projectNativeConversationItems } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; + +import { buildPushedUserEvent } from "./conversationTurnRunner"; + +describe("buildPushedUserEvent", () => { + it("keeps visible text separate from the exact agent-facing native content", () => { + const event = buildPushedUserEvent( + "Use my review skill", + "review instructions\nUse my review skill", + ["data:image/png;base64,AAAA"], + "2026-08-26T00:00:00.000Z", + "turn-1" + ); + + expect(event.displayText).toBe("Use my review skill"); + expect(projectNativeConversationItems([event])).toEqual([ + expect.objectContaining({ + kind: "message", + role: "user", + text: "review instructions\nUse my review skill", + images: ["data:image/png;base64,AAAA"], + }), + ]); + }); +}); diff --git a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts index 1f4bc477f..f8bdea2b8 100644 --- a/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts +++ b/src/features/Org2Cloud/SessionConversation/conversationTurnRunner.ts @@ -30,8 +30,10 @@ import { const log = createLogger("ConversationTurnRunner"); -function buildPushedUserEvent( +export function buildPushedUserEvent( displayText: string, + agentContent: string | undefined, + imageDataUrls: readonly string[] | undefined, createdAt: string, turnIntentId: string ): SessionEvent { @@ -47,7 +49,10 @@ function buildPushedUserEvent( args: { [CONVERSATION_TURN_ID_ARG]: turnIntentId }, result: { type: "user", - message: { content: displayText, role: "user" }, + message: { content: agentContent ?? displayText, role: "user" }, + ...(imageDataUrls && imageDataUrls.length > 0 + ? { images: [...imageDataUrls] } + : {}), turnIntentId, }, source: "user", @@ -114,7 +119,13 @@ export async function runConversationTurn( turnId: turnIntentId, events: [ boundConversationEventForPush( - buildPushedUserEvent(params.displayText, dispatchIso, turnIntentId) + buildPushedUserEvent( + params.displayText, + params.agentContent, + params.imageDataUrls, + dispatchIso, + turnIntentId + ) ), ], }); diff --git a/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts b/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts index 39a4298ae..0960897de 100644 --- a/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts +++ b/src/features/Org2Cloud/SessionConversation/discussionEvents.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; +import { projectNativeConversationItems } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; import type { SessionEvent } from "@src/engines/SessionCore/core/types"; import type { CloudSessionComment } from "../org2CloudCommentsClient"; @@ -136,6 +137,13 @@ describe("buildDiscussionEvents", () => { userId: "user-1", displayName: "Alice", }); + expect(projectNativeConversationItems(rows)).toEqual([ + expect.objectContaining({ + kind: "message", + role: "user", + text: "looks good", + }), + ]); }); it("keeps the card renderer for anchored threads and agent reports", () => { diff --git a/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts b/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts new file mode 100644 index 000000000..e8505ed80 --- /dev/null +++ b/src/features/Org2Cloud/org2CloudConversationEventsClient.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import type { SessionEvent } from "@src/engines/SessionCore/core/types"; + +import { + CLOUD_CONVERSATION_MAX_EVENT_BYTES, + Org2CloudConversationError, + boundConversationEventForPush, +} from "./org2CloudConversationEventsClient"; + +function event(displayText: string): SessionEvent { + return { + id: "event-1", + chunk_id: "event-1", + sessionId: "session-1", + createdAt: "2026-08-26T00:00:00.000Z", + functionName: "user_message", + uiCanonical: "user_message", + actionType: "raw", + args: {}, + result: { message: { role: "user", content: displayText } }, + source: "user", + displayText, + displayStatus: "completed", + displayVariant: "message", + activityStatus: "agent", + payloadRefs: [], + } as SessionEvent; +} + +describe("boundConversationEventForPush", () => { + it("preserves exact events inside the wire limit", () => { + const input = event("hello"); + expect(boundConversationEventForPush(input)).toBe(input); + }); + + it("fails closed instead of truncating native conversation history", () => { + const input = event("x".repeat(CLOUD_CONVERSATION_MAX_EVENT_BYTES)); + expect(() => boundConversationEventForPush(input)).toThrow( + Org2CloudConversationError + ); + expect(() => boundConversationEventForPush(input)).toThrow( + "ORG2_CONVERSATION_EVENT_TOO_LARGE" + ); + }); +}); diff --git a/src/features/Org2Cloud/org2CloudConversationEventsClient.ts b/src/features/Org2Cloud/org2CloudConversationEventsClient.ts index 2e377f075..93120f500 100644 --- a/src/features/Org2Cloud/org2CloudConversationEventsClient.ts +++ b/src/features/Org2Cloud/org2CloudConversationEventsClient.ts @@ -241,23 +241,17 @@ export async function pushConversationEventsChunked( } /** - * Client-side mirror of the 64KB/event CHECK: oversized display payloads - * are truncated with a marker instead of failing the whole turn. The - * transcript stays honest — the marker names the elision. + * Client-side mirror of the 64KB/event CHECK. A canonical conversation is a + * native-resume source, so silently truncating text/tool/image data would + * create a session that looks continuous while the model received incomplete + * history. Fail closed until the transport has an exact large-payload codec. */ export function boundConversationEventForPush( event: SessionEvent ): SessionEvent { const size = new TextEncoder().encode(JSON.stringify(event)).length; if (size <= CLOUD_CONVERSATION_MAX_EVENT_BYTES) return event; - const truncated: SessionEvent = { - ...event, - args: { conversationTruncated: true }, - result: {}, - payloadRefs: [], - displayText: - event.displayText.slice(0, 4000) + - "\n… [truncated for the shared conversation]", - } as SessionEvent; - return truncated; + throw new Org2CloudConversationError( + `ORG2_CONVERSATION_EVENT_TOO_LARGE: event ${event.id} is ${size} bytes; exact native continuation requires the complete event` + ); } diff --git a/src/features/TeamCollaboration/components/ForkSessionSetupDialog/index.tsx b/src/features/TeamCollaboration/components/ForkSessionSetupDialog/index.tsx index 99c1d3f76..61c040792 100644 --- a/src/features/TeamCollaboration/components/ForkSessionSetupDialog/index.tsx +++ b/src/features/TeamCollaboration/components/ForkSessionSetupDialog/index.tsx @@ -1,5 +1,5 @@ import Modal from "@/src/scaffold/ModalSystem"; -import { atom, useAtom } from "jotai"; +import { atom, useAtom, useAtomValue } from "jotai"; import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -8,6 +8,11 @@ import Button from "@src/components/Button"; import Select from "@src/components/Select"; import type { SelectOption } from "@src/components/Select"; import { getCliTransportLabel } from "@src/config/cliAgents"; +import { NATIVE_MATERIALIZATION_CLI_TARGETS } from "@src/engines/SessionCore/conversations/nativeConversationMaterializer"; +import { + getCliCompatibleAccounts, + getRustCompatibleAccounts, +} from "@src/hooks/models/useAgentCompatibility"; import { accountHasModel, accountModelIds, @@ -18,6 +23,7 @@ import type { AgentDefinition } from "@src/modules/MainApp/AgentOrgs/types"; import { useCliAgents } from "@src/modules/MainApp/Integrations/KeyVault/CliClients/hooks/useCliAgents"; import useSharedRepoList from "@src/scaffold/GlobalSpotlight/hooks/data/useSharedRepoList"; import type { RepoItem } from "@src/scaffold/GlobalSpotlight/types"; +import { agentRegistryAtom } from "@src/store/session/agentRegistryAtom"; import { normalizeRepoScopeKey } from "../../collabSyncUtils"; import type { ForkExecutionSelection } from "../../engine/collabSyncEngineHelpers"; @@ -79,6 +85,7 @@ const ForkSessionSetupForm: React.FC = ({ }) => { const { t } = useTranslation("navigation"); const { accounts } = useModelAccountLookup(); + const agentRegistry = useAtomValue(agentRegistryAtom); const { builtInAgents, agents: customAgents } = useAgentDefinitions(); const { agents: cliAgents } = useCliAgents({ enabled: request.allowCliRuntime === true, @@ -106,25 +113,40 @@ const ForkSessionSetupForm: React.FC = ({ const runnableAccounts = useMemo( () => - accounts.filter( - (account) => - account.enabled && - account.status === "ready" && - account.hasKey && - account.supportsRustAgents !== false + getRustCompatibleAccounts(agentRegistry, accounts).filter( + (account) => account.enabled ), - [accounts] + [accounts, agentRegistry] + ); + const selectedCliAgentType = useMemo(() => { + if (!chosenRuntime.startsWith("cli:")) return null; + const parsed = CliAgentTypeSchema.safeParse(chosenRuntime.slice(4)); + return parsed.success ? parsed.data : null; + }, [chosenRuntime]); + const runnableCliAccounts = useMemo( + () => + selectedCliAgentType + ? getCliCompatibleAccounts( + agentRegistry, + selectedCliAgentType, + accounts + ).filter((account) => account.enabled && account.hasKey) + : [], + [accounts, agentRegistry, selectedCliAgentType] ); + const selectedAccountPool = selectedCliAgentType + ? runnableCliAccounts + : runnableAccounts; const sourceModel = request.sourceModel; const sourceAgentDefinitionId = request.sourceAgentDefinitionId; const preferredAccount = useMemo( () => (sourceModel - ? runnableAccounts.find((account) => + ? selectedAccountPool.find((account) => accountHasModel(account, sourceModel) ) - : undefined) ?? runnableAccounts[0], - [sourceModel, runnableAccounts] + : undefined) ?? selectedAccountPool[0], + [sourceModel, selectedAccountPool] ); const preferredAgent = useMemo( () => @@ -143,23 +165,23 @@ const ForkSessionSetupForm: React.FC = ({ [allAgents, chosenAgentDefinitionId, preferredAgent?.id] ); const agentPreferredAccountId = selectedAgent?.selectedAccountId - ? runnableAccounts.find( + ? selectedAccountPool.find( (account) => account.id === selectedAgent.selectedAccountId )?.id : undefined; const accountId = chosenAccountId || agentPreferredAccountId || preferredAccount?.id || ""; - const selectedAccount = runnableAccounts.find( + const selectedAccount = selectedAccountPool.find( (account) => account.id === accountId ); const accountOptions = useMemo( () => - runnableAccounts.map((account) => ({ + selectedAccountPool.map((account) => ({ value: account.id, label: `${account.name} · ${account.modelType}`, triggerLabel: account.name, })), - [runnableAccounts] + [selectedAccountPool] ); const agentOptions = useMemo( () => @@ -174,7 +196,12 @@ const ForkSessionSetupForm: React.FC = ({ () => cliAgents.flatMap((agent) => { const parsed = CliAgentTypeSchema.safeParse(agent.name); - return agent.installed && agent.supportsGui && parsed.success + return agent.installed && + agent.supportsGui && + parsed.success && + NATIVE_MATERIALIZATION_CLI_TARGETS.includes( + parsed.data as (typeof NATIVE_MATERIALIZATION_CLI_TARGETS)[number] + ) ? [{ agent, cliAgentType: parsed.data }] : []; }), @@ -197,15 +224,13 @@ const ForkSessionSetupForm: React.FC = ({ [runnableCliAgents, t] ); const selectedCliAgent = useMemo(() => { - if (!chosenRuntime.startsWith("cli:")) return null; - const parsed = CliAgentTypeSchema.safeParse(chosenRuntime.slice(4)); - if (!parsed.success) return null; + if (!selectedCliAgentType) return null; return ( runnableCliAgents.find( - (candidate) => candidate.cliAgentType === parsed.data + (candidate) => candidate.cliAgentType === selectedCliAgentType ) ?? null ); - }, [chosenRuntime, runnableCliAgents]); + }, [selectedCliAgentType, runnableCliAgents]); const modelOptions = useMemo(() => { if (!selectedAccount) return []; return accountModelIds(selectedAccount) @@ -243,7 +268,9 @@ const ForkSessionSetupForm: React.FC = ({ Boolean(selectedAccount && accountId && model) && Boolean(selectedAccount && accountHasModel(selectedAccount, model)); const executionReady = selectedCliAgent - ? true + ? Boolean( + selectedAccount && accountId && (model || modelOptions.length === 0) + ) : chosenRuntime === "native" && nativeExecutionReady; const canContinue = Boolean(selectedAgent) && @@ -283,6 +310,8 @@ const ForkSessionSetupForm: React.FC = ({ ? { agentDefinitionId: selectedAgent.id, cliAgentType: selectedCliAgent.cliAgentType, + accountId, + model: model || undefined, } : { agentDefinitionId: selectedAgent.id, @@ -390,43 +419,43 @@ const ForkSessionSetupForm: React.FC = ({ { - setChosenAccountId(String(value)); - setChosenModel(""); - }} - style={{ width: "100%" }} - dataTestId="fork-setup-account" - /> - - +