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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: {} }),
|
||||
}));
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user