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
193 changes: 193 additions & 0 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,199 @@ describe("OrchestrationEngine", () => {
}
});

it("validates long message-boundary forks against the renderer-retained source window", async () => {
const system = await createOrchestrationSystem();
const { engine } = system;
const projectId = asProjectId("project-long-fork");
const sourceThreadId = ThreadId.makeUnsafe("thread-long-fork-source");
const baseTimestamp = Date.parse("2026-07-22T10:00:00.000Z");
const sourceMessages = Array.from({ length: 2_002 }, (_, index) => {
const sequence = String(index).padStart(4, "0");
const createdAt = new Date(baseTimestamp + index).toISOString();
return {
messageId: asMessageId(`long-source-${sequence}`),
role: index % 2 === 0 ? ("user" as const) : ("assistant" as const),
text: `Long transcript message ${sequence}`,
createdAt,
updatedAt: createdAt,
};
});

try {
await system.run(
engine.dispatch({
type: "project.create",
commandId: CommandId.makeUnsafe("cmd-project-long-fork-create"),
projectId,
title: "Long fork project",
workspaceRoot: "/tmp/project-long-fork",
defaultModelSelection: { provider: "codex", model: "gpt-5-codex" },
createdAt: sourceMessages[0]!.createdAt,
}),
);
await system.run(
engine.dispatch({
type: "thread.create",
commandId: CommandId.makeUnsafe("cmd-thread-long-fork-source-create"),
threadId: sourceThreadId,
projectId,
title: "Long source",
modelSelection: { provider: "codex", model: "gpt-5-codex" },
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: null,
createdAt: sourceMessages[0]!.createdAt,
}),
);
await system.run(
engine.dispatch({
type: "thread.messages.import",
commandId: CommandId.makeUnsafe("cmd-thread-long-fork-source-import"),
threadId: sourceThreadId,
messages: sourceMessages,
createdAt: sourceMessages.at(-1)!.createdAt,
}),
);

await expect(
system.run(
engine.dispatch({
type: "thread.fork.create",
commandId: CommandId.makeUnsafe("cmd-thread-long-fork-create"),
threadId: ThreadId.makeUnsafe("thread-long-fork-destination"),
sourceThreadId,
sourceMessageId: sourceMessages.at(-1)!.messageId,
projectId,
title: "Long fork",
modelSelection: { provider: "codex", model: "gpt-5-codex" },
runtimeMode: "approval-required",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
envMode: "local",
branch: null,
worktreePath: null,
importedMessages: sourceMessages.slice(-2_000).map((message, index) => ({
...message,
messageId: asMessageId(`long-fork-${String(index).padStart(4, "0")}`),
})),
createdAt: new Date(baseTimestamp + sourceMessages.length).toISOString(),
}),
),
).resolves.toEqual(expect.objectContaining({ sequence: expect.any(Number) }));

const destination = (await system.run(engine.getReadModel())).threads.find(
(thread) => thread.id === ThreadId.makeUnsafe("thread-long-fork-destination"),
);
expect(destination?.messages).toHaveLength(2_000);
expect(destination?.messages[0]?.text).toBe("Long transcript message 0002");
expect(destination?.messages.at(-1)?.text).toBe("Long transcript message 2001");
} finally {
await system.dispose();
}
});

it("validates hot same-timestamp fork messages in persistent projection order", async () => {
const createdAt = "2026-07-22T10:00:00.000Z";
const system = await createOrchestrationSystem();
const { engine } = system;
const projectId = asProjectId("project-same-time-fork");
const sourceThreadId = ThreadId.makeUnsafe("thread-same-time-fork-source");

try {
await system.run(
engine.dispatch({
type: "project.create",
commandId: CommandId.makeUnsafe("cmd-project-same-time-fork-create"),
projectId,
title: "Same-time fork project",
workspaceRoot: "/tmp/project-same-time-fork",
defaultModelSelection: { provider: "codex", model: "gpt-5-codex" },
createdAt,
}),
);
await system.run(
engine.dispatch({
type: "thread.create",
commandId: CommandId.makeUnsafe("cmd-thread-same-time-fork-source-create"),
threadId: sourceThreadId,
projectId,
title: "Same-time source",
modelSelection: { provider: "codex", model: "gpt-5-codex" },
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
branch: null,
worktreePath: null,
createdAt,
}),
);
// Deliberately append in the opposite order from the projection's
// created_at/message_id ordering to exercise the hot overlay.
await system.run(
engine.dispatch({
type: "thread.messages.import",
commandId: CommandId.makeUnsafe("cmd-thread-same-time-fork-source-import"),
threadId: sourceThreadId,
messages: [
{
messageId: asMessageId("same-time-z-assistant"),
role: "assistant",
text: "Same-time answer",
createdAt,
updatedAt: createdAt,
},
{
messageId: asMessageId("same-time-a-user"),
role: "user",
text: "Same-time question",
createdAt,
updatedAt: createdAt,
},
],
createdAt,
}),
);

await expect(
system.run(
engine.dispatch({
type: "thread.fork.create",
commandId: CommandId.makeUnsafe("cmd-thread-same-time-fork-create"),
threadId: ThreadId.makeUnsafe("thread-same-time-fork-destination"),
sourceThreadId,
sourceMessageId: asMessageId("same-time-z-assistant"),
projectId,
title: "Same-time fork",
modelSelection: { provider: "codex", model: "gpt-5-codex" },
runtimeMode: "approval-required",
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
envMode: "local",
branch: null,
worktreePath: null,
importedMessages: [
{
messageId: asMessageId("same-time-fork-a-user"),
role: "user",
text: "Same-time question",
createdAt,
updatedAt: createdAt,
},
{
messageId: asMessageId("same-time-fork-z-assistant"),
role: "assistant",
text: "Same-time answer",
createdAt,
updatedAt: createdAt,
},
],
createdAt,
}),
),
).resolves.toEqual(expect.objectContaining({ sequence: expect.any(Number) }));
} finally {
await system.dispose();
}
});

it("rejects a fork import that reuses a source message id without moving the source row", async () => {
const createdAt = now();
const system = await createOrchestrationSystem();
Expand Down
35 changes: 29 additions & 6 deletions apps/server/src/orchestration/Layers/OrchestrationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ThreadId,
} from "@synara/contracts";
import { OrchestrationCommand, ORCHESTRATION_WS_METHODS } from "@synara/contracts";
import { compareProjectionMessageOrderValues } from "@synara/shared/messageOrder";
import {
Cause,
Deferred,
Expand Down Expand Up @@ -255,17 +256,25 @@ const makeOrchestrationEngine = Effect.gen(function* () {
const overlayThread = (
model: OrchestrationReadModel,
thread: OrchestrationReadModel["threads"][number],
options: { readonly persistentMessageOrder?: boolean } = {},
): OrchestrationReadModel => {
const existingThread = model.threads.find((entry) => entry.id === thread.id);
const hotMessageIds = new Set((existingThread?.messages ?? []).map((message) => message.id));
// The command model starts with no message bodies after a restart, then
// accumulates new events in their authoritative order. Keep persisted-only
// history first and let the hot model replace matching rows. This also
// avoids reordering same-timestamp messages by the projection's id tie-break.
const mergedMessages = [
// history first and let the hot model replace matching rows.
const overlaidMessages = [
...thread.messages.filter((message) => !hotMessageIds.has(message.id)),
...(existingThread?.messages ?? []),
];
// Message-boundary fork commands are constructed from the browser projection,
// whose stable tie-break is message id. Normalize only that validation surface;
// other commands still require authoritative hot event order.
const mergedMessages = options.persistentMessageOrder
? overlaidMessages.toSorted((left, right) =>
compareProjectionMessageOrderValues(left.createdAt, left.id, right.createdAt, right.id),
)
: overlaidMessages;
const mergedThread = {
...thread,
messages: mergedMessages,
Expand All @@ -283,12 +292,19 @@ const makeOrchestrationEngine = Effect.gen(function* () {
command: OrchestrationCommand,
model: OrchestrationReadModel,
threadId: ThreadId,
options: {
readonly fullMessageHistory?: boolean;
readonly persistentMessageOrder?: boolean;
} = {},
): Effect.Effect<OrchestrationReadModel, OrchestrationDispatchError> =>
projectionSnapshotQuery.getThreadDetailById(threadId).pipe(
(options.fullMessageHistory
? projectionSnapshotQuery.getThreadDetailForExportById(threadId)
: projectionSnapshotQuery.getThreadDetailById(threadId)
).pipe(
Effect.map((threadOption) =>
Option.match(threadOption, {
onNone: () => model,
onSome: (thread) => overlayThread(model, thread),
onSome: (thread) => overlayThread(model, thread, options),
}),
),
Effect.mapError(
Expand All @@ -306,8 +322,15 @@ const makeOrchestrationEngine = Effect.gen(function* () {
): Effect.Effect<OrchestrationReadModel, OrchestrationDispatchError> => {
switch (command.type) {
case "thread.handoff.create":
case "thread.fork.create":
return loadThreadDetailForDecider(command, commandReadModel, command.sourceThreadId);
case "thread.fork.create":
// Message-boundary forks promise an exact authoritative prefix. The normal
// thread detail query is deliberately capped for UI reads, so validation
// must use the existing uncapped export projection instead.
return loadThreadDetailForDecider(command, commandReadModel, command.sourceThreadId, {
fullMessageHistory: true,
persistentMessageOrder: true,
});
case "thread.turn.start":
return command.sourceProposedPlan
? loadThreadDetailForDecider(
Expand Down
Loading
Loading