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
194 changes: 194 additions & 0 deletions apps/server/test/services/threads/conversation-outline-parity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, expect, it } from "vitest";
import { threadScope, turnScope } from "@bb/domain";
import {
createConnection,
createProject,
createThread,
insertEvents,
migrate,
noopNotifier,
upsertHost,
} from "@bb/db";
import {
buildThreadConversationOutline,
buildThreadTimeline,
} from "../../../src/services/threads/timeline.js";

type EventInput = Parameters<typeof insertEvents>[2][number];

function setup() {
const db = createConnection(":memory:");
migrate(db);
const host = upsertHost(db, noopNotifier, {
name: "test-host",
type: "persistent",
});
const { project } = createProject(db, noopNotifier, {
name: "test-project",
source: { type: "local_path", hostId: host.id, path: "/tmp/test" },
});
const thread = createThread(db, noopNotifier, {
projectId: project.id,
providerId: "claude-code",
});
return { db, thread };
}

function agentMessage(
threadId: string,
sequence: number,
text: string,
): EventInput {
return {
threadId,
sequence,
type: "item/completed",
scope: turnScope("turn-1"),
providerThreadId: "provider-thread-1",
itemId: `message-${sequence}`,
itemKind: "agentMessage",
data: JSON.stringify({
item: { id: `message-${sequence}`, type: "agentMessage", text },
}),
};
}

function userQuestionLifecycle(
threadId: string,
sequence: number,
status: "pending" | "resolved",
): EventInput {
return {
threadId,
sequence,
type: "system/userQuestion/lifecycle",
scope: turnScope("turn-1"),
itemId: null,
itemKind: null,
data: JSON.stringify({
interactionId: "pi-user-question",
providerId: "claude-code",
providerRequestId: "request-user-question",
status,
resolution:
status === "resolved"
? {
kind: "user_answer",
answers: { "question-1": { selected: ["staging"] } },
}
: null,
statusReason: null,
payload: {
kind: "user_question",
questions: [
{
id: "question-1",
prompt: "Which deployment target should I use?",
shortLabel: "Target",
multiSelect: false,
options: [
{ value: "staging", label: "Staging" },
{ value: "production", label: "Production" },
],
allowFreeText: false,
},
],
},
}),
};
}

describe("thread conversation outline parity", () => {
it("exposes the same conversation rows as the timeline after an answered question", () => {
const { db, thread } = setup();
const requestId = "creq_23456789ab";
insertEvents(db, noopNotifier, [
{
threadId: thread.id,
sequence: 1,
type: "client/turn/requested",
scope: threadScope(),
itemId: null,
itemKind: null,
data: JSON.stringify({
direction: "outbound",
requestId,
source: "tell",
initiator: "user",
input: [{ type: "text", text: "Audit the router", mentions: [] }],
target: { kind: "new-turn" },
request: { method: "turn/start", params: {} },
execution: {
model: "claude-opus-4",
reasoningLevel: "medium",
permissionMode: "workspace-write",
source: "client/turn/requested",
serviceTier: "default",
},
}),
},
{
threadId: thread.id,
sequence: 2,
type: "turn/started",
scope: turnScope("turn-1"),
providerThreadId: "provider-thread-1",
itemId: null,
itemKind: null,
data: JSON.stringify({}),
},
{
threadId: thread.id,
sequence: 3,
type: "turn/input/accepted",
scope: turnScope("turn-1"),
providerThreadId: "provider-thread-1",
itemId: null,
itemKind: null,
data: JSON.stringify({ clientRequestId: requestId }),
},
userQuestionLifecycle(thread.id, 4, "pending"),
userQuestionLifecycle(thread.id, 5, "resolved"),
agentMessage(thread.id, 6, "Staging it is."),
agentMessage(thread.id, 7, "Login works."),
agentMessage(thread.id, 8, "Audit complete."),
agentMessage(thread.id, 9, "Final runbook."),
{
threadId: thread.id,
sequence: 10,
type: "turn/completed",
scope: turnScope("turn-1"),
providerThreadId: "provider-thread-1",
itemId: null,
itemKind: null,
data: JSON.stringify({ status: "completed" }),
},
]);

const timeline = buildThreadTimeline(db, thread, {
eventBudget: 1_000_000,
includeProviderUnhandledOperations: false,
includeNestedRows: false,
maxInlineOutputChars: null,
maxSeq: 10,
page: { kind: "latest", segmentLimit: 20 },
});
const timelineConversation = timeline.rows.flatMap((row) =>
row.kind === "conversation" ? [{ id: row.id, text: row.text }] : [],
);
// The answered question splits the turn: the direct reply and the
// segment-final message are visible, the interim one stays folded.
expect(timelineConversation.map((row) => row.text)).toEqual([
"Audit the router",
"Staging it is.",
"Audit complete.",
"Final runbook.",
]);

const outline = buildThreadConversationOutline(db, thread, { maxSeq: 10 });
expect(
outline.items.map((item) => ({ id: item.id, text: item.preview })),
).toEqual(timelineConversation);
db.$client.close();
});
});
4 changes: 4 additions & 0 deletions packages/db/src/data/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2529,6 +2529,10 @@ export function listStoredConversationOutlineEventRows(
"system/thread/interrupted",
"system/error",
"provider/error",
// An answered question splits a completed turn's folded summary exactly
// like accepted user input does; the outline must see it to keep the
// assistant rows it exposes in step with the main timeline.
"system/userQuestion/lifecycle",
"item/agentMessage/delta",
"item/plan/delta",
] satisfies ThreadEventType[];
Expand Down
1 change: 1 addition & 0 deletions packages/db/test/query-plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ describe("slow query index plans", () => {
"system/thread/interrupted",
"system/error",
"provider/error",
"system/userQuestion/lifecycle",
"item/agentMessage/delta",
"item/plan/delta",
thread.id,
Expand Down
43 changes: 37 additions & 6 deletions packages/thread-view/src/completed-turn-grouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { getMessageStartedAt } from "./format-helpers.js";
import {
findLastTerminalTimelineMessage,
isSingletonContextManagementOperation,
isTimelineTerminalMessage,
isMidTurnUserInputBoundaryMessage,
isTimelineUngroupableMessage,
} from "./timeline-message-helpers.js";

Expand Down Expand Up @@ -167,6 +169,9 @@ function groupCompletedTurnSummaryMessages(
let groupedMessages: EventProjectionMessage[] = [];
let segmentIndex = 0;
let externalBoundaryIndex = 0;
let preserveNextTerminalMessage = false;
let sawMidTurnUserInput = false;
const phase = { sawTurnOutput: false };

function appendSummaryGroup(sourceMessages: EventProjectionMessage[]): void {
if (sourceMessages.length === 0) {
Expand All @@ -190,9 +195,14 @@ function groupCompletedTurnSummaryMessages(
return;
}

// Human follow-ups split one provider turn into multiple visible exchange
// segments. Keep each segment's last assistant/error message beside the
// user row instead of burying it inside that segment's collapsed summary.
// Mid-turn human follow-ups split one provider turn into multiple visible
// exchange segments. Keep each segment's last assistant/error message
// beside the user row instead of burying it inside that segment's
// collapsed summary. The first assistant/error message after mid-turn user
// input is kept too — it is the direct reply the user already read while
// the turn was streaming. Initial user rows (before any output) must not
// trigger any of this, or every ordinary turn would surface its interim
// messages.
const sourceMessages = groupedMessages;
groupedMessages = [];
const terminalMessage = preserveLastTerminalMessage
Expand Down Expand Up @@ -222,19 +232,40 @@ function groupCompletedTurnSummaryMessages(
) {
flushGroupedMessages(true);
externalBoundaryIndex += 1;
preserveNextTerminalMessage = true;
sawMidTurnUserInput = true;
}
}

for (const message of summaryMessages) {
flushExternalBoundariesBefore(message);
if (isTimelineUngroupableMessage(message)) {
flushGroupedMessages(
message.kind === "user" && message.initiator === "user",
const isMidTurnUserInput = isMidTurnUserInputBoundaryMessage(
message,
phase,
);
flushGroupedMessages(isMidTurnUserInput);
items.push({
kind: "ungrouped-message",
message,
});
if (isMidTurnUserInput) {
preserveNextTerminalMessage = true;
sawMidTurnUserInput = true;
// An answered question is provider output too; a user request that
// follows it is a mid-turn follow-up even with no assistant text yet.
phase.sawTurnOutput = true;
}
continue;
}
phase.sawTurnOutput = true;
if (preserveNextTerminalMessage && isTimelineTerminalMessage(message)) {
flushGroupedMessages();
items.push({
kind: "ungrouped-message",
message,
});
preserveNextTerminalMessage = false;
continue;
}
groupedMessages.push(message);
Expand All @@ -244,7 +275,7 @@ function groupCompletedTurnSummaryMessages(
flushGroupedMessages(true);
externalBoundaryIndex += 1;
}
flushGroupedMessages();
flushGroupedMessages(sawMidTurnUserInput);
return applySingleSummaryTurnBounds(turn, items);
}

Expand Down
66 changes: 66 additions & 0 deletions packages/thread-view/src/timeline-message-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,75 @@ export function isTimelineUngroupableMessage(
if (message.kind === "assistant-text") {
return message.isLegacyUserMessage === true;
}
if (message.kind === "user-question-lifecycle") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🚨 slopcop/review — The conversation outline lacks question lifecycle rows

This new grouping rule needs system/userQuestion/lifecycle events. The full conversation-outline query does not load that type, so its projection can fold assistant rows that the main timeline exposes. Add the lifecycle type to listStoredConversationOutlineEventRows. Add a server test that compares outline and timeline conversation rows for an answered question.

return message.lifecycle === "answered";
}
return message.kind === "debug/raw-event";
}

/**
* Human input that the provider actually received during a turn. Only this
* input can split a completed turn into visible exchange segments.
*
* - `accepted-user-request`: a human-sent row the provider accepted. Pending
* or rejected rows never reached the provider, so the assistant output that
* follows them answers nothing the user said.
* - `answered-question`: a provider-initiated question the human answered.
* - `none`: everything else, including agent/system steers.
*/
export type TimelineUserInputSource =
| "accepted-user-request"
| "answered-question"
| "none";

export function getTimelineUserInputSource(
message: EventProjectionMessage,
): TimelineUserInputSource {
if (message.kind === "user") {
return message.initiator === "user" &&
message.turnRequest.status === "accepted"
? "accepted-user-request"
: "none";
}
if (
message.kind === "user-question-lifecycle" &&
message.lifecycle === "answered"
) {
return "answered-question";
}
return "none";
}

export interface TimelineTurnPhase {
/**
* True once the turn has produced assistant/tool output. User input that
* precedes any output (the turn-starting row, or several initial rows from
* a grouped `inputGroups` request) is initial input, not a mid-turn
* follow-up.
*/
sawTurnOutput: boolean;
}

/**
* Whether `message` is mid-turn human input that starts a new exchange
* segment inside a completed turn. An accepted user request is a boundary
* only after the turn has produced output. An answered question is always a
* boundary: the provider asked it, so it is itself turn output.
*/
export function isMidTurnUserInputBoundaryMessage(
message: EventProjectionMessage,
phase: TimelineTurnPhase,
): boolean {
switch (getTimelineUserInputSource(message)) {
case "accepted-user-request":
return phase.sawTurnOutput;
case "answered-question":
return true;
case "none":
return false;
}
}

export function isTimelineSummaryCountedMessage(
message: EventProjectionMessage,
): boolean {
Expand Down
Loading
Loading