Fix duplicated and out-of-order agent chat messages (#2185)
* fix(app): keep agent timelines ordered during catch-up Projected catch-up pages can overlap live rows and arrive after optimistic prompts. Reconcile canonical projections before the existing stream reducer, and retain selective subscriptions briefly across quick view switches. * refactor(app): make optimistic catch-up consumption explicit * fix(app): preserve catch-up message order * fix(app): isolate delayed catch-up history * fix(app): anchor catch-up before live turns * fix(app): stage complete catch-up history * fix(app): keep timeline arrays Hermes-safe * refactor(app): preserve prompt positions during catch-up
This commit is contained in:
@@ -51,9 +51,39 @@ When a client resumes with a known cursor, it catches up after that cursor to co
|
||||
|
||||
When a client resumes without a cursor, it fetches the latest tail page.
|
||||
|
||||
## Selective and legacy delivery
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||
catch up immediately. Removals stay subscribed for a short grace period so quick tab and pane
|
||||
switches do not repeatedly unsubscribe and catch up. Backgrounding, disconnecting, and disposal
|
||||
clear that grace immediately.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
This policy is owned by `viewed-timeline-sync.ts`; downstream reducers do not branch on daemon
|
||||
version.
|
||||
|
||||
## Projected pages reconcile with live presentation
|
||||
|
||||
A projected page is canonical state, not a sequence of live deltas. One projected item can overlap
|
||||
rows already received live—for example, a tool call retained at its original display position while
|
||||
its completion advances `seqEnd`, followed by a merged assistant message. The app uses
|
||||
`sourceSeqRanges` to replace overlapping assistant and reasoning projections before applying the
|
||||
remaining page through the existing stream reducer. It must not append full projected text to a
|
||||
live prefix.
|
||||
|
||||
Optimistic user prompts are presentation state rather than canonical history. Incremental catch-up
|
||||
temporarily separates them, applies canonical entries, lets canonical user rows reconcile through
|
||||
the existing optimistic-message rules, then restores any unmatched prompts after the caught-up
|
||||
history. This keeps late history before a newly submitted prompt without duplicating an
|
||||
acknowledged prompt.
|
||||
|
||||
## Relevant code
|
||||
|
||||
- Server live stream forwarding: `packages/server/src/server/session.ts`
|
||||
- App sync planning: `packages/app/src/timeline/timeline-sync-plan.ts`
|
||||
- App viewed-agent synchronization: `packages/app/src/timeline/viewed-timeline-sync.ts`
|
||||
- App stream/timeline reducer: `packages/app/src/timeline/session-stream-reducers.ts`
|
||||
- Session wiring: `packages/app/src/contexts/session-context.tsx`
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
} from "@/timeline/session-stream-reducers";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { isTimelineCatchUpComplete } from "@/timeline/timeline-sync-plan";
|
||||
import { createViewedTimelineSync, type ViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
|
||||
import {
|
||||
createViewedTimelineSync,
|
||||
type TimelineDeliveryMode,
|
||||
type ViewedTimelineSync,
|
||||
} from "@/timeline/viewed-timeline-sync";
|
||||
import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
@@ -79,6 +83,11 @@ interface BufferedAudioChunk {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// COMPAT(selectiveAgentTimeline): added in v0.1.106, remove after 2027-01-12.
|
||||
function getTimelineDeliveryMode(selectiveAgentTimeline?: boolean): TimelineDeliveryMode {
|
||||
return selectiveAgentTimeline ? "selective" : "legacy";
|
||||
}
|
||||
|
||||
function decodeBase64Chunk(base64: string): Uint8Array {
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
@@ -712,7 +721,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
useEffect(() => {
|
||||
const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents);
|
||||
const initialDeliveryMode = getTimelineDeliveryMode(
|
||||
client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline,
|
||||
);
|
||||
const sync = createViewedTimelineSync({
|
||||
initialDeliveryMode,
|
||||
setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds),
|
||||
readCursor: (agentId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.agentTimelineCursor.get(agentId),
|
||||
@@ -723,7 +736,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
fetchPage: async (agentId, request) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
if (session?.agentAuthoritativeHistoryApplied.get(agentId) !== true) {
|
||||
const shouldInitialize = session?.agentAuthoritativeHistoryApplied.get(agentId) !== true;
|
||||
if (shouldInitialize) {
|
||||
if (!getInitDeferred(initKey)) {
|
||||
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
|
||||
void deferred.promise.catch(() => undefined);
|
||||
@@ -737,21 +751,23 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
}
|
||||
try {
|
||||
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
|
||||
if (getInitDeferred(initKey)) {
|
||||
if (shouldInitialize && getInitDeferred(initKey)) {
|
||||
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
|
||||
}
|
||||
return page;
|
||||
} catch (error) {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
|
||||
if (shouldInitialize) {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
reportError: (error) => {
|
||||
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
|
||||
},
|
||||
scheduleRetry: (retry) => {
|
||||
const timeout = setTimeout(retry, 1_000);
|
||||
schedule: (task, delayMs) => {
|
||||
const timeout = setTimeout(task, delayMs);
|
||||
return () => clearTimeout(timeout);
|
||||
},
|
||||
});
|
||||
@@ -855,6 +871,9 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
if (message.type !== "status") return;
|
||||
const serverInfo = parseServerInfoStatusPayload(message.payload);
|
||||
if (serverInfo) {
|
||||
viewedTimelineSyncRef.current?.setDeliveryMode(
|
||||
getTimelineDeliveryMode(serverInfo.features?.selectiveAgentTimeline),
|
||||
);
|
||||
updateSessionServerInfo(serverId, {
|
||||
serverId: serverInfo.serverId,
|
||||
hostname: serverInfo.hostname,
|
||||
|
||||
@@ -107,7 +107,10 @@ function makeStreamReducerEvent(
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssistantItem(text: string, id = `assistant-${text.length}`): StreamItem {
|
||||
function makeAssistantItem(
|
||||
text: string,
|
||||
id = `assistant-${text.length}`,
|
||||
): Extract<StreamItem, { kind: "assistant_message" }> {
|
||||
return {
|
||||
kind: "assistant_message",
|
||||
id,
|
||||
@@ -556,7 +559,7 @@ describe("processTimelineResponse", () => {
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "sent while catching up",
|
||||
messageId: "canonical-after",
|
||||
messageId: "optimistic-after",
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -565,10 +568,125 @@ describe("processTimelineResponse", () => {
|
||||
|
||||
const userMessages = result.tail.filter((item) => item.kind === "user_message");
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(userMessages[0]?.id).toBe("canonical-after");
|
||||
expect(userMessages[0]?.id).toBe("optimistic-after");
|
||||
expect(userMessages[0]?.optimistic).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reconciles multiple optimistic user messages in canonical order", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [
|
||||
makeOptimisticUserMessage("first prompt", "optimistic-first"),
|
||||
makeOptimisticUserMessage("second prompt", "optimistic-second"),
|
||||
],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 2 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(2, "first prompt", "user_message"),
|
||||
item: { type: "user_message", text: "first prompt", messageId: "optimistic-first" },
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(3, "second prompt", "user_message"),
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "second prompt",
|
||||
messageId: "optimistic-second",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result.tail
|
||||
.filter((item) => item.kind === "user_message")
|
||||
.map((item) => ({ id: item.id, text: item.text, optimistic: item.optimistic })),
|
||||
).toEqual([
|
||||
{ id: "optimistic-first", text: "first prompt", optimistic: undefined },
|
||||
{ id: "optimistic-second", text: "second prompt", optimistic: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a tail optimistic prompt before a reconciled live assistant head", () => {
|
||||
const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [prompt],
|
||||
currentHead: [
|
||||
{
|
||||
...makeAssistantItem("Hel", "answer-1"),
|
||||
messageId: "answer-1",
|
||||
},
|
||||
],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(2, "Hello", "assistant_message", 3),
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }],
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Hello",
|
||||
messageId: "answer-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[...result.tail, ...result.head]
|
||||
.filter((item) => item.kind === "assistant_message" || item.kind === "user_message")
|
||||
.map((item) => item.text),
|
||||
).toEqual(["new prompt", "Hello"]);
|
||||
});
|
||||
|
||||
it("keeps a tail optimistic prompt before a live head flushed by catch-up", () => {
|
||||
const prompt = makeOptimisticUserMessage("new prompt", "optimistic-new-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [prompt],
|
||||
currentHead: [
|
||||
{
|
||||
...makeAssistantItem("Live response", "answer-1"),
|
||||
messageId: "answer-1",
|
||||
},
|
||||
],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
makeToolCallTimelineEntry(3, "call-1", "running", {
|
||||
type: "read",
|
||||
filePath: "/tmp/example.ts",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[...result.tail, ...result.head]
|
||||
.filter((item) => item.kind === "assistant_message" || item.kind === "user_message")
|
||||
.map((item) => item.text),
|
||||
).toEqual(["new prompt", "Live response"]);
|
||||
});
|
||||
|
||||
it("keeps an active assistant head live when an incremental fetch accepts same-turn assistant text", () => {
|
||||
const existingCursor: TimelineCursor = {
|
||||
epoch: "epoch-1",
|
||||
@@ -597,6 +715,504 @@ describe("processTimelineResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replay an assistant prefix when catch-up completes an earlier tool call", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("call-1"), 1),
|
||||
makeStreamReducerEvent(makeAssistantTimelineEvent("Hel", "answer-1"), 2),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: live.tail,
|
||||
currentHead: live.head,
|
||||
currentCursor: live.cursor ?? undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 4 },
|
||||
entries: [
|
||||
{
|
||||
...makeToolCallTimelineEntry(1, "call-1", "completed", {
|
||||
type: "read",
|
||||
filePath: "/tmp/example.ts",
|
||||
}),
|
||||
seqEnd: 4,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 1, endSeq: 1 },
|
||||
{ startSeq: 4, endSeq: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(2, "Hello", "assistant_message", 3),
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }],
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Hello",
|
||||
messageId: "answer-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual(["Hello"]);
|
||||
});
|
||||
|
||||
it("reconciles an identified projection with an overlapping anonymous live prefix", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentHead: [makeAssistantItem("Hel")],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(2, "Hello", "assistant_message", 3),
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }],
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "Hello",
|
||||
messageId: "answer-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const assistants = [...result.tail, ...result.head].filter(
|
||||
(item) => item.kind === "assistant_message",
|
||||
);
|
||||
expect(assistants).toHaveLength(1);
|
||||
expect(assistants[0]).toMatchObject({ text: "Hello", messageId: "answer-1" });
|
||||
});
|
||||
|
||||
it("replaces every promoted assistant block when reconciling a projected message", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [makeStreamReducerEvent(makeAssistantTimelineEvent("First paragraph.\n\nSec"), 2)],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
|
||||
currentAgent: null,
|
||||
});
|
||||
expect(getAssistantTexts(live.tail)).toHaveLength(1);
|
||||
expect(getAssistantTexts(live.head)).toHaveLength(1);
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: live.tail,
|
||||
currentHead: live.head,
|
||||
currentCursor: live.cursor ?? undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
direction: "after",
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(
|
||||
2,
|
||||
"First paragraph.\n\nSecond paragraph.",
|
||||
"assistant_message",
|
||||
3,
|
||||
),
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }],
|
||||
item: {
|
||||
type: "assistant_message",
|
||||
text: "First paragraph.\n\nSecond paragraph.",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getAssistantTexts([...result.tail, ...result.head])).toEqual([
|
||||
"First paragraph.\n\nSecond paragraph.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not replay a reasoning prefix when catch-up completes an earlier tool call", () => {
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(makeToolCallTimelineEvent("call-1"), 1),
|
||||
makeStreamReducerEvent(makeTimelineEvent("Thi", "reasoning"), 2),
|
||||
],
|
||||
currentTail: [],
|
||||
currentHead: [],
|
||||
currentCursor: undefined,
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: live.tail,
|
||||
currentHead: live.head,
|
||||
currentCursor: live.cursor ?? undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 4 },
|
||||
entries: [
|
||||
{
|
||||
...makeToolCallTimelineEntry(1, "call-1", "completed", {
|
||||
type: "read",
|
||||
filePath: "/tmp/example.ts",
|
||||
}),
|
||||
seqEnd: 4,
|
||||
sourceSeqRanges: [
|
||||
{ startSeq: 1, endSeq: 1 },
|
||||
{ startSeq: 4, endSeq: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(2, "Thinking", "reasoning", 3),
|
||||
sourceSeqRanges: [{ startSeq: 2, endSeq: 3 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const thoughts = [...result.tail, ...result.head].filter((item) => item.kind === "thought");
|
||||
expect(thoughts).toHaveLength(1);
|
||||
expect(thoughts[0]?.text).toBe("Thinking");
|
||||
});
|
||||
|
||||
it("keeps delayed catch-up history before a newly submitted prompt", () => {
|
||||
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [makeAssistantItem("Earlier answer", "earlier-answer"), prompt],
|
||||
currentHead: [],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [makeTimelineEntry(3, "Missed answer")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[...result.tail, ...result.head]
|
||||
.filter((item) => item.kind === "assistant_message" || item.kind === "user_message")
|
||||
.map((item) => item.text),
|
||||
).toEqual(["Earlier answer", "Missed answer", "New prompt"]);
|
||||
});
|
||||
|
||||
it("keeps delayed catch-up history between a live head and its unmatched head prompt", () => {
|
||||
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [],
|
||||
currentHead: [makeAssistantItem("Live answer", "live-answer"), prompt],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
makeToolCallTimelineEntry(3, "missed-call", "completed", {
|
||||
type: "read",
|
||||
filePath: "/tmp/missed.ts",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
|
||||
"assistant_message",
|
||||
"tool_call",
|
||||
"user_message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps delayed catch-up history between a live head and its acknowledged head prompt", () => {
|
||||
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [],
|
||||
currentHead: [makeAssistantItem("Live answer", "live-answer"), prompt],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 4 },
|
||||
entries: [
|
||||
makeToolCallTimelineEntry(3, "missed-call", "completed", {
|
||||
type: "read",
|
||||
filePath: "/tmp/missed.ts",
|
||||
}),
|
||||
{
|
||||
...makeTimelineEntry(4, "New prompt", "user_message"),
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "New prompt",
|
||||
messageId: "new-prompt",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
|
||||
"assistant_message",
|
||||
"tool_call",
|
||||
"user_message",
|
||||
]);
|
||||
expect(
|
||||
[...result.tail, ...result.head]
|
||||
.filter((item) => item.kind === "user_message")
|
||||
.map((item) => item.optimistic),
|
||||
).toEqual([undefined]);
|
||||
});
|
||||
|
||||
it("keeps unrelated delayed history before the prompt and its live response", () => {
|
||||
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [makeAssistantItem("Earlier answer", "earlier-answer"), prompt],
|
||||
currentHead: [
|
||||
{
|
||||
...makeAssistantItem("Live response", "live-response"),
|
||||
messageId: "live-response",
|
||||
},
|
||||
],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 2 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 4 },
|
||||
entries: [
|
||||
makeTimelineEntry(3, "Missed answer"),
|
||||
{
|
||||
...makeTimelineEntry(4, "Remote prompt", "user_message"),
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Remote prompt",
|
||||
messageId: "remote-prompt",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[...result.tail, ...result.head]
|
||||
.filter((item) => item.kind === "assistant_message" || item.kind === "user_message")
|
||||
.map((item) => item.text),
|
||||
).toEqual(["Earlier answer", "Missed answer", "Remote prompt", "New prompt", "Live response"]);
|
||||
});
|
||||
|
||||
it("keeps delayed history before a prompt whose live answer has promoted blocks", () => {
|
||||
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(
|
||||
makeAssistantTimelineEvent("First paragraph.\n\nSecond paragraph", "live-response"),
|
||||
2,
|
||||
),
|
||||
],
|
||||
currentTail: [prompt],
|
||||
currentHead: [],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: live.tail,
|
||||
currentHead: live.head,
|
||||
currentCursor: live.cursor ?? undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [makeTimelineEntry(3, "Missed answer")],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
[...result.tail, ...result.head]
|
||||
.filter((item) => item.kind === "assistant_message" || item.kind === "user_message")
|
||||
.map((item) => item.text),
|
||||
).toEqual(["Missed answer", "New prompt", "First paragraph.", "Second paragraph"]);
|
||||
});
|
||||
|
||||
it("keeps delayed tool history before a prompt whose live answer has promoted blocks", () => {
|
||||
const prompt = makeOptimisticUserMessage("New prompt", "new-prompt");
|
||||
const live = processAgentStreamEvents({
|
||||
events: [
|
||||
makeStreamReducerEvent(
|
||||
makeAssistantTimelineEvent("First paragraph.\n\nSecond paragraph", "live-response"),
|
||||
2,
|
||||
),
|
||||
],
|
||||
currentTail: [prompt],
|
||||
currentHead: [],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
|
||||
currentAgent: null,
|
||||
});
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: live.tail,
|
||||
currentHead: live.head,
|
||||
currentCursor: live.cursor ?? undefined,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 3 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
makeToolCallTimelineEntry(3, "missed-call", "completed", {
|
||||
type: "read",
|
||||
filePath: "/tmp/missed.ts",
|
||||
}),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect([...result.tail, ...result.head].map((item) => item.kind)).toEqual([
|
||||
"tool_call",
|
||||
"user_message",
|
||||
"assistant_message",
|
||||
"assistant_message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches a local optimistic prompt after an unrelated remote user row", () => {
|
||||
const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [prompt],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 2 },
|
||||
endCursor: { seq: 3 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(2, "Remote prompt", "user_message"),
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Remote prompt",
|
||||
messageId: "remote-prompt",
|
||||
},
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(3, "Local prompt", "user_message"),
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Local prompt",
|
||||
messageId: "local-prompt",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result.tail
|
||||
.filter((item) => item.kind === "user_message")
|
||||
.map((item) => ({ text: item.text, optimistic: item.optimistic })),
|
||||
).toEqual([
|
||||
{ text: "Remote prompt", optimistic: undefined },
|
||||
{ text: "Local prompt", optimistic: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps an unmatched optimistic prompt when catch-up contains only a remote user row", () => {
|
||||
const prompt = makeOptimisticUserMessage("Local prompt", "local-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [prompt],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 2 },
|
||||
endCursor: { seq: 2 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(2, "Remote prompt", "user_message"),
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Remote prompt",
|
||||
messageId: "remote-prompt",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result.tail
|
||||
.filter((item) => item.kind === "user_message")
|
||||
.map((item) => ({ text: item.text, optimistic: item.optimistic })),
|
||||
).toEqual([
|
||||
{ text: "Remote prompt", optimistic: undefined },
|
||||
{ text: "Local prompt", optimistic: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not match equal prompt text when canonical message ids differ", () => {
|
||||
const prompt = makeOptimisticUserMessage("continue", "local-prompt");
|
||||
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
currentTail: [prompt],
|
||||
currentCursor: { epoch: "epoch-1", startSeq: 1, endSeq: 1 },
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
epoch: "epoch-1",
|
||||
startCursor: { seq: 2 },
|
||||
endCursor: { seq: 2 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(2, "continue", "user_message"),
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "continue",
|
||||
messageId: "remote-prompt",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
result.tail
|
||||
.filter((item) => item.kind === "user_message")
|
||||
.map((item) => ({ id: item.id, optimistic: item.optimistic })),
|
||||
).toEqual([
|
||||
{ id: "remote-prompt", optimistic: undefined },
|
||||
{ id: "local-prompt", optimistic: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates a fetched in-progress tool call as one item and streams the next update on top", () => {
|
||||
const fetched = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
flushHeadToTail,
|
||||
hydrateStreamState,
|
||||
isAgentToolCallItem,
|
||||
mergeAgentToolCallItem,
|
||||
@@ -544,6 +545,378 @@ function replaceLiveAssistantWithProjectedText(params: {
|
||||
return next;
|
||||
}
|
||||
|
||||
interface OptimisticUserMessagePosition {
|
||||
item: UserMessageItem;
|
||||
placement: "tail" | "head";
|
||||
index: number;
|
||||
}
|
||||
|
||||
function findOptimisticUserMessage(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): OptimisticUserMessagePosition | null {
|
||||
const tailIndex = params.tail.findIndex(
|
||||
(item) => item.kind === "user_message" && item.optimistic,
|
||||
);
|
||||
const tailItem = params.tail[tailIndex];
|
||||
if (tailItem?.kind === "user_message") {
|
||||
return { item: tailItem, placement: "tail", index: tailIndex };
|
||||
}
|
||||
|
||||
const headIndex = params.head.findIndex(
|
||||
(item) => item.kind === "user_message" && item.optimistic,
|
||||
);
|
||||
const headItem = params.head[headIndex];
|
||||
return headItem?.kind === "user_message"
|
||||
? { item: headItem, placement: "head", index: headIndex }
|
||||
: null;
|
||||
}
|
||||
|
||||
function replaceOptimisticUserMessage(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
position: OptimisticUserMessagePosition;
|
||||
precedingItems: StreamItem[];
|
||||
replacement: StreamItem[];
|
||||
}): { tail: StreamItem[]; head: StreamItem[] } {
|
||||
const { position } = params;
|
||||
const items = [...params.precedingItems, ...params.replacement];
|
||||
if (position.placement === "tail") {
|
||||
return {
|
||||
tail: [
|
||||
...params.tail.slice(0, position.index),
|
||||
...items,
|
||||
...params.tail.slice(position.index + 1),
|
||||
],
|
||||
head: params.head,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: params.tail,
|
||||
head: [
|
||||
...params.head.slice(0, position.index),
|
||||
...items,
|
||||
...params.head.slice(position.index + 1),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileOverlappingProjectedAssistant(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
unit: TimelineUnit;
|
||||
epoch: string;
|
||||
currentEndSeq: number;
|
||||
}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } {
|
||||
const { unit } = params;
|
||||
if (
|
||||
unit.event.type !== "timeline" ||
|
||||
unit.event.item.type !== "assistant_message" ||
|
||||
!unit.sourceSeqRanges.some(
|
||||
(range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq,
|
||||
)
|
||||
) {
|
||||
return { tail: params.tail, head: params.head, reconciled: false };
|
||||
}
|
||||
|
||||
const projectedText = unit.event.item.text;
|
||||
const projectedMessageId = unit.event.item.messageId;
|
||||
const matches = (item: StreamItem) => {
|
||||
if (item.kind !== "assistant_message") return false;
|
||||
if (projectedMessageId && item.messageId) return item.messageId === projectedMessageId;
|
||||
return projectedText.startsWith(item.text);
|
||||
};
|
||||
const findMatch = (items: StreamItem[]) => {
|
||||
const index = items.findLastIndex(matches);
|
||||
const current = items[index];
|
||||
return current?.kind === "assistant_message" ? { current, index } : null;
|
||||
};
|
||||
|
||||
const headMatch = findMatch(params.head);
|
||||
const tailMatch = headMatch ? null : findMatch(params.tail);
|
||||
const match = headMatch ?? tailMatch;
|
||||
if (!match) {
|
||||
return { tail: params.tail, head: params.head, reconciled: false };
|
||||
}
|
||||
|
||||
const blockGroupId = match.current.blockGroupId;
|
||||
const messageId = projectedMessageId ?? match.current.messageId;
|
||||
const replacement: AssistantMessageItem = {
|
||||
kind: "assistant_message",
|
||||
id: blockGroupId ?? match.current.id,
|
||||
...(messageId !== undefined ? { messageId } : {}),
|
||||
text: projectedText,
|
||||
timestamp: unit.timestamp,
|
||||
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd },
|
||||
};
|
||||
const belongsToBlockGroup = (item: StreamItem) =>
|
||||
blockGroupId !== undefined &&
|
||||
item.kind === "assistant_message" &&
|
||||
item.blockGroupId === blockGroupId;
|
||||
const removeBlockGroup = (items: StreamItem[]) =>
|
||||
blockGroupId !== undefined ? items.filter((item) => !belongsToBlockGroup(item)) : items;
|
||||
const replaceMatch = (items: StreamItem[], index: number) => {
|
||||
if (!blockGroupId) {
|
||||
const next = [...items];
|
||||
next[index] = replacement;
|
||||
return next;
|
||||
}
|
||||
const next: StreamItem[] = [];
|
||||
let inserted = false;
|
||||
for (const item of items) {
|
||||
if (!belongsToBlockGroup(item)) {
|
||||
next.push(item);
|
||||
} else if (!inserted) {
|
||||
next.push(replacement);
|
||||
inserted = true;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
if (headMatch) {
|
||||
return {
|
||||
tail: removeBlockGroup(params.tail),
|
||||
head: replaceMatch(params.head, headMatch.index),
|
||||
reconciled: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: replaceMatch(params.tail, match.index),
|
||||
head: removeBlockGroup(params.head),
|
||||
reconciled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function reconcileOverlappingProjectedReasoning(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
unit: TimelineUnit;
|
||||
currentEndSeq: number;
|
||||
}): { tail: StreamItem[]; head: StreamItem[]; reconciled: boolean } {
|
||||
const { unit } = params;
|
||||
if (
|
||||
unit.event.type !== "timeline" ||
|
||||
unit.event.item.type !== "reasoning" ||
|
||||
!unit.sourceSeqRanges.some(
|
||||
(range) => range.startSeq <= params.currentEndSeq && range.endSeq > params.currentEndSeq,
|
||||
)
|
||||
) {
|
||||
return { tail: params.tail, head: params.head, reconciled: false };
|
||||
}
|
||||
|
||||
const projectedText = unit.event.item.text;
|
||||
const replaceIn = (items: StreamItem[]): StreamItem[] | null => {
|
||||
const index = items.findLastIndex(
|
||||
(item) => item.kind === "thought" && projectedText.startsWith(item.text),
|
||||
);
|
||||
const current = items[index];
|
||||
if (!current || current.kind !== "thought") return null;
|
||||
const next = [...items];
|
||||
next[index] = {
|
||||
...current,
|
||||
text: projectedText,
|
||||
timestamp: unit.timestamp,
|
||||
status: "loading",
|
||||
};
|
||||
return next;
|
||||
};
|
||||
|
||||
const nextHead = replaceIn(params.head);
|
||||
if (nextHead) return { tail: params.tail, head: nextHead, reconciled: true };
|
||||
const nextTail = replaceIn(params.tail);
|
||||
return nextTail
|
||||
? { tail: nextTail, head: params.head, reconciled: true }
|
||||
: { tail: params.tail, head: params.head, reconciled: false };
|
||||
}
|
||||
|
||||
function reconcileOverlappingProjectedStreamItems(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
units: TimelineUnit[];
|
||||
epoch: string;
|
||||
currentEndSeq: number | undefined;
|
||||
}): { tail: StreamItem[]; head: StreamItem[]; reconciledUnits: Set<TimelineUnit> } {
|
||||
let tail = params.tail;
|
||||
let head = params.head;
|
||||
const reconciledUnits = new Set<TimelineUnit>();
|
||||
if (params.currentEndSeq === undefined) return { tail, head, reconciledUnits };
|
||||
|
||||
for (const unit of params.units) {
|
||||
let reconciled = reconcileOverlappingProjectedAssistant({
|
||||
tail,
|
||||
head,
|
||||
unit,
|
||||
epoch: params.epoch,
|
||||
currentEndSeq: params.currentEndSeq,
|
||||
});
|
||||
if (!reconciled.reconciled) {
|
||||
reconciled = reconcileOverlappingProjectedReasoning({
|
||||
tail,
|
||||
head,
|
||||
unit,
|
||||
currentEndSeq: params.currentEndSeq,
|
||||
});
|
||||
}
|
||||
tail = reconciled.tail;
|
||||
head = reconciled.head;
|
||||
if (reconciled.reconciled) reconciledUnits.add(unit);
|
||||
}
|
||||
return { tail, head, reconciledUnits };
|
||||
}
|
||||
|
||||
function matchesOptimisticUserMessage(params: {
|
||||
unit: TimelineUnit;
|
||||
optimistic: UserMessageItem;
|
||||
}): boolean {
|
||||
const { event } = params.unit;
|
||||
if (event.type !== "timeline" || event.item.type !== "user_message") {
|
||||
return false;
|
||||
}
|
||||
if (event.item.messageId !== undefined) {
|
||||
return event.item.messageId === params.optimistic.id;
|
||||
}
|
||||
return event.item.text.length > 0 && event.item.text === params.optimistic.text;
|
||||
}
|
||||
|
||||
function acknowledgeOptimisticUserMessage(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
unit: TimelineUnit;
|
||||
epoch: string;
|
||||
optimistic: OptimisticUserMessagePosition;
|
||||
precedingItems: StreamItem[];
|
||||
}): { tail: StreamItem[]; head: StreamItem[] } {
|
||||
const { event, timestamp, seqEnd } = params.unit;
|
||||
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
|
||||
const acknowledged = reduceStreamUpdate([params.optimistic.item], event, timestamp, {
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
});
|
||||
return replaceOptimisticUserMessage({
|
||||
tail: params.tail,
|
||||
head: params.head,
|
||||
position: params.optimistic,
|
||||
precedingItems: params.precedingItems,
|
||||
replacement: acknowledged,
|
||||
});
|
||||
}
|
||||
|
||||
function applyCanonicalForwardUnit(params: {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
unit: TimelineUnit;
|
||||
epoch: string;
|
||||
}): { tail: StreamItem[]; head: StreamItem[] } {
|
||||
const { event, timestamp, seqEnd } = params.unit;
|
||||
const timelineCursor = { epoch: params.epoch, seq: seqEnd };
|
||||
if (params.head.length === 0) {
|
||||
return {
|
||||
tail: reduceStreamUpdate(params.tail, event, timestamp, {
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
}),
|
||||
head: params.head,
|
||||
};
|
||||
}
|
||||
const replacedHead = replaceLiveAssistantWithProjectedText({
|
||||
head: params.head,
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor,
|
||||
});
|
||||
if (replacedHead) return { tail: params.tail, head: replacedHead };
|
||||
|
||||
const applied = applyStreamEvent({
|
||||
tail: params.tail,
|
||||
head: params.head,
|
||||
event,
|
||||
timestamp,
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
});
|
||||
return { tail: applied.tail, head: applied.head };
|
||||
}
|
||||
|
||||
function applyAcceptedForwardTimelineUnits(params: {
|
||||
units: TimelineUnit[];
|
||||
epoch: string;
|
||||
currentTail: StreamItem[];
|
||||
currentHead: StreamItem[];
|
||||
currentEndSeq: number | undefined;
|
||||
}): { tail: StreamItem[]; head: StreamItem[] } {
|
||||
const reconciled = reconcileOverlappingProjectedStreamItems({
|
||||
tail: params.currentTail,
|
||||
head: params.currentHead,
|
||||
units: params.units,
|
||||
epoch: params.epoch,
|
||||
currentEndSeq: params.currentEndSeq,
|
||||
});
|
||||
let tail = reconciled.tail;
|
||||
let head = reconciled.head;
|
||||
let delayedHistoryTail: StreamItem[] = [];
|
||||
let delayedHistoryHead: StreamItem[] = [];
|
||||
|
||||
for (const unit of params.units) {
|
||||
if (reconciled.reconciledUnits.has(unit)) continue;
|
||||
const nextOptimistic = findOptimisticUserMessage({ tail, head });
|
||||
if (nextOptimistic && matchesOptimisticUserMessage({ unit, optimistic: nextOptimistic.item })) {
|
||||
const precedingItems = flushHeadToTail(delayedHistoryTail, delayedHistoryHead);
|
||||
delayedHistoryTail = [];
|
||||
delayedHistoryHead = [];
|
||||
const applied = acknowledgeOptimisticUserMessage({
|
||||
tail,
|
||||
head,
|
||||
unit,
|
||||
epoch: params.epoch,
|
||||
optimistic: nextOptimistic,
|
||||
precedingItems,
|
||||
});
|
||||
tail = applied.tail;
|
||||
head = applied.head;
|
||||
continue;
|
||||
}
|
||||
if (nextOptimistic) {
|
||||
if (
|
||||
unit.event.type === "timeline" &&
|
||||
(unit.event.item.type === "assistant_message" || unit.event.item.type === "reasoning")
|
||||
) {
|
||||
delayedHistoryHead = reduceStreamUpdate(delayedHistoryHead, unit.event, unit.timestamp, {
|
||||
source: "canonical",
|
||||
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const applied = applyStreamEvent({
|
||||
tail: delayedHistoryTail,
|
||||
head: delayedHistoryHead,
|
||||
event: unit.event,
|
||||
timestamp: unit.timestamp,
|
||||
source: "canonical",
|
||||
timelineCursor: { epoch: params.epoch, seq: unit.seqEnd },
|
||||
});
|
||||
delayedHistoryTail = applied.tail;
|
||||
delayedHistoryHead = applied.head;
|
||||
continue;
|
||||
}
|
||||
const applied = applyCanonicalForwardUnit({ tail, head, unit, epoch: params.epoch });
|
||||
tail = applied.tail;
|
||||
head = applied.head;
|
||||
}
|
||||
|
||||
const remainingOptimistic = findOptimisticUserMessage({ tail, head });
|
||||
if (!remainingOptimistic) return { tail, head };
|
||||
const precedingItems = flushHeadToTail(delayedHistoryTail, delayedHistoryHead);
|
||||
if (precedingItems.length === 0) return { tail, head };
|
||||
return replaceOptimisticUserMessage({
|
||||
tail,
|
||||
head,
|
||||
position: remainingOptimistic,
|
||||
precedingItems,
|
||||
replacement: [remainingOptimistic.item],
|
||||
});
|
||||
}
|
||||
|
||||
function applyTimelineIncrementalPath(args: {
|
||||
timelineUnits: TimelineUnit[];
|
||||
payload: ProcessTimelineResponseInput["payload"];
|
||||
@@ -586,39 +959,16 @@ function applyTimelineIncrementalPath(args: {
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
|
||||
} else if (currentHead.length > 0) {
|
||||
for (const { event, timestamp, seqEnd } of acceptedUnits) {
|
||||
const timelineCursor = { epoch: payload.epoch, seq: seqEnd };
|
||||
const replacedHead = replaceLiveAssistantWithProjectedText({
|
||||
head: nextHead,
|
||||
event,
|
||||
timestamp,
|
||||
timelineCursor,
|
||||
});
|
||||
if (replacedHead) {
|
||||
nextHead = replacedHead;
|
||||
continue;
|
||||
}
|
||||
const applied = applyStreamEvent({
|
||||
tail: nextTail,
|
||||
head: nextHead,
|
||||
event,
|
||||
timestamp,
|
||||
source: "canonical",
|
||||
timelineCursor,
|
||||
});
|
||||
nextTail = applied.tail;
|
||||
nextHead = applied.head;
|
||||
}
|
||||
} else {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp, seqEnd }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
timelineCursor: { epoch: payload.epoch, seq: seqEnd },
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
const applied = applyAcceptedForwardTimelineUnits({
|
||||
units: acceptedUnits,
|
||||
epoch: payload.epoch,
|
||||
currentTail: nextTail,
|
||||
currentHead: nextHead,
|
||||
currentEndSeq: currentCursor?.endSeq,
|
||||
});
|
||||
nextTail = applied.tail;
|
||||
nextHead = applied.head;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from "vitest";
|
||||
import type { ProjectedTimelineForwardFetchPlan } from "./timeline-sync-plan";
|
||||
import { createViewedTimelineSync } from "./viewed-timeline-sync";
|
||||
import {
|
||||
createViewedTimelineSync,
|
||||
VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS,
|
||||
} from "./viewed-timeline-sync";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
@@ -34,6 +37,7 @@ interface TimelineFetch {
|
||||
class TimelineWorld {
|
||||
readonly errors: string[] = [];
|
||||
readonly sync = createViewedTimelineSync({
|
||||
initialDeliveryMode: "selective",
|
||||
setSubscription: async (agentIds) => {
|
||||
const result = deferred<void>();
|
||||
this.memberships.push({
|
||||
@@ -69,13 +73,17 @@ class TimelineWorld {
|
||||
const waiter = this.errorWaiters.shift();
|
||||
if (waiter) waiter(this.errors.at(-1) ?? "");
|
||||
},
|
||||
scheduleRetry: (retry) => {
|
||||
this.retries.push(retry);
|
||||
schedule: (task, delayMs) => {
|
||||
const scheduled = { task, delayMs };
|
||||
this.scheduled.push(scheduled);
|
||||
const waiter = this.retryWaiters.shift();
|
||||
if (waiter) waiter(this.retries.shift()!);
|
||||
if (waiter && delayMs === 1_000) {
|
||||
this.scheduled.splice(this.scheduled.indexOf(scheduled), 1);
|
||||
waiter(task);
|
||||
}
|
||||
return () => {
|
||||
const index = this.retries.indexOf(retry);
|
||||
if (index >= 0) this.retries.splice(index, 1);
|
||||
const index = this.scheduled.indexOf(scheduled);
|
||||
if (index >= 0) this.scheduled.splice(index, 1);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -90,7 +98,7 @@ class TimelineWorld {
|
||||
private readonly cursors = new Map<string, { epoch: string; startSeq: number; endSeq: number }>();
|
||||
private readonly authoritativeHistory = new Set<string>();
|
||||
private readonly errorWaiters: Array<(message: string) => void> = [];
|
||||
private readonly retries: Array<() => void> = [];
|
||||
private readonly scheduled: Array<{ task: () => void; delayMs: number }> = [];
|
||||
private readonly retryWaiters: Array<(retry: () => void) => void> = [];
|
||||
|
||||
setCursor(agentId: string, endSeq: number): void {
|
||||
@@ -129,11 +137,25 @@ class TimelineWorld {
|
||||
}
|
||||
|
||||
nextRetry(): Promise<() => void> {
|
||||
const retry = this.retries.shift();
|
||||
if (retry) return Promise.resolve(retry);
|
||||
const index = this.scheduled.findIndex((entry) => entry.delayMs === 1_000);
|
||||
if (index >= 0) return Promise.resolve(this.scheduled.splice(index, 1)[0].task);
|
||||
return new Promise((resolve) => this.retryWaiters.push(resolve));
|
||||
}
|
||||
|
||||
runUnsubscribeGrace(): void {
|
||||
const index = this.scheduled.findIndex(
|
||||
(entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS,
|
||||
);
|
||||
expect(index).toBeGreaterThanOrEqual(0);
|
||||
this.scheduled.splice(index, 1)[0].task();
|
||||
}
|
||||
|
||||
expectNoPendingUnsubscribe(): void {
|
||||
expect(
|
||||
this.scheduled.filter((entry) => entry.delayMs === VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS),
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
private releaseMembershipWaiter(): void {
|
||||
const waiter = this.membershipWaiters.shift();
|
||||
if (!waiter) return;
|
||||
@@ -211,6 +233,7 @@ test("membership changes during acknowledgement never catch up the stale set", a
|
||||
const staleMembership = await world.nextMembership();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
staleMembership.succeed();
|
||||
const currentMembership = await world.nextMembership();
|
||||
currentMembership.succeed();
|
||||
@@ -236,6 +259,7 @@ test("removing one agent during paging cancels only that agent", async () => {
|
||||
]);
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
const replacement = await world.nextMembership();
|
||||
agentA.respond({ hasNewer: true, seq: 4 });
|
||||
agentB.respond({ hasNewer: true, seq: 7 });
|
||||
@@ -283,7 +307,9 @@ test("overlapping sources deduplicate membership and source removal preserves re
|
||||
|
||||
world.sync.replaceVisibleAgentIds("left-route", []);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.sync.replaceVisibleAgentIds("right-route", ["agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
const remaining = await world.nextMembership();
|
||||
remaining.succeed();
|
||||
|
||||
@@ -417,6 +443,7 @@ test("stale membership retry cannot overwrite a newer effective set", async () =
|
||||
const staleRetry = await world.nextRetry();
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
const current = await world.nextMembership();
|
||||
staleRetry();
|
||||
current.succeed();
|
||||
@@ -446,3 +473,153 @@ test("membership retry cannot run while disconnected", async () => {
|
||||
|
||||
expect(restored.agentIds).toEqual(["agent-a"]);
|
||||
});
|
||||
|
||||
test("quickly returning to an agent cancels its pending unsubscribe without another catch-up", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const initialCatchUp = await world.nextFetch("agent-a");
|
||||
initialCatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.expectNoPendingMembership();
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
test("unsubscribe grace expiry removes the agent exactly once", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.runUnsubscribeGrace();
|
||||
const unsubscribe = await world.nextMembership();
|
||||
unsubscribe.succeed();
|
||||
|
||||
expect(unsubscribe.agentIds).toEqual([]);
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("a new visible agent subscribes immediately while the previous agent lingers", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const initialMembership = await world.nextMembership();
|
||||
initialMembership.succeed();
|
||||
const initialCatchUp = await world.nextFetch("agent-a");
|
||||
initialCatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-b"]);
|
||||
const expandedMembership = await world.nextMembership();
|
||||
expandedMembership.succeed();
|
||||
const agentBCatchUp = await world.nextFetch("agent-b");
|
||||
agentBCatchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(expandedMembership.agentIds).toEqual(["agent-a", "agent-b"]);
|
||||
world.runUnsubscribeGrace();
|
||||
const settledMembership = await world.nextMembership();
|
||||
settledMembership.succeed();
|
||||
expect(settledMembership.agentIds).toEqual(["agent-b"]);
|
||||
});
|
||||
|
||||
test("backgrounding clears pending unsubscribe grace and membership immediately", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.sync.setActive(false);
|
||||
const unsubscribe = await world.nextMembership();
|
||||
unsubscribe.succeed();
|
||||
|
||||
expect(unsubscribe.agentIds).toEqual([]);
|
||||
world.expectNoPendingUnsubscribe();
|
||||
});
|
||||
|
||||
test("disconnecting cancels pending unsubscribe grace without publishing on the closed socket", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.sync.setConnected(false);
|
||||
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("disposing cancels pending unsubscribe grace", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setConnected(true);
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.replaceVisibleAgentIds("workspace", []);
|
||||
world.sync.dispose();
|
||||
|
||||
world.expectNoPendingUnsubscribe();
|
||||
world.expectNoPendingMembership();
|
||||
});
|
||||
|
||||
test("legacy delivery skips subscription RPCs while retaining visibility catch-up and gap recovery", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setDeliveryMode("legacy");
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.setConnected(true);
|
||||
|
||||
world.expectNoPendingMembership();
|
||||
const initial = await world.nextFetch("agent-a");
|
||||
initial.respond({ hasNewer: false });
|
||||
|
||||
world.sync.recoverGap("agent-a", { epoch: "epoch-agent-a", endSeq: 10 });
|
||||
const recovery = await world.nextFetch("agent-a");
|
||||
recovery.respond({ hasNewer: false });
|
||||
|
||||
expect(recovery.request).toEqual({
|
||||
direction: "after",
|
||||
cursor: { epoch: "epoch-agent-a", seq: 10 },
|
||||
limit: 100,
|
||||
projection: "projected",
|
||||
});
|
||||
});
|
||||
|
||||
test("switching from legacy to selective delivery publishes membership and catches up once", async () => {
|
||||
const world = new TimelineWorld();
|
||||
world.sync.setDeliveryMode("legacy");
|
||||
world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]);
|
||||
world.sync.setConnected(true);
|
||||
const legacyCatchUp = await world.nextFetch("agent-a");
|
||||
legacyCatchUp.respond({ hasNewer: false });
|
||||
|
||||
world.sync.setDeliveryMode("selective");
|
||||
const membership = await world.nextMembership();
|
||||
membership.succeed();
|
||||
const catchUp = await world.nextFetch("agent-a");
|
||||
catchUp.respond({ hasNewer: false });
|
||||
|
||||
expect(membership.agentIds).toEqual(["agent-a"]);
|
||||
world.expectNoPendingMembership();
|
||||
world.expectNoPendingFetch();
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ interface TimelinePageResult {
|
||||
}
|
||||
|
||||
interface ViewedTimelineSyncPorts {
|
||||
initialDeliveryMode: TimelineDeliveryMode;
|
||||
setSubscription(agentIds: string[]): Promise<void>;
|
||||
readCursor(agentId: string): AgentTimelineCursorState | undefined;
|
||||
hasAuthoritativeHistory(agentId: string): boolean;
|
||||
@@ -20,9 +21,11 @@ interface ViewedTimelineSyncPorts {
|
||||
request: ProjectedTimelineForwardFetchPlan,
|
||||
): Promise<TimelinePageResult>;
|
||||
reportError(error: unknown): void;
|
||||
scheduleRetry(retry: () => void): () => void;
|
||||
schedule(task: () => void, delayMs: number): () => void;
|
||||
}
|
||||
|
||||
export type TimelineDeliveryMode = "legacy" | "selective";
|
||||
|
||||
export interface ViewedTimelineUiBridge {
|
||||
replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void;
|
||||
}
|
||||
@@ -30,10 +33,14 @@ export interface ViewedTimelineUiBridge {
|
||||
export interface ViewedTimelineSync extends ViewedTimelineUiBridge {
|
||||
setActive(active: boolean): void;
|
||||
setConnected(connected: boolean): void;
|
||||
setDeliveryMode(mode: TimelineDeliveryMode): void;
|
||||
recoverGap(agentId: string, cursor: { epoch: string; endSeq: number }): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
const RETRY_DELAY_MS = 1_000;
|
||||
export const VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS = 5_000;
|
||||
|
||||
type CatchUpStatus = "running" | "complete" | "error";
|
||||
|
||||
interface CatchUpState {
|
||||
@@ -80,8 +87,10 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
const catchUps = new Map<string, CatchUpState>();
|
||||
const catchUpGenerations = new Map<string, number>();
|
||||
const pendingGaps = new Map<string, ProjectedTimelineForwardFetchPlan>();
|
||||
const lingeringRemovals = new Map<string, () => void>();
|
||||
let active = true;
|
||||
let connected = false;
|
||||
let deliveryMode = ports.initialDeliveryMode;
|
||||
let disposed = false;
|
||||
let desired: string[] = [];
|
||||
let acknowledged: string[] = [];
|
||||
@@ -91,7 +100,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
let membershipNeedsRetry = false;
|
||||
let cancelMembershipRetry: (() => void) | null = null;
|
||||
|
||||
const effectiveAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []);
|
||||
const visibleAgentIds = () => (active ? normalizeAgentIds([...sources.values()].flat()) : []);
|
||||
const effectiveAgentIds = () =>
|
||||
normalizeAgentIds([...visibleAgentIds(), ...lingeringRemovals.keys()]);
|
||||
|
||||
const isAcknowledged = (agentId: string) => acknowledged.includes(agentId);
|
||||
const isDesired = (agentId: string) => desired.includes(agentId);
|
||||
@@ -139,11 +150,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
catchUps.set(agentId, { generation, status: "complete" });
|
||||
} catch (error) {
|
||||
if (catchUps.get(agentId)?.generation === generation) {
|
||||
const cancelRetry = ports.scheduleRetry(() => {
|
||||
const cancelRetry = ports.schedule(() => {
|
||||
const current = catchUps.get(agentId);
|
||||
if (current?.generation !== generation || current.status !== "error") return;
|
||||
startCatchUp(agentId);
|
||||
});
|
||||
}, RETRY_DELAY_MS);
|
||||
catchUps.set(agentId, { generation, status: "error", cancelRetry });
|
||||
ports.reportError(error);
|
||||
}
|
||||
@@ -188,7 +199,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
};
|
||||
|
||||
const reconcileLatestMembership = async (): Promise<void> => {
|
||||
if (disposed || !connected) return;
|
||||
if (disposed || !connected || deliveryMode !== "selective") return;
|
||||
const generation = membershipGeneration;
|
||||
const requested = desired;
|
||||
if (!membershipNeedsRetry && sameAgentIds(requested, acknowledged)) return;
|
||||
@@ -198,7 +209,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
} catch (error) {
|
||||
membershipNeedsRetry = true;
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = ports.scheduleRetry(() => {
|
||||
cancelMembershipRetry = ports.schedule(() => {
|
||||
cancelMembershipRetry = null;
|
||||
if (
|
||||
disposed ||
|
||||
@@ -209,13 +220,13 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
return;
|
||||
}
|
||||
void reconcileMembership();
|
||||
});
|
||||
}, RETRY_DELAY_MS);
|
||||
ports.reportError(error);
|
||||
return;
|
||||
}
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
if (disposed || !connected) return;
|
||||
if (disposed || !connected || deliveryMode !== "selective") return;
|
||||
acknowledged = requested;
|
||||
if (generation !== membershipGeneration) {
|
||||
await reconcileLatestMembership();
|
||||
@@ -236,12 +247,13 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
await reconcileLatestMembership();
|
||||
} finally {
|
||||
reconciling = false;
|
||||
if (reconcileRequested && !disposed && connected) {
|
||||
if (reconcileRequested && !disposed && connected && deliveryMode === "selective") {
|
||||
reconcileRequested = false;
|
||||
void reconcileMembership();
|
||||
} else if (
|
||||
!disposed &&
|
||||
connected &&
|
||||
deliveryMode === "selective" &&
|
||||
!membershipNeedsRetry &&
|
||||
!sameAgentIds(desired, acknowledged)
|
||||
) {
|
||||
@@ -256,10 +268,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
}
|
||||
};
|
||||
|
||||
const publishEffectiveMembership = () => {
|
||||
const nextDesired = effectiveAgentIds();
|
||||
const commitDesiredMembership = (nextDesired: string[]) => {
|
||||
if (sameAgentIds(nextDesired, desired)) {
|
||||
if (membershipNeedsRetry) void reconcileMembership();
|
||||
if (deliveryMode === "selective" && membershipNeedsRetry) void reconcileMembership();
|
||||
retryFailedCatchUps();
|
||||
return;
|
||||
}
|
||||
@@ -271,25 +282,60 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
cancelMembershipRetry = null;
|
||||
desired = nextDesired;
|
||||
membershipGeneration += 1;
|
||||
if (deliveryMode === "legacy") {
|
||||
acknowledged = connected ? desired : [];
|
||||
if (connected) startAcknowledgedCatchUps();
|
||||
return;
|
||||
}
|
||||
void reconcileMembership();
|
||||
};
|
||||
|
||||
const clearLingeringRemovals = () => {
|
||||
for (const cancel of lingeringRemovals.values()) cancel();
|
||||
lingeringRemovals.clear();
|
||||
};
|
||||
|
||||
const publishVisibleMembership = (allowGrace: boolean) => {
|
||||
const visible = visibleAgentIds();
|
||||
for (const agentId of visible) {
|
||||
lingeringRemovals.get(agentId)?.();
|
||||
lingeringRemovals.delete(agentId);
|
||||
}
|
||||
|
||||
if (allowGrace && connected && active && deliveryMode === "selective") {
|
||||
for (const agentId of desired) {
|
||||
if (visible.includes(agentId) || lingeringRemovals.has(agentId)) continue;
|
||||
const cancel = ports.schedule(() => {
|
||||
lingeringRemovals.delete(agentId);
|
||||
commitDesiredMembership(effectiveAgentIds());
|
||||
}, VIEWED_TIMELINE_UNSUBSCRIBE_GRACE_MS);
|
||||
lingeringRemovals.set(agentId, cancel);
|
||||
}
|
||||
} else {
|
||||
clearLingeringRemovals();
|
||||
}
|
||||
|
||||
commitDesiredMembership(effectiveAgentIds());
|
||||
};
|
||||
|
||||
return {
|
||||
replaceVisibleAgentIds(sourceId, agentIds) {
|
||||
const normalized = normalizeAgentIds(agentIds);
|
||||
if (normalized.length === 0) sources.delete(sourceId);
|
||||
else sources.set(sourceId, normalized);
|
||||
publishEffectiveMembership();
|
||||
publishVisibleMembership(true);
|
||||
},
|
||||
setActive(nextActive) {
|
||||
if (active === nextActive) return;
|
||||
active = nextActive;
|
||||
publishEffectiveMembership();
|
||||
publishVisibleMembership(false);
|
||||
},
|
||||
setConnected(nextConnected) {
|
||||
if (connected === nextConnected) return;
|
||||
connected = nextConnected;
|
||||
if (!connected) {
|
||||
clearLingeringRemovals();
|
||||
commitDesiredMembership(visibleAgentIds());
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
acknowledged = [];
|
||||
@@ -298,7 +344,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
return;
|
||||
}
|
||||
membershipGeneration += 1;
|
||||
void reconcileMembership();
|
||||
if (deliveryMode === "legacy") {
|
||||
acknowledged = desired;
|
||||
startAcknowledgedCatchUps();
|
||||
} else {
|
||||
void reconcileMembership();
|
||||
}
|
||||
},
|
||||
setDeliveryMode(nextMode) {
|
||||
if (deliveryMode === nextMode) return;
|
||||
deliveryMode = nextMode;
|
||||
clearLingeringRemovals();
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
membershipNeedsRetry = false;
|
||||
membershipGeneration += 1;
|
||||
for (const agentId of desired) cancelCatchUp(agentId);
|
||||
desired = visibleAgentIds();
|
||||
acknowledged = deliveryMode === "legacy" && connected ? desired : [];
|
||||
if (deliveryMode === "selective" && connected) void reconcileMembership();
|
||||
else if (connected) startAcknowledgedCatchUps();
|
||||
},
|
||||
recoverGap(agentId, cursor) {
|
||||
if (!isDesired(agentId)) return;
|
||||
@@ -309,6 +374,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
clearLingeringRemovals();
|
||||
cancelMembershipRetry?.();
|
||||
cancelMembershipRetry = null;
|
||||
sources.clear();
|
||||
|
||||
Reference in New Issue
Block a user