mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
Keep conversation turns distinct and history stable (#7990)
* fix(app): keep autonomous turns distinct and readable Hidden system prompts merged autonomous work into the previous response and reopened completed turns. Preserve canonical turn boundaries and expose their triggers, while making received messages attributable and expanded turn details readable in one step. * fix(timeline): mount history at actual run boundaries * fix(timeline): load and retain complete available turns * fix(app): summarize actions and preserve conversation events * test(timeline): verify whole-turn paging stays responsive * fix(app): group reasoning according to thinking preference * test(app): align history journeys with whole-turn loading
This commit is contained in:
@@ -15,7 +15,7 @@ describe("requestTimelineReplacement", () => {
|
||||
await expect(requestTimelineReplacement(client, "agent-1")).rejects.toThrow("fetch complete");
|
||||
|
||||
expect(requests).toEqual([
|
||||
["agent-1", { direction: "tail", projection: "projected", limit: 40 }],
|
||||
["agent-1", { direction: "tail", projection: "projected", wholeTurns: true, limit: 40 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("timeline sync planning", () => {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +27,7 @@ describe("timeline sync planning", () => {
|
||||
cursor: { epoch: "epoch-1", seq: 49 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,6 +39,7 @@ describe("timeline sync planning", () => {
|
||||
cursor: { epoch: "epoch-1", seq: 25 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,6 +52,7 @@ describe("timeline sync planning", () => {
|
||||
},
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
mergeWindow: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ProjectedTimelineTailFetchPlan {
|
||||
direction: "tail";
|
||||
limit: number;
|
||||
projection: "projected";
|
||||
wholeTurns: true;
|
||||
}
|
||||
|
||||
export interface ProjectedTimelineAfterFetchPlan {
|
||||
@@ -16,6 +17,7 @@ export interface ProjectedTimelineAfterFetchPlan {
|
||||
cursor: TimelineSyncCursor;
|
||||
limit: number;
|
||||
projection: "projected";
|
||||
wholeTurns: true;
|
||||
}
|
||||
|
||||
export interface ProjectedTimelineBeforeFetchPlan {
|
||||
@@ -23,6 +25,7 @@ export interface ProjectedTimelineBeforeFetchPlan {
|
||||
cursor: TimelineSyncCursor;
|
||||
limit: number;
|
||||
projection: "projected";
|
||||
wholeTurns: true;
|
||||
}
|
||||
|
||||
export type ProjectedTimelineFetchPlan =
|
||||
@@ -40,6 +43,7 @@ export function planTimelineCatchUpAfter(cursor: TimelineSyncCursor) {
|
||||
cursor,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -48,6 +52,7 @@ export function planTimelineTailFetch() {
|
||||
direction: "tail",
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -65,6 +70,7 @@ export function planTimelineOlderFetch(cursor: TimelineSyncCursor) {
|
||||
cursor,
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -75,6 +81,7 @@ export function planTimelinePromptJump(target: TimelineSyncCursor) {
|
||||
cursor: { epoch: target.epoch, seq: target.seq + newerRows + 1 },
|
||||
limit: TIMELINE_FETCH_PAGE_SIZE,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
mergeWindow: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
});
|
||||
|
||||
assert.equal(timing.runningStartedAt, secondUserAt);
|
||||
assert.equal(timing.byAssistantId.has("a2"), false);
|
||||
assert.equal(timing.byItemId.has("a2"), false);
|
||||
});
|
||||
|
||||
it("derives completed turn timing from user and assistant item timestamps", () => {
|
||||
@@ -69,7 +69,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
head: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(timing.byAssistantId.get("a1"), {
|
||||
assert.deepEqual(timing.byItemId.get("a1"), {
|
||||
completedAt: assistantAt,
|
||||
durationMs: 7000,
|
||||
});
|
||||
@@ -95,8 +95,8 @@ describe("deriveStreamTurnTiming", () => {
|
||||
completedAt: lastAssistantAt,
|
||||
durationMs: 7000,
|
||||
};
|
||||
assert.deepEqual(timing.byAssistantId.get("a1"), expected);
|
||||
assert.deepEqual(timing.byAssistantId.get("a2"), expected);
|
||||
assert.deepEqual(timing.byItemId.get("a1"), expected);
|
||||
assert.deepEqual(timing.byItemId.get("a2"), expected);
|
||||
});
|
||||
|
||||
it("preserves the completion timestamp when a canonical turn has no visible prompt", () => {
|
||||
@@ -120,7 +120,7 @@ describe("deriveStreamTurnTiming", () => {
|
||||
head: [],
|
||||
});
|
||||
|
||||
assert.deepEqual(timing.byAssistantId.get("hidden-prompt-a2"), {
|
||||
assert.deepEqual(timing.byItemId.get("hidden-prompt-a2"), {
|
||||
completedAt: hiddenPromptTurnAt,
|
||||
durationMs: null,
|
||||
});
|
||||
@@ -143,12 +143,12 @@ describe("deriveStreamTurnTiming", () => {
|
||||
const first = derive("one", "2026-05-15T00:01:04.000Z");
|
||||
const second = derive("one two", "2026-05-15T00:01:05.000Z");
|
||||
|
||||
assert.equal(second.byAssistantId, first.byAssistantId);
|
||||
assert.deepEqual(first.byAssistantId.get("a1"), {
|
||||
assert.equal(second.byItemId, first.byItemId);
|
||||
assert.deepEqual(first.byItemId.get("a1"), {
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 7000,
|
||||
});
|
||||
assert.equal(first.byAssistantId.has("a2"), false);
|
||||
assert.equal(first.byItemId.has("a2"), false);
|
||||
});
|
||||
|
||||
it("keeps the timing map identity when the head closed the tail's open turn", () => {
|
||||
@@ -168,8 +168,8 @@ describe("deriveStreamTurnTiming", () => {
|
||||
const first = derive("one", "2026-05-15T00:01:04.000Z");
|
||||
const second = derive("one two", "2026-05-15T00:01:05.000Z");
|
||||
|
||||
assert.equal(second.byAssistantId, first.byAssistantId);
|
||||
assert.deepEqual(first.byAssistantId.get("a1"), {
|
||||
assert.equal(second.byItemId, first.byItemId);
|
||||
assert.deepEqual(first.byItemId.get("a1"), {
|
||||
completedAt: lastTailAt,
|
||||
durationMs: 7000,
|
||||
});
|
||||
@@ -193,9 +193,9 @@ describe("deriveStreamTurnTiming", () => {
|
||||
head,
|
||||
});
|
||||
|
||||
assert.notEqual(completed.byAssistantId, active.byAssistantId);
|
||||
assert.equal(active.byAssistantId.has("a1"), false);
|
||||
assert.deepEqual(completed.byAssistantId.get("a1"), { completedAt, durationMs: 9000 });
|
||||
assert.notEqual(completed.byItemId, active.byItemId);
|
||||
assert.equal(active.byItemId.has("a1"), false);
|
||||
assert.deepEqual(completed.byItemId.get("a1"), { completedAt, durationMs: 9000 });
|
||||
});
|
||||
|
||||
it("derives fresh timing when the tail itself changes", () => {
|
||||
@@ -211,11 +211,39 @@ describe("deriveStreamTurnTiming", () => {
|
||||
const first = derive(firstTail);
|
||||
const second = derive(secondTail);
|
||||
|
||||
assert.notEqual(second.byAssistantId, first.byAssistantId);
|
||||
assert.equal(first.byAssistantId.has("a2"), false);
|
||||
assert.deepEqual(second.byAssistantId.get("a2"), {
|
||||
assert.notEqual(second.byItemId, first.byItemId);
|
||||
assert.equal(first.byItemId.has("a2"), false);
|
||||
assert.deepEqual(second.byItemId.get("a2"), {
|
||||
completedAt: secondTail[3]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("times a heartbeat that ends with tools and no assistant text", () => {
|
||||
const trigger: StreamItem = {
|
||||
kind: "tool_call",
|
||||
id: "trigger",
|
||||
timestamp: new Date("2026-05-15T00:00:00Z"),
|
||||
turnId: "heartbeat",
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: "trigger",
|
||||
name: "heartbeat",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text: "Check progress" },
|
||||
},
|
||||
},
|
||||
};
|
||||
const tool: StreamItem = { ...trigger, id: "last", timestamp: new Date("2026-05-15T00:00:05Z") };
|
||||
const timing = deriveStreamTurnTiming({
|
||||
tail: [trigger, tool],
|
||||
head: [],
|
||||
isTurnActive: false,
|
||||
activeTurnStartedAt: null,
|
||||
});
|
||||
assert.equal(timing.byItemId.get("last")?.durationMs, 5000);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { startsNewTurn } from "@/agent-stream/turn-membership";
|
||||
import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership";
|
||||
|
||||
export interface TurnTiming {
|
||||
completedAt: Date;
|
||||
@@ -7,7 +7,7 @@ export interface TurnTiming {
|
||||
}
|
||||
|
||||
export interface StreamTurnTiming {
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
byItemId: Map<string, TurnTiming>;
|
||||
runningStartedAt: Date | null;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ interface OpenTurn {
|
||||
|
||||
interface HeadTurnTiming {
|
||||
additions: Map<string, TurnTiming>;
|
||||
byAssistantId: Map<string, TurnTiming>;
|
||||
byItemId: Map<string, TurnTiming>;
|
||||
}
|
||||
|
||||
interface TailTurnTiming {
|
||||
@@ -37,14 +37,18 @@ const EMPTY_OPEN_TURN: OpenTurn = {
|
||||
};
|
||||
|
||||
function closeTurn(turn: OpenTurn, into: Map<string, TurnTiming>): void {
|
||||
if (!turn.lastItemAt || turn.assistantIds.length === 0) {
|
||||
if (!turn.lastItemAt) {
|
||||
return;
|
||||
}
|
||||
const timing: TurnTiming = {
|
||||
completedAt: turn.lastItemAt,
|
||||
durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null,
|
||||
};
|
||||
for (const id of turn.assistantIds) {
|
||||
let footerIds = turn.assistantIds;
|
||||
if (footerIds.length === 0 && turn.previousItem?.kind === "tool_call") {
|
||||
footerIds = [turn.previousItem.id];
|
||||
}
|
||||
for (const id of footerIds) {
|
||||
into.set(id, timing);
|
||||
}
|
||||
}
|
||||
@@ -61,7 +65,7 @@ function walkTurns(
|
||||
for (const item of items) {
|
||||
if (startsNewTurn(item, previousItem)) {
|
||||
closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into);
|
||||
userAt = item.kind === "user_message" ? item.timestamp : null;
|
||||
userAt = isTurnTrigger(item) ? item.timestamp : null;
|
||||
lastItemAt = null;
|
||||
assistantIds = [];
|
||||
}
|
||||
@@ -108,7 +112,7 @@ function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming {
|
||||
return timing;
|
||||
}
|
||||
|
||||
function resolveByAssistantId(
|
||||
function resolveByItemId(
|
||||
tail: TailTurnTiming,
|
||||
additions: Map<string, TurnTiming>,
|
||||
): Map<string, TurnTiming> {
|
||||
@@ -116,14 +120,14 @@ function resolveByAssistantId(
|
||||
return tail.completed;
|
||||
}
|
||||
if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) {
|
||||
return tail.lastHead.byAssistantId;
|
||||
return tail.lastHead.byItemId;
|
||||
}
|
||||
const byAssistantId = new Map(tail.completed);
|
||||
const byItemId = new Map(tail.completed);
|
||||
for (const [id, timing] of additions) {
|
||||
byAssistantId.set(id, timing);
|
||||
byItemId.set(id, timing);
|
||||
}
|
||||
tail.lastHead = { additions, byAssistantId };
|
||||
return byAssistantId;
|
||||
tail.lastHead = { additions, byItemId };
|
||||
return byItemId;
|
||||
}
|
||||
|
||||
export function deriveStreamTurnTiming(params: {
|
||||
@@ -139,7 +143,7 @@ export function deriveStreamTurnTiming(params: {
|
||||
closeTurn(openTurn, additions);
|
||||
}
|
||||
return {
|
||||
byAssistantId: resolveByAssistantId(tail, additions),
|
||||
byItemId: resolveByItemId(tail, additions),
|
||||
runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ class TimelineWorld {
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
} finally {
|
||||
this.forcedTimelineTailReplacements.delete(agentId);
|
||||
@@ -341,7 +342,12 @@ test("uses a tail fetch when an agent becomes visible", async () => {
|
||||
membership.succeed();
|
||||
|
||||
const fetch = await world.nextFetch("agent-a");
|
||||
expect(fetch.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
|
||||
expect(fetch.request).toEqual({
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
|
||||
@@ -366,6 +372,7 @@ test("loads the cache before choosing the authoritative network request", async
|
||||
cursor: { epoch: "cached-epoch", seq: 17 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
@@ -384,6 +391,7 @@ test("catches up after the restored cursor when an agent becomes visible", async
|
||||
cursor: { epoch: "epoch-agent-a", seq: 42 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
fetch.respond({ hasNewer: false });
|
||||
});
|
||||
@@ -401,7 +409,12 @@ test("falls back to the latest tail when a restored cursor has more than one cat
|
||||
probe.respond({ hasNewer: true, seq: 82 });
|
||||
|
||||
const fallback = await world.nextFetch("agent-a");
|
||||
expect(fallback.request).toEqual({ direction: "tail", limit: 40, projection: "projected" });
|
||||
expect(fallback.request).toEqual({
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(
|
||||
world.applyTimelineResponse({
|
||||
requestId: "fallback-tail",
|
||||
@@ -444,6 +457,7 @@ test("a gap absorbed by a running tail is recovered after the tail completes", a
|
||||
cursor: { epoch: "epoch-agent-a", seq: 9 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
recovery.respond({ hasNewer: false });
|
||||
await vi.waitFor(() => expect(world.sync.getAgentTimelineStatus("agent-a")).toBe("ready"));
|
||||
@@ -527,6 +541,7 @@ test("unchanged visible-set publication does not cancel paged catch-up", async (
|
||||
cursor: { epoch: "epoch-agent-a", seq: 5 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
});
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
@@ -897,12 +912,14 @@ test("gap recovery supersedes completed catch-up and pages through the current t
|
||||
cursor: { epoch: "epoch-agent-a", seq: 10 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
{
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 15 },
|
||||
limit: 40,
|
||||
projection: "projected",
|
||||
wholeTurns: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user