Allow failed agent turns to be forked (#2063)
* fix(chat): allow failed turns to be forked Failed turns can end in Paseo-generated timeline items without provider message IDs. Anchor forks to canonical timeline positions while retaining legacy boundaries for compatibility. Place the fork summary before the new prompt and delimit it as structured chat history. * fix(chat): tighten fork boundary handling
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
import {
|
||||
resolveAssistantTurnBoundaryMessageId,
|
||||
resolveAssistantTurnForkBoundary,
|
||||
} from "./turn-boundary";
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
@@ -62,3 +65,65 @@ describe("resolveAssistantTurnBoundaryMessageId", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAssistantTurnForkBoundary", () => {
|
||||
it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => {
|
||||
const failedTurn = {
|
||||
...assistantMessage("assistant-error", 2),
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnForkBoundary({
|
||||
items: [userMessage("user-1", 1), failedTurn],
|
||||
startIndex: 1,
|
||||
supportsTimelineCursor: true,
|
||||
}),
|
||||
).toEqual({
|
||||
boundaryCursor: { epoch: "timeline-1", seq: 42 },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes the provider message id with a supported timeline cursor", () => {
|
||||
const selected = {
|
||||
...assistantMessage("assistant-1", 2, "msg-assistant-1"),
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnForkBoundary({
|
||||
items: [selected],
|
||||
startIndex: 0,
|
||||
supportsTimelineCursor: true,
|
||||
}),
|
||||
).toEqual({
|
||||
boundaryCursor: { epoch: "timeline-1", seq: 42 },
|
||||
boundaryMessageId: "msg-assistant-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the provider message id when timeline cursors are unsupported", () => {
|
||||
const selected = {
|
||||
...assistantMessage("assistant-1", 2, "msg-assistant-1"),
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveAssistantTurnForkBoundary({
|
||||
items: [selected],
|
||||
startIndex: 0,
|
||||
supportsTimelineCursor: false,
|
||||
}),
|
||||
).toEqual({ boundaryMessageId: "msg-assistant-1" });
|
||||
});
|
||||
|
||||
it("does not offer an unavailable boundary", () => {
|
||||
expect(
|
||||
resolveAssistantTurnForkBoundary({
|
||||
items: [assistantMessage("assistant-1", 2)],
|
||||
startIndex: 0,
|
||||
supportsTimelineCursor: false,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamItem, TimelinePosition } from "@/types/stream";
|
||||
|
||||
export type AssistantTurnForkBoundary =
|
||||
| { boundaryCursor: TimelinePosition; boundaryMessageId?: string }
|
||||
| { boundaryCursor?: undefined; boundaryMessageId: string };
|
||||
|
||||
export function resolveAssistantTurnBoundaryMessageId(input: {
|
||||
items: readonly StreamItem[];
|
||||
@@ -11,3 +15,21 @@ export function resolveAssistantTurnBoundaryMessageId(input: {
|
||||
// Forking without the selected assistant's durable message id would send the wrong slice.
|
||||
return item.messageId || undefined;
|
||||
}
|
||||
|
||||
export function resolveAssistantTurnForkBoundary(input: {
|
||||
items: readonly StreamItem[];
|
||||
startIndex: number;
|
||||
supportsTimelineCursor: boolean;
|
||||
}): AssistantTurnForkBoundary | undefined {
|
||||
const item = input.items[input.startIndex];
|
||||
if (item?.kind !== "assistant_message") {
|
||||
return undefined;
|
||||
}
|
||||
if (input.supportsTimelineCursor && item.timelineCursor) {
|
||||
return {
|
||||
boundaryCursor: item.timelineCursor,
|
||||
...(item.messageId ? { boundaryMessageId: item.messageId } : {}),
|
||||
};
|
||||
}
|
||||
return item.messageId ? { boundaryMessageId: item.messageId } : undefined;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
collectAssistantTurnContentForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary";
|
||||
import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary";
|
||||
import {
|
||||
AssistantTurnFooter,
|
||||
LiveElapsed,
|
||||
@@ -31,7 +31,7 @@ const workingIndicatorColorMapping = (theme: Theme) => ({
|
||||
export type TurnContentStrategy = StreamStrategy;
|
||||
export type AssistantTurnForkHandler = (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
boundary: AssistantTurnForkBoundary;
|
||||
}) => Promise<void> | void;
|
||||
|
||||
export const TurnFooter = memo(function TurnFooter({
|
||||
@@ -39,12 +39,14 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
inFlightTurnStartedAt,
|
||||
host,
|
||||
strategy,
|
||||
supportsTimelineCursor,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
isRunning: boolean;
|
||||
inFlightTurnStartedAt: Date | null;
|
||||
host: TurnFooterHost | null;
|
||||
strategy: TurnContentStrategy;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
if (isRunning) {
|
||||
@@ -63,6 +65,7 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
supportsTimelineCursor={supportsTimelineCursor}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
);
|
||||
@@ -73,12 +76,14 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
supportsTimelineCursor,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
return (
|
||||
@@ -88,6 +93,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
supportsTimelineCursor={supportsTimelineCursor}
|
||||
onForkAssistantTurn={onForkAssistantTurn}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
@@ -130,12 +136,14 @@ function CompletedTurnFooter({
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
supportsTimelineCursor,
|
||||
onForkAssistantTurn,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}) {
|
||||
const getContent = useCallback(
|
||||
@@ -147,18 +155,27 @@ function CompletedTurnFooter({
|
||||
}),
|
||||
[strategy, items, startIndex],
|
||||
);
|
||||
const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({
|
||||
const boundary = resolveAssistantTurnForkBoundary({
|
||||
items,
|
||||
startIndex,
|
||||
supportsTimelineCursor,
|
||||
});
|
||||
const handleFork = useCallback(
|
||||
(target: AssistantForkTarget) => {
|
||||
if (!boundary) {
|
||||
return;
|
||||
}
|
||||
return onForkAssistantTurn?.({ target, boundary });
|
||||
},
|
||||
[boundary, onForkAssistantTurn],
|
||||
);
|
||||
return (
|
||||
<View style={stylesheet.turnFooterSlot}>
|
||||
<AssistantTurnFooter
|
||||
getContent={getContent}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
forkBoundaryMessageId={boundaryMessageId}
|
||||
onFork={onForkAssistantTurn}
|
||||
onFork={boundary && onForkAssistantTurn ? handleFork : undefined}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -142,6 +142,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
strategy: TurnContentStrategy;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
}): ReactNode {
|
||||
if (!input.content) {
|
||||
@@ -155,6 +156,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
items={footerHost.items}
|
||||
timing={footerHost.timing}
|
||||
startIndex={footerHost.startIndex}
|
||||
supportsTimelineCursor={input.supportsTimelineCursor}
|
||||
onForkAssistantTurn={input.onForkAssistantTurn}
|
||||
/>
|
||||
) : null;
|
||||
@@ -282,6 +284,7 @@ function buildChatHistoryAttachment(input: {
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
boundaryMessageId: input.payload.boundaryMessageId,
|
||||
boundaryCursor: input.payload.boundaryCursor,
|
||||
itemCount: input.payload.itemCount,
|
||||
},
|
||||
};
|
||||
@@ -369,6 +372,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
!readOnly &&
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
|
||||
);
|
||||
const supportsAgentForkContextCursor = useSessionStore(
|
||||
(state) =>
|
||||
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true,
|
||||
);
|
||||
|
||||
const workspaceRoot = context.cwd?.trim() || "";
|
||||
const { requestDirectoryListing } = useFileExplorerActions({
|
||||
@@ -466,7 +473,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
});
|
||||
|
||||
const handleForkAssistantTurn: AssistantTurnForkHandler = useStableEvent(
|
||||
async ({ target, boundaryMessageId }) => {
|
||||
async ({ target, boundary }) => {
|
||||
try {
|
||||
if (!supportsAgentForkContext) {
|
||||
toast?.error(t("message.actions.forkUnavailable"));
|
||||
@@ -478,10 +485,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const draftSetup = buildForkDraftSetup(context);
|
||||
const prepareForkDraft = async () => {
|
||||
const draftId = generateDraftId();
|
||||
const payload = await client.buildAgentForkContext(
|
||||
agentId,
|
||||
boundaryMessageId ? { boundaryMessageId } : {},
|
||||
);
|
||||
const payload = await client.buildAgentForkContext(agentId, boundary);
|
||||
const attachment = buildChatHistoryAttachment({
|
||||
draftId,
|
||||
serverId: resolvedServerId,
|
||||
@@ -854,10 +858,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
content,
|
||||
layoutItem,
|
||||
strategy: streamRenderStrategy,
|
||||
supportsTimelineCursor: supportsAgentForkContextCursor,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
|
||||
[
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
renderStreamItemContent,
|
||||
streamRenderStrategy,
|
||||
supportsAgentForkContextCursor,
|
||||
],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -882,6 +893,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
supportsTimelineCursor={supportsAgentForkContextCursor}
|
||||
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
|
||||
/>
|
||||
) : null,
|
||||
@@ -892,6 +904,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
streamRenderStrategy,
|
||||
supportsAgentForkContextCursor,
|
||||
],
|
||||
);
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ChatHistoryContextAttachment {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
boundaryMessageId?: string | null;
|
||||
boundaryCursor?: { epoch: string; seq: number } | null;
|
||||
itemCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -564,11 +564,7 @@ interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
forkBoundaryMessageId?: string;
|
||||
onFork?: (input: {
|
||||
target: AssistantForkTarget;
|
||||
boundaryMessageId?: string;
|
||||
}) => Promise<void> | void;
|
||||
onFork?: (target: AssistantForkTarget) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
|
||||
@@ -613,7 +609,6 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent,
|
||||
completedAt,
|
||||
durationMs,
|
||||
forkBoundaryMessageId,
|
||||
onFork,
|
||||
}: AssistantTurnFooterProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
@@ -656,11 +651,11 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
}, [canSwap]);
|
||||
const handleFork = useCallback(
|
||||
(target: AssistantForkTarget) => {
|
||||
return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId });
|
||||
return onFork?.(target);
|
||||
},
|
||||
[forkBoundaryMessageId, onFork],
|
||||
[onFork],
|
||||
);
|
||||
const canFork = Boolean(onFork && forkBoundaryMessageId);
|
||||
const canFork = Boolean(onFork);
|
||||
|
||||
return (
|
||||
<View style={assistantTurnFooterStylesheet.container}>
|
||||
|
||||
@@ -187,6 +187,25 @@ const baseStreamInput: ProcessAgentStreamEventInput = {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processTimelineResponse", () => {
|
||||
it("preserves the canonical end cursor on a projected assistant message", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "timeline-1",
|
||||
entries: [makeTimelineEntry(40, "[System Error] failed", "assistant_message", 42)],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.tail).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant_message",
|
||||
text: "[System Error] failed",
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns error path when payload.error is set", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
@@ -892,7 +911,12 @@ describe("processTimelineResponse", () => {
|
||||
});
|
||||
|
||||
it("merges assistant chunks across the older-page prepend boundary", () => {
|
||||
const currentTail = [makeAssistantItem("newer chunk", "assistant-newer")];
|
||||
const currentTail = [
|
||||
{
|
||||
...makeAssistantItem("newer chunk", "assistant-newer"),
|
||||
timelineCursor: { epoch: "epoch-1", seq: 3 },
|
||||
},
|
||||
];
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
startSeq: 3,
|
||||
@@ -914,6 +938,9 @@ describe("processTimelineResponse", () => {
|
||||
});
|
||||
|
||||
expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]);
|
||||
expect(result.tail[0]).toEqual(
|
||||
expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }),
|
||||
);
|
||||
expect(result.cursor).toEqual({
|
||||
epoch: "epoch-1",
|
||||
startSeq: 1,
|
||||
@@ -1222,6 +1249,23 @@ describe("processTimelineResponse", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("processAgentStreamEvent", () => {
|
||||
it("preserves the live timeline cursor on an assistant error", () => {
|
||||
const result = processAgentStreamEvent({
|
||||
...baseStreamInput,
|
||||
event: makeAssistantTimelineEvent("[System Error] failed"),
|
||||
epoch: "timeline-1",
|
||||
seq: 42,
|
||||
});
|
||||
|
||||
expect(result.head).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "assistant_message",
|
||||
text: "[System Error] failed",
|
||||
timelineCursor: { epoch: "timeline-1", seq: 42 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("passes through non-timeline events without cursor changes", () => {
|
||||
const turnEvent: AgentStreamEventPayload = {
|
||||
type: "turn_completed",
|
||||
|
||||
@@ -510,6 +510,7 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea
|
||||
...olderLast,
|
||||
text: `${olderLast.text}${currentFirst.text}`,
|
||||
timestamp: currentFirst.timestamp,
|
||||
...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}),
|
||||
},
|
||||
...currentTail.slice(1),
|
||||
];
|
||||
@@ -519,8 +520,9 @@ function replaceLiveAssistantWithProjectedText(params: {
|
||||
head: StreamItem[];
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
timelineCursor: { epoch: string; seq: number };
|
||||
}): StreamItem[] | null {
|
||||
const { head, event, timestamp } = params;
|
||||
const { head, event, timestamp, timelineCursor } = params;
|
||||
if (event.type !== "timeline" || event.item.type !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
@@ -537,6 +539,7 @@ function replaceLiveAssistantWithProjectedText(params: {
|
||||
...current,
|
||||
text: event.item.text,
|
||||
timestamp,
|
||||
timelineCursor,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
@@ -575,19 +578,22 @@ function applyTimelineIncrementalPath(args: {
|
||||
if (acceptedUnits.length > 0) {
|
||||
if (payload.direction === "before") {
|
||||
const olderTail = hydrateStreamState(
|
||||
acceptedUnits.map(({ event, timestamp }) => ({
|
||||
acceptedUnits.map(({ event, timestamp, seqEnd }) => ({
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
|
||||
})),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
|
||||
} else if (currentHead.length > 0) {
|
||||
for (const { event, timestamp } of acceptedUnits) {
|
||||
for (const { event, timestamp, seqEnd } of acceptedUnits) {
|
||||
const timelineCursor = { epoch: payload.epoch, seq: seqEnd };
|
||||
const replacedHead = replaceLiveAssistantWithProjectedText({
|
||||
head: nextHead,
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor,
|
||||
});
|
||||
if (replacedHead) {
|
||||
nextHead = replacedHead;
|
||||
@@ -599,15 +605,17 @@ function applyTimelineIncrementalPath(args: {
|
||||
event,
|
||||
timestamp,
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
});
|
||||
nextTail = applied.tail;
|
||||
nextHead = applied.head;
|
||||
}
|
||||
} else {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
(state, { event, timestamp, seqEnd }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
@@ -681,8 +689,16 @@ export function processTimelineResponse(
|
||||
|
||||
const toHydratedEvents = (
|
||||
units: TimelineUnit[],
|
||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
||||
): Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
timelineCursor: { epoch: string; seq: number };
|
||||
}> =>
|
||||
units.map(({ event, timestamp, seqEnd }) => ({
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
|
||||
}));
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Derive bootstrap policy (replace vs incremental)
|
||||
@@ -934,6 +950,10 @@ export function processAgentStreamEvent(
|
||||
input;
|
||||
|
||||
const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor });
|
||||
const timelineCursor =
|
||||
event.type === "timeline" && seq !== undefined && epoch !== undefined
|
||||
? { epoch, seq }
|
||||
: undefined;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Apply stream event to tail/head
|
||||
@@ -945,6 +965,7 @@ export function processAgentStreamEvent(
|
||||
event,
|
||||
timestamp,
|
||||
source: "live",
|
||||
timelineCursor,
|
||||
})
|
||||
: {
|
||||
tail: currentTail,
|
||||
|
||||
@@ -107,12 +107,18 @@ export interface AssistantMessageItem {
|
||||
kind: "assistant_message";
|
||||
id: string;
|
||||
messageId?: string;
|
||||
timelineCursor?: TimelinePosition;
|
||||
text: string;
|
||||
timestamp: Date;
|
||||
blockGroupId?: string;
|
||||
blockIndex?: number;
|
||||
}
|
||||
|
||||
export interface TimelinePosition {
|
||||
epoch: string;
|
||||
seq: number;
|
||||
}
|
||||
|
||||
export type ThoughtStatus = "loading" | "ready";
|
||||
|
||||
export interface ThoughtItem {
|
||||
@@ -202,6 +208,7 @@ export type StreamUpdateSource = "live" | "canonical";
|
||||
interface StreamUpdateOptions {
|
||||
source?: StreamUpdateSource;
|
||||
reservedItemIds?: ReadonlySet<string>;
|
||||
timelineCursor?: TimelinePosition;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -385,6 +392,7 @@ function appendAssistantMessage(
|
||||
source: StreamUpdateSource,
|
||||
messageId?: string,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const { chunk, hasContent } = normalizeChunk(text);
|
||||
if (!chunk) {
|
||||
@@ -401,6 +409,7 @@ function appendAssistantMessage(
|
||||
...last,
|
||||
text: `${last.text}${chunk}`,
|
||||
timestamp,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
};
|
||||
return [...state.slice(0, -1), updated];
|
||||
}
|
||||
@@ -418,6 +427,7 @@ function appendAssistantMessage(
|
||||
...secondLast,
|
||||
text: `${secondLast.text}${chunk}`,
|
||||
timestamp,
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
};
|
||||
return [...state.slice(0, -2), updated, last];
|
||||
}
|
||||
@@ -432,6 +442,7 @@ function appendAssistantMessage(
|
||||
kind: "assistant_message",
|
||||
id: entryId,
|
||||
...(messageId ? { messageId } : {}),
|
||||
...(timelineCursor ? { timelineCursor } : {}),
|
||||
text: chunk,
|
||||
timestamp,
|
||||
};
|
||||
@@ -817,6 +828,7 @@ function reduceTimelineEvent(
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
reservedItemIds?: ReadonlySet<string>,
|
||||
timelineCursor?: TimelinePosition,
|
||||
): StreamItem[] {
|
||||
const item = event.item;
|
||||
switch (item.type) {
|
||||
@@ -831,6 +843,7 @@ function reduceTimelineEvent(
|
||||
source,
|
||||
item.messageId,
|
||||
reservedItemIds,
|
||||
timelineCursor,
|
||||
),
|
||||
);
|
||||
case "reasoning":
|
||||
@@ -876,7 +889,14 @@ export function reduceStreamUpdate(
|
||||
const source = options?.source ?? "live";
|
||||
switch (event.type) {
|
||||
case "timeline":
|
||||
return reduceTimelineEvent(state, event, timestamp, source, options?.reservedItemIds);
|
||||
return reduceTimelineEvent(
|
||||
state,
|
||||
event,
|
||||
timestamp,
|
||||
source,
|
||||
options?.reservedItemIds,
|
||||
options?.timelineCursor,
|
||||
);
|
||||
case "thread_started":
|
||||
case "turn_started":
|
||||
case "turn_completed":
|
||||
@@ -898,11 +918,12 @@ export function hydrateStreamState(
|
||||
events: Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
timelineCursor?: TimelinePosition;
|
||||
}>,
|
||||
options?: { source?: StreamUpdateSource },
|
||||
): StreamItem[] {
|
||||
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp }) => {
|
||||
return reduceStreamUpdate(state, event, timestamp, options);
|
||||
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp, timelineCursor }) => {
|
||||
return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor });
|
||||
}, []);
|
||||
|
||||
return finalizeActiveThoughts(hydrated);
|
||||
@@ -1186,6 +1207,7 @@ export function applyStreamEvent(params: {
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
source?: StreamUpdateSource;
|
||||
timelineCursor?: TimelinePosition;
|
||||
}): ApplyStreamEventResult {
|
||||
const { tail, head, event, timestamp } = params;
|
||||
const source = params.source ?? "live";
|
||||
@@ -1256,7 +1278,11 @@ export function applyStreamEvent(params: {
|
||||
),
|
||||
)
|
||||
: undefined;
|
||||
const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, reservedItemIds });
|
||||
const reduced = reduceStreamUpdate(nextHead, event, timestamp, {
|
||||
source,
|
||||
reservedItemIds,
|
||||
timelineCursor: params.timelineCursor,
|
||||
});
|
||||
if (reduced !== nextHead) {
|
||||
nextHead = reduced;
|
||||
changedHead = true;
|
||||
@@ -1275,7 +1301,10 @@ export function applyStreamEvent(params: {
|
||||
}
|
||||
|
||||
// For non-streamable kinds or non-timeline events, apply to tail
|
||||
const reduced = reduceStreamUpdate(nextTail, event, timestamp, { source });
|
||||
const reduced = reduceStreamUpdate(nextTail, event, timestamp, {
|
||||
source,
|
||||
timelineCursor: params.timelineCursor,
|
||||
});
|
||||
if (reduced !== nextTail) {
|
||||
nextTail = reduced;
|
||||
changedTail = true;
|
||||
|
||||
Reference in New Issue
Block a user