diff --git a/packages/backend/convex/signals.test.ts b/packages/backend/convex/signals.test.ts index 6d1e508..b14e092 100644 --- a/packages/backend/convex/signals.test.ts +++ b/packages/backend/convex/signals.test.ts @@ -412,6 +412,69 @@ describe("signals", () => { expect(list).toHaveLength(2); }); + test("reversed selection preserves agent ordinals and original timestamps", async () => { + const t = newTest(); + const orgId = await ensureOrg(t, identityA); + const m1 = await seedMessage(t, identityA, orgId, "req-rev-1", "first"); + const m2 = await seedMessage(t, identityA, orgId, "req-rev-2", "second"); + + // Chronological selection: m1 then m2. + const chrono = await t + .withIdentity(identityA) + .mutation(api.signals.createFromMessages, { + conversationId: orgId, + messageIds: [m1, m2], + organizationId: orgId, + problemStatement, + processedBy, + }); + + // Reversed selection: m2 then m1 (non-chronological agent order). + const reversed = await t + .withIdentity(identityA) + .mutation(api.signals.createFromMessages, { + conversationId: orgId, + messageIds: [m2, m1], + organizationId: orgId, + problemStatement, + processedBy, + }); + + expect(reversed.signalId).not.toBe(chrono.signalId); + + const chronoSignal = await t + .withIdentity(identityA) + .query(api.signals.get, { signalId: chrono.signalId }); + const reversedSignal = await t + .withIdentity(identityA) + .query(api.signals.get, { signalId: reversed.signalId }); + + // Chronological: ordinals follow selection. + expect(chronoSignal?.sources[0]?.messageId).toBe(m1); + expect(chronoSignal?.sources[0]?.ordinal).toBe(0); + expect(chronoSignal?.sources[1]?.messageId).toBe(m2); + expect(chronoSignal?.sources[1]?.ordinal).toBe(1); + + // Reversed: ordinals follow the agent's reversed selection, and each + // source retains its original creation timestamp (unchanged by reordering). + expect(reversedSignal?.sources[0]?.messageId).toBe(m2); + expect(reversedSignal?.sources[0]?.ordinal).toBe(0); + expect(reversedSignal?.sources[1]?.messageId).toBe(m1); + expect(reversedSignal?.sources[1]?.ordinal).toBe(1); + + // Exact raw text is preserved in both orderings. + expect( + chronoSignal?.sources.map( + (s: { rawTextSnapshot: string }) => s.rawTextSnapshot + ) + ).toEqual(["first", "second"]); + expect( + reversedSignal?.sources.map( + (s: { rawTextSnapshot: string }) => s.rawTextSnapshot + ) + ).toEqual(["second", "first"]); + }); + test("creates a project-scoped signal when the project owner is an org member", async () => { const t = newTest(); const orgId = await ensureOrg(t, identityA); diff --git a/packages/primitives/src/signal.test.ts b/packages/primitives/src/signal.test.ts index e767463..0fad741 100644 --- a/packages/primitives/src/signal.test.ts +++ b/packages/primitives/src/signal.test.ts @@ -223,11 +223,22 @@ describe("composeSignal", () => { getValidationError(input, "MixedConversation"); }); - it("rejects decreasing source timestamps", () => { + it("accepts non-chronological source order with preserved timestamps", () => { const input = makeSignalInput(); - input.sourceMessages[1].createdAt = 99; + // Agent selects the later message first (logical, not chronological order). + const [earlier, later] = input.sourceMessages; + input.sourceMessages = [later, earlier]; - getValidationError(input, "OutOfOrderSource"); + const signal = runSignal(input); + + // Ordinal follows the agent's selection, not timestamps. + expect(signal.sourceMessages.map(({ messageId }) => messageId)).toEqual([ + "message-2", + "message-1", + ]); + // Original timestamps are preserved exactly on each source. + const timestamps = signal.sourceMessages.map(({ createdAt }) => createdAt); + expect(timestamps).toEqual([200, 100]); }); it("preserves source order and exact raw whitespace", () => { diff --git a/packages/primitives/src/signal.ts b/packages/primitives/src/signal.ts index ceb1f30..fc4c8ec 100644 --- a/packages/primitives/src/signal.ts +++ b/packages/primitives/src/signal.ts @@ -92,7 +92,6 @@ export const SignalValidationReason = Schema.Literals([ "InvalidInput", "DuplicateSource", "MixedConversation", - "OutOfOrderSource", ]); export type SignalValidationReason = typeof SignalValidationReason.Type; @@ -118,9 +117,11 @@ export const composeSignal = Effect.fn("Signal.compose")( ) ); - const [{ conversationId, createdAt: firstCreatedAt }] = - signal.sourceMessages; - let previousCreatedAt = firstCreatedAt; + // Source order follows the agent's logical selection, not chronological + // timestamp order. Each source retains its original createdAt so temporal + // provenance is preserved exactly; the ordinal array position reflects the + // agent's narrative composition of the problem statement. + const [{ conversationId }] = signal.sourceMessages; const messageIds = new Set(); for (const source of signal.sourceMessages) { @@ -142,16 +143,6 @@ export const composeSignal = Effect.fn("Signal.compose")( ); } messageIds.add(source.messageId); - - if (source.createdAt < previousCreatedAt) { - return yield* Effect.fail( - new SignalValidationError({ - message: "Signal source timestamps must be non-decreasing", - reason: "OutOfOrderSource", - }) - ); - } - previousCreatedAt = source.createdAt; } return signal;