fix(stream): restore live timeline emission and preserve timeline events

This commit is contained in:
Mohamed Boudra
2026-04-05 22:28:11 +07:00
parent 25213ce83a
commit 8f83876a3c
2 changed files with 34 additions and 93 deletions
@@ -1390,7 +1390,7 @@ describe("AgentManager", () => {
expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]); expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]);
}); });
test("buffers assistant chunks provisionally and streams one finalized assistant row", async () => { test("streams assistant chunks incrementally and persists canonical chunk rows", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); const storage = new AgentStorage(storagePath, logger);
@@ -1440,29 +1440,45 @@ describe("AgentManager", () => {
} }
} }
const assistantTimelineEvents = streamEvents.filter((event) => event.itemType === "assistant_message"); const assistantTimelineEvents = streamEvents.filter(
expect(assistantTimelineEvents).toHaveLength(1); (event) => event.itemType === "assistant_message",
);
expect(assistantTimelineEvents).toHaveLength(2);
expect(assistantTimelineEvents[0]).toMatchObject({ expect(assistantTimelineEvents[0]).toMatchObject({
eventType: "timeline", eventType: "timeline",
itemType: "assistant_message", itemType: "assistant_message",
text: "final reply", text: "final ",
seq: 1, seq: 1,
}); });
expect(assistantTimelineEvents[1]).toMatchObject({
eventType: "timeline",
itemType: "assistant_message",
text: "reply",
seq: 2,
});
expect(manager.getTimeline(snapshot.id)).toEqual([ expect(manager.getTimeline(snapshot.id)).toEqual([
{ {
type: "assistant_message", type: "assistant_message",
text: "final reply", text: "final ",
},
{
type: "assistant_message",
text: "reply",
}, },
]); ]);
const fetched = await manager.fetchTimeline(snapshot.id, { const fetched = await manager.fetchTimeline(snapshot.id, {
direction: "tail", direction: "tail",
limit: 0, limit: 0,
}); });
expect(fetched.rows).toHaveLength(1); expect(fetched.rows).toHaveLength(2);
expect(fetched.rows[0]?.item).toEqual({ expect(fetched.rows[0]?.item).toEqual({
type: "assistant_message", type: "assistant_message",
text: "final reply", text: "final ",
});
expect(fetched.rows[1]?.item).toEqual({
type: "assistant_message",
text: "reply",
}); });
}); });
@@ -1560,7 +1576,7 @@ describe("AgentManager", () => {
expect(fetched.window.maxSeq).toBe(3); expect(fetched.window.maxSeq).toBe(3);
}); });
test("hydrateTimeline canonicalizes tool-interleaved assistant replay into the committed turn shape", async () => { test("hydrateTimeline preserves assistant chunk, reasoning, and tool timeline history", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-canonical-assistant-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-canonical-assistant-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); const storage = new AgentStorage(storagePath, logger);
@@ -1645,6 +1661,9 @@ describe("AgentManager", () => {
await manager.hydrateTimelineFromProvider(snapshot.id); await manager.hydrateTimelineFromProvider(snapshot.id);
expect(manager.getTimeline(snapshot.id)).toEqual([ expect(manager.getTimeline(snapshot.id)).toEqual([
{ type: "assistant_message", text: "chunk one " },
{ type: "assistant_message", text: "chunk two" },
{ type: "reasoning", text: "internal" },
{ {
type: "tool_call", type: "tool_call",
callId: "call-history-1", callId: "call-history-1",
@@ -1658,11 +1677,11 @@ describe("AgentManager", () => {
}, },
error: null, error: null,
}, },
{ type: "assistant_message", text: "chunk one chunk twofinal answer" }, { type: "assistant_message", text: "final answer" },
]); ]);
}); });
test("hydrateTimeline canonicalizes reasoning-interleaved assistant replay into one committed assistant row", async () => { test("hydrateTimeline preserves reasoning between assistant chunks", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-reasoning-interleave-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-history-reasoning-interleave-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); const storage = new AgentStorage(storagePath, logger);
@@ -1727,8 +1746,10 @@ describe("AgentManager", () => {
expect(manager.getTimeline(snapshot.id)).toEqual([ expect(manager.getTimeline(snapshot.id)).toEqual([
{ {
type: "assistant_message", type: "assistant_message",
text: "before reasoning after reasoning", text: "before reasoning ",
}, },
{ type: "reasoning", text: "internal step" },
{ type: "assistant_message", text: "after reasoning" },
]); ]);
}); });
@@ -151,7 +151,6 @@ type ManagedAgentBase = {
currentModeId: string | null; currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>; pendingPermissions: Map<string, AgentPermissionRequest>;
pendingReplacement: boolean; pendingReplacement: boolean;
provisionalAssistantText: string | null;
persistence: AgentPersistenceHandle | null; persistence: AgentPersistenceHandle | null;
historyPrimed: boolean; historyPrimed: boolean;
lastUserMessageAt: Date | null; lastUserMessageAt: Date | null;
@@ -657,7 +656,6 @@ export class AgentManager {
await this.cancelAgentRun(agentId); await this.cancelAgentRun(agentId);
existing = this.requireSessionAgent(agentId); existing = this.requireSessionAgent(agentId);
} }
const preservedProvisionalAssistantText = existing.provisionalAssistantText;
const preservedHistoryPrimed = existing.historyPrimed; const preservedHistoryPrimed = existing.historyPrimed;
const preservedLastUsage = existing.lastUsage; const preservedLastUsage = existing.lastUsage;
const preservedLastError = existing.lastError; const preservedLastError = existing.lastError;
@@ -700,7 +698,6 @@ export class AgentManager {
createdAt: existing.createdAt, createdAt: existing.createdAt,
updatedAt: existing.updatedAt, updatedAt: existing.updatedAt,
lastUserMessageAt: existing.lastUserMessageAt, lastUserMessageAt: existing.lastUserMessageAt,
provisionalAssistantText: preservedProvisionalAssistantText,
historyPrimed: preservedHistoryPrimed, historyPrimed: preservedHistoryPrimed,
lastUsage: preservedLastUsage, lastUsage: preservedLastUsage,
lastError: preservedLastError, lastError: preservedLastError,
@@ -1789,7 +1786,6 @@ export class AgentManager {
timeline?: AgentTimelineItem[]; timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[]; timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number; timelineNextSeq?: number;
provisionalAssistantText?: string | null;
historyPrimed?: boolean; historyPrimed?: boolean;
lastUsage?: AgentUsage; lastUsage?: AgentUsage;
lastError?: string; lastError?: string;
@@ -1848,7 +1844,6 @@ export class AgentManager {
activeForegroundTurnId: null, activeForegroundTurnId: null,
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(), foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
unsubscribeSession: null, unsubscribeSession: null,
provisionalAssistantText: options?.provisionalAssistantText ?? null,
persistence: attachPersistenceCwd(session.describePersistence(), config.cwd), persistence: attachPersistenceCwd(session.describePersistence(), config.cwd),
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows, historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
lastUserMessageAt: options?.lastUserMessageAt ?? null, lastUserMessageAt: options?.lastUserMessageAt ?? null,
@@ -2137,36 +2132,13 @@ export class AgentManager {
} }
agent.historyPrimed = true; agent.historyPrimed = true;
const canonicalUserMessagesById = this.timelineStore.getCanonicalUserMessagesById(agent.id); const canonicalUserMessagesById = this.timelineStore.getCanonicalUserMessagesById(agent.id);
const pendingTurnItems: AgentTimelineItem[] = [];
let bufferedAssistantText = "";
const flushPendingTurn = () => {
for (const item of pendingTurnItems) {
this.recordTimeline(agent.id, item);
}
pendingTurnItems.length = 0;
if (bufferedAssistantText) {
this.recordTimeline(agent.id, {
type: "assistant_message",
text: bufferedAssistantText,
});
bufferedAssistantText = "";
}
};
try { try {
for await (const event of agent.session.streamHistory()) { for await (const event of agent.session.streamHistory()) {
if (event.type !== "timeline") { if (event.type !== "timeline") {
if (
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled"
) {
flushPendingTurn();
}
continue; continue;
} }
if (event.item.type === "user_message") { if (event.item.type === "user_message") {
flushPendingTurn();
const eventMessageId = normalizeMessageId(event.item.messageId); const eventMessageId = normalizeMessageId(event.item.messageId);
if (eventMessageId) { if (eventMessageId) {
const canonicalText = canonicalUserMessagesById.get(eventMessageId); const canonicalText = canonicalUserMessagesById.get(eventMessageId);
@@ -2174,26 +2146,10 @@ export class AgentManager {
continue; continue;
} }
} }
this.recordTimeline(agent.id, event.item);
continue;
} }
if (event.item.type === "assistant_message") { this.recordTimeline(agent.id, event.item);
bufferedAssistantText += event.item.text;
continue;
}
if (event.item.type === "reasoning") {
continue;
}
if (event.item.type === "tool_call" && event.item.status === "running") {
continue;
}
pendingTurnItems.push(event.item);
} }
flushPendingTurn();
} catch { } catch {
// ignore history failures // ignore history failures
} }
@@ -2211,7 +2167,6 @@ export class AgentManager {
const isForegroundEvent = Boolean( const isForegroundEvent = Boolean(
eventTurnId && agent.activeForegroundTurnId === eventTurnId, eventTurnId && agent.activeForegroundTurnId === eventTurnId,
); );
let suppressLiveDispatch = false;
// Only update timestamp for live events, not history replay // Only update timestamp for live events, not history replay
if (!options?.fromHistory) { if (!options?.fromHistory) {
@@ -2265,17 +2220,6 @@ export class AgentManager {
} }
} }
} }
if (event.item.type === "assistant_message") {
agent.provisionalAssistantText = `${agent.provisionalAssistantText ?? ""}${event.item.text}`;
suppressLiveDispatch = true;
break;
}
if (event.item.type === "reasoning") {
break;
}
if (event.item.type === "tool_call" && event.item.status === "running") {
break;
}
timelineRow = this.recordTimeline(agent.id, event.item); timelineRow = this.recordTimeline(agent.id, event.item);
if (!options?.fromHistory && event.item.type === "user_message") { if (!options?.fromHistory && event.item.type === "user_message") {
agent.lastUserMessageAt = new Date(); agent.lastUserMessageAt = new Date();
@@ -2292,27 +2236,6 @@ export class AgentManager {
}, },
"handleStreamEvent: turn_completed", "handleStreamEvent: turn_completed",
); );
if (agent.provisionalAssistantText) {
const item: AgentTimelineItem = {
type: "assistant_message",
text: agent.provisionalAssistantText,
};
timelineRow = this.recordTimeline(agent.id, item);
if (!options?.fromHistory) {
this.dispatchStream(
agent.id,
{
type: "timeline",
item,
provider: event.provider,
},
{
seq: timelineRow.seq,
},
);
}
agent.provisionalAssistantText = null;
}
agent.lastUsage = event.usage; agent.lastUsage = event.usage;
agent.lastError = undefined; agent.lastError = undefined;
// For autonomous turns (not foreground), transition to idle // For autonomous turns (not foreground), transition to idle
@@ -2336,7 +2259,6 @@ export class AgentManager {
}, },
"handleStreamEvent: turn_failed", "handleStreamEvent: turn_failed",
); );
agent.provisionalAssistantText = null;
// For autonomous turns, set error state directly // For autonomous turns, set error state directly
if (!isForegroundEvent) { if (!isForegroundEvent) {
agent.lifecycle = "error"; agent.lifecycle = "error";
@@ -2373,7 +2295,6 @@ export class AgentManager {
}, },
"handleStreamEvent: turn_canceled", "handleStreamEvent: turn_canceled",
); );
agent.provisionalAssistantText = null;
// For autonomous turns, transition to idle // For autonomous turns, transition to idle
// unless a replacement is pending (avoid idle flash during replace) // unless a replacement is pending (avoid idle flash during replace)
if (!isForegroundEvent && !agent.pendingReplacement) { if (!isForegroundEvent && !agent.pendingReplacement) {
@@ -2405,7 +2326,6 @@ export class AgentManager {
}, },
"handleStreamEvent: turn_started", "handleStreamEvent: turn_started",
); );
agent.provisionalAssistantText = null;
// For autonomous turn_started (no foreground match), set running // For autonomous turn_started (no foreground match), set running
if (!isForegroundEvent) { if (!isForegroundEvent) {
(agent as ActiveManagedAgent).lifecycle = "running"; (agent as ActiveManagedAgent).lifecycle = "running";
@@ -2435,7 +2355,7 @@ export class AgentManager {
} }
// Skip dispatching individual stream events during history replay. // Skip dispatching individual stream events during history replay.
if (!options?.fromHistory && !suppressLiveDispatch) { if (!options?.fromHistory) {
this.dispatchStream( this.dispatchStream(
agent.id, agent.id,
event, event,