refactor(app): organize agent stream rendering
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
orderHeadForStreamRenderStrategy,
|
||||
orderTailForStreamRenderStrategy,
|
||||
type StreamStrategy,
|
||||
} from "./strategy";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { layoutStream, type StreamLayout, type StreamLayoutItem } from "./layout";
|
||||
|
||||
function timestamp(seed: number): Date {
|
||||
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
|
||||
}
|
||||
|
||||
function userMessage(id: string, seed: number): Extract<StreamItem, { kind: "user_message" }> {
|
||||
return {
|
||||
kind: "user_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
};
|
||||
}
|
||||
|
||||
function assistantMessage(
|
||||
id: string,
|
||||
seed: number,
|
||||
block?: { groupId: string; index: number },
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
...(block ? { blockGroupId: block.groupId, blockIndex: block.index } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, seed: number): Extract<StreamItem, { kind: "tool_call" }> {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: timestamp(seed),
|
||||
payload: {
|
||||
source: "orchestrator",
|
||||
data: {
|
||||
toolCallId: id,
|
||||
toolName: "Shell",
|
||||
arguments: "echo hi",
|
||||
result: null,
|
||||
status: "completed",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function thought(id: string, seed: number): Extract<StreamItem, { kind: "thought" }> {
|
||||
return {
|
||||
kind: "thought",
|
||||
id,
|
||||
text: id,
|
||||
timestamp: timestamp(seed),
|
||||
status: "ready",
|
||||
};
|
||||
}
|
||||
|
||||
function timingFor(...ids: string[]): Map<string, TurnTiming> {
|
||||
const timing = {
|
||||
startedAt: timestamp(1),
|
||||
completedAt: timestamp(9),
|
||||
durationMs: 8000,
|
||||
};
|
||||
return new Map(ids.map((id) => [id, timing]));
|
||||
}
|
||||
|
||||
function strategyFor(platform: "web" | "android"): StreamStrategy {
|
||||
return resolveStreamRenderStrategy({
|
||||
platform,
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
}
|
||||
|
||||
function layoutFor(input: {
|
||||
platform: "web" | "android";
|
||||
agentStatus?: string;
|
||||
tail: StreamItem[];
|
||||
head?: StreamItem[];
|
||||
timingIds?: string[];
|
||||
}): StreamLayout {
|
||||
const strategy = strategyFor(input.platform);
|
||||
return layoutStream({
|
||||
strategy,
|
||||
agentStatus: input.agentStatus ?? "idle",
|
||||
history: orderTailForStreamRenderStrategy({
|
||||
strategy,
|
||||
streamItems: input.tail,
|
||||
}),
|
||||
liveHead: orderHeadForStreamRenderStrategy({
|
||||
strategy,
|
||||
streamHead: input.head ?? [],
|
||||
}),
|
||||
timingByAssistantId: timingFor(...(input.timingIds ?? [])),
|
||||
});
|
||||
}
|
||||
|
||||
function footerOwners(layout: StreamLayout): string[] {
|
||||
const owners = [
|
||||
...layout.history.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
|
||||
...layout.liveHead.flatMap((item) => (item.completedFooter ? [item.item.id] : [])),
|
||||
...(layout.auxiliaryTurnFooter ? [layout.auxiliaryTurnFooter.itemId] : []),
|
||||
];
|
||||
return owners;
|
||||
}
|
||||
|
||||
function findLayoutItem(layout: StreamLayout, id: string): StreamLayoutItem {
|
||||
const item = [...layout.history, ...layout.liveHead].find(
|
||||
(candidate) => candidate.item.id === id,
|
||||
);
|
||||
if (!item) {
|
||||
throw new Error(`Missing layout item ${id}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
describe("layoutStream", () => {
|
||||
it("does not duplicate footers when a native assistant turn spans history and live head", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(footerOwners(layout)).toEqual([headBlock.id]);
|
||||
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
|
||||
expect(findLayoutItem(layout, historyBlock.id).completedFooter).toBeNull();
|
||||
});
|
||||
|
||||
it("does not duplicate footers when a web assistant turn spans history and live head", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(footerOwners(layout)).toEqual([headBlock.id]);
|
||||
expect(findLayoutItem(layout, historyBlock.id).belowItem?.id).toBe(headBlock.id);
|
||||
expect(findLayoutItem(layout, headBlock.id).aboveItem?.id).toBe(historyBlock.id);
|
||||
});
|
||||
|
||||
it("keeps the completed footer visually after the assistant after a native user reply", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
const assistantRow = findLayoutItem(layout, assistant.id);
|
||||
|
||||
expect(layout.auxiliaryTurnFooter).toBeNull();
|
||||
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(assistantRow.belowItem?.id).toBe("u2");
|
||||
expect(assistantRow.frameOrder).toBe("footer-then-content");
|
||||
});
|
||||
|
||||
it("keeps forward stream content before its completed footer", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), assistant, userMessage("u2", 3)],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
const assistantRow = findLayoutItem(layout, assistant.id);
|
||||
|
||||
expect(assistantRow.completedFooter?.itemId).toBe(assistant.id);
|
||||
expect(assistantRow.frameOrder).toBe("content-then-footer");
|
||||
});
|
||||
|
||||
it("compacts assistant block spacing across the history and live-head boundary", () => {
|
||||
const historyBlock = assistantMessage("turn:block:0", 2, { groupId: "turn", index: 0 });
|
||||
const headBlock = assistantMessage("turn:head", 3, { groupId: "turn", index: 1 });
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), historyBlock],
|
||||
head: [headBlock],
|
||||
timingIds: [historyBlock.id, headBlock.id],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, historyBlock.id).assistantSpacing).toBe("compactBottom");
|
||||
expect(findLayoutItem(layout, headBlock.id).assistantSpacing).toBe("compactTop");
|
||||
});
|
||||
|
||||
it("computes tool sequence position from strategy-aware neighbors", () => {
|
||||
const shell = toolCall("tool-1", 2);
|
||||
const thinking = thought("thought-1", 3);
|
||||
const layout = layoutFor({
|
||||
platform: "android",
|
||||
tail: [userMessage("u1", 1), shell, thinking, assistantMessage("a1", 4)],
|
||||
});
|
||||
|
||||
expect(findLayoutItem(layout, shell.id).toolSequence).toBe("first");
|
||||
expect(findLayoutItem(layout, thinking.id).toolSequence).toBe("last");
|
||||
});
|
||||
|
||||
it("keeps bottom and inline footer ownership mutually exclusive", () => {
|
||||
const assistant = assistantMessage("a1", 2);
|
||||
const layout = layoutFor({
|
||||
platform: "web",
|
||||
tail: [userMessage("u1", 1), assistant],
|
||||
timingIds: [assistant.id],
|
||||
});
|
||||
|
||||
expect(layout.auxiliaryTurnFooter?.itemId).toBe(assistant.id);
|
||||
expect(findLayoutItem(layout, assistant.id).completedFooter).toBeNull();
|
||||
expect(footerOwners(layout)).toEqual([assistant.id]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user