fix: restore codex streaming responsiveness
This commit is contained in:
@@ -0,0 +1,258 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { StreamItem } from "@/types/stream";
|
||||||
|
import { AgentStreamView } from "./agent-stream-view";
|
||||||
|
|
||||||
|
const assistantMessageCalls = vi.hoisted(
|
||||||
|
() => [] as Array<{ message: string; spacing: string | undefined }>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockSessionState = vi.hoisted(() => ({
|
||||||
|
sessions: {
|
||||||
|
server: {
|
||||||
|
client: null,
|
||||||
|
agentStreamHead: new Map<string, StreamItem[]>(),
|
||||||
|
workspaces: new Map(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-unistyles", () => ({
|
||||||
|
StyleSheet: {
|
||||||
|
create: (
|
||||||
|
factory: (theme: {
|
||||||
|
borderRadius: Record<string, number>;
|
||||||
|
borderWidth: Record<number, number>;
|
||||||
|
colors: Record<string, string>;
|
||||||
|
fontSize: Record<string, number>;
|
||||||
|
fontWeight: Record<string, string>;
|
||||||
|
shadow: Record<string, object>;
|
||||||
|
spacing: Record<number, number>;
|
||||||
|
}) => unknown,
|
||||||
|
) =>
|
||||||
|
factory({
|
||||||
|
borderRadius: {
|
||||||
|
full: 9999,
|
||||||
|
md: 6,
|
||||||
|
},
|
||||||
|
borderWidth: {
|
||||||
|
1: 1,
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
foreground: "#fff",
|
||||||
|
foregroundMuted: "#aaa",
|
||||||
|
surface0: "#000",
|
||||||
|
surface1: "#111",
|
||||||
|
surface2: "#222",
|
||||||
|
border: "#333",
|
||||||
|
borderAccent: "#444",
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
sm: 14,
|
||||||
|
base: 16,
|
||||||
|
xs: 12,
|
||||||
|
},
|
||||||
|
fontWeight: {
|
||||||
|
normal: "normal",
|
||||||
|
},
|
||||||
|
shadow: {
|
||||||
|
sm: {},
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
1: 4,
|
||||||
|
2: 8,
|
||||||
|
3: 12,
|
||||||
|
4: 16,
|
||||||
|
12: 48,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
useUnistyles: () => ({ rt: { breakpoint: "md" } }),
|
||||||
|
withUnistyles: (Component: unknown) => Component,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-native-reanimated", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
View: ({ children, ...props }: { children?: React.ReactNode }) =>
|
||||||
|
ReactModule.createElement("div", props, children),
|
||||||
|
},
|
||||||
|
Easing: { linear: vi.fn() },
|
||||||
|
FadeIn: { duration: vi.fn(() => undefined) },
|
||||||
|
FadeOut: { duration: vi.fn(() => undefined) },
|
||||||
|
cancelAnimation: vi.fn(),
|
||||||
|
useAnimatedStyle: (factory: () => unknown) => factory(),
|
||||||
|
useSharedValue: (value: unknown) => ({ value }),
|
||||||
|
withRepeat: (value: unknown) => value,
|
||||||
|
withTiming: (value: unknown) => value,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("lucide-react-native", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
const Icon = () => ReactModule.createElement("span");
|
||||||
|
return {
|
||||||
|
Check: Icon,
|
||||||
|
ChevronDown: Icon,
|
||||||
|
X: Icon,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./message", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
return {
|
||||||
|
ActivityLog: () => null,
|
||||||
|
AssistantMessage: (props: { message: string; spacing?: string }) => {
|
||||||
|
assistantMessageCalls.push({ message: props.message, spacing: props.spacing });
|
||||||
|
return ReactModule.createElement("div", {
|
||||||
|
"data-message": props.message,
|
||||||
|
"data-spacing": props.spacing ?? "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
CompactionMarker: () => null,
|
||||||
|
MessageOuterSpacingProvider: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
ReactModule.createElement(ReactModule.Fragment, null, children),
|
||||||
|
SpeakMessage: () => null,
|
||||||
|
TodoListCard: () => null,
|
||||||
|
ToolCall: () => null,
|
||||||
|
TurnCopyButton: () => null,
|
||||||
|
UserMessage: () => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./tool-call-sheet", async () => {
|
||||||
|
const ReactModule = await import("react");
|
||||||
|
return {
|
||||||
|
ToolCallSheetProvider: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
ReactModule.createElement(ReactModule.Fragment, null, children),
|
||||||
|
useToolCallSheet: () => ({ open: vi.fn() }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./tool-call-details", () => ({ ToolCallDetailsContent: () => null }));
|
||||||
|
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
|
||||||
|
vi.mock("./question-form-card", () => ({ QuestionFormCard: () => null }));
|
||||||
|
vi.mock("./plan-card", () => ({ PlanCard: () => null }));
|
||||||
|
vi.mock("@/hooks/use-file-explorer-actions", () => ({
|
||||||
|
useFileExplorerActions: () => ({ requestDirectoryListing: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/stores/panel-store", () => ({
|
||||||
|
usePanelStore: (selector: (state: Record<string, unknown>) => unknown) =>
|
||||||
|
selector({
|
||||||
|
openFileExplorerForCheckout: vi.fn(),
|
||||||
|
setExplorerTabForCheckout: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/stores/session-store", () => ({
|
||||||
|
useSessionStore: Object.assign(
|
||||||
|
(selector: (state: typeof mockSessionState) => unknown) => selector(mockSessionState),
|
||||||
|
{
|
||||||
|
getState: () => mockSessionState,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
vi.mock("expo-router", () => ({ useRouter: () => ({ navigate: vi.fn() }) }));
|
||||||
|
|
||||||
|
function assistantBlock(params: {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
blockIndex: number;
|
||||||
|
}): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||||
|
return {
|
||||||
|
kind: "assistant_message",
|
||||||
|
id: params.id,
|
||||||
|
blockGroupId: "group-1",
|
||||||
|
blockIndex: params.blockIndex,
|
||||||
|
text: params.text,
|
||||||
|
timestamp: new Date("2026-05-01T00:00:00.000Z"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AgentStreamView", () => {
|
||||||
|
let root: Root | null = null;
|
||||||
|
let container: HTMLDivElement | null = null;
|
||||||
|
let originalScrollTo: HTMLElement["scrollTo"] | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", {
|
||||||
|
value: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
originalScrollTo = HTMLElement.prototype.scrollTo;
|
||||||
|
HTMLElement.prototype.scrollTo = vi.fn();
|
||||||
|
assistantMessageCalls.length = 0;
|
||||||
|
mockSessionState.sessions.server.agentStreamHead = new Map();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.appendChild(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (root) {
|
||||||
|
act(() => {
|
||||||
|
root?.unmount();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
container?.remove();
|
||||||
|
container = null;
|
||||||
|
if (originalScrollTo) {
|
||||||
|
HTMLElement.prototype.scrollTo = originalScrollTo;
|
||||||
|
} else {
|
||||||
|
Reflect.deleteProperty(HTMLElement.prototype, "scrollTo");
|
||||||
|
}
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compacts assistant block spacing across the history/live-head boundary", () => {
|
||||||
|
const tailBlock = assistantBlock({
|
||||||
|
id: "group-1:block:0",
|
||||||
|
text: "First paragraph",
|
||||||
|
blockIndex: 0,
|
||||||
|
});
|
||||||
|
const headBlock = assistantBlock({
|
||||||
|
id: "group-1:head",
|
||||||
|
text: "Second paragraph",
|
||||||
|
blockIndex: 1,
|
||||||
|
});
|
||||||
|
mockSessionState.sessions.server.agentStreamHead.set("agent-1", [headBlock]);
|
||||||
|
const agent = {
|
||||||
|
id: "agent-1",
|
||||||
|
serverId: "server",
|
||||||
|
status: "idle",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
} as never;
|
||||||
|
const streamItems = [tailBlock];
|
||||||
|
const pendingPermissions = new Map();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
root?.render(
|
||||||
|
React.createElement(AgentStreamView, {
|
||||||
|
agentId: "agent-1",
|
||||||
|
serverId: "server",
|
||||||
|
agent,
|
||||||
|
streamItems,
|
||||||
|
pendingPermissions,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const tailCalls = assistantMessageCalls.filter((call) => call.message === "First paragraph");
|
||||||
|
const headCalls = assistantMessageCalls.filter((call) => call.message === "Second paragraph");
|
||||||
|
|
||||||
|
expect(tailCalls.length).toBeGreaterThan(0);
|
||||||
|
expect(headCalls.length).toBeGreaterThan(0);
|
||||||
|
expect(tailCalls.map((call) => call.spacing)).toEqual(
|
||||||
|
Array.from({ length: tailCalls.length }, () => "compactBottom"),
|
||||||
|
);
|
||||||
|
expect(headCalls.map((call) => call.spacing)).toEqual(
|
||||||
|
Array.from({ length: headCalls.length }, () => "compactTop"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {
|
import React, {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
memo,
|
memo,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -126,6 +126,12 @@ const getAssistantBlockSpacing = (params: {
|
|||||||
}
|
}
|
||||||
return "default";
|
return "default";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface StreamItemBoundarySeams {
|
||||||
|
aboveItem?: StreamItem | null;
|
||||||
|
belowItem?: StreamItem | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentStreamViewHandle {
|
export interface AgentStreamViewHandle {
|
||||||
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void;
|
||||||
prepareForViewportChange(): void;
|
prepareForViewportChange(): void;
|
||||||
@@ -397,7 +403,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
item: Extract<StreamItem, { kind: "assistant_message" }>,
|
item: Extract<StreamItem, { kind: "assistant_message" }>,
|
||||||
index: number,
|
index: number,
|
||||||
items: StreamItem[],
|
items: StreamItem[],
|
||||||
seamAboveItem: StreamItem | null,
|
seams: StreamItemBoundarySeams,
|
||||||
) => {
|
) => {
|
||||||
const aboveItem =
|
const aboveItem =
|
||||||
getStreamNeighborItem({
|
getStreamNeighborItem({
|
||||||
@@ -406,14 +412,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
index,
|
index,
|
||||||
relation: "above",
|
relation: "above",
|
||||||
}) ??
|
}) ??
|
||||||
seamAboveItem ??
|
seams.aboveItem ??
|
||||||
|
undefined;
|
||||||
|
const belowItem =
|
||||||
|
getStreamNeighborItem({
|
||||||
|
strategy: streamRenderStrategy,
|
||||||
|
items,
|
||||||
|
index,
|
||||||
|
relation: "below",
|
||||||
|
}) ??
|
||||||
|
seams.belowItem ??
|
||||||
undefined;
|
undefined;
|
||||||
const belowItem = getStreamNeighborItem({
|
|
||||||
strategy: streamRenderStrategy,
|
|
||||||
items,
|
|
||||||
index,
|
|
||||||
relation: "below",
|
|
||||||
});
|
|
||||||
const spacing = getAssistantBlockSpacing({
|
const spacing = getAssistantBlockSpacing({
|
||||||
item,
|
item,
|
||||||
aboveItem,
|
aboveItem,
|
||||||
@@ -520,14 +529,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
item: StreamItem,
|
item: StreamItem,
|
||||||
index: number,
|
index: number,
|
||||||
items: StreamItem[],
|
items: StreamItem[],
|
||||||
seamAboveItem: StreamItem | null = null,
|
seams: StreamItemBoundarySeams = {},
|
||||||
) => {
|
) => {
|
||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case "user_message":
|
case "user_message":
|
||||||
return renderUserMessageItem(item, index, items, seamAboveItem);
|
return renderUserMessageItem(item, index, items, seams.aboveItem ?? null);
|
||||||
|
|
||||||
case "assistant_message":
|
case "assistant_message":
|
||||||
return renderAssistantMessageItem(item, index, items, seamAboveItem);
|
return renderAssistantMessageItem(item, index, items, seams);
|
||||||
|
|
||||||
case "thought":
|
case "thought":
|
||||||
return renderThoughtItem(item, index, items);
|
return renderThoughtItem(item, index, items);
|
||||||
@@ -563,9 +572,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
item: StreamItem,
|
item: StreamItem,
|
||||||
index: number,
|
index: number,
|
||||||
items: StreamItem[],
|
items: StreamItem[],
|
||||||
seamAboveItem: StreamItem | null = null,
|
seams: StreamItemBoundarySeams = {},
|
||||||
) => {
|
) => {
|
||||||
const content = renderStreamItemContent(item, index, items, seamAboveItem);
|
const content = renderStreamItemContent(item, index, items, seams);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -664,6 +673,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
const _liveHeadItems = renderModel.segments.liveHead;
|
const _liveHeadItems = renderModel.segments.liveHead;
|
||||||
const { boundary, auxiliary } = renderModel;
|
const { boundary, auxiliary } = renderModel;
|
||||||
const lastHistoryItem = historyItems.at(-1) ?? null;
|
const lastHistoryItem = historyItems.at(-1) ?? null;
|
||||||
|
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
|
||||||
|
|
||||||
const historyIndexById = useMemo(() => {
|
const historyIndexById = useMemo(() => {
|
||||||
const indexById = new Map<string, number>();
|
const indexById = new Map<string, number>();
|
||||||
@@ -679,9 +689,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
if (historyIndex === undefined) {
|
if (historyIndex === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return renderStreamItem(item, historyIndex, historyItems);
|
const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null;
|
||||||
|
return renderStreamItem(item, historyIndex, historyItems, {
|
||||||
|
belowItem: seamBelowItem,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[historyIndexById, historyItems, renderStreamItem],
|
[firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderHistoryVirtualizedRow = useCallback<
|
const renderHistoryVirtualizedRow = useCallback<
|
||||||
@@ -693,7 +706,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
||||||
(item, index, items) =>
|
(item, index, items) =>
|
||||||
renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null),
|
renderStreamItem(item, index, items, {
|
||||||
|
aboveItem: index === 0 ? lastHistoryItem : null,
|
||||||
|
}),
|
||||||
[lastHistoryItem, renderStreamItem],
|
[lastHistoryItem, renderStreamItem],
|
||||||
);
|
);
|
||||||
const liveAuxiliaryHeaderStyle = useMemo(() => {
|
const liveAuxiliaryHeaderStyle = useMemo(() => {
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ import type { StreamItem } from "@/types/stream";
|
|||||||
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
|
import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy";
|
||||||
import { createWebStreamStrategy } from "./stream-strategy-web";
|
import { createWebStreamStrategy } from "./stream-strategy-web";
|
||||||
|
|
||||||
|
vi.hoisted(() => {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => ({
|
||||||
|
addEventListener: () => {},
|
||||||
|
addListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
matches: false,
|
||||||
|
media: "",
|
||||||
|
onchange: null,
|
||||||
|
removeEventListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./use-web-scrollbar", () => ({ useWebElementScrollbar: () => null }));
|
||||||
|
|
||||||
function userMessage(index: number): StreamItem {
|
function userMessage(index: number): StreamItem {
|
||||||
return {
|
return {
|
||||||
kind: "user_message",
|
kind: "user_message",
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
expect(coalescer.handle("agent-1", assistant("lo"))).toBe(true);
|
expect(coalescer.handle("agent-1", assistant("lo"))).toBe(true);
|
||||||
|
|
||||||
expect(flushes).toEqual([]);
|
expect(flushes).toEqual([]);
|
||||||
await vi.advanceTimersByTimeAsync(199);
|
await vi.advanceTimersByTimeAsync(59);
|
||||||
expect(flushes).toEqual([]);
|
expect(flushes).toEqual([]);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(1);
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
@@ -119,7 +119,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
test("uses constructor windowMs instead of a hard-coded value", async () => {
|
test("uses constructor windowMs instead of a hard-coded value", async () => {
|
||||||
const { coalescer, flushes } = createHarness(10);
|
const { coalescer, flushes } = createHarness(10);
|
||||||
|
|
||||||
expect(AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS).toBe(200);
|
expect(AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS).toBe(60);
|
||||||
expect(coalescer.handle("agent-1", assistant("fast"))).toBe(true);
|
expect(coalescer.handle("agent-1", assistant("fast"))).toBe(true);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(9);
|
await vi.advanceTimersByTimeAsync(9);
|
||||||
@@ -203,7 +203,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", reasoning("r1"));
|
coalescer.handle("agent-1", reasoning("r1"));
|
||||||
coalescer.handle("agent-1", assistant("a2"));
|
coalescer.handle("agent-1", assistant("a2"));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -231,7 +231,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", assistant("a2"));
|
coalescer.handle("agent-1", assistant("a2"));
|
||||||
coalescer.handle("agent-1", reasoning("r2"));
|
coalescer.handle("agent-1", reasoning("r2"));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes.map((flush) => flush.item)).toEqual([
|
expect(flushes.map((flush) => flush.item)).toEqual([
|
||||||
{ type: "assistant_message", text: "a1" },
|
{ type: "assistant_message", text: "a1" },
|
||||||
{ type: "reasoning", text: "r1" },
|
{ type: "reasoning", text: "r1" },
|
||||||
@@ -246,7 +246,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", assistant("c", { provider: "codex" }));
|
coalescer.handle("agent-1", assistant("c", { provider: "codex" }));
|
||||||
coalescer.handle("agent-1", assistant("o", { provider: "opencode" }));
|
coalescer.handle("agent-1", assistant("o", { provider: "opencode" }));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -267,7 +267,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", assistant("one", { turnId: "turn-1" }));
|
coalescer.handle("agent-1", assistant("one", { turnId: "turn-1" }));
|
||||||
coalescer.handle("agent-1", assistant("two", { turnId: "turn-2" }));
|
coalescer.handle("agent-1", assistant("two", { turnId: "turn-2" }));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -290,7 +290,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
expect(coalescer.handle("agent-1", assistant(""))).toBe(true);
|
expect(coalescer.handle("agent-1", assistant(""))).toBe(true);
|
||||||
expect(coalescer.handle("agent-2", reasoning(""))).toBe(true);
|
expect(coalescer.handle("agent-2", reasoning(""))).toBe(true);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([]);
|
expect(flushes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -302,7 +302,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", assistant("\t"));
|
coalescer.handle("agent-1", assistant("\t"));
|
||||||
coalescer.handle("agent-1", assistant(" mixed \n\t whitespace "));
|
coalescer.handle("agent-1", assistant(" mixed \n\t whitespace "));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -332,7 +332,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", assistant(chunk));
|
coalescer.handle("agent-1", assistant(chunk));
|
||||||
}
|
}
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -350,7 +350,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", assistant("b"));
|
coalescer.handle("agent-1", assistant("b"));
|
||||||
coalescer.handle("agent-2", assistant("y"));
|
coalescer.handle("agent-2", assistant("y"));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -431,7 +431,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -452,7 +452,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", assistant("manual"));
|
coalescer.handle("agent-1", assistant("manual"));
|
||||||
coalescer.flushFor("agent-1");
|
coalescer.flushFor("agent-1");
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -468,7 +468,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", assistant("durable"));
|
coalescer.handle("agent-1", assistant("durable"));
|
||||||
coalescer.flushAndDiscard("agent-1");
|
coalescer.flushAndDiscard("agent-1");
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -532,7 +532,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", timeline(firstItem));
|
coalescer.handle("agent-1", timeline(firstItem));
|
||||||
coalescer.handle("agent-1", assistant("llo"));
|
coalescer.handle("agent-1", assistant("llo"));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
@@ -552,7 +552,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
expect(coalescer.handle("agent-1", toolCall({ output: "first" }))).toBe(true);
|
expect(coalescer.handle("agent-1", toolCall({ output: "first" }))).toBe(true);
|
||||||
expect(coalescer.handle("agent-1", toolCall({ output: "second" }))).toBe(true);
|
expect(coalescer.handle("agent-1", toolCall({ output: "second" }))).toBe(true);
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
|
|
||||||
expect(flushes).toEqual([
|
expect(flushes).toEqual([
|
||||||
{
|
{
|
||||||
@@ -583,7 +583,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", toolCall({ callId: "tool-1", output: "one-b" }));
|
coalescer.handle("agent-1", toolCall({ callId: "tool-1", output: "one-b" }));
|
||||||
coalescer.handle("agent-1", toolCall({ callId: "tool-2", output: "two-b" }));
|
coalescer.handle("agent-1", toolCall({ callId: "tool-2", output: "two-b" }));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
|
|
||||||
expect(flushes.map((flush) => flush.item)).toEqual([
|
expect(flushes.map((flush) => flush.item)).toEqual([
|
||||||
{
|
{
|
||||||
@@ -651,7 +651,7 @@ describe("AgentStreamCoalescer", () => {
|
|||||||
coalescer.handle("agent-1", toolCall({ output: "latest" }));
|
coalescer.handle("agent-1", toolCall({ output: "latest" }));
|
||||||
coalescer.handle("agent-1", assistant("b"));
|
coalescer.handle("agent-1", assistant("b"));
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(200);
|
await vi.advanceTimersByTimeAsync(60);
|
||||||
|
|
||||||
expect(flushes.map((flush) => flush.item)).toEqual([
|
expect(flushes.map((flush) => flush.item)).toEqual([
|
||||||
{ type: "assistant_message", text: "a" },
|
{ type: "assistant_message", text: "a" },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js";
|
import type { AgentProvider, AgentStreamEvent, AgentTimelineItem } from "./agent-sdk-types.js";
|
||||||
|
|
||||||
export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 200;
|
export const AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS = 60;
|
||||||
|
|
||||||
type CoalescableTextKind = "assistant_message" | "reasoning";
|
type CoalescableTextKind = "assistant_message" | "reasoning";
|
||||||
type CoalescableTimelineKind = CoalescableTextKind | "tool_call";
|
type CoalescableTimelineKind = CoalescableTextKind | "tool_call";
|
||||||
|
|||||||
@@ -903,6 +903,166 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("streams Codex assistant message deltas and does not replay completed text", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/agentMessage/delta", {
|
||||||
|
itemId: "assistant-item-1",
|
||||||
|
delta: "Hel",
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/agentMessage/delta", {
|
||||||
|
itemId: "assistant-item-1",
|
||||||
|
delta: "lo",
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
item: {
|
||||||
|
id: "assistant-item-1",
|
||||||
|
type: "agentMessage",
|
||||||
|
text: "Hello",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "assistant_message", text: "Hel" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "assistant_message", text: "lo" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits only the missing assistant suffix when completed text extends streamed deltas", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/agentMessage/delta", {
|
||||||
|
itemId: "assistant-item-2",
|
||||||
|
delta: "Hel",
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/agentMessage/delta", {
|
||||||
|
itemId: "assistant-item-2",
|
||||||
|
delta: "lo",
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
item: {
|
||||||
|
id: "assistant-item-2",
|
||||||
|
type: "agentMessage",
|
||||||
|
text: "Hello!",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "assistant_message", text: "Hel" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "assistant_message", text: "lo" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "assistant_message", text: "!" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("streams Codex reasoning deltas and does not replay completed reasoning", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
|
||||||
|
itemId: "reasoning-item-1",
|
||||||
|
delta: "Think",
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
|
||||||
|
itemId: "reasoning-item-1",
|
||||||
|
delta: "ing",
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
item: {
|
||||||
|
id: "reasoning-item-1",
|
||||||
|
type: "reasoning",
|
||||||
|
summary: ["Thinking"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "reasoning", text: "Think" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "reasoning", text: "ing" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emits only the missing reasoning suffix when completed reasoning extends streamed deltas", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
|
||||||
|
itemId: "reasoning-item-2",
|
||||||
|
delta: "Think",
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/reasoning/summaryTextDelta", {
|
||||||
|
itemId: "reasoning-item-2",
|
||||||
|
delta: "ing",
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
item: {
|
||||||
|
id: "reasoning-item-2",
|
||||||
|
type: "reasoning",
|
||||||
|
summary: ["Thinking!"],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toEqual([
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "reasoning", text: "Think" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "reasoning", text: "ing" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "timeline",
|
||||||
|
provider: "codex",
|
||||||
|
turnId: "test-turn",
|
||||||
|
item: { type: "reasoning", text: "!" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: true },
|
featureValues: { plan_mode: true, fast_mode: true },
|
||||||
|
|||||||
@@ -3541,11 +3541,6 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private emitEvent(event: AgentStreamEvent): void {
|
private emitEvent(event: AgentStreamEvent): void {
|
||||||
if (event.type === "timeline") {
|
|
||||||
if (event.item.type === "assistant_message") {
|
|
||||||
this.pendingAgentMessages.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.notifySubscribers(event);
|
this.notifySubscribers(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3638,12 +3633,22 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (parsed.kind === "agent_message_delta") {
|
if (parsed.kind === "agent_message_delta") {
|
||||||
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
|
const prev = this.pendingAgentMessages.get(parsed.itemId) ?? "";
|
||||||
this.pendingAgentMessages.set(parsed.itemId, prev + parsed.delta);
|
this.pendingAgentMessages.set(parsed.itemId, prev + parsed.delta);
|
||||||
|
this.emitEvent({
|
||||||
|
type: "timeline",
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
item: { type: "assistant_message", text: parsed.delta },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsed.kind === "reasoning_delta") {
|
if (parsed.kind === "reasoning_delta") {
|
||||||
const prev = this.pendingReasoning.get(parsed.itemId) ?? [];
|
const prev = this.pendingReasoning.get(parsed.itemId) ?? [];
|
||||||
prev.push(parsed.delta);
|
prev.push(parsed.delta);
|
||||||
this.pendingReasoning.set(parsed.itemId, prev);
|
this.pendingReasoning.set(parsed.itemId, prev);
|
||||||
|
this.emitEvent({
|
||||||
|
type: "timeline",
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
item: { type: "reasoning", text: parsed.delta },
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsed.kind === "exec_command_output_delta") {
|
if (parsed.kind === "exec_command_output_delta") {
|
||||||
@@ -3705,6 +3710,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.emittedItemCompletedIds.clear();
|
this.emittedItemCompletedIds.clear();
|
||||||
this.emittedExecCommandStartedCallIds.clear();
|
this.emittedExecCommandStartedCallIds.clear();
|
||||||
this.emittedExecCommandCompletedCallIds.clear();
|
this.emittedExecCommandCompletedCallIds.clear();
|
||||||
|
this.pendingAgentMessages.clear();
|
||||||
|
this.pendingReasoning.clear();
|
||||||
this.pendingCommandOutputDeltas.clear();
|
this.pendingCommandOutputDeltas.clear();
|
||||||
this.pendingFileChangeOutputDeltas.clear();
|
this.pendingFileChangeOutputDeltas.clear();
|
||||||
this.warnedIncompleteEditToolCallIds.clear();
|
this.warnedIncompleteEditToolCallIds.clear();
|
||||||
@@ -3885,6 +3892,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (itemId && this.emittedItemCompletedIds.has(itemId)) {
|
if (itemId && this.emittedItemCompletedIds.has(itemId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
|
||||||
|
if (itemId) {
|
||||||
|
this.emittedItemCompletedIds.add(itemId);
|
||||||
|
this.emittedItemStartedIds.delete(itemId);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
|
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
|
||||||
if (timelineItem.type === "tool_call") {
|
if (timelineItem.type === "tool_call") {
|
||||||
if (timelineItem.detail.type === "plan") {
|
if (timelineItem.detail.type === "plan") {
|
||||||
@@ -3906,6 +3920,47 @@ class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private consumeStreamedTextCompletion(
|
||||||
|
timelineItem: AgentTimelineItem,
|
||||||
|
itemId: string | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!itemId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (timelineItem.type === "assistant_message" && this.pendingAgentMessages.has(itemId)) {
|
||||||
|
const streamedText = this.pendingAgentMessages.get(itemId) ?? "";
|
||||||
|
this.pendingAgentMessages.delete(itemId);
|
||||||
|
this.emitMissingFinalTextSuffix(timelineItem, streamedText);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (timelineItem.type === "reasoning" && this.pendingReasoning.has(itemId)) {
|
||||||
|
const streamedText = this.pendingReasoning.get(itemId)?.join("") ?? "";
|
||||||
|
this.pendingReasoning.delete(itemId);
|
||||||
|
this.emitMissingFinalTextSuffix(timelineItem, streamedText);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitMissingFinalTextSuffix(
|
||||||
|
timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
|
||||||
|
streamedText: string,
|
||||||
|
): void {
|
||||||
|
if (!timelineItem.text.startsWith(streamedText)) {
|
||||||
|
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const suffix = timelineItem.text.slice(streamedText.length);
|
||||||
|
if (!suffix) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.emitEvent({
|
||||||
|
type: "timeline",
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
item: { type: timelineItem.type, text: suffix },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private applyBufferedDeltaTextToTimelineItem(
|
private applyBufferedDeltaTextToTimelineItem(
|
||||||
timelineItem: AgentTimelineItem,
|
timelineItem: AgentTimelineItem,
|
||||||
itemId: string | null | undefined,
|
itemId: string | null | undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user