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
108 changes: 90 additions & 18 deletions application/single_app/functions_simplechat_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,13 +61,24 @@
)
from functions_notifications import create_notification
from functions_personal_workflows import save_personal_workflow
from functions_public_workspaces import (

Check warning on line 64 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
check_public_workspace_status_allows_operation,

Check warning on line 65 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
find_public_workspace_by_id,

Check warning on line 66 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
)
from functions_settings import get_settings, is_user_workflows_enabled_for_user
from utils_cache import invalidate_group_search_cache, invalidate_personal_search_cache


SIMPLECHAT_PLUGIN_TYPE = "simplechat"
SIMPLECHAT_DEFAULT_ENDPOINT = "simplechat://internal"
PERSONAL_FORK_CHAT_TYPES = {"", "new", "personal", "personal_single_user"}
FORKABLE_SINGLE_USER_CHAT_TYPES = {
"",
"new",
"personal",
"personal_single_user",
"group-single-user",
"public",
}
PERSONAL_FORK_CONVERSATION_FIELDS = (
"context",
"tags",
Expand Down Expand Up @@ -244,20 +255,77 @@
"""Raised when the source changes or cannot be forked safely."""


def is_forkable_personal_conversation(conversation_item: Dict[str, Any]) -> bool:
"""Return whether a legacy personal conversation is eligible for forking."""
def is_forkable_single_user_conversation(conversation_item: Dict[str, Any]) -> bool:

Check warning on line 258 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"""Return whether a user-owned single-user conversation can be considered for forking."""

Check warning on line 259 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
chat_type = str((conversation_item or {}).get("chat_type") or "").strip().lower()
if chat_type not in PERSONAL_FORK_CHAT_TYPES:
return False
return chat_type in FORKABLE_SINGLE_USER_CHAT_TYPES


def _authorize_fork_conversation_context(

Check warning on line 264 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
conversation_item: Dict[str, Any],

Check warning on line 265 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
user_id: str,
) -> str:
"""Revalidate stored workspace context and return the destination chat type."""

Check warning on line 268 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
if not is_forkable_single_user_conversation(conversation_item):

Check warning on line 269 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
raise ConversationForkConflictError("Only supported single-user conversations can be forked")

Check warning on line 270 in application/single_app/functions_simplechat_operations.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

for context_item in (conversation_item or {}).get("context") or []:
authorized_scopes = set()
for context_item in conversation_item.get("context") or []:
if not isinstance(context_item, dict):
continue
raise ConversationForkConflictError("The conversation context is invalid")

scope = str(context_item.get("scope") or "").strip().lower()
if scope and scope != "personal":
return False
if scope in {"", "personal"}:
authorized_scopes.add("personal")
continue

return True
context_id = str(context_item.get("id") or "").strip()
if not context_id:
raise ConversationForkConflictError("The conversation workspace context is incomplete")

if scope == "group":
group_doc = find_group_by_id(context_id)
if not group_doc:
raise ConversationForkConflictError("The conversation group is no longer available")
allowed, _ = check_group_status_allows_operation(group_doc, "chat")
if not allowed:
raise ConversationForkConflictError("The conversation group no longer allows chat")
try:
assert_group_role(
user_id,
context_id,
allowed_roles=("Owner", "Admin", "DocumentManager", "User"),
)
except (LookupError, PermissionError) as access_error:
raise ConversationForkConflictError(
"The conversation group is no longer available to this user"
) from access_error
authorized_scopes.add(scope)
continue

if scope == "public":
workspace_doc = find_public_workspace_by_id(context_id)
if not workspace_doc:
raise ConversationForkConflictError("The public workspace is no longer available")
allowed, _ = check_public_workspace_status_allows_operation(workspace_doc, "chat")
if not allowed:
raise ConversationForkConflictError("The public workspace no longer allows chat")
authorized_scopes.add(scope)
continue

raise ConversationForkConflictError("The conversation has an unsupported workspace context")

if "group" in authorized_scopes and "public" in authorized_scopes:
raise ConversationForkConflictError("Mixed group and public workspace context cannot be forked")
if "group" in authorized_scopes:
return "group-single-user"
if "public" in authorized_scopes:
return "public"

source_chat_type = str(conversation_item.get("chat_type") or "").strip().lower()
if source_chat_type in {"group-single-user", "public"}:
raise ConversationForkConflictError("The conversation workspace context is incomplete")
return "personal_single_user"


def _message_fork_sort_key(message_doc: Dict[str, Any]) -> Tuple[str, int, str]:
Expand Down Expand Up @@ -462,6 +530,7 @@
selected_message_id: str,
fork_conversation_id: str,
user_id: str,
destination_chat_type: str,
) -> Dict[str, Any]:
source_title = str(source_conversation.get("title") or "New Conversation").strip() or "New Conversation"
fork_title = f"Fork of {source_title}"
Expand All @@ -476,7 +545,7 @@
"strict": False,
"is_pinned": False,
"is_hidden": False,
"chat_type": "personal_single_user",
"chat_type": destination_chat_type,
"has_unread_assistant_response": False,
"last_unread_assistant_message_id": None,
"last_unread_assistant_at": None,
Expand Down Expand Up @@ -602,7 +671,7 @@
log_event(
f"[ConversationFork] Failed to clean up fork message: {cleanup_error}",
level=logging.ERROR,
properties={
extra={
"fork_conversation_id": fork_conversation_id,
"message_id": message_id,
},
Expand All @@ -620,7 +689,7 @@
log_event(
f"[ConversationFork] Failed to clean up fork blob: {cleanup_error}",
level=logging.ERROR,
properties={
extra={
"fork_conversation_id": fork_conversation_id,
"blob_container": blob_container,
"blob_path": blob_path,
Expand All @@ -635,13 +704,13 @@
log_event(
"[ConversationFork] Fork conversation already absent during cleanup",
level=logging.INFO,
properties={"fork_conversation_id": fork_conversation_id},
extra={"fork_conversation_id": fork_conversation_id},
)
except Exception as cleanup_error:
log_event(
f"[ConversationFork] Failed to clean up fork conversation: {cleanup_error}",
level=logging.ERROR,
properties={"fork_conversation_id": fork_conversation_id},
extra={"fork_conversation_id": fork_conversation_id},
)


Expand All @@ -658,8 +727,10 @@
raise ValueError("Source conversation, assistant message, and user are required")
if source_conversation.get("user_id") != normalized_user_id:
raise PermissionError("Forbidden")
if not is_forkable_personal_conversation(source_conversation):
raise ConversationForkConflictError("Only personal conversations can be forked")
destination_chat_type = _authorize_fork_conversation_context(
source_conversation,
normalized_user_id,
)

query = (
"SELECT * FROM c "
Expand Down Expand Up @@ -711,6 +782,7 @@
normalized_message_id,
fork_conversation_id,
normalized_user_id,
destination_chat_type,
)
written_message_ids = []
created_blob_targets: List[Tuple[str, str]] = []
Expand Down Expand Up @@ -746,7 +818,7 @@
log_event(
f"[ConversationFork] Fork created but activity logging failed: {log_error}",
level=logging.WARNING,
properties={"fork_conversation_id": fork_conversation_id},
extra={"fork_conversation_id": fork_conversation_id},
)

return {
Expand Down
8 changes: 4 additions & 4 deletions application/single_app/route_backend_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,7 @@ def fork_conversation(conversation_id):
log_event(
f'[ConversationFork] Fork created but cache invalidation failed: {cache_error}',
level=logging.WARNING,
custom_dimensions={
extra={
'fork_conversation_id': fork_conversation['id'],
'user_id': user_id,
},
Expand All @@ -1193,7 +1193,7 @@ def fork_conversation(conversation_id):
f'[ConversationFork] Validation failed while creating conversation fork: {validation_error}',
level=logging.WARNING,
exceptionTraceback=True,
custom_dimensions={
extra={
'source_conversation_id': conversation_id,
'selected_message_id': selected_message_id,
'user_id': user_id,
Expand All @@ -1204,7 +1204,7 @@ def fork_conversation(conversation_id):
log_event(
f'[ConversationFork] Conflict while creating conversation fork: {conflict_error}',
level=logging.WARNING,
custom_dimensions={
extra={
'source_conversation_id': conversation_id,
'selected_message_id': selected_message_id,
'user_id': user_id,
Expand All @@ -1216,7 +1216,7 @@ def fork_conversation(conversation_id):
f'[ConversationFork] Failed to create conversation fork: {error}',
level=logging.ERROR,
exceptionTraceback=True,
custom_dimensions={
extra={
'source_conversation_id': conversation_id,
'selected_message_id': selected_message_id,
'user_id': user_id,
Expand Down
15 changes: 11 additions & 4 deletions application/single_app/static/js/chat/chat-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -3153,7 +3153,7 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
return !isStreamingAssistantPlaceholder(messageId, fullMessageObject);
}

function getForkablePersonalConversationId(fullMessageObject = null) {
function getForkableSingleUserConversationId(fullMessageObject = null) {
const conversationId = resolveMessageConversationId(fullMessageObject);
const activeConversationId = String(
window.chatConversations?.getCurrentConversationId?.()
Expand All @@ -3173,7 +3173,14 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
}

const chatType = String(conversationItem.dataset.chatType || '').trim().toLowerCase();
return ['', 'new', 'personal', 'personal_single_user'].includes(chatType)
return [
'',
'new',
'personal',
'personal_single_user',
'group-single-user',
'public',
].includes(chatType)
? conversationId
: '';
}
Expand All @@ -3185,7 +3192,7 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
normalizedMessageId
&& persistedMessageId === normalizedMessageId
&& shouldRenderCompletedAssistantActions(messageId, fullMessageObject)
&& getForkablePersonalConversationId(fullMessageObject)
&& getForkableSingleUserConversationId(fullMessageObject)
);
}

Expand All @@ -3204,7 +3211,7 @@ function renderReplyQuoteHtml(fullMessageObject = null) {
}

const messageId = String(messageDiv?.dataset?.messageId || '').trim();
const conversationId = getForkablePersonalConversationId(fullMessageObject);
const conversationId = getForkableSingleUserConversationId(fullMessageObject);
if (!messageId || !conversationId) {
showToast('This message is not available to fork.', 'warning');
return;
Expand Down
51 changes: 31 additions & 20 deletions docs/explanation/features/FORK_CONVERSATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
## Overview

**Implemented in version: 0.250.074**
**Workspace-context support fixed in version: 0.250.101**

Fork Conversation lets a user branch a personal conversation from a persisted
assistant response without changing the source conversation. The fork contains
the active message history from the beginning through the selected assistant
message, inclusive, and opens immediately as an independent conversation.
Fork Conversation lets a user branch an owned single-user conversation from a
persisted assistant response without changing the source conversation. The fork
contains the active message history from the beginning through the selected
assistant message, inclusive, and opens immediately as an independent
conversation.

### Purpose

Expand All @@ -28,7 +30,7 @@ message, inclusive, and opens immediately as an independent conversation.
### Architecture

1. The assistant message action is rendered only for a persisted, completed
assistant message in an active personal conversation.
assistant message in a supported active single-user conversation.
2. A Bootstrap confirmation modal submits the source conversation ID and
selected message ID.
3. The backend authorizes ownership and resolves the authoritative persisted
Expand Down Expand Up @@ -72,15 +74,19 @@ The endpoint can return:
- `400` for a missing message ID or a non-assistant fork point.
- `403` when the current user does not own the source conversation.
- `404` when the selected message is not on the active persisted timeline.
- `409` when the source is not an eligible personal conversation or changes
during the operation.
- `409` when the source is not an eligible single-user conversation, its
workspace context is no longer authorized, or it changes during the
operation.
- `500` when destination persistence fails.

### Security and authorization

- Source ownership is revalidated at the API boundary.
- Group, public, and collaborative conversation types are rejected.
- Stored context is eligible only when it remains personal to the same owner.
- Collaborative, multi-user, workflow, and unknown conversation types are
rejected.
- Personal, group-single-user, and public context is copied only after the
current user's group role or public workspace access and chat status are
revalidated.
- The selected message must belong to the authorized source conversation and
must be an active assistant message.
- Source conversation and message snapshots are checked again before writes to
Expand All @@ -90,11 +96,12 @@ The endpoint can return:

### Preserved and reset state

The fork preserves personal context, tags, strict mode, classification, scope
lock state, supported model/agent selections, message content, attachments,
citations, and assistant artifact metadata. It assigns new conversation,
message, artifact, and thread identifiers, and copies blob-backed message files
to independent destination paths.
The fork preserves authorized personal, group, or public context, its normalized
single-user chat type, tags, strict mode, classification, scope lock state,
supported model/agent selections, message content, attachments, citations, and
assistant artifact metadata. It assigns new conversation, message, artifact,
and thread identifiers, and copies blob-backed message files to independent
destination paths.

Pin, hidden, unread, Cosmos system, streaming, and other destination lifecycle
state is reset. The source conversation and all later messages remain
Expand All @@ -111,14 +118,15 @@ unchanged.

## Usage

1. Open a personal conversation containing a completed assistant response.
1. Open an owned personal, group-single-user, or public single-user conversation
containing a completed assistant response.
2. Open the response's **More actions** menu.
3. Select **Fork conversation**.
4. Confirm the operation.
5. Continue in the newly opened `Fork of <source title>` conversation.

The action is unavailable for streaming or transient assistant messages and for
group, public, or collaborative conversations.
collaborative, multi-user, workflow, or unknown conversation types.

## Testing and Validation

Expand All @@ -129,8 +137,11 @@ group, public, or collaborative conversations.
- Independent and remapped message, artifact, reply, conversation, and thread
identifiers.
- Source immutability.
- Missing, non-assistant, inactive, foreign, and non-personal fork requests.
- Missing, non-assistant, inactive, foreign, and unsupported fork requests.
- Authorized group/public context and stale, inactive, or inaccessible
workspace context.
- Concurrent source changes and failed-write cleanup.
- Route-level conflict logging that preserves the intended HTTP 409 response.
- Browser action visibility, confirmation and cancellation, failure feedback,
duplicate-click prevention, list insertion, and fork activation.

Expand All @@ -142,8 +153,8 @@ conversation is not listed until all destination message writes complete.

### Limitations

- Only personal, single-user conversations are supported.
- Group, public, and collaborative conversations require separate resource and
participant authorization designs.
- Only owned single-user conversations are supported.
- Collaborative and multi-user conversations require separate participant
authorization and destination-membership designs.
- Cross-container atomic transactions are not available. The implementation
uses publish-last behavior plus cleanup to provide an atomic user experience.
Loading
Loading