Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/engineering/external_conversation_import.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ report rather than hidden.

## Display in Conversations

Raw-document identity includes provider account and workspace when present,
matching the conversation's source scope. This prevents reused provider session
IDs (including Gemini A2A runs with the same start time) from sharing one mutable
raw-document pointer across workspaces. Existing raw artefacts remain retained;
reconciliation can refresh an affected transcript's raw-document reference
without replacing its history or dates.

Imported conversations are hidden from the Conversations tray and its search
results by default. Select **Show imported** in the tray filters to include them
for the current page session. This uses the existing
Expand Down
2 changes: 2 additions & 0 deletions src/backend/services/chat_history_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -4833,6 +4833,8 @@ def _event_fingerprint(entry: Mapping[str, Any]) -> str:
== stored_manifest.get("parser_version")
and existing_manifest.get("raw_file_copy_concept_id")
== stored_manifest.get("raw_file_copy_concept_id")
and existing_manifest.get("raw_document_concept_id")
== stored_manifest.get("raw_document_concept_id")
):
existing_synchronisation = existing_manifest.get("synchronisation")
existing_divergence = (
Expand Down
12 changes: 12 additions & 0 deletions src/backend/services/external_conversation_import_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,18 @@ def _build_source_record(
f"external-import://custodian/{custodian_scope}/"
f"{package.provider}/{package.source_session_id}"
)
# Provider session IDs can repeat across accounts/workspaces. Raw custody
# must distinguish the same source scopes as the conversation projection.
# Hash exact values before the ingestion layer normalises paths to lowercase.
if package.source_account or package.source_workspace:
source_scope = hashlib.sha256(
json.dumps(
[package.source_account or "", package.source_workspace or ""],
ensure_ascii=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
canonical_source_path += f"/scope/{source_scope}"
return SessionRecord(
environment=package.provider,
source_session_id=package.source_session_id,
Expand Down
42 changes: 42 additions & 0 deletions tests/backend/test_external_conversation_import_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,36 @@ def parse(start: str, *, append: bool = False, session_id: str = "a2a-server"):
)


def test_raw_custody_distinguishes_gemini_workspace_and_account(tmp_path):
def record(workspace, account=None, *, append=False):
messages = [{"type": "user", "content": "Inspect this workspace"}]
if append:
messages.append({"type": "gemini", "content": "Inspected"})
path = tmp_path / "same-filename.jsonl"
path.write_bytes(_jsonl({
"sessionId": "a2a-server",
"startTime": "2026-06-28T16:27:59Z",
"projectHash": workspace,
"messages": messages,
}))
package = external_conversation_import_service.parse_external_conversation_file(
path, provider_hint="gemini", source_account=account
)
return external_conversation_import_service._build_source_record(
path=path, package=package, custodian_user_id="#V#user", namespace="#V#user"
)

first = record("workspace")
grown = record("workspace", append=True)
other_workspace = record("Workspace")
other_account = record("workspace", "another-account")
assert first.source_session_id == other_workspace.source_session_id
assert first.document_concept_id == grown.document_concept_id
assert len({first.document_concept_id, other_workspace.document_concept_id,
other_account.document_concept_id}) == 3
assert "another-account" not in other_account.canonical_source_path


def test_codex_parser_projects_visible_messages_without_importing_instructions():
package = parse_external_conversation_bytes(
_jsonl(
Expand Down Expand Up @@ -389,6 +419,18 @@ def test_chat_projection_is_read_only_idempotent_and_actor_scoped(monkeypatch):
manifest=manifest,
)
assert repeated["action"] == "unchanged"
# A corrected raw-document reference must refresh even when bytes, parsed
# events and the file-copy reference did not change. Keep transcript intact.
history_before = collection.find_one({"session_id": "external-session"})["history"]
repaired = chat_history_service.upsert_external_conversation_projection(
user_id="#V#user", session_id="external-session", session_name="Imported session",
namespace="#V#user@org", organisation_concept_id="#V#org", role_in_org="member",
history=history, manifest={**manifest, "raw_document_concept_id": "#V#scoped-source"},
)
assert repaired["action"] == "refreshed"
after = collection.find_one({"session_id": "external-session"})
assert after["external_conversation_import"]["raw_document_concept_id"] == "#V#scoped-source"
assert after["history"] == history_before
assert (
collection.find_one({"session_id": "external-session"})["session_name"]
== "Imported session"
Expand Down
Loading