From 2a48fd62b055cf1a21ad2d294d13616e4cb60969 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:04:18 +0000 Subject: [PATCH 1/2] fix(conversations): Add participants only when they start a Turn Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../src/api/conversations/participants.ts | 27 +++++++--- .../src/chat/conversations/sql/history.ts | 49 ++++++++++++++++--- .../db/schema/conversation-participants.ts | 4 +- .../api/conversations/list.test.ts | 46 +++++++++++------ 4 files changed, 95 insertions(+), 31 deletions(-) diff --git a/packages/junior/src/api/conversations/participants.ts b/packages/junior/src/api/conversations/participants.ts index 342297d860..ce938cfa87 100644 --- a/packages/junior/src/api/conversations/participants.ts +++ b/packages/junior/src/api/conversations/participants.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, inArray, ne } from "drizzle-orm"; +import { and, asc, eq, inArray, sql } from "drizzle-orm"; import { identityAvatarUrl, readUserAvatarUrls, @@ -6,6 +6,7 @@ import { import type { JuniorDatabase } from "@/db/db"; import { juniorConversationEvents, + juniorConversationParticipants, juniorConversations, juniorIdentities, juniorUsers, @@ -96,15 +97,27 @@ export async function readConversationParticipants( eq(juniorIdentities.id, juniorConversationEvents.actorIdentityId), ) .leftJoin(juniorUsers, eq(juniorUsers.id, juniorIdentities.userId)) - .where( + .innerJoin( + juniorConversations, + eq( + juniorConversations.conversationId, + juniorConversationEvents.conversationId, + ), + ) + // Message authors show only when they joined by starting a Turn. + .innerJoin( + juniorConversationParticipants, and( - inArray(juniorConversationEvents.conversationId, [ - ...conversationIds, - ]), - // System Messages, such as events, carry the system Identity. - ne(juniorIdentities.kind, "system"), + eq(juniorConversationParticipants.userId, juniorIdentities.userId), + eq( + juniorConversationParticipants.rootConversationId, + sql`coalesce(${juniorConversations.rootConversationId}, ${juniorConversations.conversationId})`, + ), ), ) + .where( + inArray(juniorConversationEvents.conversationId, [...conversationIds]), + ) .orderBy( asc(juniorConversationEvents.conversationId), asc(juniorConversationEvents.actorIdentityId), diff --git a/packages/junior/src/chat/conversations/sql/history.ts b/packages/junior/src/chat/conversations/sql/history.ts index 7306e4c24a..8161f95a60 100644 --- a/packages/junior/src/chat/conversations/sql/history.ts +++ b/packages/junior/src/chat/conversations/sql/history.ts @@ -239,16 +239,16 @@ class SqlConversationEventStore implements ConversationEventStore { actorIdentityId, ), ); - if (actorIdentityId) { - await recordConversationParticipant(this.executor, { - actorIdentityId, + } + await this.executor.db().insert(juniorConversationEvents).values(rows); + for (const event of pending) { + if (event.data.type === "turn_started") { + await this.recordTurnInputParticipants( conversationId, - atMs: event.createdAtMs, - restoreArchive: eventUnarchivesConversation(event.data), - }); + event.data.inputMessageIds, + ); } } - await this.executor.db().insert(juniorConversationEvents).values(rows); return rows.map((row) => ({ seq: row.seq, historyVersion: row.historyVersion, @@ -257,6 +257,41 @@ class SqlConversationEventStore implements ConversationEventStore { ); } + /** + * Add the authors of Turn input messages to the conversation. + * Only people who start a Turn join it. Thread context, such as backfilled + * or skipped messages, keeps its author without membership. + */ + private async recordTurnInputParticipants( + conversationId: string, + inputMessageIds: readonly string[], + ): Promise { + const rows = await this.executor + .db() + .select() + .from(juniorConversationEvents) + .where( + and( + eq(juniorConversationEvents.conversationId, conversationId), + eq(juniorConversationEvents.type, "message"), + inArray( + sql`${juniorConversationEvents.payload}->>'messageId'`, + [...inputMessageIds], + ), + ), + ); + for (const row of rows) { + const { data } = eventFromRow(row); + if (!row.actorIdentityId || data.type !== "message") continue; + await recordConversationParticipant(this.executor, { + actorIdentityId: row.actorIdentityId, + conversationId, + atMs: row.createdAt.getTime(), + restoreArchive: eventUnarchivesConversation(data), + }); + } + } + async replaceHistory( conversationId: string, replacement: HistoryReplacement, diff --git a/packages/junior/src/db/schema/conversation-participants.ts b/packages/junior/src/db/schema/conversation-participants.ts index 23d20bd2d2..40774ed0c4 100644 --- a/packages/junior/src/db/schema/conversation-participants.ts +++ b/packages/junior/src/db/schema/conversation-participants.ts @@ -5,8 +5,8 @@ import { timestamptz } from "./timestamps"; /** * Materialized personal-feed membership for one linked user on one root - * conversation. Rows come from the root actor and from durable human user - * message authors. + * conversation. Rows come from the root actor and from the authors of Turn + * input messages. Thread context authors do not get a row. */ export const juniorConversationParticipants = pgTable( "junior_conversation_participants", diff --git a/packages/junior/tests/integration/api/conversations/list.test.ts b/packages/junior/tests/integration/api/conversations/list.test.ts index 84d63c506d..6974301a2d 100644 --- a/packages/junior/tests/integration/api/conversations/list.test.ts +++ b/packages/junior/tests/integration/api/conversations/list.test.ts @@ -938,21 +938,37 @@ describe("conversation list API", () => { .limit(1); expect(event?.actorIdentityId).toBe("identity-dashboard-participant"); - const participantRows = await fixture.sql - .db() - .select({ - userId: juniorConversationParticipants.userId, - }) - .from(juniorConversationParticipants) - .where( - eq( - juniorConversationParticipants.rootConversationId, - "slack:C1:dashboard-author", - ), - ); - expect(participantRows.map((row) => row.userId)).toContain( - participantUserId, - ); + const readParticipantUserIds = async () => + ( + await fixture.sql + .db() + .select({ + userId: juniorConversationParticipants.userId, + }) + .from(juniorConversationParticipants) + .where( + eq( + juniorConversationParticipants.rootConversationId, + "slack:C1:dashboard-author", + ), + ) + ).map((row) => row.userId); + // A message that does not start a Turn is context, not membership. + expect(await readParticipantUserIds()).not.toContain(participantUserId); + + await eventStore.append("slack:C1:dashboard-author", [ + { + createdAtMs: 5_600, + data: { + inputMessageIds: ["dashboard-msg-1"], + surface: "api", + turnId: "turn-dashboard-msg-1", + type: "turn_started", + }, + idempotencyKey: "turn:turn-dashboard-msg-1:started", + }, + ]); + expect(await readParticipantUserIds()).toContain(participantUserId); const feed = await readConversationFeedFromSql({ viewer: { From 55ae52ff213a68524785baebf5f4e67e74515242 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:43:46 +0000 Subject: [PATCH 2/2] test(evals): Check that people with their own Turn become participants Co-Authored-By: David Cramer <23610+dcramer@users.noreply.github.com> --- .../evals/integration/conversation/steering.eval.ts | 4 ++++ packages/junior-evals/src/fixture/agent.ts | 5 +++++ packages/junior-evals/src/fixture/results.ts | 7 +++++++ 3 files changed, 16 insertions(+) diff --git a/packages/junior-evals/evals/integration/conversation/steering.eval.ts b/packages/junior-evals/evals/integration/conversation/steering.eval.ts index 5ca559edcb..ed7ead94c0 100644 --- a/packages/junior-evals/evals/integration/conversation/steering.eval.ts +++ b/packages/junior-evals/evals/integration/conversation/steering.eval.ts @@ -80,6 +80,10 @@ describe("Slack Turn Steering", () => { "succeeded", "succeeded", ]); + // Each person whose mention got its own turn joins the conversation. + expect(conversation.participantSlackUserIds).toEqual( + expect.arrayContaining(["U0SAM", "U0RIO", "U0KAI"]), + ); }); test("when cross-actor steering is configured, another person's mention joins the running turn", async ({ diff --git a/packages/junior-evals/src/fixture/agent.ts b/packages/junior-evals/src/fixture/agent.ts index 586020d5af..9078c6060c 100644 --- a/packages/junior-evals/src/fixture/agent.ts +++ b/packages/junior-evals/src/fixture/agent.ts @@ -59,6 +59,7 @@ import { readCallEvents, readFailedCallRun, readConversationDetail, + participantSlackUserIds, slackCallReplies, toHarnessRun, VIEWER_HEADER, @@ -138,6 +139,8 @@ export interface Conversation extends HarnessRun { threadTitles: string[]; /** The title that the dashboard shows after the call. */ title: string; + /** Slack user ids of the participants that the dashboard lists after the call. */ + participantSlackUserIds: string[]; /** * The label of the connect prompt that the dashboard shows to the person * after the call, when a turn waits for their authorization. @@ -745,6 +748,7 @@ export async function createFixtureAgent( reactions, replies, statuses, + participantSlackUserIds: participantSlackUserIds(detail), threadTitles, title: detail.displayTitle, toolCalls: events.toolCalls, @@ -855,6 +859,7 @@ export async function createFixtureAgent( reactions: [], replies: [], statuses: [], + participantSlackUserIds: participantSlackUserIds(detail), threadTitles: [], title: detail.displayTitle, toolCalls: [], diff --git a/packages/junior-evals/src/fixture/results.ts b/packages/junior-evals/src/fixture/results.ts index 91080f8b20..59bab7b90f 100644 --- a/packages/junior-evals/src/fixture/results.ts +++ b/packages/junior-evals/src/fixture/results.ts @@ -156,6 +156,13 @@ export async function readConversationDetail( return conversationDetailReportSchema.parse(await response.json()); } +/** Slack user ids of the participants that the dashboard lists. */ +export function participantSlackUserIds(detail: ConversationDetail): string[] { + return (detail.participants ?? []).flatMap((participant) => + participant.slackUserId ? [participant.slackUserId] : [], + ); +} + type CallTranscriptSource = { conversationId: string; viewerEmail: string;