Fix draft agent handoff continuity

Move optimistic user message reconciliation into the stream reducers and keep draft-created agents in a handoff lifecycle until authoritative history lands.
This commit is contained in:
Mohamed Boudra
2026-05-25 20:53:49 +07:00
parent e5658654c0
commit 53faba64ca
21 changed files with 677 additions and 458 deletions
@@ -71,4 +71,21 @@ describe("create-flow-store", () => {
useCreateFlowStore.getState().clear({ draftId: "draft-1" });
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
});
it("clears pending handoff state by agent", () => {
useCreateFlowStore.getState().setPending({
draftId: "draft-1",
serverId: "server-1",
agentId: null,
clientMessageId: "msg-1",
text: "hello",
timestamp: Date.now(),
});
useCreateFlowStore.getState().updateAgentId({ draftId: "draft-1", agentId: "agent-1" });
useCreateFlowStore.getState().markLifecycle({ draftId: "draft-1", lifecycle: "sent" });
useCreateFlowStore.getState().clearByAgent({ serverId: "server-1", agentId: "agent-1" });
expect(useCreateFlowStore.getState().pendingByDraftId["draft-1"]).toBeUndefined();
});
});
+14 -1
View File
@@ -4,7 +4,7 @@ import type { AgentAttachment } from "@server/shared/messages";
export type CreateFlowLifecycleState = "active" | "abandoned" | "sent";
interface PendingCreateAttempt {
export interface PendingCreateAttempt {
draftId: string;
serverId: string;
agentId: string | null;
@@ -23,6 +23,7 @@ interface CreateFlowState {
markLifecycle: (input: { draftId: string; lifecycle: CreateFlowLifecycleState }) => void;
rekeyDraft: (input: { fromDraftId: string; toDraftId: string }) => void;
clear: (input: { draftId: string }) => void;
clearByAgent: (input: { serverId: string; agentId: string }) => void;
clearAll: () => void;
}
@@ -89,5 +90,17 @@ export const useCreateFlowStore = create<CreateFlowState>((set) => ({
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
return { pendingByDraftId: rest };
}),
clearByAgent: ({ serverId, agentId }) =>
set((state) => {
const next = Object.fromEntries(
Object.entries(state.pendingByDraftId).filter(
([, pending]) => pending.serverId !== serverId || pending.agentId !== agentId,
),
);
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
return state;
}
return { pendingByDraftId: next };
}),
clearAll: () => set({ pendingByDraftId: {} }),
}));
+59 -1
View File
@@ -3,7 +3,12 @@ import { create } from "zustand";
import { subscribeWithSelector } from "zustand/middleware";
import type { DaemonClient } from "@server/client/daemon-client";
import type { AgentDirectoryEntry } from "@/types/agent-directory";
import type { StreamItem } from "@/types/stream";
import {
appendOptimisticUserMessageToStream,
type OptimisticUserMessagePlacement,
type StreamItem,
type UserMessageItem,
} from "@/types/stream";
import type { PendingPermission } from "@/types/shared";
import type { ComposerAttachment } from "@/attachments/types";
import type { AgentLifecycleStatus } from "@server/shared/agent-lifecycle";
@@ -351,6 +356,15 @@ interface SessionStoreActions {
agentId: string,
state: { tail?: StreamItem[]; head?: StreamItem[] },
) => void;
appendOptimisticUserMessageToAgentStream: (
serverId: string,
agentId: string,
message: UserMessageItem,
options: {
placement: OptimisticUserMessagePlacement;
skipIfUserMessageExists?: boolean;
},
) => boolean;
clearAgentStreamHead: (serverId: string, agentId: string) => void;
setAgentTimelineCursor: (
serverId: string,
@@ -836,6 +850,50 @@ export const useSessionStore = create<SessionStore>()(
});
},
appendOptimisticUserMessageToAgentStream: (serverId, agentId, message, options) => {
let didAppend = false;
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const currentTail = session.agentStreamTail.get(agentId) ?? [];
const currentHead = session.agentStreamHead.get(agentId) ?? [];
const result = appendOptimisticUserMessageToStream({
tail: currentTail,
head: currentHead,
message,
placement: options.placement,
skipIfUserMessageExists: options.skipIfUserMessageExists,
});
if (!result.changedTail && !result.changedHead) {
return prev;
}
const nextTail = result.changedTail
? new Map(session.agentStreamTail).set(agentId, result.tail)
: session.agentStreamTail;
const nextHead = result.changedHead
? new Map(session.agentStreamHead).set(agentId, result.head)
: session.agentStreamHead;
didAppend = true;
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: {
...session,
agentStreamTail: nextTail,
agentStreamHead: nextHead,
},
},
};
});
return didAppend;
},
clearAgentStreamHead: (serverId, agentId) => {
set((prev) => {
const session = prev.sessions[serverId];