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
181 changes: 181 additions & 0 deletions apps/server/test/services/threads/timeline-in-turn-window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,187 @@ function walkAllPages(
}

describe("in-turn timeline windows", () => {
it("keeps an accepted steer out of details for work that spans the steer", () => {
const { db, thread } = setup();
const initialRequestId = requestId(1);
const steerRequestId = requestId(2);
const turnId = "turn-1";
const commandId = "command-1";

insertEvents(db, noopNotifier, [
{
threadId: thread.id,
sequence: 1,
type: "client/turn/requested",
scope: threadScope(),
itemId: null,
itemKind: null,
parentToolCallId: null,
data: JSON.stringify({
direction: "outbound",
source: "tell",
initiator: "user",
request: { method: "turn/start", params: {} },
requestId: initialRequestId,
senderThreadId: null,
input: [{ type: "text", text: "Run the command", mentions: [] }],
target: { kind: "thread-start" },
execution,
}),
},
{
threadId: thread.id,
sequence: 2,
type: "turn/started",
scope: turnScope(turnId),
providerThreadId,
itemId: null,
itemKind: null,
parentToolCallId: null,
data: JSON.stringify({}),
},
{
threadId: thread.id,
sequence: 3,
type: "turn/input/accepted",
scope: turnScope(turnId),
providerThreadId,
itemId: null,
itemKind: null,
parentToolCallId: null,
data: JSON.stringify({ clientRequestId: initialRequestId }),
},
{
threadId: thread.id,
sequence: 4,
type: "item/started",
scope: turnScope(turnId),
providerThreadId,
itemId: commandId,
itemKind: "commandExecution",
parentToolCallId: null,
data: JSON.stringify({
item: {
type: "commandExecution",
id: commandId,
command: "sleep 20",
cwd: "/tmp/test",
status: "pending",
approvalStatus: null,
},
}),
},
{
threadId: thread.id,
sequence: 5,
type: "client/turn/requested",
scope: threadScope(),
itemId: null,
itemKind: null,
parentToolCallId: null,
data: JSON.stringify({
direction: "outbound",
source: "tell",
initiator: "user",
request: { method: "turn/start", params: {} },
requestId: steerRequestId,
senderThreadId: null,
input: [
{
type: "text",
text: "Stop waiting and answer immediately.",
mentions: [],
},
],
target: { kind: "steer", expectedTurnId: turnId },
execution,
}),
},
{
threadId: thread.id,
sequence: 6,
type: "turn/input/accepted",
scope: turnScope(turnId),
providerThreadId,
itemId: null,
itemKind: null,
parentToolCallId: null,
data: JSON.stringify({ clientRequestId: steerRequestId }),
},
{
threadId: thread.id,
sequence: 7,
type: "item/completed",
scope: turnScope(turnId),
providerThreadId,
itemId: commandId,
itemKind: "commandExecution",
parentToolCallId: null,
data: JSON.stringify({
item: {
type: "commandExecution",
id: commandId,
command: "sleep 20",
cwd: "/tmp/test",
status: "completed",
approvalStatus: null,
exitCode: 0,
aggregatedOutput: "",
},
}),
},
{
threadId: thread.id,
sequence: 8,
type: "turn/completed",
scope: turnScope(turnId),
providerThreadId,
itemId: null,
itemKind: null,
parentToolCallId: null,
data: JSON.stringify({ status: "completed", providerThreadId }),
},
]);

const timeline = buildPage(db, thread, LARGE_BUDGET, null).response;
const turnRow = timeline.rows.find(
(row): row is Extract<TimelineRow, { kind: "turn" }> =>
row.kind === "turn",
);
expect(turnRow).toBeDefined();
if (!turnRow) {
throw new Error("expected a turn row");
}
expect(
timeline.rows.filter(
(row) =>
row.kind === "conversation" &&
row.role === "user" &&
row.turnRequest?.kind === "steer",
),
).toHaveLength(1);

const details = buildTimelineTurnSummaryDetails(db, thread, {
includeProviderUnhandledOperations: false,
sourceSeqEnd: turnRow.sourceSeqEnd,
sourceSeqStart: turnRow.sourceSeqStart,
turnId: turnRow.turnId,
});
expect(
details.rows.filter(
(row) =>
row.kind === "conversation" &&
row.role === "user" &&
row.turnRequest?.kind === "steer",
),
).toEqual([]);
expect(
details.rows.filter(
(row) => row.kind === "work" && row.workKind === "command",
),
).toHaveLength(1);
});

it("bounds a running turn that is larger than the whole budget", () => {
const { db, thread } = setup();
// One 300-item turn still running: no user message inside it to cut on.
Expand Down
18 changes: 17 additions & 1 deletion packages/thread-view/src/build-thread-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1281,6 +1281,16 @@ function hasTurnSummaryRows(rows: TimelineRow[]): boolean {
return rows.some((row) => row.kind === "turn");
}

function isAcceptedHumanSteerRow(row: TimelineRow): boolean {
return (
row.kind === "conversation" &&
row.role === "user" &&
row.initiator === "user" &&
row.turnRequest?.kind === "steer" &&
row.turnRequest.status === "accepted"
);
}

function collectExternalUserBoundarySeqs(
projection: EventProjection,
): number[] {
Expand Down Expand Up @@ -1476,6 +1486,12 @@ export function buildThreadTimelineTurnDetailsFromEvents(

return {
kind: "ungrouped",
rows: nestedRows,
// A work item can begin before a steer and complete after it, so the
// summary's source range necessarily overlaps the steer. Lazy details do
// not include the later turn/completed event and therefore project that
// slice as ungrouped rows. Accepted human steers belong to the root
// timeline, just as they do when children are built eagerly; returning one
// here would render the same row both inside and outside the summary.
rows: nestedRows.filter((row) => !isAcceptedHumanSteerRow(row)),
};
}
Loading