fix(app): stabilize Mermaid streaming lifecycle (#4210)

Drive the streaming assertion from the actual turn lifecycle so iframe replacement at completion is not mistaken for diagram loss. Preserve the cached timeline range during reload reconciliation so a projected message spanning the cache cursor replaces its partial prefix instead of being appended to it.
This commit is contained in:
Mohamed Boudra
2026-09-02 13:37:32 +02:00
committed by GitHub
parent 708f9260b1
commit 77aff0f4fb
4 changed files with 86 additions and 17 deletions
@@ -35,6 +35,7 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
page,
}) => {
test.setTimeout(120_000);
let diagramTurnCompleted!: Promise<void>;
const agent = await seedMockAgentWorkspace({
repoPrefix: "mermaid-streaming-",
@@ -48,15 +49,16 @@ test("keeps a Mermaid diagram rendered while its message streams, completes, and
await test.step("Open the conversation and request a diagram", async () => {
await openAgentRoute(page, agent);
await requestDiagram(agent);
diagramTurnCompleted = waitForDiagramTurnToComplete(agent);
});
await test.step("The diagram renders and stays rendered while tokens arrive", async () => {
await expectDiagramWithLabels(page, ["Start", "Middle"]);
await expectDiagramRemainsRenderedWhileStreaming(page);
await expectDiagramRemainsRenderedWhileStreaming(page, diagramTurnCompleted);
});
await test.step("The completed diagram shows the final streamed content", async () => {
await waitForDiagramTurnToComplete(agent);
await diagramTurnCompleted;
await expectCompletedDiagram(page, ["Start", "Done", "Release"]);
});
+10 -7
View File
@@ -25,13 +25,16 @@ export async function expectDiagramWithLabels(
}
}
export async function expectDiagramRemainsRenderedWhileStreaming(page: Page): Promise<void> {
const { diagram, svg } = renderedDiagram(page);
const samples = 80;
for (let sample = 0; sample < samples; sample += 1) {
await expect(diagram).toBeVisible({ timeout: 100 });
await expect(svg).toBeVisible({ timeout: 500 });
await page.waitForTimeout(25);
export async function expectDiagramRemainsRenderedWhileStreaming(
page: Page,
completion: Promise<void>,
): Promise<void> {
const diagram = page.getByRole("img", { name: DIAGRAM_NAME }).last();
const completed = completion.then(() => true);
for (;;) {
expect(await diagram.isVisible()).toBe(true);
const didComplete = await Promise.race([completed, page.waitForTimeout(16).then(() => false)]);
if (didComplete) return completion;
}
}
+58
View File
@@ -115,6 +115,64 @@ describe("viewed timeline persistence", () => {
owner.dispose();
});
it("reconciles an overlapping projected message against its cached cursor", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
const partial = "```mermaid\nflowchart LR\n Start --> Mid";
const complete = `${partial}dle\n Middle --> Done\n\`\`\``;
const owner = createOwner({
readTimeline: async () => ({
agentId: AGENT_ID,
items: [item("cached", partial, 4)],
range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 },
hasOlder: false,
}),
commitTimeline: () => undefined,
});
owner.replaceVisibleAgentIds("test", [AGENT_ID]);
await expect
.poll(() =>
selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID),
)
.toMatchObject({ status: "painted" });
owner.applyTimelineResponse({
requestId: "page-after-cache",
agentId: AGENT_ID,
agent: null,
direction: "after",
projection: "projected",
reset: false,
epoch: "epoch-1",
window: { minSeq: 1, maxSeq: 5, nextSeq: 6 },
startCursor: { epoch: "epoch-1", seq: 5 },
endCursor: { epoch: "epoch-1", seq: 5 },
entries: [
{
provider: "mock",
item: { type: "assistant_message", text: complete },
timestamp: "2026-08-26T10:00:00.000Z",
seqStart: 2,
seqEnd: 5,
sourceSeqRanges: [{ startSeq: 2, endSeq: 5 }],
collapsed: ["assistant_merge"],
},
],
error: null,
hasNewer: false,
hasOlder: false,
staleCursor: false,
gap: false,
});
const session = useSessionStore.getState().sessions[SERVER_ID];
expect([
...(session?.agentStreamTail.get(AGENT_ID) ?? []),
...(session?.agentStreamHead.get(AGENT_ID) ?? []),
]).toMatchObject([{ kind: "assistant_message", text: complete }]);
owner.dispose();
});
it("does not let a late cache read overwrite newer network state", async () => {
useSessionStore.getState().initializeSession(SERVER_ID, null);
let release!: (value: CachedTimeline) => void;
@@ -90,11 +90,12 @@ async function prepareCachedTimeline(input: {
export interface TimelineReplica {
prepare(agentId: string): Promise<void>;
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined;
readRange(agentId: string): AgentTimelineCursorState | undefined;
timelineUpdated(agentId: string): void;
}
class TimelineReplicaOwner implements TimelineReplica {
private readonly cachedCursors = new Map<string, { epoch: string; endSeq: number }>();
private readonly cachedRanges = new Map<string, AgentTimelineCursorState>();
private readonly preparations = new Map<string, Promise<void>>();
constructor(
@@ -124,22 +125,24 @@ class TimelineReplicaOwner implements TimelineReplica {
});
if (!stored) return;
if (stored.range) {
this.cachedCursors.set(agentId, {
epoch: stored.range.epoch,
endSeq: stored.range.endSeq,
});
this.cachedRanges.set(agentId, stored.range);
}
}
readCursor(agentId: string): { epoch: string; endSeq: number } | undefined {
return this.cachedCursors.get(agentId);
const range = this.cachedRanges.get(agentId);
return range ? { epoch: range.epoch, endSeq: range.endSeq } : undefined;
}
readRange(agentId: string): AgentTimelineCursorState | undefined {
return this.cachedRanges.get(agentId);
}
timelineUpdated(agentId: string): void {
const session = useSessionStore.getState().sessions[this.serverId];
const timeline = selectAgentTimelineState(session, agentId);
if (timeline.status !== "synced") return;
this.cachedCursors.delete(agentId);
this.cachedRanges.delete(agentId);
this.storage.commitTimeline(this.serverId, agentId, {
agentId,
items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])],
@@ -255,6 +258,7 @@ function finalizeProcessedTimeline(input: {
function applyAuthoritativeTimelineResponse(input: {
serverId: string;
payload: TimelineResponsePayload;
cachedCursor?: AgentTimelineCursorState;
recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void;
drainQueuedAgentMessage: (agentId: string) => void;
}): boolean {
@@ -264,7 +268,8 @@ function applyAuthoritativeTimelineResponse(input: {
const session = useSessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
const activeInitDeferred = getInitDeferred(initKey);
const currentCursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined;
const currentCursor =
timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor;
const result = processTimelineResponse({
payload,
currentTail: timeline.status === "cold" ? [] : timeline.items,
@@ -371,6 +376,7 @@ export function createViewedTimelineOwner(input: {
const accepted = applyAuthoritativeTimelineResponse({
serverId: input.serverId,
payload,
cachedCursor: input.replica.readRange(payload.agentId),
recoverGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
drainQueuedAgentMessage: input.drainQueuedAgentMessage,
});