diff --git a/apps/server/src/orchestration/Normalizer.test.ts b/apps/server/src/orchestration/Normalizer.test.ts new file mode 100644 index 00000000000..113b6e1baee --- /dev/null +++ b/apps/server/src/orchestration/Normalizer.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + CommandId, + type ClientOrchestrationCommand, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; + +import { canonicalizeClientCommandTimestamps } from "./Normalizer.ts"; + +const clientCreatedAt = "2031-01-01T00:00:00.000Z"; +const serverReceivedAt = "2026-07-18T00:00:00.000Z"; + +describe("canonicalizeClientCommandTimestamps", () => { + it("replaces a client command timestamp with the server receipt timestamp", () => { + const command: ClientOrchestrationCommand = { + type: "project.create", + commandId: CommandId.make("command-1"), + projectId: ProjectId.make("project-1"), + title: "Clock-safe project", + workspaceRoot: "/tmp/clock-safe-project", + createdAt: clientCreatedAt, + }; + + expect(canonicalizeClientCommandTimestamps(command, serverReceivedAt)).toEqual({ + ...command, + createdAt: serverReceivedAt, + }); + }); + + it("replaces both timestamps when the first turn bootstraps a thread", () => { + const command: ClientOrchestrationCommand = { + type: "thread.turn.start", + commandId: CommandId.make("command-2"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-1"), + role: "user", + text: "Start a thread", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: ProjectId.make("project-1"), + title: "Clock-safe thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: clientCreatedAt, + }, + }, + createdAt: clientCreatedAt, + }; + + const result = canonicalizeClientCommandTimestamps(command, serverReceivedAt); + + expect(result.type).toBe("thread.turn.start"); + if (result.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command"); + } + expect(result.createdAt).toBe(serverReceivedAt); + expect(result.bootstrap?.createThread?.createdAt).toBe(serverReceivedAt); + }); +}); diff --git a/apps/server/src/orchestration/Normalizer.ts b/apps/server/src/orchestration/Normalizer.ts index bed166eba45..24c65900b29 100644 --- a/apps/server/src/orchestration/Normalizer.ts +++ b/apps/server/src/orchestration/Normalizer.ts @@ -1,8 +1,10 @@ +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { type ClientOrchestrationCommand, + type IsoDateTime, type OrchestrationCommand, OrchestrationDispatchCommandError, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, @@ -13,8 +15,38 @@ import { ServerConfig } from "../config.ts"; import { parseBase64DataUrl } from "../imageMime.ts"; import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +export const canonicalizeClientCommandTimestamps = ( + command: ClientOrchestrationCommand, + receivedAt: IsoDateTime, +): ClientOrchestrationCommand => { + const canonicalCommand = + "createdAt" in command + ? { + ...command, + createdAt: receivedAt, + } + : command; + + if (canonicalCommand.type !== "thread.turn.start" || !canonicalCommand.bootstrap?.createThread) { + return canonicalCommand; + } + + return { + ...canonicalCommand, + bootstrap: { + ...canonicalCommand.bootstrap, + createThread: { + ...canonicalCommand.bootstrap.createThread, + createdAt: receivedAt, + }, + }, + }; +}; + export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => Effect.gen(function* () { + const receivedAt = DateTime.formatIso(yield* DateTime.now); + const canonicalCommand = canonicalizeClientCommandTimestamps(command, receivedAt); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const serverConfig = yield* ServerConfig; @@ -47,30 +79,33 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => ), ); - if (command.type === "project.create") { + if (canonicalCommand.type === "project.create") { return { - ...command, + ...canonicalCommand, workspaceRoot: yield* normalizeProjectWorkspaceRootForCreate( - command.workspaceRoot, - command.createWorkspaceRootIfMissing, + canonicalCommand.workspaceRoot, + canonicalCommand.createWorkspaceRootIfMissing, ), - createWorkspaceRootIfMissing: command.createWorkspaceRootIfMissing === true, + createWorkspaceRootIfMissing: canonicalCommand.createWorkspaceRootIfMissing === true, } satisfies OrchestrationCommand; } - if (command.type === "project.meta.update" && command.workspaceRoot !== undefined) { + if ( + canonicalCommand.type === "project.meta.update" && + canonicalCommand.workspaceRoot !== undefined + ) { return { - ...command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(command.workspaceRoot), + ...canonicalCommand, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(canonicalCommand.workspaceRoot), } satisfies OrchestrationCommand; } - if (command.type !== "thread.turn.start") { - return command as OrchestrationCommand; + if (canonicalCommand.type !== "thread.turn.start") { + return canonicalCommand as OrchestrationCommand; } const normalizedAttachments = yield* Effect.forEach( - command.message.attachments, + canonicalCommand.message.attachments, (attachment) => Effect.gen(function* () { const parsed = parseBase64DataUrl(attachment.dataUrl); @@ -87,7 +122,7 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => }); } - const attachmentId = createAttachmentId(command.threadId); + const attachmentId = createAttachmentId(canonicalCommand.threadId); if (!attachmentId) { return yield* new OrchestrationDispatchCommandError({ message: "Failed to create a safe attachment id.", @@ -135,9 +170,9 @@ export const normalizeDispatchCommand = (command: ClientOrchestrationCommand) => ); return { - ...command, + ...canonicalCommand, message: { - ...command.message, + ...canonicalCommand.message, attachments: normalizedAttachments, }, } satisfies OrchestrationCommand;