Add the complete project-scoped Zopu chat, Signal extraction, and work-routing loop: Backend (signalRouting.ts): - Agent-token-gated functions for the full routing cycle: list evidence, create signal, list signals, list active issues, attach signal to issue, create issue from signal, begin issue, get project context, list projects - attachSignalToIssue requires signal.projectId to exist and equal issue.projectId (org equality alone is insufficient) - createIssueFromSignal is idempotent: queries existing attachments before creating, returns created/reused flag - Duplicate attach retries return existing relation without emitting a duplicate event - All functions deny-by-default with agent token validation Schema: - Add signalIssueAttachments table with indexes by signal, issue, and the composite (signalId, issueId) for idempotent lookups Agent tools (tools/signals.ts): - Nine Flue tool definitions bound to the organization-scoped agent instance ID, calling the agent-gated Convex functions - Organization ID never accepted as free input; always from the bound instance Zopu agent (zopu.ts): - Comprehensive routing-loop instructions: assess actionability, identify project, create signal only when actionable, route via attach or create, explain outcome, optionally begin - Wiring of routing tools into the agent definition Tests (signalRouting.test.ts): - Authentication: invalid token rejected on every function - Project scoping: evidence, signals, issues scoped correctly - Idempotency: duplicate attach, duplicate create-from-signal, duplicate signal creation - Attach vs create: both paths verified - Cross-project rejection: signal and issue must be in same project - Org-scoped signal attach rejected (no projectId) - Provenance: exact raw text preserved through routing loop - Begin issue: transition and auth verification
553 lines
17 KiB
TypeScript
553 lines
17 KiB
TypeScript
import { env } from "@code/env/convex";
|
|
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";
|
|
|
|
declare global {
|
|
interface ImportMeta {
|
|
readonly glob: (pattern: string) => Record<string, () => Promise<unknown>>;
|
|
}
|
|
}
|
|
|
|
const modules = import.meta.glob("./**/*.ts");
|
|
const api = anyApi;
|
|
|
|
const TOKEN = env.FLUE_DB_TOKEN;
|
|
const BAD_TOKEN = "not-the-right-token";
|
|
|
|
const ID_A = "https://convex.test|user-a";
|
|
const identityA = { tokenIdentifier: ID_A };
|
|
|
|
const newTest = () => convexTest({ schema, modules });
|
|
|
|
const ensureOrg = async (t: TestConvex<typeof schema>) => {
|
|
const org = await t
|
|
.withIdentity(identityA)
|
|
.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 seedMessage = async (
|
|
t: TestConvex<typeof schema>,
|
|
orgId: Id<"organizations">,
|
|
clientRequestId: string,
|
|
rawText: string
|
|
): Promise<string> => {
|
|
const begun = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.beginUserMessage, {
|
|
clientRequestId,
|
|
organizationId: orgId,
|
|
rawText,
|
|
});
|
|
await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.conversationMessages.markAdmitted, {
|
|
clientRequestId,
|
|
organizationId: orgId,
|
|
submissionId: `sub-${clientRequestId}`,
|
|
});
|
|
return begun.messageId;
|
|
};
|
|
|
|
const createProject = async (
|
|
t: TestConvex<typeof schema>,
|
|
ownerId: string,
|
|
repoName: string
|
|
): Promise<Id<"projects">> => {
|
|
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: repoName,
|
|
repositoryPath: `owner/${repoName}`,
|
|
normalizedUrl: `https://github.com/owner/${repoName}`,
|
|
url: `https://github.com/owner/${repoName}`,
|
|
},
|
|
remote: {
|
|
defaultBranch: "main",
|
|
documents: [
|
|
{
|
|
kind: "readme" as const,
|
|
path: "README.md",
|
|
content: `# ${repoName}\n\nTest.\n`,
|
|
},
|
|
],
|
|
warnings: [],
|
|
},
|
|
});
|
|
return outcome.id as unknown as Id<"projects">;
|
|
};
|
|
|
|
// Create a project-scoped signal via the routing backend.
|
|
const createRoutingSignal = async (
|
|
t: TestConvex<typeof schema>,
|
|
orgId: Id<"organizations">,
|
|
projectId: Id<"projects">,
|
|
messageIds: string[]
|
|
): Promise<Id<"signals">> => {
|
|
const result = await t.mutation(api.signalRouting.createSignal, {
|
|
messageIds,
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedByAgentInstanceId: orgId,
|
|
projectId,
|
|
token: TOKEN,
|
|
});
|
|
return result.signalId as Id<"signals">;
|
|
};
|
|
|
|
describe("signalRouting authentication", () => {
|
|
test("invalid token is rejected on every function", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "auth-test");
|
|
const messageId = await seedMessage(t, orgId, "req-auth", "test message");
|
|
|
|
await expect(
|
|
t.mutation(api.signalRouting.createSignal, {
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedByAgentInstanceId: orgId,
|
|
projectId,
|
|
token: BAD_TOKEN,
|
|
})
|
|
).rejects.toThrow(/Invalid agent control token/u);
|
|
|
|
await expect(
|
|
t.query(api.signalRouting.listEvidence, {
|
|
organizationId: orgId,
|
|
token: BAD_TOKEN,
|
|
})
|
|
).rejects.toThrow(/Invalid agent control token/u);
|
|
|
|
await expect(
|
|
t.query(api.signalRouting.listActiveIssues, {
|
|
organizationId: orgId,
|
|
projectId,
|
|
token: BAD_TOKEN,
|
|
})
|
|
).rejects.toThrow(/Invalid agent control token/u);
|
|
|
|
await expect(
|
|
t.query(api.signalRouting.listProjects, {
|
|
organizationId: orgId,
|
|
token: BAD_TOKEN,
|
|
})
|
|
).rejects.toThrow(/Invalid agent control token/u);
|
|
});
|
|
});
|
|
|
|
describe("signalRouting create and route", () => {
|
|
test("create signal, list evidence, and list signals", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "route-test");
|
|
const messageId = await seedMessage(
|
|
t,
|
|
orgId,
|
|
"req-route",
|
|
"The work-unit card should expose the generated PR."
|
|
);
|
|
|
|
// Evidence appears before signal creation.
|
|
const evidenceBefore = await t.query(api.signalRouting.listEvidence, {
|
|
organizationId: orgId,
|
|
token: TOKEN,
|
|
});
|
|
expect(evidenceBefore).toHaveLength(1);
|
|
expect(evidenceBefore[0]?.rawText).toBe(
|
|
"The work-unit card should expose the generated PR."
|
|
);
|
|
|
|
// Create the signal.
|
|
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
|
messageId,
|
|
]);
|
|
|
|
// Evidence is consumed after signal creation.
|
|
const evidenceAfter = await t.query(api.signalRouting.listEvidence, {
|
|
organizationId: orgId,
|
|
token: TOKEN,
|
|
});
|
|
expect(evidenceAfter).toHaveLength(0);
|
|
|
|
// Signal appears in the project list.
|
|
const signals = await t.query(api.signalRouting.listSignals, {
|
|
organizationId: orgId,
|
|
projectId,
|
|
token: TOKEN,
|
|
});
|
|
expect(signals).toHaveLength(1);
|
|
expect(signals[0]?._id).toBe(signalId);
|
|
expect(signals[0]?.problemStatement.title).toBe("Deploy fails on staging");
|
|
});
|
|
|
|
test("attach signal to existing issue", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "attach-test");
|
|
const messageId = await seedMessage(t, orgId, "req-attach", "problem");
|
|
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
|
messageId,
|
|
]);
|
|
|
|
// Create an issue via the existing projectIssues backend.
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "Existing issue body that is long enough.",
|
|
projectId,
|
|
title: "Existing UI issue",
|
|
});
|
|
|
|
const result = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
|
issueId: issueId as string,
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
});
|
|
|
|
expect(result.alreadyAttached).toBe(false);
|
|
|
|
// List active issues includes the attached issue.
|
|
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
|
organizationId: orgId,
|
|
projectId,
|
|
token: TOKEN,
|
|
});
|
|
expect(issues).toHaveLength(1);
|
|
expect(issues[0]?._id).toBe(issueId);
|
|
});
|
|
|
|
test("create issue from signal (attach-vs-create fallback)", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "create-test");
|
|
const messageId = await seedMessage(t, orgId, "req-create", "new problem");
|
|
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
|
messageId,
|
|
]);
|
|
|
|
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
});
|
|
|
|
expect(result.created).toBe(true);
|
|
expect(result.projectId).toBe(projectId);
|
|
|
|
// The issue exists and is open.
|
|
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
|
organizationId: orgId,
|
|
projectId,
|
|
token: TOKEN,
|
|
});
|
|
expect(issues).toHaveLength(1);
|
|
expect(issues[0]?.title).toBe("Deploy fails on staging");
|
|
});
|
|
});
|
|
|
|
describe("signalRouting idempotency", () => {
|
|
test("duplicate attach retry returns existing without duplicate event", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "idem-attach");
|
|
const messageId = await seedMessage(t, orgId, "req-idem-att", "problem");
|
|
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
|
messageId,
|
|
]);
|
|
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "Existing issue body that is long enough for validation.",
|
|
projectId,
|
|
title: "Pre-existing issue",
|
|
});
|
|
|
|
// First attach.
|
|
const first = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
|
issueId: issueId as string,
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
});
|
|
expect(first.alreadyAttached).toBe(false);
|
|
|
|
// Retry the same attach.
|
|
const second = await t.mutation(api.signalRouting.attachSignalToIssue, {
|
|
issueId: issueId as string,
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
});
|
|
expect(second.alreadyAttached).toBe(true);
|
|
expect(second.attachmentId).toBe(first.attachmentId);
|
|
|
|
// Verify only one signal.attached event was emitted (not duplicated).
|
|
const events = await t
|
|
.withIdentity(identityA)
|
|
.query(api.projectIssues.events, { projectId });
|
|
const attached = events.filter((e: { kind: string }) =>
|
|
e.kind.includes("signal.attached")
|
|
);
|
|
expect(attached).toHaveLength(1);
|
|
});
|
|
|
|
test("duplicate create-from-signal retry returns existing issue", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "idem-create");
|
|
const messageId = await seedMessage(t, orgId, "req-idem-crt", "problem");
|
|
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
|
messageId,
|
|
]);
|
|
|
|
// First create.
|
|
const first = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
});
|
|
expect(first.created).toBe(true);
|
|
|
|
// Retry — should return existing, not create a new issue.
|
|
const second = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
});
|
|
expect(second.created).toBe(false);
|
|
expect(second.issueId).toBe(first.issueId);
|
|
|
|
// Only one issue exists.
|
|
const issues = await t.query(api.signalRouting.listActiveIssues, {
|
|
organizationId: orgId,
|
|
projectId,
|
|
token: TOKEN,
|
|
});
|
|
expect(issues).toHaveLength(1);
|
|
});
|
|
|
|
test("duplicate signal creation is idempotent (sourceKey)", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "idem-signal");
|
|
const messageId = await seedMessage(t, orgId, "req-idem-sig", "test");
|
|
|
|
const first = await t.mutation(api.signalRouting.createSignal, {
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedByAgentInstanceId: orgId,
|
|
projectId,
|
|
token: TOKEN,
|
|
});
|
|
const second = await t.mutation(api.signalRouting.createSignal, {
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedByAgentInstanceId: orgId,
|
|
projectId,
|
|
token: TOKEN,
|
|
});
|
|
|
|
expect(second.signalId).toBe(first.signalId);
|
|
});
|
|
});
|
|
|
|
describe("signalRouting cross-project rejection", () => {
|
|
test("attach rejects when signal and issue are in different projects", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectA = await createProject(t, ID_A, "cross-a");
|
|
const projectB = await createProject(t, ID_A, "cross-b");
|
|
|
|
const messageId = await seedMessage(t, orgId, "req-cross", "problem");
|
|
// Signal is scoped to project A.
|
|
const signalId = await createRoutingSignal(t, orgId, projectA, [messageId]);
|
|
|
|
// Issue is in project B.
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "Issue in project B with enough body text.",
|
|
projectId: projectB,
|
|
title: "Project B issue",
|
|
});
|
|
|
|
await expect(
|
|
t.mutation(api.signalRouting.attachSignalToIssue, {
|
|
issueId: issueId as string,
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
})
|
|
).rejects.toThrow(/must belong to the same project/u);
|
|
});
|
|
|
|
test("attach rejects org-scoped signal (no projectId)", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "org-scope-test");
|
|
|
|
const messageId = await seedMessage(t, orgId, "req-org", "problem");
|
|
|
|
// Create an org-scoped signal (no projectId).
|
|
const signalId = await t.mutation(api.signalRouting.createSignal, {
|
|
messageIds: [messageId],
|
|
organizationId: orgId,
|
|
problemStatement,
|
|
processedByAgentInstanceId: orgId,
|
|
token: TOKEN,
|
|
});
|
|
|
|
const issueId = await t
|
|
.withIdentity(identityA)
|
|
.mutation(api.projectIssues.create, {
|
|
body: "Issue body that is long enough for the validator.",
|
|
projectId,
|
|
title: "Some issue",
|
|
});
|
|
|
|
await expect(
|
|
t.mutation(api.signalRouting.attachSignalToIssue, {
|
|
issueId: issueId as string,
|
|
organizationId: orgId,
|
|
signalId: signalId.signalId as string,
|
|
token: TOKEN,
|
|
})
|
|
).rejects.toThrow(/not project-scoped/u);
|
|
});
|
|
});
|
|
|
|
describe("signalRouting provenance", () => {
|
|
test("exact message raw text is preserved through the routing loop", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "prov-test");
|
|
const rawText = " The work-unit card should expose the generated PR. ";
|
|
const messageId = await seedMessage(t, orgId, "req-prov", rawText);
|
|
|
|
// Evidence shows exact raw text.
|
|
const evidence = await t.query(api.signalRouting.listEvidence, {
|
|
organizationId: orgId,
|
|
token: TOKEN,
|
|
});
|
|
expect(evidence[0]?.rawText).toBe(rawText);
|
|
|
|
// Signal creation preserves exact snapshot.
|
|
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
|
messageId,
|
|
]);
|
|
|
|
// Verify via the existing signals.get that the snapshot is exact.
|
|
const composed = await t
|
|
.withIdentity(identityA)
|
|
.query(api.signals.get, { signalId });
|
|
expect(composed?.sources[0]?.rawTextSnapshot).toBe(rawText);
|
|
});
|
|
});
|
|
|
|
describe("signalRouting begin issue", () => {
|
|
test("begin transitions an open issue to queued", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "begin-test");
|
|
const messageId = await seedMessage(t, orgId, "req-begin", "start this");
|
|
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
|
messageId,
|
|
]);
|
|
|
|
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
});
|
|
|
|
const status = await t.mutation(api.signalRouting.beginIssue, {
|
|
issueId: result.issueId as string,
|
|
organizationId: orgId,
|
|
token: TOKEN,
|
|
});
|
|
|
|
expect(status).toBe("queued");
|
|
});
|
|
|
|
test("begin rejects invalid token", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "begin-auth");
|
|
const messageId = await seedMessage(t, orgId, "req-begin-auth", "msg");
|
|
const signalId = await createRoutingSignal(t, orgId, projectId, [
|
|
messageId,
|
|
]);
|
|
|
|
const result = await t.mutation(api.signalRouting.createIssueFromSignal, {
|
|
organizationId: orgId,
|
|
signalId: signalId as string,
|
|
token: TOKEN,
|
|
});
|
|
|
|
await expect(
|
|
t.mutation(api.signalRouting.beginIssue, {
|
|
issueId: result.issueId as string,
|
|
organizationId: orgId,
|
|
token: BAD_TOKEN,
|
|
})
|
|
).rejects.toThrow(/Invalid agent control token/u);
|
|
});
|
|
});
|
|
|
|
describe("signalRouting project context", () => {
|
|
test("get project context returns project and documents", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "ctx-test");
|
|
|
|
const context = await t.query(api.signalRouting.getProjectContext, {
|
|
organizationId: orgId,
|
|
projectId,
|
|
token: TOKEN,
|
|
});
|
|
|
|
expect(context.project._id).toBe(projectId);
|
|
expect(context.project.name).toBe("ctx-test");
|
|
expect(context.contextDocuments.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("list projects returns organization projects", async () => {
|
|
const t = newTest();
|
|
const orgId = await ensureOrg(t);
|
|
const projectId = await createProject(t, ID_A, "list-proj-test");
|
|
|
|
const projects = await t.query(api.signalRouting.listProjects, {
|
|
organizationId: orgId,
|
|
token: TOKEN,
|
|
});
|
|
|
|
expect(projects).toHaveLength(1);
|
|
expect(projects[0]?._id).toBe(projectId);
|
|
});
|
|
});
|