Revert the timeline optimistic and pagination rework (#2596)
* Add npm release track invariant and beta dist-tag procedure * Revert "Keep older chat history and image previews stable (#2490)" The timeline optimistic/pagination rework needs more work than main releases can wait for. Reverted together with #2484; both are reapplied on integration/timeline-optimistic-rework. * Revert "Stop completed turns from appearing stuck (#2484)" See the #2490 revert. #2490 built on this submission-authority change, so the two revert and reapply as a pair.
This commit is contained in:
@@ -6,17 +6,7 @@ import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
removeSubmittedUserMessage,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
rejectMessageSubmission,
|
||||
type MessageSubmissionRecord,
|
||||
} from "@/composer/submission/model";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
@@ -30,7 +20,7 @@ import {
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
type MessageSubmissionWriter,
|
||||
type AgentStreamWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
type ComposerSendClient,
|
||||
@@ -178,16 +168,14 @@ interface FakeSendCall {
|
||||
}
|
||||
|
||||
function createFakeSendClient(
|
||||
options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => void } = {},
|
||||
options: { rejection?: Error } = {},
|
||||
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||
const calls: FakeSendCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
sendAgentMessage: async (agentId, text, opts) => {
|
||||
const call = { agentId, text, options: opts };
|
||||
calls.push(call);
|
||||
calls.push({ agentId, text, options: opts });
|
||||
if (options.rejection) {
|
||||
options.beforeRejection?.(call);
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
@@ -195,7 +183,7 @@ function createFakeSendClient(
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStream extends MessageSubmissionWriter {
|
||||
interface FakeStream extends AgentStreamWriter {
|
||||
head: Map<string, StreamItem[]>;
|
||||
tail: Map<string, StreamItem[]>;
|
||||
}
|
||||
@@ -204,70 +192,18 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): F
|
||||
const fake: FakeStream = {
|
||||
head: new Map(initialHead),
|
||||
tail: new Map(),
|
||||
begin: (agentId, message) => {
|
||||
const current = readSubmission(fake, agentId);
|
||||
const stream = appendSubmittedUserMessage({
|
||||
tail: current.tail,
|
||||
head: current.head,
|
||||
message,
|
||||
});
|
||||
writeSubmission(fake, agentId, {
|
||||
...stream,
|
||||
submissions: beginMessageSubmission(current.submissions, {
|
||||
clientMessageId: message.clientMessageId!,
|
||||
submittedAt: message.timestamp,
|
||||
}),
|
||||
});
|
||||
getTail: (agentId) => fake.tail.get(agentId),
|
||||
getHead: (agentId) => fake.head.get(agentId),
|
||||
setHead: (updater) => {
|
||||
fake.head = updater(fake.head);
|
||||
},
|
||||
accept: (agentId, clientMessageId) => {
|
||||
const current = readSubmission(fake, agentId);
|
||||
writeSubmission(fake, agentId, {
|
||||
...current,
|
||||
submissions: acceptMessageSubmission(current.submissions, clientMessageId, true, false),
|
||||
});
|
||||
},
|
||||
reject: (agentId, clientMessageId) => {
|
||||
const current = readSubmission(fake, agentId);
|
||||
const result = rejectMessageSubmission(current.submissions, clientMessageId);
|
||||
const stream =
|
||||
result.outcome === "rejected"
|
||||
? removeSubmittedUserMessage({
|
||||
tail: current.tail,
|
||||
head: current.head,
|
||||
clientMessageId,
|
||||
})
|
||||
: current;
|
||||
writeSubmission(fake, agentId, { ...stream, submissions: result.submissions });
|
||||
return result.outcome;
|
||||
setTail: (updater) => {
|
||||
fake.tail = updater(fake.tail);
|
||||
},
|
||||
};
|
||||
return fake;
|
||||
}
|
||||
|
||||
const submissionsByFakeStream = new WeakMap<FakeStream, Map<string, MessageSubmissionRecord[]>>();
|
||||
|
||||
interface FakeSubmissionState {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
submissions: MessageSubmissionRecord[];
|
||||
}
|
||||
|
||||
function readSubmission(fake: FakeStream, agentId: string): FakeSubmissionState {
|
||||
return {
|
||||
tail: fake.tail.get(agentId) ?? [],
|
||||
head: fake.head.get(agentId) ?? [],
|
||||
submissions: submissionsByFakeStream.get(fake)?.get(agentId) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeSubmission(fake: FakeStream, agentId: string, state: FakeSubmissionState): void {
|
||||
fake.tail = new Map(fake.tail).set(agentId, state.tail);
|
||||
fake.head = new Map(fake.head).set(agentId, state.head);
|
||||
const submissions = submissionsByFakeStream.get(fake) ?? new Map();
|
||||
submissions.set(agentId, state.submissions);
|
||||
submissionsByFakeStream.set(fake, submissions);
|
||||
}
|
||||
|
||||
function createFakeQueue(
|
||||
initial: Map<string, QueuedComposerMessage[]> = new Map(),
|
||||
): QueueWriter & { state: Map<string, QueuedComposerMessage[]> } {
|
||||
@@ -401,7 +337,7 @@ describe("pickAndPersistImages", () => {
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("removes the submitted prompt when the host rejects it", async () => {
|
||||
it("removes the optimistic prompt when the host rejects it", async () => {
|
||||
const rejection = new Error("Host rejected prompt");
|
||||
const client = createFakeSendClient({ rejection });
|
||||
const stream = createFakeStream();
|
||||
@@ -413,54 +349,14 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "rejected prompt",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
stream,
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
|
||||
expect(stream.head.get("agent")).toEqual([]);
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
expect(stream.tail.get("agent") ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("rolls back an already-running force send when its RPC fails", async () => {
|
||||
const stream = createFakeStream();
|
||||
const transportError = new Error("Force send failed while the prior turn was running");
|
||||
const client = createFakeSendClient({ rejection: transportError });
|
||||
|
||||
await expect(
|
||||
dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "force send",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
}),
|
||||
).rejects.toBe(transportError);
|
||||
|
||||
expect(stream.tail.get("agent") ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not swallow a transport error when submission state is missing", async () => {
|
||||
const transportError = new Error("Connection lost with unknown submission state");
|
||||
const client = createFakeSendClient({ rejection: transportError });
|
||||
const submission: MessageSubmissionWriter = {
|
||||
begin: () => {},
|
||||
accept: () => {},
|
||||
reject: () => "unknown",
|
||||
};
|
||||
|
||||
await expect(
|
||||
dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: "agent",
|
||||
text: "unknown state",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission,
|
||||
}),
|
||||
).rejects.toBe(transportError);
|
||||
});
|
||||
|
||||
it("sends text + image data + structured attachments and appends user_message to the tail when head is empty", async () => {
|
||||
const client = createFakeSendClient();
|
||||
const stream = createFakeStream();
|
||||
@@ -475,7 +371,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
{ kind: "github_pr", item: prItem },
|
||||
],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls).toHaveLength(1);
|
||||
@@ -497,7 +393,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(stream.head.get("agent")).toEqual([]);
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
const tail = stream.tail.get("agent");
|
||||
expect(tail).toHaveLength(1);
|
||||
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||
@@ -506,8 +402,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
expect(userMessage.clientMessageId).toBe(call.options.messageId);
|
||||
expect(userMessage.messageId).toBeUndefined();
|
||||
expect(userMessage.optimistic).toBe(true);
|
||||
});
|
||||
|
||||
it("can send legacy GitHub attachment payloads for old daemons", async () => {
|
||||
@@ -521,7 +416,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [{ kind: "forge_change_request", item: prItem }],
|
||||
attachmentSubmitFormat: "legacy-github",
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0].options.attachments).toEqual([
|
||||
@@ -554,11 +449,11 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "next message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(stream.head.get("agent")).toHaveLength(2);
|
||||
expect(stream.tail.get("agent")).toEqual([]);
|
||||
expect(stream.tail.get("agent")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||
@@ -571,7 +466,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "plain message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options).toMatchObject({
|
||||
@@ -591,7 +486,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "review this",
|
||||
attachments: [review],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||
@@ -609,7 +504,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "inspect element",
|
||||
attachments: [browserElement],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
submission: stream,
|
||||
stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([
|
||||
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
splitComposerAttachmentsForSubmit,
|
||||
type ComposerAttachmentSubmitFormat,
|
||||
} from "@/composer/attachments/submit";
|
||||
import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream";
|
||||
import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model";
|
||||
import {
|
||||
appendOptimisticUserMessageToStream,
|
||||
buildOptimisticUserMessage,
|
||||
generateMessageId,
|
||||
type StreamItem,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -46,7 +51,7 @@ export interface ComposerSendClient {
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void | { outOfBand?: boolean }>;
|
||||
) => Promise<void>;
|
||||
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
|
||||
requestId: string;
|
||||
file: {
|
||||
@@ -65,10 +70,11 @@ export interface ComposerCancelClient {
|
||||
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export interface MessageSubmissionWriter {
|
||||
begin: (agentId: string, message: UserMessageItem) => void;
|
||||
accept: (agentId: string, clientMessageId: string, outOfBand: boolean | undefined) => void;
|
||||
reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome;
|
||||
export interface AgentStreamWriter {
|
||||
getTail: (agentId: string) => StreamItem[] | undefined;
|
||||
getHead: (agentId: string) => StreamItem[] | undefined;
|
||||
setHead: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
setTail: (updater: (prev: Map<string, StreamItem[]>) => Map<string, StreamItem[]>) => void;
|
||||
}
|
||||
|
||||
export interface QueueWriter {
|
||||
@@ -163,7 +169,7 @@ export interface DispatchComposerAgentMessageInput {
|
||||
encodeImages: (
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
submission: MessageSubmissionWriter;
|
||||
stream: AgentStreamWriter;
|
||||
}
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
@@ -172,30 +178,60 @@ export async function dispatchComposerAgentMessage(
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
|
||||
format: input.attachmentSubmitFormat,
|
||||
});
|
||||
const clientMessageId = generateMessageId();
|
||||
const userMessage = createUserMessage({
|
||||
clientMessageId,
|
||||
const messageId = generateMessageId();
|
||||
const userMessage = buildOptimisticUserMessage({
|
||||
id: messageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
images: wirePayload.images,
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
input.submission.begin(input.agentId, userMessage);
|
||||
const rollbackOptimisticMessage = appendUserMessageToStream(
|
||||
input.agentId,
|
||||
userMessage,
|
||||
input.stream,
|
||||
);
|
||||
try {
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
const result = await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId: clientMessageId,
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
input.submission.accept(input.agentId, clientMessageId, result?.outOfBand);
|
||||
} catch (error) {
|
||||
const outcome = input.submission.reject(input.agentId, clientMessageId);
|
||||
if (outcome === "accepted") return;
|
||||
rollbackOptimisticMessage();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function appendUserMessageToStream(
|
||||
agentId: string,
|
||||
userMessage: UserMessageItem,
|
||||
stream: AgentStreamWriter,
|
||||
): () => void {
|
||||
const result = appendOptimisticUserMessageToStream({
|
||||
tail: stream.getTail(agentId) ?? [],
|
||||
head: stream.getHead(agentId) ?? [],
|
||||
message: userMessage,
|
||||
placement: "active-head",
|
||||
});
|
||||
const write = result.changedHead ? stream.setHead : stream.setTail;
|
||||
const items = result.changedHead ? result.head : result.tail;
|
||||
write((prev) => new Map(prev).set(agentId, items));
|
||||
|
||||
return () => {
|
||||
write((prev) => {
|
||||
const current = prev.get(agentId);
|
||||
if (!current) return prev;
|
||||
const nextItems = current.filter(
|
||||
(item) => item.id !== userMessage.id || item.kind !== "user_message" || !item.optimistic,
|
||||
);
|
||||
if (nextItems.length === current.length) return prev;
|
||||
return new Map(prev).set(agentId, nextItems);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export interface QueueComposerMessageInput {
|
||||
agentId: string;
|
||||
text: string;
|
||||
|
||||
@@ -13,7 +13,7 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
useCreateFlowStore.setState({ pendingByDraftId: {} });
|
||||
});
|
||||
|
||||
it("renders a prepared new-workspace submission before continuing it", async () => {
|
||||
it("renders a prepared new-workspace create attempt as optimistic chat before continuing it", async () => {
|
||||
const image: UserMessageImageAttachment = {
|
||||
id: "image-1",
|
||||
mimeType: "image/png",
|
||||
@@ -60,13 +60,13 @@ describe("useDraftAgentCreateFlow", () => {
|
||||
|
||||
expect(result.current.isSubmitting).toBe(true);
|
||||
expect(result.current.draftAgent).toEqual({ currentAttempt: attempt });
|
||||
expect(result.current.submittedStreamItems).toEqual([
|
||||
expect(result.current.optimisticStreamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "msg-prepared",
|
||||
clientMessageId: "msg-prepared",
|
||||
text: "build this",
|
||||
timestamp: attempt.timestamp,
|
||||
optimistic: true,
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
|
||||
@@ -8,13 +8,12 @@ import {
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createUserMessage,
|
||||
buildOptimisticUserMessage,
|
||||
generateMessageId,
|
||||
type StreamItem,
|
||||
type UserMessageImageAttachment,
|
||||
} from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
|
||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||
|
||||
@@ -134,7 +133,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||
const isSubmitting = machine.tag === "creating";
|
||||
|
||||
const submittedStreamItems = useMemo<StreamItem[]>(() => {
|
||||
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
@@ -148,8 +147,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}
|
||||
|
||||
return [
|
||||
createUserMessage({
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
buildOptimisticUserMessage({
|
||||
id: machine.attempt.clientMessageId,
|
||||
text: machine.attempt.text,
|
||||
timestamp: machine.attempt.timestamp,
|
||||
images: machine.attempt.images,
|
||||
@@ -157,15 +156,6 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}),
|
||||
];
|
||||
}, [machine]);
|
||||
const pendingMessageSubmissions = useMemo<readonly PendingMessageSubmission[]>(() => {
|
||||
if (machine.tag !== "creating") return [];
|
||||
return [
|
||||
{
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
submittedAt: machine.attempt.timestamp,
|
||||
},
|
||||
];
|
||||
}, [machine]);
|
||||
|
||||
const draftAgent = useMemo<TDraftAgent | null>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
@@ -205,8 +195,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
handoffCreatedAgentUserMessage(
|
||||
pendingServerId,
|
||||
createResult.agentId,
|
||||
createUserMessage({
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
buildOptimisticUserMessage({
|
||||
id: attempt.clientMessageId,
|
||||
text: attempt.text,
|
||||
timestamp: attempt.timestamp,
|
||||
images: attempt.images,
|
||||
@@ -336,8 +326,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
machine,
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
optimisticStreamItems,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
|
||||
@@ -479,8 +479,7 @@ export function WorkspaceDraftAgentTab({
|
||||
const {
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
optimisticStreamItems,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
@@ -643,8 +642,7 @@ export function WorkspaceDraftAgentTab({
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
streamItems={optimisticStreamItems}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
uploadFileAttachments,
|
||||
type AgentStreamWriter,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "@/composer/actions";
|
||||
@@ -90,7 +91,6 @@ import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
|
||||
import { submitAgentInput } from "@/composer/submit";
|
||||
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
|
||||
import { ComposerKeyboardScopeProvider } from "@/composer/keyboard-scope";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
@@ -1079,6 +1079,8 @@ export function Composer({
|
||||
const queuedMessages = queuedMessagesRaw ?? EMPTY_ARRAY;
|
||||
|
||||
const setQueuedMessages = useSessionStore((state) => state.setQueuedMessages);
|
||||
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
|
||||
const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead);
|
||||
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
const isCompactLayout = resolveCompactLayout(isCompactLayoutOverride, isCompactFormFactor);
|
||||
@@ -1281,6 +1283,12 @@ export function Composer({
|
||||
if (!client) {
|
||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||
}
|
||||
const stream: AgentStreamWriter = {
|
||||
getTail: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamTail?.get(id),
|
||||
getHead: (id) => useSessionStore.getState().sessions[serverId]?.agentStreamHead?.get(id),
|
||||
setHead: (updater) => setAgentStreamHead(serverId, updater),
|
||||
setTail: (updater) => setAgentStreamTail(serverId, updater),
|
||||
};
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId: targetAgentId,
|
||||
@@ -1290,11 +1298,19 @@ export function Composer({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
encodeImages,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
stream,
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
|
||||
}, [
|
||||
client,
|
||||
onAttentionPromptSend,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
getActiveMessageSubmissions,
|
||||
getSendingClientMessageIds,
|
||||
observeAcceptedMessageSubmissionsRunning,
|
||||
observeMessageSubmissionCanonical,
|
||||
rejectMessageSubmission,
|
||||
} from "./model";
|
||||
|
||||
const submittedAt = new Date("2026-07-26T10:00:00.000Z");
|
||||
|
||||
describe("message submission transactions", () => {
|
||||
it("tracks every in-flight submission independently", () => {
|
||||
const first = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
const both = beginMessageSubmission(first, {
|
||||
clientMessageId: "client-2",
|
||||
submittedAt: new Date(submittedAt.getTime() + 1),
|
||||
});
|
||||
|
||||
expect(getActiveMessageSubmissions(both).map((item) => item.clientMessageId)).toEqual([
|
||||
"client-1",
|
||||
"client-2",
|
||||
]);
|
||||
expect(getSendingClientMessageIds(both)).toEqual(["client-1", "client-2"]);
|
||||
});
|
||||
|
||||
it("removes only the RPC-accepted transaction", () => {
|
||||
const both = beginMessageSubmission(
|
||||
beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }),
|
||||
{ clientMessageId: "client-2", submittedAt },
|
||||
);
|
||||
|
||||
expect(acceptMessageSubmission(both, "client-1", true, false)).toEqual([
|
||||
{
|
||||
clientMessageId: "client-2",
|
||||
submittedAt,
|
||||
rpcAccepted: false,
|
||||
providerAcknowledged: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("bridges an accepted RPC until the correlated running state is observed", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
const accepted = acceptMessageSubmission(sending, "client-1", false, false);
|
||||
|
||||
expect(getActiveMessageSubmissions(accepted)).toHaveLength(1);
|
||||
expect(accepted[0].rpcAccepted).toBe(true);
|
||||
expect(observeAcceptedMessageSubmissionsRunning(accepted)).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles an accepted RPC when provider acknowledgement arrives after running was missed", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
const accepted = acceptMessageSubmission(sending, "client-1", false, false);
|
||||
|
||||
expect(observeMessageSubmissionCanonical(accepted, ["client-1"])).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles an explicitly out-of-band acceptance without lifecycle inference", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
|
||||
expect(acceptMessageSubmission(sending, "client-1", false, true)).toEqual([]);
|
||||
});
|
||||
|
||||
it("settles an idle acceptance from a daemon without submission disposition", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
|
||||
expect(acceptMessageSubmission(sending, "client-1", false, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it("records provider acknowledgement without settling another transaction", () => {
|
||||
const both = beginMessageSubmission(
|
||||
beginMessageSubmission([], { clientMessageId: "client-1", submittedAt }),
|
||||
{ clientMessageId: "client-2", submittedAt },
|
||||
);
|
||||
const observed = observeMessageSubmissionCanonical(both, ["client-1"]);
|
||||
|
||||
expect(observed).toEqual([
|
||||
{
|
||||
clientMessageId: "client-1",
|
||||
submittedAt,
|
||||
rpcAccepted: false,
|
||||
providerAcknowledged: true,
|
||||
},
|
||||
{
|
||||
clientMessageId: "client-2",
|
||||
submittedAt,
|
||||
rpcAccepted: false,
|
||||
providerAcknowledged: false,
|
||||
},
|
||||
]);
|
||||
expect(getSendingClientMessageIds(observed)).toEqual(["client-2"]);
|
||||
});
|
||||
|
||||
it("does not roll back a provider-acknowledged prompt on a later transport error", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
const observed = observeMessageSubmissionCanonical(sending, ["client-1"]);
|
||||
|
||||
expect(rejectMessageSubmission(observed, "client-1")).toEqual({
|
||||
outcome: "accepted",
|
||||
submissions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an unacknowledged transaction", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
|
||||
expect(rejectMessageSubmission(sending, "client-1")).toEqual({
|
||||
outcome: "rejected",
|
||||
submissions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create duplicate transaction identity", () => {
|
||||
const sending = beginMessageSubmission([], { clientMessageId: "client-1", submittedAt });
|
||||
|
||||
expect(() =>
|
||||
beginMessageSubmission(sending, { clientMessageId: "client-1", submittedAt }),
|
||||
).toThrow("Message submission already exists");
|
||||
});
|
||||
});
|
||||
@@ -1,108 +0,0 @@
|
||||
export interface PendingMessageSubmission {
|
||||
clientMessageId: string;
|
||||
submittedAt: Date;
|
||||
}
|
||||
|
||||
export type MessageSubmissionRecord = PendingMessageSubmission & {
|
||||
rpcAccepted: boolean;
|
||||
providerAcknowledged: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_MESSAGE_SUBMISSIONS: readonly MessageSubmissionRecord[] = [];
|
||||
|
||||
export function getActiveMessageSubmissions(
|
||||
submissions: readonly MessageSubmissionRecord[] | null | undefined,
|
||||
): readonly PendingMessageSubmission[] {
|
||||
return submissions ?? EMPTY_MESSAGE_SUBMISSIONS;
|
||||
}
|
||||
|
||||
export function getSendingClientMessageIds(
|
||||
submissions: readonly MessageSubmissionRecord[] | null | undefined,
|
||||
): string[] {
|
||||
return (submissions ?? [])
|
||||
.filter((submission) => !submission.providerAcknowledged)
|
||||
.map((submission) => submission.clientMessageId);
|
||||
}
|
||||
|
||||
export type MessageSubmissionRejectionOutcome = "rejected" | "accepted" | "unknown";
|
||||
|
||||
export interface MessageSubmissionRejectionResult {
|
||||
submissions: MessageSubmissionRecord[];
|
||||
outcome: MessageSubmissionRejectionOutcome;
|
||||
}
|
||||
|
||||
export function beginMessageSubmission(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
input: PendingMessageSubmission,
|
||||
): MessageSubmissionRecord[] {
|
||||
if (submissions.some((submission) => submission.clientMessageId === input.clientMessageId)) {
|
||||
throw new Error(`Message submission already exists: ${input.clientMessageId}`);
|
||||
}
|
||||
return [...submissions, { ...input, rpcAccepted: false, providerAcknowledged: false }];
|
||||
}
|
||||
|
||||
export function acceptMessageSubmission(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
clientMessageId: string,
|
||||
isAgentRunning: boolean,
|
||||
outOfBand: boolean | undefined,
|
||||
): MessageSubmissionRecord[] {
|
||||
const index = submissions.findIndex(
|
||||
(submission) => submission.clientMessageId === clientMessageId,
|
||||
);
|
||||
if (index < 0) return submissions as MessageSubmissionRecord[];
|
||||
// COMPAT(messageSubmissionDisposition): daemons before v0.2.3 omitted outOfBand.
|
||||
// Their normal-send response follows the ordered running/canonical events, while an
|
||||
// out-of-band response arrives with the agent still idle. Remove after 2027-01-27.
|
||||
const legacyOutOfBand = outOfBand === undefined && !isAgentRunning;
|
||||
if (
|
||||
outOfBand === true ||
|
||||
legacyOutOfBand ||
|
||||
isAgentRunning ||
|
||||
submissions[index].providerAcknowledged
|
||||
) {
|
||||
return submissions.filter((_, submissionIndex) => submissionIndex !== index);
|
||||
}
|
||||
if (submissions[index].rpcAccepted) return submissions as MessageSubmissionRecord[];
|
||||
const next = submissions.slice();
|
||||
next[index] = { ...next[index], rpcAccepted: true };
|
||||
return next;
|
||||
}
|
||||
|
||||
export function observeAcceptedMessageSubmissionsRunning(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
): MessageSubmissionRecord[] {
|
||||
const next = submissions.filter((submission) => !submission.rpcAccepted);
|
||||
return next.length === submissions.length ? (submissions as MessageSubmissionRecord[]) : next;
|
||||
}
|
||||
|
||||
export function observeMessageSubmissionCanonical(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
clientMessageIds: readonly string[],
|
||||
): MessageSubmissionRecord[] {
|
||||
if (clientMessageIds.length === 0) return submissions as MessageSubmissionRecord[];
|
||||
const observed = new Set(clientMessageIds);
|
||||
let changed = false;
|
||||
const next = submissions.flatMap((submission): MessageSubmissionRecord[] => {
|
||||
if (submission.providerAcknowledged || !observed.has(submission.clientMessageId)) {
|
||||
return [submission];
|
||||
}
|
||||
changed = true;
|
||||
return submission.rpcAccepted ? [] : [{ ...submission, providerAcknowledged: true }];
|
||||
});
|
||||
return changed ? next : (submissions as MessageSubmissionRecord[]);
|
||||
}
|
||||
|
||||
export function rejectMessageSubmission(
|
||||
submissions: readonly MessageSubmissionRecord[],
|
||||
clientMessageId: string,
|
||||
): MessageSubmissionRejectionResult {
|
||||
const submission = submissions.find((item) => item.clientMessageId === clientMessageId);
|
||||
if (!submission) {
|
||||
return { outcome: "unknown", submissions: submissions as MessageSubmissionRecord[] };
|
||||
}
|
||||
return {
|
||||
outcome: submission.providerAcknowledged || submission.rpcAccepted ? "accepted" : "rejected",
|
||||
submissions: submissions.filter((item) => item.clientMessageId !== clientMessageId),
|
||||
};
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { MessageSubmissionWriter } from "@/composer/actions";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
|
||||
/**
|
||||
* Binds the submission lifecycle to a host session. Every path that sends a message to an
|
||||
* agent — composer send, queued send-now, automatic queue drain — goes through this so a
|
||||
* submitted row and its pending state are always created together.
|
||||
*/
|
||||
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
|
||||
return {
|
||||
begin: (agentId, message) =>
|
||||
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
|
||||
accept: (agentId, clientMessageId, outOfBand) =>
|
||||
useSessionStore
|
||||
.getState()
|
||||
.acceptAgentMessageSubmission(serverId, agentId, clientMessageId, outOfBand),
|
||||
reject: (agentId, clientMessageId) =>
|
||||
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
|
||||
};
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export async function submitAgentInput<TAttachment>(
|
||||
return "queued";
|
||||
}
|
||||
|
||||
// Clear immediately so the submitted timeline row and composer state stay in sync.
|
||||
// Clear immediately so optimistic stream updates and composer state stay in sync.
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
|
||||
Reference in New Issue
Block a user