Files
PaperClipAI/server/src/__tests__/decision-queues-routes.test.ts
T
DottaandPaperclip d5766e4074 perf(chat): scope task questions and batch inbox queue checks (#18422)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Standing chats show recent messages and current task questions.
> - Recent-message pagination bounds history work, but it does not bound
question work.
> - The question read built the full company attention feed before it
selected the chat's tasks.
> - Each feed read also checked seeded queue membership with one insert
per item.
> - This pull request scopes question collection in the attention owner
and batches queue membership checks.
> - Chats keep their current question cards and initial layout while
doing less work.

## Linked Issues or Issue Description

Follow-up to #18358 and #18323. Related work: #18312.

**What happened?**

A hosted standing chat remained slow after recent-message pagination
shipped. On commit `4eaa435bf4`, an embedded-browser reload made the
conversation usable at about 8.6 seconds. Recent comments finished at
about 1.9 seconds. The pending-question request held the initial reveal
until about 8.4 seconds. That request called the complete company
attention feed and then filtered its results. The separate badge summary
also did broad work.

**Expected behavior**

Chat question cost should depend on the relevant tasks. Reading an
existing queue must not require a separate insert for every member.
Current access and decision policy must remain in force.

**Steps to reproduce**

1. Create a standing chat with questions on tasks assigned to its agent.
2. Add many unrelated tasks and pending questions to the company.
3. Open the chat from a fresh application cache.
4. Compare recent-comment arrival with the pending-question response and
content reveal.

**Paperclip version or commit**

Baseline: `909e29dd071b1aeeb24c2933af049da90a6066d3`. Its affected
services match the observed hosted revision `4eaa435bf4`.

## What Changed

- Add an internal task-question projection to the attention service.
Filter pending interactions by company, task IDs and question kind
before card enrichment.
- Keep the same access, audience, dismissal, queue, snooze, retention,
archive and training-example logic. Ranks are local to the returned
question list.
- Use the projection from the conversation receipt service. Keep current
chat audience and session checks there.
- Batch seeded membership inserts in groups of 500. Keep the queue lock,
conflict protection, audit events and activity logging for each new
membership. Existing members produce no new audit records.
- Add real PostgreSQL regression cases and a reproducible paired
benchmark. No schema migration or UI reveal change is needed.

## Verification

- `pnpm -r typecheck` passed. The final server typecheck also passed
after the queue change.
- `pnpm build` passed.
- Real PostgreSQL service coverage: 57 attention and chat-handoff tests
passed. Seven queue-route tests passed, including concurrent reads,
mixed existing/new items, duplicates, disabled seeds and audit
preservation.
- Red/green proof: the unrelated-attention regression fails with the
baseline conversation implementation and passes with the scoped read.
- `node scripts/check-module-boundaries.mjs` and `git diff --check`
passed.
- Full `pnpm test:run` was attempted and stopped with exit 130 after
embedded PostgreSQL startup failures and 17 failures in the untouched
native-session executor suite. It is not a local pass. The focused
Docker/PostgreSQL results above completed before Docker Desktop became
unavailable. A direct native PostgreSQL probe identified the host
semaphore limit (`could not create semaphores: No space left on
device`). No shared kernel settings or other test clusters were changed.
[Full
CI](https://github.com/paperclipai/paperclip/actions/runs/41162314995)
passed on `2809f096b3fb2c01b44a96a1b82a27809c9359df`, including all
eight browser shards. Final status: 55 successful checks/statuses, three
skipped and one neutral. Greptile is 5/5 with no findings or unresolved
threads. The fresh local browser retest is blocked because Docker
Desktop cannot start and native PostgreSQL cannot allocate semaphores.
The original hosted reproduction above is complete; no candidate browser
pass is claimed.

Local service benchmark: PostgreSQL 17.11, two relevant tasks, five
alternating pairs per scenario, after warmup. These are local service
timings, not a claim about deployed latency.

| Fixture | Read | Baseline median (range) | Candidate median (range) |
SQL calls |
| --- | --- | --- | --- | --- |
| 1,000 unrelated tasks/questions | Chat questions | 670 ms (632–1,905)
| 12 ms (11–15) | 1,076 → 22 |
| 1,000 unrelated tasks/questions | Company badge summary | 641 ms
(622–717) | 167 ms (161–171) | 1,068 → 69 |
| Small company | Chat questions | 38 ms (37–52) | 13 ms (12–16) | 52 →
22 |
| Small company | Company badge summary | 34 ms (33–39) | 34 ms (32–40)
| 44 → 43 |

All question fields and ordering matched, except rank is relative to the
scoped list. Summary fields matched except the generation timestamp. The
interaction plan changed from 1,002 selected rows to two rows through
the existing company/issue/status index. The benchmark loads baseline
attention, conversation and queue modules together so the queue change
cannot contaminate the baseline.

```sh
pnpm --filter @paperclipai/server exec tsx ../scripts/benchmark-chat-questions.ts \
  --baseline-ref=909e29dd07 --unrelated=1000
```

## Risks

- Scoped and complete feeds must share policy. This change reuses the
same attention collection and finalization path and tests dismissals,
named audiences, snoozing, archives and private access.
- Batched inserts must audit only new members. The concurrent PostgreSQL
test checks unique membership and one event/activity record per new item
across batch boundaries.
- Full inbox summaries still collect the company feed. The small-company
summary timing is unchanged. Other chat requests and transcript
hydration can still contribute to hosted latency.
- The first reveal still waits for question cards to preserve layout.
This follow-up has not been deployed, so its hosted effect remains
unverified.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, code
execution and browser profiling. The exact serving model ID and
context-window size are not exposed in this session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` OR (b) described the issue in-PR following the relevant issue
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-31 07:37:47 -05:00

527 lines
21 KiB
TypeScript

import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { and, eq } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
agents,
approvals,
companies,
createDb, applyPendingMigrations,
decisionQueueItems,
decisionQueues,
decisionTriage,
decisionTriageEvents,
issueThreadInteractions,
issueWorkProducts,
issues,
} from "@paperclipai/db";
import type { AttentionItem } from "@paperclipai/shared";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { errorHandler } from "../middleware/index.js";
import { decisionQueueRoutes } from "../routes/decision-queues.js";
import { decisionQueueService } from "../services/decision-queues.js";
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_DECISION_QUEUES_TEST_DATABASE_URL");
const embeddedPostgresSupport = external ? { supported: true, reason: undefined } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
if (!embeddedPostgresSupport.supported) {
console.warn(
`Skipping embedded Postgres decision queue tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
);
}
describeEmbeddedPostgres("decision queue routes", () => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
if (external) await applyPendingMigrations(external);
else tempDb = await startEmbeddedPostgresTestDatabase("paperclip-decision-queues-");
db = createDb(external ?? tempDb!.connectionString);
}, 30_000);
afterEach(async () => {
await db.delete(decisionTriageEvents);
await db.delete(decisionQueueItems);
await db.delete(decisionTriage);
await db.delete(decisionQueues);
await db.delete(activityLog);
await db.delete(issueThreadInteractions);
await db.delete(issueWorkProducts);
await db.delete(approvals);
await db.delete(issues);
await db.delete(agents);
await db.delete(companies);
});
afterAll(async () => {
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
async function seed() {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
const interactionId = randomUUID();
const approvalId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Decision Queue Co",
issuePrefix: "DQC",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Prioritizer",
role: "engineer",
status: "idle",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
identifier: "DQC-1",
title: "Review the rollout",
status: "in_review",
assigneeAgentId: agentId,
});
await db.insert(issueThreadInteractions).values({
id: interactionId,
companyId,
issueId,
kind: "ask_user_questions",
status: "pending",
payload: { version: 1, questions: [] } as never,
});
await db.insert(approvals).values({
id: approvalId,
companyId,
type: "request_board_approval",
status: "pending",
payload: {},
});
return { companyId, agentId, issueId, interactionId, approvalId };
}
function app(actor: Record<string, unknown>) {
const testApp = express();
testApp.use(express.json());
testApp.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
testApp.use("/api", decisionQueueRoutes(db));
testApp.use(errorHandler);
return testApp;
}
function boardActor(companyId: string, userId = "board-user") {
return {
type: "board",
source: "local_implicit",
userId,
companyIds: [companyId],
isInstanceAdmin: false,
};
}
function agentActor(companyId: string, agentId: string) {
return {
type: "agent",
source: "agent_key",
companyId,
agentId,
keyId: null,
keyScope: { kind: "standard" },
runId: null,
};
}
it("creates idempotently, patches, lists by updated time, and audits queue mutations", async () => {
const { companyId } = await seed();
const board = boardActor(companyId);
const first = await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "launches",
title: "Launches",
description: "Ship decisions",
}).expect(201);
const repeated = await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "launches",
title: "Ignored duplicate title",
}).expect(200);
expect(repeated.body.id).toBe(first.body.id);
expect(repeated.body.title).toBe("Launches");
expect(repeated.body.itemCount).toBe(0);
const patched = await request(app(board))
.patch(`/api/companies/${companyId}/decision-queues/launches`)
.send({ title: "Launch desk", retentionDays: 45 })
.expect(200);
expect(patched.body).toMatchObject({ title: "Launch desk", retentionDays: 45 });
await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "older",
title: "Older",
}).expect(201);
const listed = await request(app(board)).get(`/api/companies/${companyId}/decision-queues`).expect(200);
expect(listed.body.map((queue: { key: string }) => queue.key)).toEqual(["older", "launches"]);
const seedRules = await request(app(board))
.get(`/api/companies/${companyId}/decision-queue-seed-rules`)
.expect(200);
expect(seedRules.body.map((seed: { key: string }) => seed.key)).toEqual(["prs", "plans", "questions"]);
const events = await db.select().from(decisionTriageEvents)
.where(eq(decisionTriageEvents.queueId, first.body.id));
expect(events.map((event) => event.action)).toEqual(["queue.created", "queue.updated"]);
expect(events.every((event) => event.actorUserId === "board-user")).toBe(true);
});
it("adds and removes three attention source kinds and hides board-only membership from agents", async () => {
const { companyId, agentId, issueId, interactionId, approvalId } = await seed();
const board = boardActor(companyId);
await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "triage",
title: "Triage",
}).expect(201);
for (const source of [
{ sourceKind: "approval", sourceId: approvalId },
{ sourceKind: "issue_thread_interaction", sourceId: interactionId },
{ sourceKind: "review", sourceId: issueId },
]) {
await request(app(board))
.post(`/api/companies/${companyId}/decision-queues/triage/items`)
.send(source)
.expect(201);
}
const items = await request(app(board))
.get(`/api/companies/${companyId}/decision-queues/triage/items`)
.expect(200);
expect(items.body).toHaveLength(3);
const repeated = await request(app(board)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "triage",
title: "Ignored duplicate title",
}).expect(200);
expect(repeated.body.itemCount).toBe(3);
const agentList = await request(app(agentActor(companyId, agentId)))
.get(`/api/companies/${companyId}/decision-queues`)
.expect(200);
expect(agentList.body[0].itemCount).toBe(2);
const agentItems = await request(app(agentActor(companyId, agentId)))
.get(`/api/companies/${companyId}/decision-queues/triage/items`)
.expect(200);
expect(agentItems.body.map((item: { sourceKind: string }) => item.sourceKind).sort()).toEqual([
"issue_thread_interaction",
"review",
]);
await request(app(agentActor(companyId, agentId)))
.delete(`/api/companies/${companyId}/decision-queues/triage/items/approval/${approvalId}`)
.expect(404);
await request(app(board))
.delete(`/api/companies/${companyId}/decision-queues/triage/items/review/${issueId}`)
.send({ reason: "Handled in the release review" })
.expect(200);
expect(await db.select().from(decisionQueueItems).where(and(
eq(decisionQueueItems.companyId, companyId),
eq(decisionQueueItems.sourceKind, "review"),
))).toHaveLength(0);
const removalAudit = await db.select().from(activityLog).where(and(
eq(activityLog.companyId, companyId),
eq(activityLog.action, "decision_queue_item.removed"),
)).then((rows) => rows[0]);
expect(removalAudit?.details).toMatchObject({
sourceKind: "review",
sourceId: issueId,
reason: "Handled in the release review",
});
const removalEvent = await db.select().from(decisionTriageEvents).where(and(
eq(decisionTriageEvents.companyId, companyId),
eq(decisionTriageEvents.sourceKind, "review"),
eq(decisionTriageEvents.sourceId, issueId),
eq(decisionTriageEvents.action, "queue_item.removed"),
)).then((rows) => rows[0]);
expect(removalEvent?.details).toEqual({ reason: "Handled in the release review" });
await db.delete(approvals).where(eq(approvals.id, approvalId));
await request(app(board))
.delete(`/api/companies/${companyId}/decision-queues/triage/items/approval/${approvalId}`)
.expect(200);
expect(await db.select().from(decisionQueueItems).where(and(
eq(decisionQueueItems.companyId, companyId),
eq(decisionQueueItems.sourceKind, "approval"),
))).toHaveLength(0);
});
it("materializes data-backed starter queues from plan, question, and pull-request signals", async () => {
const { companyId, issueId, interactionId } = await seed();
await db.insert(issueWorkProducts).values({
companyId,
issueId,
type: "pull_request",
provider: "github",
title: "PR 42",
status: "open",
});
function attentionItem(input: {
sourceKind: AttentionItem["sourceKind"];
sourceId: string;
subjectKind: AttentionItem["subject"]["kind"];
metadata?: Record<string, unknown>;
issueId?: string;
}): AttentionItem {
return {
id: `${input.sourceKind}:${input.sourceId}`,
companyId,
sourceKind: input.sourceKind,
subject: {
kind: input.subjectKind,
id: input.sourceId,
companyId,
title: "Seed candidate",
identifier: null,
status: "pending",
href: null,
metadata: input.metadata,
},
whyNow: "test",
decisionVerbs: [],
inlineResolvable: true,
entryRule: "test",
exitRule: "test",
dedupKey: `${input.sourceKind}:${input.sourceId}`,
dismissalKey: `${input.sourceKind}:${input.sourceId}`,
dismissal: null,
severity: "medium",
rank: 1,
activityAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
relatedIssue: input.issueId ? {
kind: "issue",
id: input.issueId,
companyId,
title: "Issue",
identifier: "DQC-1",
status: "in_review",
href: null,
} : null,
project: null,
workspace: null,
detail: null,
trainingExampleId: null,
};
}
const planId = randomUUID();
const candidates = [
attentionItem({ sourceKind: "review", sourceId: issueId, subjectKind: "issue", issueId }),
attentionItem({
sourceKind: "issue_thread_interaction",
sourceId: planId,
subjectKind: "interaction",
metadata: { kind: "request_confirmation", isPlanTarget: true, issueId },
issueId,
}),
attentionItem({
sourceKind: "issue_thread_interaction",
sourceId: interactionId,
subjectKind: "interaction",
metadata: { kind: "ask_user_questions", issueId },
issueId,
}),
];
await decisionQueueService(db).materializeSeededQueues(companyId, candidates);
const queues = await db.select().from(decisionQueues).where(eq(decisionQueues.companyId, companyId));
expect(queues.map((queue) => queue.key).sort()).toEqual(["plans", "prs", "questions"]);
expect(queues.every((queue) => queue.seedRulesEnabled && queue.seedRules.length === 1)).toBe(true);
const seededItems = await db.select({ queueId: decisionQueueItems.queueId })
.from(decisionQueueItems)
.where(eq(decisionQueueItems.companyId, companyId));
expect(seededItems).toHaveLength(5);
const prQueue = queues.find((queue) => queue.key === "prs")!;
expect(seededItems.filter((item) => item.queueId === prQueue.id)).toHaveLength(3);
const firstEvents = await db.select().from(decisionTriageEvents)
.where(eq(decisionTriageEvents.companyId, companyId));
const firstActivity = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId));
const firstUpdatedAt = new Map(queues.map((queue) => [queue.key, queue.updatedAt.toISOString()]));
await decisionQueueService(db).materializeSeededQueues(companyId, candidates);
const unchangedQueues = await db.select().from(decisionQueues).where(eq(decisionQueues.companyId, companyId));
expect(unchangedQueues.every((queue) => queue.updatedAt.toISOString() === firstUpdatedAt.get(queue.key))).toBe(true);
expect(await db.select().from(decisionTriageEvents).where(eq(decisionTriageEvents.companyId, companyId)))
.toHaveLength(firstEvents.length);
expect(await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)))
.toHaveLength(firstActivity.length);
const questionsQueue = queues.find((queue) => queue.key === "questions")!;
await db.update(decisionQueues).set({ seedRulesEnabled: false }).where(eq(decisionQueues.id, questionsQueue.id));
await decisionQueueService(db).materializeSeededQueues(companyId, [attentionItem({
sourceKind: "issue_thread_interaction",
sourceId: randomUUID(),
subjectKind: "interaction",
metadata: { kind: "ask_user_questions", issueId },
issueId,
})]);
expect(await db.select().from(decisionQueueItems).where(eq(decisionQueueItems.queueId, questionsQueue.id)))
.toHaveLength(1);
});
it("batches repeated seed checks and records only new memberships across concurrent reads", async () => {
const { companyId } = await seed();
const service = decisionQueueService(db);
const items = Array.from({ length: 501 }, () => ({ sourceKind: "issue_thread_interaction" as const,
sourceId: randomUUID(), issueId: null, interactionKind: "ask_user_questions" }));
await service.materializeSeedSources(companyId, items.slice(0, 2));
await Promise.all([
service.materializeSeedSources(companyId, [...items, items[0]!]),
service.materializeSeedSources(companyId, items),
]);
const memberships = await db.select().from(decisionQueueItems).where(eq(decisionQueueItems.companyId, companyId));
expect(new Set(memberships.map(item => item.sourceId))).toEqual(new Set(items.map(item => item.sourceId)));
expect(memberships).toHaveLength(items.length);
const events = await db.select().from(decisionTriageEvents).where(and(
eq(decisionTriageEvents.companyId, companyId), eq(decisionTriageEvents.action, "queue_item.seeded")));
const activity = await db.select().from(activityLog).where(and(
eq(activityLog.companyId, companyId), eq(activityLog.action, "decision_queue_item.seeded")));
expect(events).toHaveLength(items.length);
expect(activity).toHaveLength(items.length);
const queries: string[] = [];
const previousDebug = db.$client.options.debug;
try {
db.$client.options.debug = (_connection, text) => queries.push(text);
await service.materializeSeedSources(companyId, items);
} finally { db.$client.options.debug = previousDebug; }
expect(queries.filter(query => query.startsWith('insert into "decision_queue_items"')).length).toBeLessThan(10);
expect(await db.select().from(decisionTriageEvents).where(and(
eq(decisionTriageEvents.companyId, companyId), eq(decisionTriageEvents.action, "queue_item.seeded")))).toHaveLength(items.length);
});
it("records agent decide-by, preserves override history, and exposes the board override attribution", async () => {
const { companyId, agentId, issueId } = await seed();
const agent = agentActor(companyId, agentId);
const board = boardActor(companyId, "override-user");
const agentSet = await request(app(agent))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ decideBy: "today", snoozedUntil: "2026-08-03T12:00:00.000Z" })
.expect(200);
expect(agentSet.body).toMatchObject({
decideBy: "today",
setByType: "agent",
setByAgentId: agentId,
version: 1,
});
const overridden = await request(app(board))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ decideBy: "2026-08-08", snoozedUntil: null })
.expect(200);
expect(overridden.body).toMatchObject({
decideBy: "2026-08-08",
setByType: "user",
setByUserId: "override-user",
version: 2,
});
const events = await db.select().from(decisionTriageEvents).where(and(
eq(decisionTriageEvents.companyId, companyId),
eq(decisionTriageEvents.sourceKind, "review"),
eq(decisionTriageEvents.sourceId, issueId),
));
expect(events).toHaveLength(2);
expect(events[0]?.actorAgentId).toBe(agentId);
expect(events[1]?.actorUserId).toBe("override-user");
expect(events[1]?.details).toMatchObject({ previousDecideBy: "today", decideBy: "2026-08-08" });
const current = await db.select().from(decisionTriage).where(eq(decisionTriage.sourceId, issueId));
expect(current[0]?.setByUserId).toBe("override-user");
});
it("serializes concurrent partial triage updates without losing state or reusing a version", async () => {
const { companyId, issueId } = await seed();
const board = boardActor(companyId);
const [decideByResult, snoozeResult] = await Promise.all([
request(app(board))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ decideBy: "today" }),
request(app(board))
.put(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.send({ snoozedUntil: "2026-08-03T12:00:00.000Z" }),
]);
expect(decideByResult.status).toBe(200);
expect(snoozeResult.status).toBe(200);
expect([decideByResult.body.version, snoozeResult.body.version].sort()).toEqual([1, 2]);
const current = await request(app(board))
.get(`/api/companies/${companyId}/decision-triage/review/${issueId}`)
.expect(200);
expect(current.body).toMatchObject({
decideBy: "today",
snoozedUntil: "2026-08-03T12:00:00.000Z",
version: 2,
});
const events = await db.select().from(decisionTriageEvents).where(and(
eq(decisionTriageEvents.companyId, companyId),
eq(decisionTriageEvents.sourceKind, "review"),
eq(decisionTriageEvents.sourceId, issueId),
));
expect(events.map((event) => event.details.version).sort()).toEqual([1, 2]);
});
it("rejects task-bridge JWTs and returns the same 404 for missing and unauthorized sources", async () => {
const { companyId, agentId, approvalId } = await seed();
const bridge = {
...agentActor(companyId, agentId),
source: "agent_jwt",
keyId: randomUUID(),
keyScope: { kind: "task_bridge", parentIssueId: randomUUID() },
};
await request(app(bridge)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "blocked",
title: "Blocked",
}).expect(403);
const skillTest = {
...agentActor(companyId, agentId),
source: "agent_jwt",
keyScope: { kind: "skill_test", issueId: randomUUID() },
};
await request(app(skillTest)).post(`/api/companies/${companyId}/decision-queues`).send({
key: "skill-test-blocked",
title: "Skill test blocked",
}).expect(403);
await request(app(boardActor(companyId))).post(`/api/companies/${companyId}/decision-queues`).send({
key: "secure",
title: "Secure",
}).expect(201);
const standardAgent = app(agentActor(companyId, agentId));
const unauthorized = await request(standardAgent)
.post(`/api/companies/${companyId}/decision-queues/secure/items`)
.send({ sourceKind: "approval", sourceId: approvalId })
.expect(404);
const missing = await request(standardAgent)
.post(`/api/companies/${companyId}/decision-queues/secure/items`)
.send({ sourceKind: "approval", sourceId: randomUUID() })
.expect(404);
expect(unauthorized.body).toEqual(missing.body);
});
});