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
621 lines
19 KiB
TypeScript
621 lines
19 KiB
TypeScript
import { type TestConvex, convexTest } from "convex-test";
|
|
import { anyApi } from "convex/server";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import { internal } from "./_generated/api";
|
|
import type { Id } from "./_generated/dataModel";
|
|
import schema from "./schema";
|
|
|
|
// `import.meta.glob` is provided by the Vitest/Vite test runner at runtime; it
|
|
// has no type definition under the Convex tsconfig (which scopes `types` to
|
|
// node), so declare the minimal shape the test relies on.
|
|
declare global {
|
|
interface ImportMeta {
|
|
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
|
}
|
|
}
|
|
|
|
const modules = import.meta.glob("./**/*.ts");
|
|
const api = anyApi;
|
|
|
|
const ID_A = "https://convex.test|user-a";
|
|
const ID_B = "https://convex.test|user-b";
|
|
const identityA = { tokenIdentifier: ID_A };
|
|
const identityB = { tokenIdentifier: ID_B };
|
|
|
|
const newTest = () => convexTest({ schema, modules });
|
|
|
|
const ensureOrg = async (
|
|
t: TestConvex<typeof schema>,
|
|
identity: { readonly tokenIdentifier: string }
|
|
) => {
|
|
const org = await t
|
|
.withIdentity(identity)
|
|
.mutation(api.organizations.ensurePersonalOrganization, {});
|
|
return org._id as Id<"organizations">;
|
|
};
|
|
|
|
const problemStatement = {
|
|
title: "Deploy fails on staging",
|
|
summary: "The staging deploy command exits with a DNS resolution error.",
|
|
desiredOutcome: "Staging deploys successfully without DNS errors.",
|
|
constraints: ["Must not change the production pipeline"],
|
|
};
|
|
|
|
const processedBy = { agentInstanceId: "zopu-instance-1", agentName: "zopu" };
|
|
|
|
// Begin and admit one user message, returning its messageId.
|
|
const seedMessage = async (
|
|
t: TestConvex<typeof schema>,
|
|
identity: { readonly tokenIdentifier: string },
|
|
orgId: Id<"organizations">,
|
|
clientRequestId: string,
|
|
rawText: string
|
|
): Promise<string> => {
|
|
const begun = await t
|
|
.withIdentity(identity)
|
|
.mutation(api.conversationMessages.beginUserMessage, {
|
|
clientRequestId,
|
|
organizationId: orgId,
|
|
rawText,
|
|
});
|
|
await t
|
|
.withIdentity(identity)
|
|
.mutation(api.conversationMessages.markAdmitted, {
|
|
clientRequestId,
|
|
organizationId: orgId,
|
|
submissionId: `sub-${clientRequestId}`,
|
|
});
|
|
return begun.messageId;
|
|
};
|
|
|
|
const createProject = async (
|
|
t: TestConvex<typeof schema>,
|
|
ownerId: string
|
|
): Promise<Id<"projects">> => {
|
|
// Resolve or create the owner's personal organization
|
|
const ownerIdentity = { tokenIdentifier: ownerId };
|
|
await t
|
|
.withIdentity(ownerIdentity)
|
|
.mutation(api.organizations.ensurePersonalOrganization);
|
|
const outcome = await t
|
|
.withIdentity(ownerIdentity)
|
|
.mutation(internal.projects.persistPublicGitImport, {
|
|
userId: ownerId,
|
|
source: {
|
|
host: "github.com",
|
|
projectName: "test-repo",
|
|
repositoryPath: `owner-${ownerId.slice(-4)}/test-repo`,
|
|
normalizedUrl: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
|
url: `https://github.com/owner-${ownerId.slice(-4)}/test-repo`,
|
|
},
|
|
remote: {
|
|
defaultBranch: "main",
|
|
documents: [
|
|
{
|
|
kind: "readme" as const,
|
|
path: "README.md",
|
|
content: "# test-repo\n\nTest.\n",
|
|
},
|
|
],
|
|
warnings: [],
|
|
},
|
|
});
|
|
return outcome.id as unknown as Id<"projects">;
|
|
};
|
|
|
|
describe("signals", () => {
|
|
test("unauthenticated create is rejected", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
|
|
await expect(
|
|
t.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: ["m1"],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/Authentication required/u);
|
|
});
|
|
|
|
test("creates one Signal from a single admitted user message", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const rawText = " I cannot deploy to staging ";
|
|
const messageId = await seedMessage(t, identityA, orgId, "req-1", rawText);
|
|
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId: result.signalId });
|
|
|
|
expect(composed).not.toBeNull();
|
|
expect(composed?.signal.organizationId).toBe(orgId);
|
|
expect(composed?.signal.projectId).toBeNull();
|
|
expect(composed?.signal.conversationId).toBe(orgId);
|
|
expect(composed?.sources).toHaveLength(1);
|
|
expect(composed?.sources[0]?.messageId).toBe(messageId);
|
|
// Exact raw snapshot preserved verbatim (whitespace intact).
|
|
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
|
expect(composed?.sources[0]?.submissionId).toBe("sub-req-1");
|
|
expect(composed?.sources[0]?.ordinal).toBe(0);
|
|
});
|
|
|
|
test("composes several ordered user messages into one Signal", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const m1 = await seedMessage(t, identityA, orgId, "req-a", "first message");
|
|
const m2 = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgId,
|
|
"req-b",
|
|
"second message"
|
|
);
|
|
const m3 = await seedMessage(t, identityA, orgId, "req-c", "third message");
|
|
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m1, m2, m3],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId: result.signalId });
|
|
|
|
expect(composed?.sources).toHaveLength(3);
|
|
expect(composed?.sources[0]?.messageId).toBe(m1);
|
|
expect(composed?.sources[0]?.ordinal).toBe(0);
|
|
expect(composed?.sources[1]?.messageId).toBe(m2);
|
|
expect(composed?.sources[1]?.ordinal).toBe(1);
|
|
expect(composed?.sources[2]?.messageId).toBe(m3);
|
|
expect(composed?.sources[2]?.ordinal).toBe(2);
|
|
expect(
|
|
composed?.sources.map(
|
|
(s: { rawTextSnapshot: string }) => s.rawTextSnapshot
|
|
)
|
|
).toEqual(["first message", "second message", "third message"]);
|
|
});
|
|
|
|
test("preserves exact raw snapshots and a separate structured problem statement", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const rawText = "\n\t leading and trailing \n";
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgId,
|
|
"req-exact",
|
|
rawText
|
|
);
|
|
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId: result.signalId });
|
|
|
|
// Raw evidence is exact and untouched.
|
|
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
|
// The structured problem statement is stored separately.
|
|
expect(composed?.signal.problemStatement).toEqual(problemStatement);
|
|
});
|
|
|
|
test("rejects cross-organization messages", async () => {
|
|
const t = newTest();
|
|
const orgA = await ensureOrg(t, identityA);
|
|
const orgB = await ensureOrg(t, identityB);
|
|
// Message belongs to org A.
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgA,
|
|
"req-x",
|
|
"secret A"
|
|
);
|
|
|
|
// User B tries to build a signal under org B referencing org A's message.
|
|
await expect(
|
|
t.withIdentity(identityB).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgB,
|
|
messageIds: [messageId],
|
|
organizationId: orgB,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/Source message not found/u);
|
|
});
|
|
|
|
test("rejects cross-conversation mismatch", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const messageId = await seedMessage(t, identityA, orgId, "req-conv", "hi");
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: "different-conversation",
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/Conversation does not belong to organization/u);
|
|
});
|
|
|
|
test("rejects a failed (non-admitted) message", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
|
|
// Begin then fail a message.
|
|
const begun = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.beginUserMessage, {
|
|
clientRequestId: "req-failed",
|
|
organizationId: orgId,
|
|
rawText: "doomed",
|
|
});
|
|
await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.markFailed, {
|
|
clientRequestId: "req-failed",
|
|
organizationId: orgId,
|
|
});
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [begun.messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/not admitted/u);
|
|
});
|
|
|
|
test("rejects a missing message id", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: ["nonexistent-id"],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/Source message not found/u);
|
|
});
|
|
|
|
test("rejects duplicate message ids in the selection", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const messageId = await seedMessage(t, identityA, orgId, "req-dup", "dup");
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [messageId, messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/must be unique/u);
|
|
});
|
|
|
|
test("rejects an empty message selection", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/At least one source message is required/u);
|
|
});
|
|
|
|
test("idempotent retry returns the same signal without duplicate sources", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const m1 = await seedMessage(t, identityA, orgId, "req-idem-1", "a");
|
|
const m2 = await seedMessage(t, identityA, orgId, "req-idem-2", "b");
|
|
|
|
const first = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m1, m2],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
// Retry with the same ordered selection.
|
|
const second = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m1, m2],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
expect(second.signalId).toBe(first.signalId);
|
|
|
|
// Exactly one signal and two sources exist.
|
|
const list = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.list, { organizationId: orgId });
|
|
expect(list).toHaveLength(1);
|
|
expect(list[0]?.sources).toHaveLength(2);
|
|
});
|
|
|
|
test("different ordered selections produce different signals", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t, identityA);
|
|
const m1 = await seedMessage(t, identityA, orgId, "req-ord-1", "x");
|
|
const m2 = await seedMessage(t, identityA, orgId, "req-ord-2", "y");
|
|
|
|
const first = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m1, m2],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
const second = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [m2, m1],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
expect(second.signalId).not.toBe(first.signalId);
|
|
|
|
const list = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.list, { organizationId: orgId });
|
|
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);
|
|
// Project owned by user A, who is a member of orgId.
|
|
const projectId = await createProject(t, ID_A);
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgId,
|
|
"req-proj",
|
|
"build"
|
|
);
|
|
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgId,
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
projectId,
|
|
processedBy,
|
|
});
|
|
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId: result.signalId });
|
|
|
|
expect(composed?.signal.projectId).toBe(projectId);
|
|
});
|
|
|
|
test("rejects a project whose owner is not an org member (cross-tenant)", async () => {
|
|
const t = newTest();
|
|
const orgA = await ensureOrg(t, identityA);
|
|
const _orgB = await ensureOrg(t, identityB);
|
|
// Project owned by user B.
|
|
const projectId = await createProject(t, ID_B);
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgA,
|
|
"req-cross-proj",
|
|
"z"
|
|
);
|
|
|
|
// User A is a member of orgA but the project owner (B) is not.
|
|
await expect(
|
|
t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [messageId],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
projectId,
|
|
processedBy,
|
|
})
|
|
).rejects.toThrow(/does not belong to the target organization/u);
|
|
});
|
|
|
|
test("authorized list returns only the organization's signals with composed sources", async () => {
|
|
const t = newTest();
|
|
const orgA = await ensureOrg(t, identityA);
|
|
const orgB = await ensureOrg(t, identityB);
|
|
|
|
const mA1 = await seedMessage(t, identityA, orgA, "req-list-1", "a1");
|
|
const mA2 = await seedMessage(t, identityA, orgA, "req-list-2", "a2");
|
|
await seedMessage(t, identityB, orgB, "req-list-b", "b1");
|
|
|
|
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [mA1],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
await t.withIdentity(identityA).mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [mA1, mA2],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
const forA = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.list, { organizationId: orgA });
|
|
const forB = await t
|
|
.withIdentity(identityB)
|
|
.query(api.signals.list, { organizationId: orgB });
|
|
|
|
expect(forA).toHaveLength(2);
|
|
expect(forA[0]?.sources.length).toBeGreaterThan(0);
|
|
expect(forB).toHaveLength(0);
|
|
|
|
// Cross-org list is denied.
|
|
await expect(
|
|
t
|
|
.withIdentity(identityB)
|
|
.query(api.signals.list, { organizationId: orgA })
|
|
).rejects.toThrow(/Organization membership required/u);
|
|
});
|
|
|
|
test("authorized get denies cross-organization signal access", async () => {
|
|
const t = newTest();
|
|
const orgA = await ensureOrg(t, identityA);
|
|
const _orgB = await ensureOrg(t, identityB);
|
|
|
|
const messageId = await seedMessage(
|
|
t,
|
|
identityA,
|
|
orgA,
|
|
"req-get",
|
|
"secret"
|
|
);
|
|
const result = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [messageId],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
|
|
// User B cannot read org A's signal.
|
|
await expect(
|
|
t
|
|
.withIdentity(identityB)
|
|
.query(api.signals.get, { signalId: result.signalId })
|
|
).rejects.toThrow(/Organization membership required/u);
|
|
|
|
// get returns null for an unknown but well-formed id when authorized.
|
|
const other = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.signals.createFromMessages, {
|
|
conversationId: orgA,
|
|
messageIds: [await seedMessage(t, identityA, orgA, "req-ghost", "g")],
|
|
organizationId: orgA,
|
|
problemStatement,
|
|
processedBy,
|
|
});
|
|
expect(other.signalId).not.toBe(result.signalId);
|
|
});
|
|
});
|