mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 09:24:41 +00:00
* 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
150 lines
4.1 KiB
TypeScript
150 lines
4.1 KiB
TypeScript
import type { StreamItem } from "@/types/stream";
|
|
import { startsNewTurn, isTurnTrigger } from "@/agent-stream/turn-membership";
|
|
|
|
export interface TurnTiming {
|
|
completedAt: Date;
|
|
durationMs: number | null;
|
|
}
|
|
|
|
export interface StreamTurnTiming {
|
|
byItemId: Map<string, TurnTiming>;
|
|
runningStartedAt: Date | null;
|
|
}
|
|
|
|
interface OpenTurn {
|
|
userAt: Date | null;
|
|
lastItemAt: Date | null;
|
|
assistantIds: readonly string[];
|
|
previousItem: StreamItem | null;
|
|
}
|
|
|
|
interface HeadTurnTiming {
|
|
additions: Map<string, TurnTiming>;
|
|
byItemId: Map<string, TurnTiming>;
|
|
}
|
|
|
|
interface TailTurnTiming {
|
|
completed: Map<string, TurnTiming>;
|
|
openTurn: OpenTurn;
|
|
lastHead: HeadTurnTiming | null;
|
|
}
|
|
|
|
const EMPTY_OPEN_TURN: OpenTurn = {
|
|
userAt: null,
|
|
lastItemAt: null,
|
|
assistantIds: [],
|
|
previousItem: null,
|
|
};
|
|
|
|
function closeTurn(turn: OpenTurn, into: Map<string, TurnTiming>): void {
|
|
if (!turn.lastItemAt) {
|
|
return;
|
|
}
|
|
const timing: TurnTiming = {
|
|
completedAt: turn.lastItemAt,
|
|
durationMs: turn.userAt ? Math.max(0, turn.lastItemAt.getTime() - turn.userAt.getTime()) : null,
|
|
};
|
|
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);
|
|
}
|
|
}
|
|
|
|
function walkTurns(
|
|
items: readonly StreamItem[],
|
|
from: OpenTurn,
|
|
into: Map<string, TurnTiming>,
|
|
): OpenTurn {
|
|
let userAt = from.userAt;
|
|
let lastItemAt = from.lastItemAt;
|
|
let assistantIds = [...from.assistantIds];
|
|
let previousItem = from.previousItem;
|
|
for (const item of items) {
|
|
if (startsNewTurn(item, previousItem)) {
|
|
closeTurn({ userAt, lastItemAt, assistantIds, previousItem }, into);
|
|
userAt = isTurnTrigger(item) ? item.timestamp : null;
|
|
lastItemAt = null;
|
|
assistantIds = [];
|
|
}
|
|
lastItemAt = item.timestamp;
|
|
if (item.kind === "assistant_message") {
|
|
assistantIds.push(item.id);
|
|
}
|
|
previousItem = item;
|
|
}
|
|
return { userAt, lastItemAt, assistantIds, previousItem };
|
|
}
|
|
|
|
function areTimingsEqual(a: Map<string, TurnTiming>, b: Map<string, TurnTiming>): boolean {
|
|
if (a.size !== b.size) {
|
|
return false;
|
|
}
|
|
for (const [id, timing] of a) {
|
|
const other = b.get(id);
|
|
if (
|
|
!other ||
|
|
other.completedAt.getTime() !== timing.completedAt.getTime() ||
|
|
other.durationMs !== timing.durationMs
|
|
) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// The tail walk is cached by array identity so a streamed delta, which only replaces the
|
|
// head, walks the head from the carried open turn. The returned Map keeps its identity
|
|
// while the head adds no completed turn, so memos keyed on it do not miss per tick.
|
|
const tailTurnTimingCache = new WeakMap<StreamItem[], TailTurnTiming>();
|
|
|
|
function getTailTurnTiming(tail: StreamItem[]): TailTurnTiming {
|
|
const cached = tailTurnTimingCache.get(tail);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const completed = new Map<string, TurnTiming>();
|
|
const openTurn = walkTurns(tail, EMPTY_OPEN_TURN, completed);
|
|
const timing: TailTurnTiming = { completed, openTurn, lastHead: null };
|
|
tailTurnTimingCache.set(tail, timing);
|
|
return timing;
|
|
}
|
|
|
|
function resolveByItemId(
|
|
tail: TailTurnTiming,
|
|
additions: Map<string, TurnTiming>,
|
|
): Map<string, TurnTiming> {
|
|
if (additions.size === 0) {
|
|
return tail.completed;
|
|
}
|
|
if (tail.lastHead && areTimingsEqual(tail.lastHead.additions, additions)) {
|
|
return tail.lastHead.byItemId;
|
|
}
|
|
const byItemId = new Map(tail.completed);
|
|
for (const [id, timing] of additions) {
|
|
byItemId.set(id, timing);
|
|
}
|
|
tail.lastHead = { additions, byItemId };
|
|
return byItemId;
|
|
}
|
|
|
|
export function deriveStreamTurnTiming(params: {
|
|
isTurnActive: boolean;
|
|
activeTurnStartedAt: Date | null;
|
|
tail: StreamItem[];
|
|
head: StreamItem[];
|
|
}): StreamTurnTiming {
|
|
const tail = getTailTurnTiming(params.tail);
|
|
const additions = new Map<string, TurnTiming>();
|
|
const openTurn = walkTurns(params.head, tail.openTurn, additions);
|
|
if (!params.isTurnActive) {
|
|
closeTurn(openTurn, additions);
|
|
}
|
|
return {
|
|
byItemId: resolveByItemId(tail, additions),
|
|
runningStartedAt: params.isTurnActive ? params.activeTurnStartedAt : null,
|
|
};
|
|
}
|