diff --git a/packages/app/src/components/agent-stream-view.test.tsx b/packages/app/src/components/agent-stream-view.test.tsx new file mode 100644 index 000000000..00ef122b7 --- /dev/null +++ b/packages/app/src/components/agent-stream-view.test.tsx @@ -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(), + workspaces: new Map(), + }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: ( + factory: (theme: { + borderRadius: Record; + borderWidth: Record; + colors: Record; + fontSize: Record; + fontWeight: Record; + shadow: Record; + spacing: Record; + }) => 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) => 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 { + 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"), + ); + }); +}); diff --git a/packages/app/src/components/agent-stream-view.tsx b/packages/app/src/components/agent-stream-view.tsx index 40d4bf6f1..412d756fd 100644 --- a/packages/app/src/components/agent-stream-view.tsx +++ b/packages/app/src/components/agent-stream-view.tsx @@ -1,4 +1,4 @@ -import { +import React, { forwardRef, memo, useCallback, @@ -126,6 +126,12 @@ const getAssistantBlockSpacing = (params: { } return "default"; }; + +interface StreamItemBoundarySeams { + aboveItem?: StreamItem | null; + belowItem?: StreamItem | null; +} + export interface AgentStreamViewHandle { scrollToBottom(reason?: BottomAnchorLocalRequest["reason"]): void; prepareForViewportChange(): void; @@ -397,7 +403,7 @@ const AgentStreamViewComponent = forwardRef, index: number, items: StreamItem[], - seamAboveItem: StreamItem | null, + seams: StreamItemBoundarySeams, ) => { const aboveItem = getStreamNeighborItem({ @@ -406,14 +412,17 @@ const AgentStreamViewComponent = forwardRef { switch (item.kind) { case "user_message": - return renderUserMessageItem(item, index, items, seamAboveItem); + return renderUserMessageItem(item, index, items, seams.aboveItem ?? null); case "assistant_message": - return renderAssistantMessageItem(item, index, items, seamAboveItem); + return renderAssistantMessageItem(item, index, items, seams); case "thought": return renderThoughtItem(item, index, items); @@ -563,9 +572,9 @@ const AgentStreamViewComponent = forwardRef { - const content = renderStreamItemContent(item, index, items, seamAboveItem); + const content = renderStreamItemContent(item, index, items, seams); if (!content) { return null; } @@ -664,6 +673,7 @@ const AgentStreamViewComponent = forwardRef { const indexById = new Map(); @@ -679,9 +689,12 @@ const AgentStreamViewComponent = forwardRef( (item, index, items) => - renderStreamItem(item, index, items, index === 0 ? lastHistoryItem : null), + renderStreamItem(item, index, items, { + aboveItem: index === 0 ? lastHistoryItem : null, + }), [lastHistoryItem, renderStreamItem], ); const liveAuxiliaryHeaderStyle = useMemo(() => { diff --git a/packages/app/src/components/stream-strategy-web.test.tsx b/packages/app/src/components/stream-strategy-web.test.tsx index 1b49e9c8a..9be47ca58 100644 --- a/packages/app/src/components/stream-strategy-web.test.tsx +++ b/packages/app/src/components/stream-strategy-web.test.tsx @@ -9,6 +9,24 @@ import type { StreamItem } from "@/types/stream"; import type { StreamSegmentRenderers, StreamViewportHandle } from "./stream-strategy"; 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 { return { kind: "user_message", diff --git a/packages/server/src/server/agent/agent-stream-coalescer.test.ts b/packages/server/src/server/agent/agent-stream-coalescer.test.ts index 270ba8253..3e409ccf4 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.test.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.test.ts @@ -103,7 +103,7 @@ describe("AgentStreamCoalescer", () => { expect(coalescer.handle("agent-1", assistant("lo"))).toBe(true); expect(flushes).toEqual([]); - await vi.advanceTimersByTimeAsync(199); + await vi.advanceTimersByTimeAsync(59); expect(flushes).toEqual([]); await vi.advanceTimersByTimeAsync(1); @@ -119,7 +119,7 @@ describe("AgentStreamCoalescer", () => { test("uses constructor windowMs instead of a hard-coded value", async () => { 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); await vi.advanceTimersByTimeAsync(9); @@ -203,7 +203,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", reasoning("r1")); coalescer.handle("agent-1", assistant("a2")); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -231,7 +231,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", assistant("a2")); coalescer.handle("agent-1", reasoning("r2")); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes.map((flush) => flush.item)).toEqual([ { type: "assistant_message", text: "a1" }, { type: "reasoning", text: "r1" }, @@ -246,7 +246,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", assistant("c", { provider: "codex" })); coalescer.handle("agent-1", assistant("o", { provider: "opencode" })); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -267,7 +267,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", assistant("one", { turnId: "turn-1" })); coalescer.handle("agent-1", assistant("two", { turnId: "turn-2" })); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -290,7 +290,7 @@ describe("AgentStreamCoalescer", () => { expect(coalescer.handle("agent-1", assistant(""))).toBe(true); expect(coalescer.handle("agent-2", reasoning(""))).toBe(true); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([]); }); @@ -302,7 +302,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", assistant("\t")); coalescer.handle("agent-1", assistant(" mixed \n\t whitespace ")); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -332,7 +332,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", assistant(chunk)); } - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -350,7 +350,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", assistant("b")); coalescer.handle("agent-2", assistant("y")); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -431,7 +431,7 @@ describe("AgentStreamCoalescer", () => { }, ]); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -452,7 +452,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", assistant("manual")); coalescer.flushFor("agent-1"); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -468,7 +468,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", assistant("durable")); coalescer.flushAndDiscard("agent-1"); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { agentId: "agent-1", @@ -532,7 +532,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", timeline(firstItem)); coalescer.handle("agent-1", assistant("llo")); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes).toEqual([ { 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: "second" }))).toBe(true); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); 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-2", output: "two-b" })); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes.map((flush) => flush.item)).toEqual([ { @@ -651,7 +651,7 @@ describe("AgentStreamCoalescer", () => { coalescer.handle("agent-1", toolCall({ output: "latest" })); coalescer.handle("agent-1", assistant("b")); - await vi.advanceTimersByTimeAsync(200); + await vi.advanceTimersByTimeAsync(60); expect(flushes.map((flush) => flush.item)).toEqual([ { type: "assistant_message", text: "a" }, diff --git a/packages/server/src/server/agent/agent-stream-coalescer.ts b/packages/server/src/server/agent/agent-stream-coalescer.ts index 78fd3e45f..88ba74051 100644 --- a/packages/server/src/server/agent/agent-stream-coalescer.ts +++ b/packages/server/src/server/agent/agent-stream-coalescer.ts @@ -1,6 +1,6 @@ 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 CoalescableTimelineKind = CoalescableTextKind | "tool_call"; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index d4e53ae3c..f2be5f099 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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 () => { const session = createSession({ featureValues: { plan_mode: true, fast_mode: true }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 9d5f65b3d..b410603f2 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3541,11 +3541,6 @@ class CodexAppServerAgentSession implements AgentSession { } private emitEvent(event: AgentStreamEvent): void { - if (event.type === "timeline") { - if (event.item.type === "assistant_message") { - this.pendingAgentMessages.clear(); - } - } this.notifySubscribers(event); } @@ -3638,12 +3633,22 @@ class CodexAppServerAgentSession implements AgentSession { if (parsed.kind === "agent_message_delta") { const prev = this.pendingAgentMessages.get(parsed.itemId) ?? ""; this.pendingAgentMessages.set(parsed.itemId, prev + parsed.delta); + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { type: "assistant_message", text: parsed.delta }, + }); return; } if (parsed.kind === "reasoning_delta") { const prev = this.pendingReasoning.get(parsed.itemId) ?? []; prev.push(parsed.delta); this.pendingReasoning.set(parsed.itemId, prev); + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { type: "reasoning", text: parsed.delta }, + }); return; } if (parsed.kind === "exec_command_output_delta") { @@ -3705,6 +3710,8 @@ class CodexAppServerAgentSession implements AgentSession { this.emittedItemCompletedIds.clear(); this.emittedExecCommandStartedCallIds.clear(); this.emittedExecCommandCompletedCallIds.clear(); + this.pendingAgentMessages.clear(); + this.pendingReasoning.clear(); this.pendingCommandOutputDeltas.clear(); this.pendingFileChangeOutputDeltas.clear(); this.warnedIncompleteEditToolCallIds.clear(); @@ -3885,6 +3892,13 @@ class CodexAppServerAgentSession implements AgentSession { if (itemId && this.emittedItemCompletedIds.has(itemId)) { return; } + if (this.consumeStreamedTextCompletion(timelineItem, itemId)) { + if (itemId) { + this.emittedItemCompletedIds.add(itemId); + this.emittedItemStartedIds.delete(itemId); + } + return; + } this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); if (timelineItem.type === "tool_call") { 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, + 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( timelineItem: AgentTimelineItem, itemId: string | null | undefined,