Skip to content

Conversation

@pjain1
Copy link
Member

@pjain1 pjain1 commented Dec 18, 2025

Adds supports for sharing and forking shared conversations. Once a conversation is shared, anyone knowing the conversation id can access till the shared message. User can then start a new conversation from it which copies over the shared messages to new conversation.

Now after forking new user might not have same access as previous in which case they might get access errors on continuing conversation, we let LLM know about this by injecting this info in the system prompt for shared conversations. On testing I have seen replies like this from LLM

It looks like I don't have permission to access the detailed profit trends over time by label in this environment. This may be due to a change in access permissions or a conversation transfer (fork). If you have access, you can try re-running this analysis, or let me know if you'd like to explore a different dataset or angle!

Unhandled case - However, there is an edge case around chart message. Existing charts does MV agg API calls which will fail if the new user does not have access to the underlying mv. So charts will just show spinner.

New APIs

  • ShareConversation, optionally accepts shared_until_message_id, returns nothing: Only owners (or rill dev) may call. Validates the provided message exists in the caller’s view. Persists shared_until_message_id as the last router result at or before the requested message; if omitted, uses the latest router result.

  • ForkConversation, accepts nothing and returns new conversation_id: Clones the conversation; owners get all messages, non-owners only get messages up to the shared boundary.

Checklist:

  • Covered by tests
  • Ran it and it works as intended
  • Reviewed the diff before requesting a review
  • Checked for unhandled edge cases
  • Linked the issues it closes
  • Checked if the docs need to be updated. If so, create a separate Linear DOCS issue
  • Intend to cherry-pick into the release branch
  • I'm proud of this work!

@pjain1 pjain1 marked this pull request as draft December 18, 2025 11:56
@pjain1 pjain1 changed the title share conversation support share/fork conversation support Dec 18, 2025
@pjain1 pjain1 changed the title share/fork conversation support [PLAT-333] share/fork conversation support Dec 18, 2025
@pjain1 pjain1 marked this pull request as ready for review December 20, 2025 15:10
Comment on lines 1033 to 1036
message ShareConversationRequest {
string instance_id = 1 [(validate.rules).string = {pattern: "^[_\\-a-zA-Z0-9]+$"}];
string conversation_id = 2 [(validate.rules).string.min_len = 1];
string shared_until_message_id = 3; // optional message ID up to which to share otherwise share all current messages, only valid conversation having last message of "result" type from "router" agent till until this message ID will be shared
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. nit: can avoid repeating type name in field name, i.e. call the API field ShareConversationRequest.until_message_id
  2. Is there an easy way to support un-sharing? Like a bool flag or supporting "none" as a constant value for shared_until_message_id?

Copy link
Member Author

@pjain1 pjain1 Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Sure
  2. Yeah we can support some constant to un-share or a flag.

Comment on lines +209 to +249
// Load the session in the catalog
session, err := catalog.FindAISession(ctx, opts.SessionID)
if err != nil {
return "", fmt.Errorf("failed to find session %q: %w", opts.SessionID, err)
}

// Check access: for now, only allow users to access their own sessions or shared sessions with trimmed messages.
// Checking !SkipChecks to ensure access for superusers and for Rill Developer (where auth is disabled and SkipChecks is true).
if opts.Claims.UserID != session.OwnerID && !opts.Claims.SkipChecks && session.SharedUntilMessageID == "" {
return "", fmt.Errorf("access denied to session %q", session.ID)
}

retrieveUntilMessageID := session.SharedUntilMessageID
if session.OwnerID == opts.Claims.UserID || opts.Claims.SkipChecks {
// If the user owns the session or skipCheck enabled, they can see all messages.
retrieveUntilMessageID = ""
}

var messages []*Message
ms, err := catalog.FindAIMessages(ctx, opts.SessionID)
if err != nil {
return "", fmt.Errorf("failed to find messages for session %q: %w", opts.SessionID, err)
}
for _, m := range ms {
messages = append(messages, &Message{
ID: m.ID,
ParentID: m.ParentID,
SessionID: m.SessionID,
Time: m.CreatedOn,
Index: m.Index,
Role: Role(m.Role),
Type: MessageType(m.Type),
Tool: m.Tool,
ContentType: MessageContentType(m.ContentType),
Content: m.Content,
})
// only load messages up to and including that retrieveUntilMessageID; messages are ordered by "Index" ascending.
if m.ID == retrieveUntilMessageID {
break
}
}
Copy link
Contributor

@begelundmuller begelundmuller Dec 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this the same logic as in r.Session()? Can it use r.Session(...).Messages() instead to get the messages?

Or what do you think about making it a on Session, like s.ai.Session(...).Fork(newOwner)?

Copy link
Member Author

@pjain1 pjain1 Dec 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating a Session does a lot more than what is needed here, like setting up the logger, creating activity client etc. and may add more things in future. Thus it seems unnecessary, mainly we want messages with some access checks so not much logic there anyways.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session() is actually meant to be the single entrypoint for accessing a session, to make sure we parse DB messages and enforce access checks uniformly. So it would be nice to avoid the duplication.

The logger/activity clients are cheap, so no need to worry about those. And if we need to add other non-cheap logic in the future, we can do it in a lazy-loading way to make sure Session remains useful as the single entrypoint for all use cases.

Comment on lines 65 to 69
var anonUser bool
if claims != nil && claims.UserID == "" && !claims.SkipChecks {
anonUser = true
}
if !claims.Can(runtime.UseAI) && !anonUser {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we should still require project access to see shared conversations.

And otherwise, it's not safe to handle anon users like this – we'd have to break UseAI up into separate ReadAI and WriteAI permissions and handle it using auth.OwnerTypeAnon around here:

if claims.OwnerType() == auth.OwnerTypeUser {

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I thought any shared conversation can be viewable just using link without logging like ChatGPT. Anyways, removed anon access.

Comment on lines 82 to 84
if anonUser && !session.Shareable() {
return nil, ErrForbidden
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't access be checked inside s.ai.Session? Actually, it looks like it's already checked there

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes its already checked inside and allows either owner or if its shared then trims messages till shared id.

Comment on lines 122 to 135
// validate that the requested message id exists
foundMsg := req.SharedUntilMessageId == ""
lastRouterResultMsgID := ""
for _, msg := range session.Messages() {
// only allow sharing until the last router agent result message
if msg.Tool == ai.RouterAgentName && msg.Type == ai.MessageTypeResult {
lastRouterResultMsgID = msg.ID
}
if msg.ID == req.SharedUntilMessageId {
// found the message, proceed
foundMsg = true
break
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The session has predicate support for searching messages, which it would be nice to use, for example:

var preds []ai.Predicate
if req.SharedUntilMessageId == "" {
  preds = []ai.Predicate{ai.FilterByTool(ai.RouterAgentName), ai.FilterByType(ai.MessageTypeResult)}
} else {
  preds = []ai.Predicate{ai.FilterByID(req.SharedUntilMessageId)}
}
msg, ok := s.LatestMessage(preds)
...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I saw that but for the case when req.SharedUntilMessageId != "" and the SharedUntilMessageId is not a router result then I am looking for latest router result before this message and using that. Since that will require manual looping anyways so I just clubbed both the cases together in single loop lookup. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since that will require manual looping anyways

I'm not sure I follow manual looping is required anyway? I think it should be possible to remove manual looping completely.

Are you just thinking about the error message? It can just be a generic error like "shared_until_message_id does not match a router_agent result message" (doesn't need to handle not found vs. wrong type explicitly)

Copy link
Member Author

@pjain1 pjain1 Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I mean when req.SharedUntilMessageId != "" and if this message is not a router result then we will need to loop through messages to find latest router result before this message.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's okay to just error if shared_until_message_id does not point to a valid router result message. (The UI can easily ensure this.)

Sessions can also have other root calls than router calls (like MCP messages and soon feedback messages), so too much inference can anyway open up new edge cases.

Copy link
Contributor

@begelundmuller begelundmuller left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pjain1 I also responded with some suggestions on the unresolved comments above :)

{{ .fork_instructions }}
This conversation has been transferred and the new owner may have different access permissions.
If you start seeing access errors like "action not allowed"", "resource not found" (for resources earlier available) etc., consider repeating metadata listings and lookups.
If you run into these issues, explicitly mention to user that this may be due to conversation forking and user may not have access to metrics view that previous user had.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
If you run into these issues, explicitly mention to user that this may be due to conversation forking and user may not have access to metrics view that previous user had.
If you run into such issues, explicitly mention to the user that this may be due to conversation forking and that they may not have access to the data that the previous user had.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants