Files
PaperClipAI/server/src/__tests__/agent-chat-messages.test.ts
T
DottaandPaperclip 2e340d6235 fix(ui): show unread message counts in agent chat pickers (#19627)
## Thinking Path

> - Paperclip helps people manage AI agents and their work.
> - People switch between agent chats to read replies.
> - The agent picker does not show how many messages are unread.
> - People need that count before they select a chat.
> - The chat list already has the viewer's read state.
> - This pull request adds numeric badges from that shared list.
> - People can find unread replies without extra requests for each
agent.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

The chat header agent picker and the secondary sidebar's Add chat
picker.

**Subsystem affected**

Agent Chat UI, shared task types, and the chat read-state service.

**Current behavior**

The pickers do not show the unread message count for each agent.

**Proposed behavior**

Show the unread count beside each agent. For example, show 3 for three
unread agent messages. Hide the badge when the count is zero. Keep
switching available while chat data loads.

**Reason and benefit**

People can see which chats need attention before they switch. The count
uses the existing shared request and cache.

**Breaking changes**

The chat response gains an optional `unreadMessageCount` field. Existing
fields and read receipts keep their behavior. There is no database
migration.

**Additional context**

Related: #19425 changes chat structure. This change adds counts to the
current agent pickers. The duplicate search found no PR for these count
badges.

## What Changed

- Count unread agent comments in the existing grouped read-state query.
Keep company and viewer scope.
- Exclude human messages, deleted comments, system notices, and
fast-response receipts.
- Pass memoized count maps to both agent pickers. Use a direct lookup
for each row.
- Show numeric badges and accessible singular or plural labels.
- Test count changes, account isolation, cached opens, search, and large
agent lists.
- Update the chat stories, fixture data, and development guide.
- Make the Slack owner retry-deadline test deterministic after
reproducing its CI failure.

## Verification

- On current head `05f5fa6201`, the [main CI
workflow](https://github.com/paperclipai/paperclip/actions/runs/42305845930)
passed, including build, typecheck, all applicable test shards, browser
shards, and final verification. The prior server shard 2, 6, and 7
failures do not recur.
- [Greptile's current-head
review](https://github.com/paperclipai/paperclip/pull/19627#issuecomment-6696161631)
is 5/5, its check succeeded, and both previous review threads are
resolved. Current head check inventory is complete: 55 successful
checks, three intentional skips, and no pending or failed checks. The
Paperclip application review summary reports paused; no new completed
Paperclip review is claimed. Its existing findings are resolved, and the
independent current-head Greptile review is 5/5.

- Current head `05f5fa6201` merges master `8621bcf53e` to resolve an
import conflict. Master now includes the same model-default test repair.
This branch keeps its upstream import, so that file no longer differs
from master.
- After this merge: all 21 server tests in the Slack owner and
model-default suites pass, all 22 tests in the three picker/sidebar
suites pass, and token gates pass. The standard preflight still cannot
build Rust because `cargo` is absent. Fresh CI must supply the broad
checks.

- Head `7d125fb8c7` replaces the Slack owner test's real timer wait with
the worker's existing clock input. It checks that the intent stays
pending one millisecond before the retry deadline and clears at the
deadline. All 15 Slack owner integration tests pass locally. Runtime
retry behavior is unchanged.
- On `4cfb1e9d55`, both review checks gave 5/5. CI [server shard
2](https://github.com/paperclipai/paperclip/actions/runs/42302458830/job/128183671349)
failed the Slack credential-lease retry test. The same assertion failed
in an unchanged local run. A diagnostic run with added logging passed;
the exact timer deviation was not captured. The new test removes the
real-time wait assumption and checks the clock boundary directly. The
earlier model-default and cleanup failures did not recur in that CI run.

- On `a3378dfc40`, both Greptile and Paperclip Review gave 5/5 with no
open findings. Typecheck, build, all eight browser shards, and most test
shards passed.
- That CI attempt failed two server shards. The model-default test still
expected `gpt-5.6-sol`, while the current default is `gpt-6.1-sol`. I
reproduced both failures locally. Commit `4cfb1e9d55` uses the exported
default-model constant; all six tests in that suite now pass. Production
model selection does not change.
- The retained-cleanup backlog test exceeded its 15-second limit in
[server shard
6](https://github.com/paperclipai/paperclip/actions/runs/42299858777/job/128175193320).
The unchanged test passes locally with the same limit. Its CI timeout
cause is not established. No assertion or timeout was changed. The fresh
CI run must recheck it.

- Review repair: commit `0db631b215` keeps the existing-chat and
unread-agent arrays stable when chat data and the active agent do not
change. A new 500-agent test fails before the repair and passes after
it. The test also checks count refreshes.
- The first CI typecheck failed on duplicate `IMAGE_ONLY` and
`TESTOPS_REQUESTED` keys in `tests/runner-e2e/workflow-target.test.ts`.
This was present in upstream code. Master commit `e482f0f632` removes
the duplicate keys. This branch now includes that commit. All nine
workflow-target tests pass locally.
- After the latest master merge: all 22 tests in the three affected
picker/sidebar suites pass. Before that merge, all 38 focused UI tests
passed.
- The current head is `05f5fa620134ad199a3d85b66fb2eb93d3bb6077`. All
current status checks are complete, the aggregate verify gate succeeds,
mergeability is CLEAN, and no unresolved review threads remain. The
maintainer has instructed normal merge after checks pass.

- After merging current master: 37 focused UI tests pass across
AgentChatPicker, CoordinatorAgentSwitcher, AgentConversationsSidebar,
useAgentChatRead, and SidebarAgentChats.
- After merging current master: all 38 proactive-chat integration tests
pass with a real disposable PostgreSQL database. They check counts of 2,
1, and 0 at exact read cutoffs.
- The prior revision passed UI typecheck and build, shared build,
isolated low-memory server typecheck, token gates, and 30 map/catalog
tests.
- The 500-agent test bounds index work and count lookups. Search does
not rebuild the existing-chat index.
- Browser checks used production components with simulated API data.
Dark, light, mobile, and Add chat cases passed. Counts of 3 and 12
stayed visible through search. Read agents had no badge. Cached opens
made no chat API requests.
- Twenty local cached opens took 34.5–57.6 ms, with a 38.4 ms median.
This is an unthrottled fixture measurement.
- Earlier full local checks could not complete because Rust cargo is
absent and the workspace has limited memory. The standard worktree
preflight also stops at missing cargo. Required CI must supply broad
checks for this PR head.
- The first server typecheck ran out of memory while Storybook was
active. Its isolated low-memory retry passed. No visual baseline
comparison or live provider delivery is claimed.

## Risks

- Low risk. The count replaces a boolean aggregate in the same grouped
query. Very long chat histories still need that existing scan.
- A count can be stale until the existing chat-list cache refreshes.
Read receipts still control the unread cutoff.
- The change adds no polling, per-agent history request, database
migration, or permission change.

## Model Used

OpenAI GPT-6 through Codex, with code editing, terminal tools, and
browser verification. The runtime did not expose a more specific served
model ID or context-window size.

## 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
#` / `Refs #` 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-11-06 17:10:56 -06:00

421 lines
32 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { eq, sql } from "drizzle-orm";
import { activityLog, agentApiKeys, agents, authUsers, chatCompletionDeliveries, chatTaskHandoffs, companyMemberships, heartbeatRuns, issueComments, issueThreadInteractions, issues, projects } from "@paperclipai/db";
import type { AgentApiKeyScope } from "@paperclipai/shared";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { sendAgentChatMessage } from "../services/agent-chat-messages.js";
import { withConversationReadState } from "../services/agent-chat-read-state.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { issueService } from "../services/issues.js";
import { activityService } from "../services/activity.js";
import { recordAcceptedChatHandoff } from "../services/chat-completion-delivery.js";
import { conversationReplay, deliverConversationComments, undeliveredConversationComments } from "../services/agent-conversations.js";
import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js";
import { CROSS_ISSUE_INFLUENCE_LIMIT } from "../services/cross-issue-influence-limit.js";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { recoveryPostgresSupport as support } from "./helpers/recovery-postgres.js";
(support.supported ? describe : describe.skip)("proactive agent chat messages", () => {
let server: Awaited<ReturnType<typeof startRunnerApiTestServer>>;
const originalSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
beforeAll(async () => {
process.env.PAPERCLIP_AGENT_JWT_SECRET = randomUUID();
server = await startRunnerApiTestServer({ humanSessions: true });
await instanceSettingsService(server.db).updateExperimental({ enableAgentChat: true });
}, 60_000);
afterAll(async () => {
await server?.close();
if (originalSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = originalSecret;
});
const fixture = () => server.fixture({ connectionScenario: "fresh", apiToolsEnabled: false, disableWakeOnDemand: true });
type Fixture = Awaited<ReturnType<typeof fixture>>;
const input = { body: "## Morning review\n\nThree PRs need your review.", idempotencyKey: "morning-pr-digest" };
const send = (f: Fixture, value: unknown = input) => sendAgentChatMessage(server.db, f, value);
const httpSend = (f: Fixture, value: unknown = input, keyScope: AgentApiKeyScope = { kind: "standard" }, authorizingUserId?: string) => fetch(`${server.apiUrl}/api/agents/me/chat/messages`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId, keyScope, authorizingUserId)}` },
body: JSON.stringify(value),
});
const apiKey = async (f: Fixture, scopeConfig: AgentApiKeyScope = { kind: "standard" }) => {
const token = `pcp_${randomUUID()}`;
const [key] = await server.db.insert(agentApiKeys).values({ companyId: f.companyId, agentId: f.agentId, name: "Chat delivery test",
responsibleUserId: f.responsibleUserId, scopeConfig, keyHash: createHash("sha256").update(token).digest("hex") }).returning();
return { ...key, token };
};
const keySend = (f: Fixture, token: string, value: unknown = input) => fetch(`${server.apiUrl}/api/agents/me/chat/messages`, {
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, "x-paperclip-run-id": f.runId },
body: JSON.stringify(value),
});
const spendInfluence = (f: Fixture, count: number) => server.db.insert(activityLog).values(Array.from({ length: count }, (_, index) => ({
companyId: f.companyId, actorType: "agent" as const, actorId: f.agentId, agentId: f.agentId, runId: f.runId,
action: "issue.cross_issue_influence_observed", entityType: "issue", entityId: f.issueId,
details: { kind: ["comment", "update", "interaction_resolution"][index % 3] },
})));
it.each(["native", "http"])("enforces the shared run cap for %s delivery without creating an empty chat", async transport => {
const f = await fixture();
await spendInfluence(f, CROSS_ISSUE_INFLUENCE_LIMIT);
if (transport === "native") {
await expect(f.authority.execute({ tool: "send_chat_message", arguments: input, callId: randomUUID() }))
.rejects.toMatchObject({ status: 429, details: { code: "cross_issue_influence_cap_exceeded", cap: CROSS_ISSUE_INFLUENCE_LIMIT } });
} else {
const response = await httpSend(f);
expect(response.status, await response.clone().text()).toBe(429);
expect(await response.json()).toMatchObject({ details: { code: "cross_issue_influence_cap_exceeded", cap: CROSS_ISSUE_INFLUENCE_LIMIT } });
}
const snapshot = await f.snapshot();
expect(snapshot.comments).toEqual([]);
expect(snapshot.issues.filter(issue => issue.conversationAgentId)).toEqual([]);
expect(snapshot.activity).toHaveLength(CROSS_ISSUE_INFLUENCE_LIMIT);
});
it("charges a committed message once across retries, recovery and conflicting content", async () => {
const f = await fixture(), receipt = (await send(f))!;
expect((await httpSend(f)).status).toBe(200);
expect(await f.authority.recover({ tool: "send_chat_message", arguments: input, callId: randomUUID() }))
.toMatchObject({ result: { commentId: receipt.commentId, replayed: true } });
expect((await httpSend(f, { ...input, body: "Different content" })).status).toBe(409);
expect(await f.authority.recover({ tool: "send_chat_message", arguments: { ...input, idempotencyKey: "not-committed" }, callId: randomUUID() })).toBeNull();
expect((await f.snapshot()).activity.filter(row => row.action === "issue.cross_issue_influence_observed")).toHaveLength(1);
expect((await f.snapshot()).comments).toHaveLength(1);
});
it("cannot race native and HTTP delivery past the shared final slot", async () => {
const f = await fixture();
await spendInfluence(f, CROSS_ISSUE_INFLUENCE_LIMIT - 1);
const outcomes = await Promise.all([
f.authority.execute({ tool: "send_chat_message", arguments: { ...input, idempotencyKey: "native-last-slot" }, callId: randomUUID() })
.then(() => 201, error => error.status),
httpSend(f, { ...input, idempotencyKey: "http-last-slot" }).then(response => response.status),
]);
expect(outcomes.sort()).toEqual([201, 429]);
const snapshot = await f.snapshot();
expect(snapshot.activity.filter(row => row.action === "issue.cross_issue_influence_observed")).toHaveLength(CROSS_ISSUE_INFLUENCE_LIMIT);
expect(snapshot.comments).toHaveLength(1);
expect(snapshot.issues.filter(issue => issue.conversationAgentId)).toHaveLength(1);
});
it("supports standard task API keys without changing agent and run attribution", async () => {
const f = await fixture(), key = await apiKey(f);
const response = await keySend(f, key.token);
expect(response.status, await response.clone().text()).toBe(201);
expect((await f.snapshot()).activity).toContainEqual(expect.objectContaining({ action: "issue.comment_added",
actorId: f.agentId, agentId: f.agentId, runId: f.runId, responsibleUserId: f.responsibleUserId }));
});
it("preserves an external authorizer's current access intersection", async () => {
const f = await fixture();
expect((await httpSend(f, input, { kind: "standard" }, randomUUID())).status).toBe(403);
await expect(sendAgentChatMessage(server.db, { ...f, authorizingUserId: randomUUID() }, input)).rejects.toMatchObject({ status: 403 });
expect((await f.snapshot()).issues.filter(issue => issue.conversationAgentId)).toEqual([]);
expect((await f.snapshot()).comments).toEqual([]);
});
it("keeps skill-test JWT and API-key scopes confined to their own task", async () => {
const f = await fixture(), scope = { kind: "skill_test", issueId: f.issueId } as const;
const key = await apiKey(f, scope);
expect((await httpSend(f, input, scope)).status).toBe(403);
expect((await keySend(f, key.token)).status).toBe(403);
expect((await f.snapshot()).issues.filter(issue => issue.conversationAgentId)).toEqual([]);
expect((await f.snapshot()).comments).toEqual([]);
});
it.each(["admitted", "legacy", "saved-task"])("retains a persisted %s skill-test run boundary after task mode changes", async kind => {
const f = await fixture();
const contextSnapshot = kind === "admitted" ? { paperclipAgentJwtScope: { kind: "skill_test", issueId: f.issueId } }
: kind === "legacy" ? { paperclipSkillTest: { id: randomUUID() } }
: { paperclipIssue: { id: f.issueId, workMode: "skill_test" } };
await server.db.update(heartbeatRuns).set({ contextSnapshot }).where(eq(heartbeatRuns.id, f.runId));
await server.db.update(issues).set({ workMode: "skill_test" }).where(eq(issues.id, f.issueId));
await server.db.update(issues).set({ workMode: "standard" }).where(eq(issues.id, f.issueId));
expect((await httpSend(f)).status).toBe(403);
await expect(sendAgentChatMessage(server.db, f, input, { receiptOnly: true })).rejects.toThrow(/skill.test/i);
expect((await f.snapshot()).issues.filter(issue => issue.conversationAgentId)).toEqual([]);
expect((await f.snapshot()).comments).toEqual([]);
});
it("does not let task-bridge credentials publish outside their admitted task", async () => {
const f = await fixture(), key = await apiKey(f, { kind: "task_bridge", parentIssueId: f.issueId });
expect((await keySend(f, key.token)).status).toBe(403);
expect((await f.snapshot()).issues.filter(issue => issue.conversationAgentId)).toEqual([]);
});
it.each(["revoked", "narrowed", "responsible-human"])("rechecks a %s API key after waiting for the destination", async kind => {
const f = await fixture(), receipt = (await send(f))!, key = await apiKey(f);
let unlock!: () => void, ready!: (pid: number) => void;
const locked = new Promise<number>(resolve => { ready = resolve; });
const release = new Promise<void>(resolve => { unlock = resolve; });
const gate = server.db.transaction(async tx => {
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
await tx.select({ id: issues.id }).from(issues).where(eq(issues.id, receipt.conversationId)).for("update");
ready(pid); await release;
});
const pid = await locked;
const delivery = keySend(f, key.token, { ...input, idempotencyKey: "key-after-wait" });
try {
await vi.waitFor(async () => {
const [row] = await server.db.execute(sql<{ blocked: boolean }>`select exists(select 1 from pg_stat_activity
where datname=current_database() and ${pid}=any(pg_blocking_pids(pid))) as blocked`);
expect(row.blocked).toBe(true);
}, { timeout: 5_000, interval: 10 });
await server.db.update(agentApiKeys).set(kind === "revoked" ? { revokedAt: new Date() }
: kind === "narrowed" ? { scopeConfig: { kind: "skill_test", issueId: f.issueId } }
: { responsibleUserId: randomUUID() }).where(eq(agentApiKeys.id, key.id));
unlock(); await gate;
expect((await delivery).status).toBe(403);
expect((await f.snapshot()).comments).toHaveLength(1);
} finally { unlock(); await gate; await delivery; }
}, 30_000);
it("publishes through the native tool without generic API tools, without starting a turn or changing the source", async () => {
const f = await fixture();
expect(f.authority.definitions().map(d => d.name)).toContain("send_chat_message");
const before = await issueService(server.db).getById(f.issueId);
const receipt = await f.authority.execute({ tool: "send_chat_message", arguments: input, callId: randomUUID() }) as { commentId: string; conversationId: string; chatUrl: string };
const snapshot = await f.snapshot();
const comment = snapshot.comments.find(c => c.id === receipt.commentId)!;
expect(comment).toMatchObject({ body: input.body, authorAgentId: f.agentId, createdByRunId: f.runId, onBehalfOfUserId: f.responsibleUserId,
metadata: { chatMessage: { sourceTaskId: f.issueId, sourceRunId: f.runId } } });
expect(snapshot.issues.find(i => i.id === receipt.conversationId)).toMatchObject({ conversationAgentId: f.agentId,
conversationUserId: f.responsibleUserId, conversationState: "waiting", executionRunId: null, status: "in_review" });
expect(await issueService(server.db).getById(f.issueId)).toEqual(before);
expect(await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
expect(snapshot.activity.filter(a => a.action === "issue.comment_added")).toHaveLength(1);
expect(await activityService(server.db).runsForIssue(f.companyId, receipt.conversationId)).toEqual([]);
expect(receipt.chatUrl).toContain(`/chats/${f.agentId}`);
expect(await withConversationReadState(server.db, f.companyId, f.responsibleUserId!, [{ id: receipt.conversationId }])).toMatchObject([{ isUnreadForMe: true }]);
expect(await f.authority.recover({ tool: "send_chat_message", arguments: input, callId: randomUUID() }))
.toMatchObject({ result: { commentId: receipt.commentId, replayed: true } });
});
it("creates once under concurrent first sends and recovers a committed response across run retries", async () => {
const f = await fixture();
expect(await f.authority.recover({ tool: "send_chat_message", arguments: input, callId: randomUUID() })).toBeNull();
expect((await f.snapshot()).issues.filter(i => i.conversationAgentId)).toHaveLength(0);
const [a, b] = await Promise.all([send(f), send(f)]);
expect(a!.commentId).toBe(b!.commentId);
expect((await f.snapshot()).comments).toHaveLength(1);
const other = await send(f, { ...input, idempotencyKey: "second" });
expect(other!.commentId).not.toBe(a!.commentId);
await expect(send(f, { ...input, body: "Different digest" })).rejects.toThrow(/different content/);
const nextRunId = randomUUID();
await server.db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, f.runId));
await server.db.insert(heartbeatRuns).values({ id: nextRunId, companyId: f.companyId, agentId: f.agentId,
status: "running", nativeIssueId: f.issueId, responsibleUserId: f.responsibleUserId });
await server.db.update(issues).set({ executionRunId: nextRunId }).where(eq(issues.id, f.issueId));
expect(await send({ ...f, runId: nextRunId })).toMatchObject({ commentId: a!.commentId, replayed: true });
expect((await f.snapshot()).comments).toHaveLength(2);
});
it("supports HTTP routine delivery during an active chat reply and after a context reset", async () => {
const f = await fixture();
await server.db.update(heartbeatRuns).set({ invocationSource: "automation", triggerDetail: "routine" }).where(eq(heartbeatRuns.id, f.runId));
const first = await httpSend(f);
expect(first.status, await first.clone().text()).toBe(201);
const receipt = await first.json();
const replyRun = randomUUID();
await server.db.insert(heartbeatRuns).values({ id: replyRun, companyId: f.companyId, agentId: f.agentId, status: "running",
nativeIssueId: receipt.conversationId, responsibleUserId: f.responsibleUserId, contextSnapshot: { issueId: receipt.conversationId, conversationSessionGeneration: 0 } });
await server.db.update(issues).set({ executionRunId: replyRun, conversationState: "active" }).where(eq(issues.id, receipt.conversationId));
const before = await issueService(server.db).getById(receipt.conversationId);
expect((await httpSend(f, { ...input, idempotencyKey: "during-reply" })).status).toBe(201);
const after = await issueService(server.db).getById(receipt.conversationId);
expect(after).toMatchObject({ executionRunId: replyRun, conversationState: before!.conversationState, status: before!.status });
await server.db.update(issues).set({ conversationSessionGeneration: 1, conversationState: "waiting", executionRunId: null }).where(eq(issues.id, receipt.conversationId));
expect((await httpSend(f, { ...input, idempotencyKey: "after-new" })).status).toBe(201);
await expect(issueService(server.db).addComment(receipt.conversationId, "Stale reply", { agentId: f.agentId, runId: replyRun })).rejects.toThrow(/earlier session/);
expect(await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(2);
expect((await f.snapshot()).comments).toHaveLength(3);
});
it("never enqueues a digest during recovery or the next human reply", async () => {
const f = await fixture(), svc = issueService(server.db);
const receipt = (await send(f))!;
const conversation = (await svc.getById(receipt.conversationId))!;
const enqueue = vi.fn(async (_agentId: string, _options: unknown) => null);
expect(await undeliveredConversationComments(server.db, f.companyId, conversation.id)).toEqual([]);
await deliverConversationComments(server.db, conversation, enqueue);
expect(enqueue).not.toHaveBeenCalled();
const reply = await svc.addComment(conversation.id, "Which PR should I review first?", { userId: f.responsibleUserId! }, { clientRequestId: randomUUID() });
expect((await undeliveredConversationComments(server.db, f.companyId, conversation.id)).map(comment => comment.id)).toEqual([reply.id]);
await deliverConversationComments(server.db, conversation, enqueue);
expect(enqueue).toHaveBeenCalledOnce();
expect(enqueue.mock.calls[0]?.[1]).toMatchObject({ requestedByActorType: "user", requestedByActorId: f.responsibleUserId,
contextSnapshot: { wakeCommentId: reply.id, wakeCommentIds: [reply.id] } });
expect(await conversationReplay(server.db, f.companyId, conversation.id, reply.id))
.toContain(JSON.stringify({ author: "agent", body: input.body }));
});
it.each([
{ transport: "native", replay: false }, { transport: "native", replay: true },
{ transport: "http", replay: false }, { transport: "http", replay: true },
])("serializes $transport delivery with a native chat mutation, replay=$replay", async ({ transport, replay }) => {
const f = await fixture(), receipt = (await send(f))!, chatRunId = randomUUID();
await server.db.update(agents).set({ runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: false, maxConcurrentRuns: 2 } } }).where(eq(agents.id, f.agentId));
await server.db.insert(heartbeatRuns).values({ id: chatRunId, companyId: f.companyId, agentId: f.agentId, status: "running",
runtimeMode: "native", nativeIssueId: receipt.conversationId, responsibleUserId: f.responsibleUserId,
contextSnapshot: { issueId: receipt.conversationId, conversationSessionGeneration: 0 } });
await server.db.update(issues).set({ executionRunId: chatRunId, conversationState: "active" }).where(eq(issues.id, receipt.conversationId));
const chat = new PaperclipRunnerToolAuthority(server.db, { companyId: f.companyId, agentId: f.agentId,
issueId: receipt.conversationId, runId: chatRunId, apiUrl: server.apiUrl, apiToolsEnabled: false });
let unlock!: () => void, ready!: (pid: number) => void;
const locked = new Promise<number>(resolve => { ready = resolve; });
const release = new Promise<void>(resolve => { unlock = resolve; });
const gate = server.db.transaction(async tx => {
const [{ pid }] = await tx.execute<{ pid: number }>(sql`select pg_backend_pid() as pid`);
await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, f.agentId)).for("update");
ready(pid);
await release;
});
const pid = await locked;
const waitForBlocked = async (count: number) => vi.waitFor(async () => {
const [row] = await server.db.execute(sql<{ count: number }>`with recursive blocked(pid) as (
select pid from pg_stat_activity where datname = current_database() and ${pid} = any(pg_blocking_pids(pid))
union select activity.pid from pg_stat_activity activity join blocked on blocked.pid = any(pg_blocking_pids(activity.pid))
where activity.datname = current_database()
) select count(*)::int as count from blocked`);
expect(row.count).toBeGreaterThanOrEqual(count);
}, { timeout: 5000, interval: 10 });
const message = replay ? input : { ...input, idempotencyKey: "concurrent-message" };
let settled: Promise<PromiseSettledResult<unknown>[]> | undefined;
let observedDelivery: Promise<PromiseSettledResult<unknown>[]> | undefined;
try {
const delivery = transport === "native"
? f.authority.execute({ tool: "send_chat_message", arguments: message, callId: randomUUID() })
: httpSend(f, message).then(async response => {
expect(response.status, await response.clone().text()).toBe(replay ? 200 : 201);
return response.json();
});
observedDelivery = Promise.allSettled([delivery]);
await waitForBlocked(1);
const mutation = chat.execute({ tool: "write_document", callId: randomUUID(), arguments: {
key: "concurrent-note", title: "Concurrent note", body: "The chat reply stays independent.", baseRevisionId: null, idempotencyKey: "concurrent-note",
} });
settled = Promise.allSettled([delivery, mutation]);
await waitForBlocked(2);
unlock();
await gate;
const results = await settled;
expect(results.map(result => result.status), JSON.stringify(results)).toEqual(["fulfilled", "fulfilled"]);
expect(results[1]).toMatchObject({ status: "fulfilled", value: { disposition: "applied", document: { key: "concurrent-note" } } });
expect(await issueService(server.db).getById(receipt.conversationId)).toMatchObject({ conversationState: "active" });
} finally {
unlock();
await gate;
await settled;
await observedDelivery;
}
}, 30_000);
it("isolates two humans sharing an agent and two agents serving one human", async () => {
const f = await fixture();
const a = await send(f);
const userId = randomUUID();
await server.db.insert(authUsers).values({ id: userId, name: "Second human", email: `${userId}@fixture.invalid`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
await server.db.insert(companyMemberships).values({ companyId: f.companyId, principalType: "user", principalId: userId, status: "active", membershipRole: "member" });
await server.db.update(heartbeatRuns).set({ responsibleUserId: userId }).where(eq(heartbeatRuns.id, f.runId));
const b = await send(f);
expect(b!.conversationId).not.toBe(a!.conversationId);
const [agent] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Second agent", status: "active", adapterType: "process" }).returning();
const runId = randomUUID();
await server.db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: agent.id, status: "running", issueId: f.issueId, responsibleUserId: userId });
await server.db.update(issues).set({ assigneeAgentId: agent.id, executionRunId: runId }).where(eq(issues.id, f.issueId));
const c = await send({ ...f, agentId: agent.id, runId });
expect(new Set([a!.conversationId, b!.conversationId, c!.conversationId]).size).toBe(3);
await expect(issueService(server.db).addComment(a!.conversationId, "Wrong agent", { agentId: agent.id, runId })).rejects.toThrow(/Only this conversation/);
await expect(issueService(server.db).addComment(b!.conversationId, "Bypass", { agentId: f.agentId, runId: f.runId })).rejects.toThrow(/earlier session/);
});
it("delivers to its own chat while preserving pending questions and the original handoff completion", async () => {
const f = await fixture(), svc = issueService(server.db);
const own = await svc.create(f.companyId, { title: "Own chat", assigneeAgentId: f.agentId,
conversationAgentId: f.agentId, conversationUserId: f.responsibleUserId!, conversationState: "waiting", status: "in_review" });
const [coordinator] = await server.db.insert(agents).values({ companyId: f.companyId, name: "Coordinator", status: "active", adapterType: "process" }).returning();
const origin = await svc.create(f.companyId, { title: "Origin chat", assigneeAgentId: coordinator.id,
conversationAgentId: coordinator.id, conversationUserId: f.responsibleUserId!, conversationState: "waiting", status: "in_review" });
await recordAcceptedChatHandoff(server.db, (await svc.getById(f.issueId))!, origin);
const [question] = await server.db.insert(issueThreadInteractions).values({ companyId: f.companyId, issueId: own.id,
kind: "request_confirmation", status: "pending", payload: { version: 1, prompt: "Proceed?" } }).returning();
expect(await send(f)).toMatchObject({ conversationId: own.id });
expect(await server.db.select().from(issueComments).where(eq(issueComments.issueId, origin.id))).toEqual([]);
expect(await server.db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, question.id))).toEqual([question]);
expect(await server.db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, f.companyId))).toHaveLength(1);
await svc.update(f.issueId, { status: "done" });
expect(await server.db.select().from(chatCompletionDeliveries).where(eq(chatCompletionDeliveries.taskId, f.issueId)))
.toMatchObject([{ status: "pending", responseCommentId: null }]);
expect(await server.db.select().from(chatTaskHandoffs).where(eq(chatTaskHandoffs.taskId, f.issueId)))
.toMatchObject([{ conversationId: origin.id, agentId: coordinator.id }]);
});
it.each(["ask", "planning", "review", "watchdog", "stopped", "stale", "missing-human", "revoked", "private-task", "private-project", "private-ancestor", "paused-agent"])("rejects %s before creating a chat", async kind => {
const f = await fixture();
if (kind === "ask" || kind === "planning") await server.db.update(issues).set({ workMode: kind }).where(eq(issues.id, f.issueId));
if (kind === "review") await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, nativeReviewInteractionId: randomUUID(), nativeReviewDecisionId: randomUUID() } }).where(eq(heartbeatRuns.id, f.runId));
if (kind === "watchdog") await server.db.update(heartbeatRuns).set({ contextSnapshot: { issueId: f.issueId, taskWatchdog: true, watchedIssueId: f.issueId } }).where(eq(heartbeatRuns.id, f.runId));
if (kind === "stopped") await server.db.update(heartbeatRuns).set({ resultJson: { executionCancellation: { state: "requested" } } }).where(eq(heartbeatRuns.id, f.runId));
if (kind === "stale") await server.db.update(issues).set({ executionRunId: null }).where(eq(issues.id, f.issueId));
if (kind === "missing-human") await server.db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, f.runId));
if (kind === "revoked") await server.db.delete(companyMemberships).where(eq(companyMemberships.principalId, f.responsibleUserId!));
if (kind === "private-task") await server.db.update(issues).set({ visibility: "private" }).where(eq(issues.id, f.issueId));
if (kind === "private-project") await server.db.update(projects).set({ visibility: "private", privacyOwnerUserId: f.responsibleUserId }).where(eq(projects.id, f.projectId));
if (kind === "private-ancestor") {
await server.db.update(issues).set({ visibility: "private" }).where(eq(issues.id, f.blockerId));
await server.db.update(issues).set({ parentId: f.blockerId, privacyParentIssueId: f.blockerId, privacyRootIssueId: f.blockerId }).where(eq(issues.id, f.issueId));
}
if (kind === "paused-agent") await server.db.update(agents).set({ status: "paused" }).where(eq(agents.id, f.agentId));
await expect(send(f)).rejects.toThrow();
expect((await f.snapshot()).comments).toHaveLength(0);
expect((await f.snapshot()).issues.filter(i => i.conversationAgentId)).toHaveLength(0);
});
it("rejects forged destinations and server-owned provenance while ignoring the retired flag", async () => {
const f = await fixture();
expect((await httpSend(f, { ...input, agentId: f.agentId })).status).toBe(400);
await instanceSettingsService(server.db).updateExperimental({ enableAgentChat: false });
try { expect((await httpSend(f)).status).toBe(201); }
finally { await instanceSettingsService(server.db).updateExperimental({ enableAgentChat: true }); }
const receipt = await send(f);
const [comment] = await server.db.select().from(issueComments).where(eq(issueComments.id, receipt!.commentId));
await expect(issueService(server.db).addComment(f.issueId, "Forged source", { agentId: f.agentId, runId: f.runId }, { metadata: comment.metadata! })).rejects.toThrow(/server-owned/);
});
it("keeps unseen concurrent arrivals unread with exact comment cutoffs and monotonic receipts", async () => {
const f = await fixture();
const a = (await send(f))!, b = (await send(f, { ...input, idempotencyKey: "later" }))!;
await server.db.update(issueComments).set({ createdAt: sql`'2026-10-30 12:00:00.123456+00'::timestamptz` }).where(eq(issueComments.id, a.commentId));
await server.db.update(issueComments).set({ createdAt: sql`'2026-10-30 12:00:00.123789+00'::timestamptz` }).where(eq(issueComments.id, b.commentId));
const cookie = await server.humanSession(f.responsibleUserId!);
await server.db.insert(issueComments).values([
{ companyId: f.companyId, issueId: a.conversationId, authorUserId: f.responsibleUserId, authorType: "user", body: "Human message" },
{ companyId: f.companyId, issueId: a.conversationId, authorAgentId: f.agentId, authorType: "system", body: "System message" },
{ companyId: f.companyId, issueId: a.conversationId, authorAgentId: f.agentId, body: "Deleted message", deletedAt: new Date() },
{ companyId: f.companyId, issueId: a.conversationId, authorAgentId: f.agentId, body: "Receipt", origin: "fast_response" },
{ companyId: f.companyId, issueId: a.conversationId, authorAgentId: f.agentId, body: "Notice", presentation: { kind: "system_notice", tone: "neutral", detailsDefaultOpen: false } },
]);
const mark = (id: string) => fetch(`${server.apiUrl}/api/issues/${a.conversationId}/read`, { method: "POST", headers: { Cookie: cookie, Origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ throughCommentId: id }) });
const unread = async () => (await withConversationReadState(server.db, f.companyId, f.responsibleUserId!, [{ id: a.conversationId }]))[0];
expect(await unread()).toMatchObject({ isUnreadForMe: true, unreadMessageCount: 2 });
const response = await mark(a.commentId);
expect(response.status, await response.text()).toBe(200);
expect(await unread()).toMatchObject({ isUnreadForMe: true, unreadMessageCount: 1 });
expect((await mark(b.commentId)).status).toBe(200);
expect(await unread()).toMatchObject({ isUnreadForMe: false, unreadMessageCount: 0 });
expect(await withConversationReadState(server.db, f.companyId, randomUUID(), [{ id: a.conversationId }, { id: f.issueId }]))
.toMatchObject([{ unreadMessageCount: 2, isUnreadForMe: true }, { unreadMessageCount: 0, isUnreadForMe: false }]);
expect(await withConversationReadState(server.db, randomUUID(), f.responsibleUserId!, [{ id: a.conversationId }]))
.toMatchObject([{ unreadMessageCount: 0, isUnreadForMe: false }]);
await mark(a.commentId);
expect(await unread()).toMatchObject({ isUnreadForMe: false, unreadMessageCount: 0 });
const foreign = await server.fixture({ connectionScenario: "fresh" });
const foreignMessage = (await send(foreign))!;
expect((await mark(foreignMessage.commentId)).status).toBe(404);
const chats = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/chats`, { headers: { Cookie: cookie } });
expect(chats.status).toBe(200);
expect(await chats.json()).toMatchObject([{ id: a.conversationId, isUnreadForMe: false, unreadMessageCount: 0 }]);
const detail = await fetch(`${server.apiUrl}/api/companies/${f.companyId}/chats/${f.agentId}`, { headers: { Cookie: cookie } });
expect(detail.status).toBe(200);
expect(await detail.json()).toMatchObject({ id: a.conversationId, unreadMessageCount: 0 });
});
});