Stop completed turns from appearing stuck (#2484)
* fix(app): stop completed turns from appearing stuck Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow. * fix(app): guard pending message identity * test(app): measure submission layout independent of scroll * fix(app): keep submitted messages consistent through reconnects Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization. * fix(app): close canonical submission races * test(app): cover canonical submission races in browser * fix(app): keep replacement submissions authoritative Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle. * fix(app): keep submitted messages stable across sync Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows. * fix(app): settle submissions in either acknowledgement order Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity. * fix(app): keep agent visible during history handoff Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied. * fix(app): settle attachment-only submissions Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row. * fix(agent): settle out-of-band message submissions Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer. * fix(app): settle out-of-band submissions compatibly * fix(app): preserve canonical prompt order * test(app): keep workspace status check timing-independent The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
This commit is contained in:
@@ -6,7 +6,17 @@ import type {
|
||||
UserComposerAttachment,
|
||||
WorkspaceComposerAttachment,
|
||||
} from "@/attachments/types";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
appendSubmittedUserMessage,
|
||||
removeSubmittedUserMessage,
|
||||
type StreamItem,
|
||||
} from "@/types/stream";
|
||||
import {
|
||||
acceptMessageSubmission,
|
||||
beginMessageSubmission,
|
||||
rejectMessageSubmission,
|
||||
type MessageSubmissionRecord,
|
||||
} from "@/composer/submission/model";
|
||||
import {
|
||||
cancelComposerAgent,
|
||||
dispatchComposerAgentMessage,
|
||||
@@ -20,7 +30,7 @@ import {
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachment,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
type AgentStreamWriter,
|
||||
type MessageSubmissionWriter,
|
||||
type AttachmentPersister,
|
||||
type ComposerCancelClient,
|
||||
type ComposerSendClient,
|
||||
@@ -168,14 +178,16 @@ interface FakeSendCall {
|
||||
}
|
||||
|
||||
function createFakeSendClient(
|
||||
options: { rejection?: Error } = {},
|
||||
options: { rejection?: Error; beforeRejection?: (call: FakeSendCall) => void } = {},
|
||||
): ComposerSendClient & { calls: FakeSendCall[] } {
|
||||
const calls: FakeSendCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
sendAgentMessage: async (agentId, text, opts) => {
|
||||
calls.push({ agentId, text, options: opts });
|
||||
const call = { agentId, text, options: opts };
|
||||
calls.push(call);
|
||||
if (options.rejection) {
|
||||
options.beforeRejection?.(call);
|
||||
throw options.rejection;
|
||||
}
|
||||
},
|
||||
@@ -183,7 +195,7 @@ function createFakeSendClient(
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeStream extends AgentStreamWriter {
|
||||
interface FakeStream extends MessageSubmissionWriter {
|
||||
head: Map<string, StreamItem[]>;
|
||||
tail: Map<string, StreamItem[]>;
|
||||
}
|
||||
@@ -192,18 +204,70 @@ function createFakeStream(initialHead: Map<string, StreamItem[]> = new Map()): F
|
||||
const fake: FakeStream = {
|
||||
head: new Map(initialHead),
|
||||
tail: new Map(),
|
||||
getTail: (agentId) => fake.tail.get(agentId),
|
||||
getHead: (agentId) => fake.head.get(agentId),
|
||||
setHead: (updater) => {
|
||||
fake.head = updater(fake.head);
|
||||
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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
setTail: (updater) => {
|
||||
fake.tail = updater(fake.tail);
|
||||
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;
|
||||
},
|
||||
};
|
||||
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[]> } {
|
||||
@@ -337,7 +401,7 @@ describe("pickAndPersistImages", () => {
|
||||
});
|
||||
|
||||
describe("dispatchComposerAgentMessage", () => {
|
||||
it("removes the optimistic prompt when the host rejects it", async () => {
|
||||
it("removes the submitted prompt when the host rejects it", async () => {
|
||||
const rejection = new Error("Host rejected prompt");
|
||||
const client = createFakeSendClient({ rejection });
|
||||
const stream = createFakeStream();
|
||||
@@ -349,14 +413,54 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "rejected prompt",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
}),
|
||||
).rejects.toBe(rejection);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
expect(stream.head.get("agent")).toEqual([]);
|
||||
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();
|
||||
@@ -371,7 +475,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
{ kind: "github_pr", item: prItem },
|
||||
],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls).toHaveLength(1);
|
||||
@@ -393,7 +497,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(stream.head.get("agent")).toBeUndefined();
|
||||
expect(stream.head.get("agent")).toEqual([]);
|
||||
const tail = stream.tail.get("agent");
|
||||
expect(tail).toHaveLength(1);
|
||||
const userMessage = tail?.[0] as Extract<StreamItem, { kind: "user_message" }>;
|
||||
@@ -402,7 +506,8 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
expect(userMessage.images).toEqual([image]);
|
||||
expect(userMessage.attachments).toEqual(call.options.attachments);
|
||||
expect(userMessage.id).toBe(call.options.messageId);
|
||||
expect(userMessage.optimistic).toBe(true);
|
||||
expect(userMessage.clientMessageId).toBe(call.options.messageId);
|
||||
expect(userMessage.messageId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can send legacy GitHub attachment payloads for old daemons", async () => {
|
||||
@@ -416,7 +521,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
attachments: [{ kind: "forge_change_request", item: prItem }],
|
||||
attachmentSubmitFormat: "legacy-github",
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0].options.attachments).toEqual([
|
||||
@@ -449,11 +554,11 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "next message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(stream.head.get("agent")).toHaveLength(2);
|
||||
expect(stream.tail.get("agent")).toBeUndefined();
|
||||
expect(stream.tail.get("agent")).toEqual([]);
|
||||
});
|
||||
|
||||
it("submits empty wire arrays when no attachments are provided", async () => {
|
||||
@@ -466,7 +571,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "plain message",
|
||||
attachments: [],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options).toMatchObject({
|
||||
@@ -486,7 +591,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "review this",
|
||||
attachments: [review],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([review.attachment]);
|
||||
@@ -504,7 +609,7 @@ describe("dispatchComposerAgentMessage", () => {
|
||||
text: "inspect element",
|
||||
attachments: [browserElement],
|
||||
encodeImages: passthroughEncodeImages,
|
||||
stream,
|
||||
submission: stream,
|
||||
});
|
||||
|
||||
expect(client.calls[0]?.options.attachments).toEqual([
|
||||
|
||||
@@ -12,13 +12,8 @@ import {
|
||||
splitComposerAttachmentsForSubmit,
|
||||
type ComposerAttachmentSubmitFormat,
|
||||
} from "@/composer/attachments/submit";
|
||||
import {
|
||||
appendOptimisticUserMessageToStream,
|
||||
buildOptimisticUserMessage,
|
||||
generateMessageId,
|
||||
type StreamItem,
|
||||
type UserMessageItem,
|
||||
} from "@/types/stream";
|
||||
import { createUserMessage, generateMessageId, type UserMessageItem } from "@/types/stream";
|
||||
import type { MessageSubmissionRejectionOutcome } from "@/composer/submission/model";
|
||||
import type { PickedImageAttachmentInput } from "@/hooks/image-attachment-picker";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -51,7 +46,7 @@ export interface ComposerSendClient {
|
||||
images: Array<{ data: string; mimeType: string }>;
|
||||
attachments: ReturnType<typeof splitComposerAttachmentsForSubmit>["attachments"];
|
||||
},
|
||||
) => Promise<void>;
|
||||
) => Promise<void | { outOfBand?: boolean }>;
|
||||
uploadFile: (input: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise<{
|
||||
requestId: string;
|
||||
file: {
|
||||
@@ -70,11 +65,10 @@ export interface ComposerCancelClient {
|
||||
cancelAgent: (agentId: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
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 MessageSubmissionWriter {
|
||||
begin: (agentId: string, message: UserMessageItem) => void;
|
||||
accept: (agentId: string, clientMessageId: string, outOfBand: boolean | undefined) => void;
|
||||
reject: (agentId: string, clientMessageId: string) => MessageSubmissionRejectionOutcome;
|
||||
}
|
||||
|
||||
export interface QueueWriter {
|
||||
@@ -169,7 +163,7 @@ export interface DispatchComposerAgentMessageInput {
|
||||
encodeImages: (
|
||||
images: AttachmentMetadata[],
|
||||
) => Promise<Array<{ data: string; mimeType: string }> | undefined>;
|
||||
stream: AgentStreamWriter;
|
||||
submission: MessageSubmissionWriter;
|
||||
}
|
||||
|
||||
export async function dispatchComposerAgentMessage(
|
||||
@@ -178,60 +172,30 @@ export async function dispatchComposerAgentMessage(
|
||||
const wirePayload = splitComposerAttachmentsForSubmit(input.attachments, {
|
||||
format: input.attachmentSubmitFormat,
|
||||
});
|
||||
const messageId = generateMessageId();
|
||||
const userMessage = buildOptimisticUserMessage({
|
||||
id: messageId,
|
||||
const clientMessageId = generateMessageId();
|
||||
const userMessage = createUserMessage({
|
||||
clientMessageId,
|
||||
text: input.text,
|
||||
timestamp: new Date(),
|
||||
images: wirePayload.images,
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
const rollbackOptimisticMessage = appendUserMessageToStream(
|
||||
input.agentId,
|
||||
userMessage,
|
||||
input.stream,
|
||||
);
|
||||
input.submission.begin(input.agentId, userMessage);
|
||||
try {
|
||||
const imagesData = await input.encodeImages(wirePayload.images);
|
||||
await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId,
|
||||
const result = await input.client.sendAgentMessage(input.agentId, input.text, {
|
||||
messageId: clientMessageId,
|
||||
images: imagesData ?? [],
|
||||
attachments: wirePayload.attachments,
|
||||
});
|
||||
input.submission.accept(input.agentId, clientMessageId, result?.outOfBand);
|
||||
} catch (error) {
|
||||
rollbackOptimisticMessage();
|
||||
const outcome = input.submission.reject(input.agentId, clientMessageId);
|
||||
if (outcome === "accepted") return;
|
||||
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 create attempt as optimistic chat before continuing it", async () => {
|
||||
it("renders a prepared new-workspace submission 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.optimisticStreamItems).toEqual([
|
||||
expect(result.current.submittedStreamItems).toEqual([
|
||||
{
|
||||
kind: "user_message",
|
||||
id: "msg-prepared",
|
||||
clientMessageId: "msg-prepared",
|
||||
text: "build this",
|
||||
timestamp: attempt.timestamp,
|
||||
optimistic: true,
|
||||
images: [image],
|
||||
attachments: [attachment],
|
||||
},
|
||||
|
||||
@@ -8,12 +8,13 @@ import {
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
buildOptimisticUserMessage,
|
||||
createUserMessage,
|
||||
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[] = [];
|
||||
|
||||
@@ -133,7 +134,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
const formErrorMessage = machine.tag === "draft" ? machine.errorMessage : "";
|
||||
const isSubmitting = machine.tag === "creating";
|
||||
|
||||
const optimisticStreamItems = useMemo<StreamItem[]>(() => {
|
||||
const submittedStreamItems = useMemo<StreamItem[]>(() => {
|
||||
if (machine.tag !== "creating") {
|
||||
return EMPTY_STREAM_ITEMS;
|
||||
}
|
||||
@@ -147,8 +148,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
}
|
||||
|
||||
return [
|
||||
buildOptimisticUserMessage({
|
||||
id: machine.attempt.clientMessageId,
|
||||
createUserMessage({
|
||||
clientMessageId: machine.attempt.clientMessageId,
|
||||
text: machine.attempt.text,
|
||||
timestamp: machine.attempt.timestamp,
|
||||
images: machine.attempt.images,
|
||||
@@ -156,6 +157,15 @@ 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") {
|
||||
@@ -195,8 +205,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
handoffCreatedAgentUserMessage(
|
||||
pendingServerId,
|
||||
createResult.agentId,
|
||||
buildOptimisticUserMessage({
|
||||
id: attempt.clientMessageId,
|
||||
createUserMessage({
|
||||
clientMessageId: attempt.clientMessageId,
|
||||
text: attempt.text,
|
||||
timestamp: attempt.timestamp,
|
||||
images: attempt.images,
|
||||
@@ -326,7 +336,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
|
||||
machine,
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
optimisticStreamItems,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
|
||||
@@ -479,7 +479,8 @@ export function WorkspaceDraftAgentTab({
|
||||
const {
|
||||
formErrorMessage,
|
||||
isSubmitting,
|
||||
optimisticStreamItems,
|
||||
submittedStreamItems,
|
||||
pendingMessageSubmissions,
|
||||
draftAgent,
|
||||
handleCreateFromInput,
|
||||
continueCreateFromAttempt,
|
||||
@@ -642,7 +643,8 @@ export function WorkspaceDraftAgentTab({
|
||||
agentId={tabId}
|
||||
serverId={serverId}
|
||||
context={draftAgent}
|
||||
streamItems={optimisticStreamItems}
|
||||
streamItems={submittedStreamItems}
|
||||
pendingMessageSubmissions={pendingMessageSubmissions}
|
||||
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
|
||||
@@ -64,7 +64,6 @@ import {
|
||||
sendQueuedComposerMessageNow,
|
||||
toggleGithubAttachmentFromPicker,
|
||||
uploadFileAttachments,
|
||||
type AgentStreamWriter,
|
||||
type QueueWriter,
|
||||
type QueuedComposerMessage,
|
||||
} from "@/composer/actions";
|
||||
@@ -91,6 +90,7 @@ 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,8 +1079,6 @@ 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);
|
||||
@@ -1283,12 +1281,6 @@ 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,
|
||||
@@ -1298,19 +1290,11 @@ export function Composer({
|
||||
supportsForgeAttachments: supportsForgeSearch,
|
||||
}),
|
||||
encodeImages,
|
||||
stream,
|
||||
submission: createMessageSubmissionWriter(serverId),
|
||||
});
|
||||
onAttentionPromptSend?.();
|
||||
};
|
||||
}, [
|
||||
client,
|
||||
onAttentionPromptSend,
|
||||
serverId,
|
||||
setAgentStreamTail,
|
||||
setAgentStreamHead,
|
||||
supportsForgeSearch,
|
||||
t,
|
||||
]);
|
||||
}, [client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
|
||||
|
||||
useEffect(() => {
|
||||
onSubmitMessageRef.current = onSubmitMessage;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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 optimistic stream updates and composer state stay in sync.
|
||||
// Clear immediately so the submitted timeline row and composer state stay in sync.
|
||||
if (shouldClearOnSubmit) {
|
||||
input.setUserInput("");
|
||||
input.setAttachments([]);
|
||||
|
||||
Reference in New Issue
Block a user