Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -898,7 +898,7 @@ describe("orchestrator MCP toolkit", () => {
text: "Rewrite the automatic delivery.",
})
.pipe(Effect.flip);
expect(completionEditError._tag).toBe("OrchestratorDispatchError");
expect(completionEditError._tag).toBe("OrchestratorCommandRejectedError");
const completionReorderError = yield* orchestrator
.dispatch({
type: "queued-run.reorder",
Expand Down
23 changes: 22 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,15 @@ export class OrchestratorDispatchError extends Schema.TaggedError<OrchestratorDi
}
}

export class OrchestratorCommandRejectedError extends Schema.TaggedError<OrchestratorCommandRejectedError>()(
"OrchestratorCommandRejectedError",
{ commandId: CommandId, commandType: Schema.String, cause: Schema.optional(Schema.Defect()) },
) {
override get message(): string {
return `Orchestration command ${this.commandType} (${this.commandId}) was rejected before commit.`;
}
}

export class OrchestratorProjectionError extends Schema.TaggedError<OrchestratorProjectionError>()(
"OrchestratorProjectionError",
{
Expand Down Expand Up @@ -174,6 +183,7 @@ export function canReplayCommandReceipt(

export const OrchestratorV2Error = Schema.Union([
OrchestratorDispatchError,
OrchestratorCommandRejectedError,
OrchestratorProjectionError,
OrchestratorDomainEventStreamError,
OrchestratorProviderAdapterError,
Expand Down Expand Up @@ -7825,7 +7835,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
Effect.catch((cause) =>
Effect.gen(function* () {
const rejectedAt = yield* DateTime.now;
yield* eventSink
const receipt = yield* eventSink
.commitRejectedCommand({
commandId: command.commandId,
threadId: commandThreadId(command),
Expand All @@ -7843,6 +7853,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
}),
),
);
if (
command.type === "queued-run.edit" &&
receipt.status === "rejected" &&
cause._tag === "OrchestratorDispatchError"
) {
return yield* new OrchestratorCommandRejectedError({
commandId: cause.commandId,
commandType: cause.commandType,
cause: cause.cause,
});
}
return yield* cause;
}),
),
Expand Down
62 changes: 61 additions & 1 deletion apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1549,6 +1549,66 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch
});
yield* dispatch;
yield* dispatch;
const queuedProjection = yield* threads.getThreadProjection(input.threadId);
const queuedRun = queuedProjection.runs.find(
(run) => run.userMessageId === MessageId.make("intake-second"),
);
assert.isDefined(queuedRun);
assert.equal(queuedRun.status, "queued");
const queuedMessage = queuedProjection.messages.find(
(message) => message.id === queuedRun.userMessageId,
);
assert.isDefined(queuedMessage);
const file: ChatAttachment = {
type: "file",
id: ChatAttachmentId.make(createPendingAttachmentId("pdf")),
name: "queued.pdf",
mimeType: "application/pdf",
sizeBytes: 4,
};
const filePath = resolveAttachmentPath({
attachmentsDir: config.attachmentsDir,
attachment: file,
});
assert.isNotNull(filePath);
yield* fs.writeFile(filePath, new Uint8Array([5, 6, 7, 8]));
const edit = ThreadMessageIntake.dispatchCommand({
type: "queued-run.edit",
commandId: CommandId.make("intake-edit"),
threadId: input.threadId,
runId: queuedRun.id,
text: "Edited with a file",
attachments: [...queuedMessage.attachments, file],
});
yield* edit;
yield* edit;
const editedProjection = yield* threads.getThreadProjection(input.threadId);
const editedMessage = editedProjection.messages.find(
(message) => message.id === queuedRun.userMessageId,
);
assert.isDefined(editedMessage);
assert.equal(editedMessage.attachments.length, 2);
assert.deepEqual(editedMessage.attachments[0], queuedMessage.attachments[0]);
assert.notEqual(editedMessage.attachments[1]?.id, file.id);
const durableFilePath = resolveAttachmentPath({
attachmentsDir: config.attachmentsDir,
attachment: editedMessage.attachments[1]!,
});
assert.isNotNull(durableFilePath);
assert.deepEqual(yield* fs.readFile(durableFilePath), new Uint8Array([5, 6, 7, 8]));
assert.deepEqual(yield* fs.readFile(filePath), new Uint8Array([5, 6, 7, 8]));
const beforeRejectedEdit = (yield* claimedFiles).length;
const rejectedEdit = yield* ThreadMessageIntake.dispatchCommand({
type: "queued-run.edit",
commandId: CommandId.make("intake-edit-rejected"),
threadId: input.threadId,
runId: queuedRun.id,
text: "",
attachments: [file],
}).pipe(Effect.flip);
assert.equal(rejectedEdit._tag, "OrchestratorCommandRejectedError");
assert.equal((yield* claimedFiles).length, beforeRejectedEdit);
assert.deepEqual(yield* fs.readFile(filePath), new Uint8Array([5, 6, 7, 8]));
const send = ThreadMessageIntake.sendToThread({
commandId: CommandId.make("intake-send"),
projectId,
Expand All @@ -1562,7 +1622,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch
});
yield* send;
yield* send;
assert.equal((yield* claimedFiles).length, 3);
assert.equal((yield* claimedFiles).length, 4);
const final = yield* threads.getThreadProjection(input.threadId);
assert.equal(final.messages.length, 3);
for (const message of final.messages) {
Expand Down
9 changes: 7 additions & 2 deletions apps/server/src/orchestration-v2/ThreadMessageIntake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ function dispatchWasNotAccepted(
error: Orchestrator.OrchestratorV2Error | ThreadManagement.ThreadManagementError,
) {
switch (error._tag) {
case "OrchestratorCommandRejectedError":
case "OrchestratorProjectionError":
case "OrchestratorProviderAdapterError":
case "OrchestratorCommandPreviouslyRejectedError":
Expand Down Expand Up @@ -74,10 +75,14 @@ export const dispatchCommand = Effect.fn("ThreadMessageIntake.dispatchCommand")(
);
return yield* threads.dispatch({ ...command, answers, attachmentsByQuestionId });
}
if (command.type !== "message.dispatch") return yield* threads.dispatch(command);
if (
command.type !== "message.dispatch" &&
(command.type !== "queued-run.edit" || command.attachments === undefined)
)
return yield* threads.dispatch(command);
const claimed = yield* AttachmentClaims.claimPendingAttachments({
threadId: command.threadId,
attachments: command.attachments,
attachments: command.attachments ?? [],
});
return yield* threads.dispatch({ ...command, attachments: claimed.attachments }).pipe(
Effect.tap((result) =>
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2375,7 +2375,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => {
text: " ",
})
.pipe(Effect.flip);
assert.equal(emptyEditError._tag, "OrchestratorDispatchError");
assert.equal(emptyEditError._tag, "OrchestratorCommandRejectedError");

yield* orchestrator.dispatch({
type: "queued-run.cancel",
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3890,6 +3890,7 @@ export default function ChatView(props: ChatViewProps) {
reportFailure: false,
});
const queuedEditSaveInFlightRef = useRef(false);
const [isSavingQueuedEdit, setIsSavingQueuedEdit] = useState(false);
const queuedEditImageResources = useMemo(
() =>
(editingQueuedRun?.existingAttachments ?? [])
Expand Down Expand Up @@ -7259,6 +7260,17 @@ export default function ChatView(props: ChatViewProps) {
const editText = promptForSend.trim();
const newEditImages = [...composerImages];
const newEditFiles = [...composerFiles];
const newEditAttachments = [...newEditImages, ...newEditFiles];
if (
editingQueuedRun.existingAttachments.length + newEditAttachments.length >
PROVIDER_SEND_TURN_MAX_ATTACHMENTS
) {
setThreadError(
editingQueuedRun.threadId,
`A message can have at most ${PROVIDER_SEND_TURN_MAX_ATTACHMENTS} attachments.`,
);
return;
}
if (
editText.length === 0 &&
editingQueuedRun.existingAttachments.length === 0 &&
Expand All @@ -7268,6 +7280,7 @@ export default function ChatView(props: ChatViewProps) {
return;
}
queuedEditSaveInFlightRef.current = true;
setIsSavingQueuedEdit(true);
try {
const uploads = await prepareQueuedEditAttachments({
existingAttachments: editingQueuedRun.existingAttachments,
Expand Down Expand Up @@ -7319,6 +7332,7 @@ export default function ChatView(props: ChatViewProps) {
return;
}
setThreadError(editingQueuedRun.threadId, null);
releaseDraftAttachments(newEditAttachments);
promptRef.current = "";
clearComposerDraftContent(queuedEditDraftTargetFor(editingQueuedRun.runId));
composerRef.current?.resetCursorState();
Expand All @@ -7331,6 +7345,7 @@ export default function ChatView(props: ChatViewProps) {
);
} finally {
queuedEditSaveInFlightRef.current = false;
setIsSavingQueuedEdit(false);
}
return;
}
Expand Down Expand Up @@ -9145,7 +9160,10 @@ export default function ChatView(props: ChatViewProps) {
<ComposerSurface.Shell
contextStrip={showComposerContextStrip || showComposerModelStrip}
>
<ComposerSurface.Host>
<ComposerSurface.Host
inert={isSavingQueuedEdit}
aria-busy={isSavingQueuedEdit}
>
<div className="relative z-10">
<ChatComposer
composerRef={composerRef}
Expand All @@ -9169,7 +9187,7 @@ export default function ChatView(props: ChatViewProps) {
projectSelectionRequired={isLocalDraftThread && activeProject === null}
phase={phase}
isConnecting={isConnecting}
isSendBusy={isSendBusy}
isSendBusy={isSendBusy || isSavingQueuedEdit}
isPreparingWorktree={isPreparingWorktree}
queuedRunsControl={
isServerThread && activeThread ? (
Expand Down
Loading