fix(signals): source order follows agent selection, not chronological timestamps
Remove the OutOfOrderSource constraint from composeSignal. The agent's selection order is authoritative for source ordinals; timestamps remain as per-source provenance metadata. This resolves the contract conflict where reversed message selections (valid agent compositions) were rejected by the primitive. Each source still retains its original createdAt, so chronological provenance is preserved exactly. The sourceKey includes ordered message IDs, so different orderings produce distinct signals with deterministic idempotency. Coverage added: - Primitive: non-chronological order accepted, timestamps preserved - Backend: reversed selection preserves ordinals and exact raw text
This commit is contained in:
@@ -412,6 +412,69 @@ describe("signals", () => {
|
|||||||
expect(list).toHaveLength(2);
|
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 () => {
|
test("creates a project-scoped signal when the project owner is an org member", async () => {
|
||||||
const t = newTest();
|
const t = newTest();
|
||||||
const orgId = await ensureOrg(t, identityA);
|
const orgId = await ensureOrg(t, identityA);
|
||||||
|
|||||||
@@ -223,11 +223,22 @@ describe("composeSignal", () => {
|
|||||||
getValidationError(input, "MixedConversation");
|
getValidationError(input, "MixedConversation");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects decreasing source timestamps", () => {
|
it("accepts non-chronological source order with preserved timestamps", () => {
|
||||||
const input = makeSignalInput();
|
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", () => {
|
it("preserves source order and exact raw whitespace", () => {
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ export const SignalValidationReason = Schema.Literals([
|
|||||||
"InvalidInput",
|
"InvalidInput",
|
||||||
"DuplicateSource",
|
"DuplicateSource",
|
||||||
"MixedConversation",
|
"MixedConversation",
|
||||||
"OutOfOrderSource",
|
|
||||||
]);
|
]);
|
||||||
export type SignalValidationReason = typeof SignalValidationReason.Type;
|
export type SignalValidationReason = typeof SignalValidationReason.Type;
|
||||||
|
|
||||||
@@ -118,9 +117,11 @@ export const composeSignal = Effect.fn("Signal.compose")(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const [{ conversationId, createdAt: firstCreatedAt }] =
|
// Source order follows the agent's logical selection, not chronological
|
||||||
signal.sourceMessages;
|
// timestamp order. Each source retains its original createdAt so temporal
|
||||||
let previousCreatedAt = firstCreatedAt;
|
// 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<ConversationMessageId>();
|
const messageIds = new Set<ConversationMessageId>();
|
||||||
|
|
||||||
for (const source of signal.sourceMessages) {
|
for (const source of signal.sourceMessages) {
|
||||||
@@ -142,16 +143,6 @@ export const composeSignal = Effect.fn("Signal.compose")(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
messageIds.add(source.messageId);
|
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;
|
return signal;
|
||||||
|
|||||||
Reference in New Issue
Block a user