Files
PaperClipAI/server/src/__tests__/chat-interaction-publications.test.ts
T
Nicky LeachandClaude Opus 5.5 11ac90e31f fix(build): typecheck server/src/__tests__ and fix every type error (#18739)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The server has about 1,100 test and helper files in
`server/src/__tests__`
> - `server/tsconfig.json` excludes that directory, Vitest does not
check types, and no script runs the tsconfig in that directory
> - So a test can call a function with the wrong arguments and still
pass, and no tool reports it
> - This gap hid 2,168 real type errors, and some of them hid tests that
did not test what they claim
> - This pull request fixes all of these errors and adds the directory
to the server layers from paperclipai/paperclip#18737
> - The benefit is that a type error in a server test now fails the
typecheck, with no baseline and no backlog

## Linked Issues or Issue Description

Refs paperclipai/paperclip#18737, which is merged and added the server
layers. Refs paperclipai/paperclip#18738, which is merged and checks the
test projects outside `pnpm -r`, so it is not a duplicate. Refs
paperclipai/paperclip#18137, which records that this directory has no
check.

**What happened?**

No tool checked the types of 1,074 of the 1,101 files in
`server/src/__tests__`. I searched `package.json`, the workflows, and
all tsconfig files. 27 files are in the program of another tsconfig, and
these 27 have 0 errors. 11 of the 27 are in
`helpers/maintenance/tsconfig.json`. No script ran that project when I
measured this. paperclipai/paperclip#18738 now runs it.

**Expected behavior**

The server typecheck checks these files, and a type error fails the
check.

**Steps to reproduce**

1. On the base commit, run `tsc -p server/src/__tests__/tsconfig.json
--noEmit`. It reports 6,536 errors and needs 12.3 GiB.
2. Add a type error to any file in `server/src/__tests__`.
3. Run `pnpm --filter @paperclipai/server typecheck`. It passes.

**Paperclip version or commit**

I measured the error breakdown below on commit `8dfd632452`, with
TypeScript 7.0.2. The branch now includes master up to
paperclipai/paperclip#18865.

**Deployment mode**

Not applicable. This issue applies to development and CI. No production
source file in the server changes.

### Error breakdown

Each row adds one change to the row above.

| Step | Total errors | Change | Cause |
| --- | --- | --- | --- |
| Config as committed | 6,536 | | |
| Add `src/types/*.d.ts` | 4,447 | -2,089 | Config. The Express request
types were missing. |
| Set `rootDir` to the repository root | 2,436 | -2,011 | Config. |
| Set `allowImportingTsExtensions` | 2,181 | -255 | Config. Vitest
accepts `.ts` imports. |
| Add `ES2024.Promise` to `lib` | 2,138 | -43 | Config. Node 24 has
`Promise.withResolvers`. |
| Set `allowJs` for `.mjs` imports | 2,148 | +10 | Config. It removes 8
`TS7016` errors and shows new real errors. |
| Set `declaration` to false | 2,142 | -6 | Config. Tests emit nothing.
|
| Count only files in `server/src` | 2,058 | -84 | Other packages: 53
Daytona, 29 Runner test, 2 UI. |

So 4,478 of the 6,536 errors (69%) came from config or scope, and 2,058
are real type errors in 314 files. The 29 Runner test errors are also
real, which gives 2,087 errors. Tests that master added after this
measurement had 81 more errors, so this pull request fixes 2,168 errors.

This table classifies the first 2,058 errors.

| Kind of real error | Errors |
| --- | --- |
| Wrong argument or assignment type | 1,023 |
| Stale or partial fixture objects | 525 |
| Property that the type does not have | 317 |
| Missing narrowing of null or unknown | 101 |
| Implicit any | 60 |
| Missing name or module | 16 |
| Other | 16 |

### What the type errors hid

Most errors were stale fixtures and loose mocks. These errors hid real
test problems, and this pull request fixes them:

- **A test that never ran.** `instrumentation.test.ts` required
`sdk-trace-base`, which is not installed. So 3 span tests were skipped.
They now run and pass.
- **Assertions that could not fail.**
- `issue-thread-interactions-service` used a company ID as an issue ID.
- `status-card-evaluation-work` compared `[]` with `[]` in a rollback
check.
- `slack-conversation-lifecycle` read `item.issueId`, which does not
exist.
  - `email-channels` used a permission key that does not exist.
- `heartbeat-project-env` imported `LOW_TRUST_REVIEW_PRESET`, which is
not exported. The value was undefined.
- **Postgres processes that did not stop.** 11 test files called `stop`
on the test database helper. That method does not exist, and the helper
method is `cleanup`.
- **Arguments that the service ignored.**
- `methodKey` is now `connectionMethodKey` in the Daytona, Unikraft,
1Password, and Railway tests.
- 108 calls passed `"owner-user"` to a parameter that expects an options
object.
- 9 chat wizard fixtures passed `managementContext` as `source`, so they
ran `existing_app` and not `manifest`.
  - 2 tests passed a `{ userId }` actor that the function ignores.
- Inserts into `environments` passed a `companyId` column that the table
does not have.
- **Problems in tests that master added during this work.**
- `sandbox-creation-operations` read `database.connectionString` when an
external test database was set. In that mode `database` is undefined, so
the fixture would stop with a TypeError. It now uses the external URL.
- `idle-completed-history` set a Dot operation to `dispatched`. That is
a Muse status, and the Dot type does not have it. The fixture now uses
`admitted`, which is a real in-flight Dot status.

### Possible product problems, not changed

These may be real problems in production code or types. I did not change
them, because this pull request does not change production behavior:

- `decisions-service` `service.create` does not validate the lenient
`cancel_issue_tree` input.
- `createConnection` ignores `input.connectionPurpose`.
- The `createConnection` route schema default stores `{}` for
`transportConfig`.
- `prepareManagedAiRuntime` accepted the invalid value `mode:
"connection"`.
- `createBinding` accepts invalid `projectionClass` strings outside
class 3.
- `ensureManagedProjectWorkspace` forwards an untyped `repoRef` by
spread.
- `routineVariableSchema.label` is optional, but `RoutineVariable.label`
is required.
- `ToolRunContext.projectId` may be too strict.
- `withdrawInteraction` reads `issue.status`, which its type omits.
- `issueService.list` and `reconcileNativeFinalizations` omit fields
that they return at runtime.
- `createGitHubExternalObjectProvider` handles `tokenProvider: null`,
but its type forbids this value.

## What Changed

- Fixed the 2,168 type errors. The diff changes 322 test and helper
files.
- Corrected `server/src/__tests__/tsconfig.json`. Editors and the
typecheck now read the same options and report the same errors.
- Added three `test-tree` layers to
`server/scripts/typecheck-layers.mjs`. They read the server layers as
declarations, and any error fails them. A hash of the path selects the
shard, so a new file does not move other files.
- Added `server/scripts/typecheck-stubs/daytona-sdk.d.ts`. Server tests
reach Daytona plugin source, and the root install does not have
`@daytonaio/sdk`.
- Changed two imports in `ui/src/lib/issue-execution-policy.ts` to use
`.js` extensions, because server tests import this file.
- Made `declarationsConfig` and `--declarations-only` skip the
`test-tree` layers, because these layers emit nothing. Other projects
read only the source layers. Added a test for this.
- Updated `doc/DEVELOPING.md`.

## Verification

Run these commands:

```sh
pnpm --filter @paperclipai/server typecheck
pnpm exec vitest run server/scripts/typecheck-layers.test.mjs
```

Results on macOS (arm64), Node 24, 16 cores. Each check ran after the
merge of paperclipai/paperclip#18738 unless the row says otherwise:

| Check | Commit | Result |
| --- | --- | --- |
| Server typecheck, `PAPERCLIP_TYPECHECK_LOW_MEMORY=1`, all 7 layers |
`6e45a08573` | Exit 0, 0 errors, 62 seconds, peak 3.52 GiB |
| `node scripts/typecheck-test-projects.mjs --low-memory` | `a3306bd13e`
| Exit 0, 0 errors, 42 seconds, peak 3.25 GiB |
| The same script with `--reuse-declarations` | `1b878aabc0` | Exit 0, 0
errors |
| `typecheck-layers.test.mjs` and `live-recovery-work.test.ts` |
`01db5634e4` | 90 passed. These include real compiler runs for a pass, a
type error, and an invalid tsconfig. |
| `tsconfig.declarations.json` after a full server typecheck |
`0fb1e4fb0a` | It references only `below`, `cycle`, and `above`. No
generated config names a `tsconfig.layers.json` file. |
| Owned runtime test in `idle-completed-history` | `e8f2f448ac` |
Passed, with the fix that master later replaced |
| `idle-incoming-wake`, `plugin-idle-manager`, and the changed
`chat-channels` test | `6e45a08573` | 30 passed |
| Runner `native-execution.test.ts` | `eab4dcc1f4` | 114 passed |
| The 19 server test files that changed since `eab4dcc1f4`, and
`typecheck-layers.test.mjs` | `259737b700` | 4,275 of 4,277 passed. The
2 failures are in `chat-channels` and are in the list of 7 below. |

The peak is the largest single process, from `/usr/bin/time -l`. Peaks
for the server typecheck were between 3.42 GiB and 3.56 GiB across 8
runs. Peaks for the test projects were 3.25 GiB and 3.37 GiB across 2
runs. I did not measure a cgroup.

**Runtime check of the fixes.** Types do not prove that a test still
works. So I ran all 309 changed server test files, 11,929 tests, before
the rebase onto paperclipai/paperclip#18737:

| Result | Tests | Detail |
| --- | --- | --- |
| Passed | 11,913 | Includes the 2 tests and the 54 `dot-runner` tests
that passed in a later run, as described below. |
| Failed | 7 | The same 7 tests also fail on the commit before the
fixes. |
| Skipped | 9 | These tests have their own skip conditions: `sentry` 5,
and 1 each in `bundled-plugin-registration`, `customer-success-cloud`,
`onepassword-connection`, and `workspace-runtime-https-live-exercise`. |

The 7 tests that fail with and without this change, on my machine:

- 3 tests in `chat-channels` and `email-channels` stop with `ENOENT` on
a `SKILL.md` file. The path is in the parent checkout, above the git
worktree that I used, so I expect that the worktree is the cause. I did
not prove this.
- `workspace-instance-cleanup` compares `/private/var/...` with
`/var/...`. This is a macOS symlink difference.
- 2 tests in `git-reproducible-managed-checkouts` get
`unknown_base_git_files` and expect `unpushed_managed_checkout`. I did
not find the cause.
- 1 more test in `chat-channels`, `materializes direct external-chat
finals once`, gets `undefined`. I did not find the cause.

CI is the check for these 7 tests. `ci / verify` passed on `eab4dcc1f4`.

Failures during this work:

- My fix to `agent-runtime-connector-access` changed an invalid
permission key in the setup, but not in the assertion. The runtime check
found this. I corrected the assertion, and the file passes.
- `heartbeat-plugin-environment` failed one wait in the full run. It
passed three times alone, so I think that load caused the failure. It
can fail again under load.
- `dot-runner` skipped all 54 tests in the full run because `cargo` was
not on my `PATH`. It then passed 54 of 54 with the toolchain on the
`PATH`.
- My first `lib` value removed the default DOM types, and the error
count went up by 375. I added the DOM libraries.
- My Daytona stub with bare `any` left 48 errors, because generic
helpers then inferred `unknown`. I added shapes that return promises.
- One fix to `agent-secrets-routes` used `class_3_static_lease`, and the
test failed. That class allows only specific paths. I reverted to
`unclassified`.
- `ci / General tests (server (8/12))` failed on `259737b700` because of
the flaky port in `idle-completed-history`. paperclipai/paperclip#18853
fixed this test on master, so this pull request keeps that fix.

**Audit of the diff.** I counted these patterns in added and removed
lines, to find fixes that only hide an error:

| Pattern | Added | Removed |
| --- | --- | --- |
| `@ts-ignore` | 0 | 0 |
| `@ts-expect-error` | 0 | 1 |
| `.skip` | 0 | 0 |
| `as any` | 9 | 9 |
| `: any` | 11 | 2 |
| `as never` | 8 | 2 |
| `as unknown as` | 35 | 4 |
| `Promise<unknown>` | 16 | 5 |
| `expect(` | 128 | 113 |

No hunk removes more `expect(` calls than it adds. Three removals of
test content were intentional, and I checked each one:

- `muse-control` removed `signalToken`. Line 1012 already asserts that
it is absent.
- `merged-pr-confirmation-sweep` removed legacy aliases. Other tests
cover them.
- `heartbeat-workspace-branch-containment` removed a `finalize` branch
that no test row can reach.

Some partial fixtures stay unchecked through `as unknown as` and
`Promise<unknown>`, mostly in `issue-agent-mutation-ownership-routes`
and `issue-comment-reopen-routes`.

**Accuracy of the check.** One program over the tree and the three
shards reported the same errors at the same positions. Memory, as the
peak of the largest process on one thread, was 3.93 GiB for one program
and 2.43 GiB for the largest shard. I measured these two values before
the fixes.

Not run or not proven:

- I did not run the full `pnpm test:run`, or a root `pnpm build`. I
expect CI to run the full suites.
- I did not run the 4 GiB container check, or the full
`scripts/typecheck-low-memory.mjs`. The peaks above come from macOS, so
they are not a Linux cgroup measurement.
- I did not run the UI typecheck after the merges.
- `workspace-runtime-https-live-exercise` is skipped, so its `cleanup`
fix did not run.

## Risks

- **A large diff in test files.** 328 files change. A pull request that
edits the same tests can get merge conflicts. New tests on master can
also add type errors before this merges. Any such error fails this check
and needs a fix here.
- **A higher cost for changes to source types.** A change to a type can
add errors in test files that the author did not touch. The author must
then fix them. This is the purpose of the check, but it is new work.
- **Changed test behavior.** Some tests now run paths that they did not
run before, such as the 9 chat wizard fixtures and the 3 span tests.
They pass here, but they can be more sensitive to timing or to the
environment.
- **The Daytona stub can go stale.** The stub is written by hand. If the
plugin uses a new SDK member, the typecheck reports an error until
someone updates the stub.
- **The release path.** `release-verify.yml` and `scripts/release.sh`
run `pnpm -r typecheck`, so they now run this check.
- **The tsconfig change.** The test transform reads
`server/src/__tests__/tsconfig.json`. The options that I changed are for
type checks, and the runs above passed.
- **A gap that remains.** Tsconfigs in `packages/paperclip-runner` and
in most sandbox provider plugins exclude `*.test.ts`. This check does
not read most of those test files, so they can still drift. That work is
separate from this pull request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

Anthropic Claude Opus 5.5 (`claude-opus-5-5`) through Claude Code, with
reasoning, tool use, code execution, and subagents. The runtime does not
show the 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
- [ ] I have run tests locally and they pass (7 tests fail locally, with
and without this change. See Verification.)
- [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
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:27:38 -08:00

1501 lines
58 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
agents,
applyPendingMigrations,
EMBEDDED_POSTGRES_TEST_TIMEOUT_MS,
chatActions,
chatConversations,
chatEndpoints,
chatPublications,
companies,
createDb,
documentRevisions,
documents,
heartbeatRuns,
issueComments,
issueDocuments,
issueThreadInteractions,
issues,
toolApplications,
toolConnections,
} from "@paperclipai/db";
import type { ChatProvider } from "@paperclipai/shared";
import { enqueueIssueInteractionChatPublications, enqueueTerminalIssueInteractionChatPublications } from "../services/chat-interaction-publications.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
const externalUrl = resolveExternalTestDatabaseUrl("PAPERCLIP_CHAT_PUBLICATIONS_TEST_DATABASE_URL");
const embeddedPostgresSupport = externalUrl ? { supported: true } : await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported
? describe
: describe.skip;
describeEmbeddedPostgres(
"terminal native chat interaction publications",
() => {
let db!: ReturnType<typeof createDb>;
let tempDb: Awaited<
ReturnType<typeof startEmbeddedPostgresTestDatabase>
> | null = null;
const previousPublicUrl = process.env.PAPERCLIP_PUBLIC_URL;
beforeAll(async () => {
process.env.PAPERCLIP_PUBLIC_URL = "https://paperclip.example";
if (externalUrl) await applyPendingMigrations(externalUrl);
else tempDb = await startEmbeddedPostgresTestDatabase("paperclip-terminal-chat-interaction-");
db = createDb(externalUrl ?? tempDb!.connectionString);
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
afterAll(async () => {
if (previousPublicUrl === undefined)
delete process.env.PAPERCLIP_PUBLIC_URL;
else process.env.PAPERCLIP_PUBLIC_URL = previousPublicUrl;
await db?.$client.end({ timeout: 1 });
await tempDb?.cleanup();
});
async function seedBoundIssue(
providers: ChatProvider[] = ["slack", "telegram"],
) {
const companyId = randomUUID();
const agentId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: `Terminal chat ${companyId}`,
issuePrefix: `TC${companyId.replaceAll("-", "").slice(0, 6).toUpperCase()}`,
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Native chat agent",
role: "operator",
status: "idle",
adapterType: "paperclip_runner",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Resolve the native provider card",
status: "in_progress",
priority: "medium",
assigneeAgentId: agentId,
});
const endpointIds: Record<string, string> = {};
for (const provider of providers) {
const applicationId = randomUUID();
const connectionId = randomUUID();
const endpointId = randomUUID();
endpointIds[provider] = endpointId;
await db.insert(toolApplications).values({
id: applicationId,
companyId,
applicationKey: `chat:${provider}:${endpointId}`,
name: `${provider} ${endpointId}`,
type: "chat",
status: "active",
});
await db.insert(toolConnections).values({
id: connectionId,
companyId,
applicationId,
name: `${provider} channel`,
uid: `chat-${provider}-${endpointId}`,
connectionPurpose: "channel",
transport: "chat_sdk",
status: "active",
enabled: true,
});
await db.insert(chatEndpoints).values({
id: endpointId,
companyId,
connectionId,
provider,
publicId: randomUUID(),
assignedAgentId: agentId,
status: "active",
capabilities: {
threads: true,
directMessages: true,
nativeStreaming: false,
messageEdits: true,
messageDeletes: false,
reactions: true,
files: true,
cards: true,
actions: true,
modals: provider !== "telegram",
slashCommands: true,
ephemeralMessages: provider === "slack",
proactiveDirectMessages: true,
},
});
await db.insert(chatConversations).values({
companyId,
endpointId,
issueId,
externalConversationId: `${provider}-conversation`,
externalThreadId: `${provider}:thread:${issueId}`,
externalLabel: `${provider} thread`,
state: "active",
});
}
return { agentId, companyId, endpointIds, issueId };
}
async function markInteractionCardsPublished(
companyId: string,
interactionId: string,
) {
const originals = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
interactionId,
),
),
);
for (const original of originals) {
await db
.update(chatPublications)
.set({
state: "published",
providerMessageId: `provider-${original.endpointId}`,
})
.where(eq(chatPublications.id, original.id));
}
return originals;
}
async function publicationsForInteraction(
companyId: string,
interactionId: string,
) {
return db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
interactionId,
),
),
);
}
function interceptAfterFirstSelect(
afterSelect: () => Promise<void>,
): Parameters<typeof enqueueTerminalIssueInteractionChatPublications>[0] {
let intercepted = false;
const wrapQuery = (query: any): any =>
new Proxy(query, {
get(target, property) {
if (property === "then") {
return (onFulfilled: unknown, onRejected: unknown) =>
Promise.resolve(target)
.then(async (value) => {
if (!intercepted) {
intercepted = true;
await afterSelect();
}
return value;
})
.then(onFulfilled as never, onRejected as never);
}
const member = Reflect.get(target, property, target);
return typeof member === "function"
? (...args: unknown[]) =>
wrapQuery(Reflect.apply(member, target, args))
: member;
},
});
return {
select: ((...args: unknown[]) =>
wrapQuery(Reflect.apply(db.select, db, args))) as typeof db.select,
insert: db.insert.bind(db),
update: db.update.bind(db),
transaction: db.transaction.bind(db),
};
}
it("publishes a larger Slack choice as an inline selection backed by durable answer tokens", async () => {
const fixture = await seedBoundIssue(["slack", "microsoft-teams"]);
const interaction = await issueThreadInteractionService(db).create(
{ id: fixture.issueId, companyId: fixture.companyId },
{ kind: "ask_user_questions", payload: { version: 1, questions: [{
id: "fixture", prompt: "Which fixture?", selectionMode: "single", options: Array.from({ length: 13 }, (_, i) => ({ id: `fixture-${i}`, label: `Fixture ${i + 1}` })),
}] } }, { agentId: fixture.agentId },
);
const publications = await publicationsForInteraction(fixture.companyId, interaction.id);
const native = publications.find(row => row.payload.card?.selection);
expect(native?.payload.card?.selection?.options).toHaveLength(13);
expect(native?.payload.card?.actions).toBeUndefined();
expect(native?.payload.card?.body).not.toContain("- Fixture");
const tokens = await db.select().from(chatActions).where(eq(chatActions.endpointId, native!.endpointId));
expect(tokens.filter(token => token.kind === "question_answer")).toHaveLength(13);
expect(publications.find(row => row.id !== native!.id)?.payload.card?.actions).toEqual([expect.objectContaining({ label: "Respond" })]);
});
it("shows the free-text question and context alongside Respond", async () => {
const fixture = await seedBoundIssue(["slack"]);
const interaction = await issueThreadInteractionService(db).create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "ask_user_questions",
title: "Identify the failed change",
summary: "The general migration checks passed. I need one detail to reproduce your specific failure.",
payload: {
version: 1,
title: "Identify the failed change",
questions: [{
id: "agent", prompt: "Which agent did you try to change?",
helpText: "Send the agent link and the error text, if available.",
selectionMode: "single", allowOther: true,
options: [{ id: "text", label: "Type an answer", freeText: true }],
}],
},
},
{ agentId: fixture.agentId },
);
const [publication] = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(publication.payload.card).toMatchObject({
title: "Identify the failed change",
body: "The general migration checks passed. I need one detail to reproduce your specific failure.\n\nWhich agent did you try to change?\n\nSend the agent link and the error text, if available.",
actions: [expect.objectContaining({ label: "Respond", type: "callback" })],
});
});
it.each(["text", "single_select", "multi_select", "custom", "pattern", "oversized"] as const)("publishes the complete %s question with an actionable response path", async shape => {
const fixture = await seedBoundIssue(["slack"]);
const question = {
id: "agent", prompt: "Which agent failed?", helpText: "Send the link and error text.", required: true,
answerMode: shape === "multi_select" ? "multi_select" as const : shape === "text" || shape === "pattern" || shape === "oversized" ? "text" as const : "single_select" as const,
...(shape === "single_select" || shape === "multi_select" || shape === "custom" ? { options: [{ id: "codie", label: "Codie", description: "Native runner", recommended: true }, { id: "claude", label: "Claude", description: "Legacy runner" }] } : {}),
...(shape === "custom" ? { customAnswer: { enabled: true, label: "Another agent", placeholder: "Paste the agent link" } } : {}),
...(shape === "pattern" ? { textValidation: { pattern: "^[A-Z]+$" } } : {}),
...(shape === "oversized" ? { initialText: "x".repeat(3001) } : {}),
};
const service = issueThreadInteractionService(db);
const interaction = await service.create({ id: fixture.issueId, companyId: fixture.companyId }, {
kind: "ask_user_questions", title: "Identify the failed change",
payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", description: "The answer determines the next test.", questions: [question] } },
} as Parameters<typeof service.create>[1], { agentId: fixture.agentId });
const [publication] = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(publication.payload.card?.body).toContain(question.prompt);
expect(publication.payload.card?.body).toContain(question.helpText);
expect(publication.payload.card?.body).toContain("The answer determines the next test.");
expect(publication.payload.text).toContain(question.prompt);
if (shape === "multi_select") {
expect(publication.payload.card?.selection).toMatchObject({ multiple: true, options: [
{ label: "Codie (recommended)", description: "Native runner" }, { label: "Claude", description: "Legacy runner" },
] });
} else if (question.options && shape !== "custom") {
expect(publication.payload.card?.body).toContain("Codie (recommended): Native runner");
expect(publication.payload.card?.body).toContain("Claude: Legacy runner");
}
if (shape === "custom") {
expect(publication.payload.card?.body).toContain("- Codie (recommended): Native runner");
expect(publication.payload.card?.actions?.map(action => action.label)).toEqual(["Codie", "Claude", "Other…"]);
const tokens = await db.select().from(chatActions).where(eq(chatActions.endpointId, publication.endpointId));
expect(tokens.map(token => token.kind).sort()).toEqual(["question_answer", "question_answer", "question_form_open", "question_form_submit"].sort());
}
if (shape === "oversized") {
expect(publication.payload.card?.actions).toEqual([{ type: "link", label: "Open in Paperclip", url: expect.stringContaining(fixture.issueId) }]);
} else if (shape !== "multi_select") {
expect(publication.payload.card?.actions?.every(action => action.type === "callback")).toBe(true);
}
});
it.each([100, 101, 128])("preserves the complete response path for %i choices plus Other", async count => {
const fixture = await seedBoundIssue(["slack"]);
const service = issueThreadInteractionService(db);
const options = Array.from({ length: count }, (_, index) => ({ id: `choice-${index}`, label: `Choice ${index}` }));
const interaction = await service.create({ id: fixture.issueId, companyId: fixture.companyId }, {
kind: "ask_user_questions",
payload: { version: 1, questionSet: { schema: "paperclip.question_set.v1", questions: [{
id: "choice", prompt: "Choose a fixture", answerMode: "single_select", required: true,
options, customAnswer: { enabled: true },
}] } },
}, { agentId: fixture.agentId });
const [publication] = await publicationsForInteraction(fixture.companyId, interaction.id);
const tokens = await db.select().from(chatActions).where(eq(chatActions.endpointId, publication.endpointId));
if (count === 100) {
expect(publication.payload.card?.selection?.options).toHaveLength(count);
expect(publication.payload.card?.actions).toEqual([
expect.objectContaining({ type: "callback", label: "Other…" }),
]);
expect(tokens.filter(token => token.kind === "question_answer")).toHaveLength(count);
} else {
expect(publication.payload.card?.selection).toBeUndefined();
expect(publication.payload.card?.actions).toEqual([
{ type: "link", label: "Open in Paperclip", url: expect.stringContaining(fixture.issueId) },
]);
expect(tokens).toEqual([]);
await markInteractionCardsPublished(fixture.companyId, interaction.id);
await service.answerQuestions({ id: fixture.issueId, companyId: fixture.companyId }, interaction.id, {
answers: [{ questionId: "choice", optionIds: [options[count - 1]!.id] }],
}, { userId: "board-user" });
const settled = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(settled.find(row => row.id !== publication.id)?.payload.card?.body).toContain(`Answered: Choice ${count - 1}`);
}
});
it("keeps repeated choices and help under each question", async () => {
const fixture = await seedBoundIssue(["slack"]);
const interaction = await issueThreadInteractionService(db).create(
{ id: fixture.issueId, companyId: fixture.companyId },
{ kind: "ask_user_questions", payload: { version: 1, questions: ["Codie", "Claude"].map(agent => ({ id: agent, prompt: `Convert ${agent}?`, helpText: "Keep the existing configuration.", selectionMode: "single" as const, options: [{ id: "yes", label: "Yes" }, { id: "no", label: "No" }] })) } },
{ agentId: fixture.agentId },
);
const [publication] = await publicationsForInteraction(fixture.companyId, interaction.id);
const body = publication.payload.card!.body!;
expect(body.match(/- Yes/g)).toHaveLength(2);
expect(body.match(/- No/g)).toHaveLength(2);
expect(body.match(/Keep the existing configuration/g)).toHaveLength(2);
expect(body.slice(body.indexOf("Convert Claude?"))).toContain("- Yes");
});
it("publishes prompts only for the endpoint's verified immutable agent", async () => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
const foreignAgentId = randomUUID();
await db.insert(agents).values({
id: foreignAgentId,
companyId: fixture.companyId,
name: "Foreign agent",
role: "operator",
status: "idle",
adapterType: "paperclip_runner",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
const foreignRunId = randomUUID();
await db.insert(heartbeatRuns).values({
id: foreignRunId,
companyId: fixture.companyId,
agentId: foreignAgentId,
status: "succeeded",
});
const question = {
kind: "ask_user_questions" as const,
payload: {
version: 1 as const,
questions: [
{
id: "priority",
prompt: "Which priority?",
selectionMode: "single" as const,
allowOther: false,
options: [{ id: "high", label: "High" }],
},
],
},
};
const userAuthored = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
question,
{ userId: "board-user" },
);
const foreignAuthored = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
question,
{ agentId: foreignAgentId },
);
const mismatchedRun = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{ ...question, sourceRunId: foreignRunId },
{ agentId: foreignAgentId },
);
await expect(
publicationsForInteraction(fixture.companyId, userAuthored.id),
).resolves.toEqual([]);
await expect(
publicationsForInteraction(fixture.companyId, foreignAuthored.id),
).resolves.toEqual([]);
await expect(
publicationsForInteraction(fixture.companyId, mismatchedRun.id),
).resolves.toEqual([]);
const assignedAgent = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
question,
{ agentId: fixture.agentId },
);
const publications = await publicationsForInteraction(
fixture.companyId,
assignedAgent.id,
);
expect(publications).toHaveLength(2);
expect(
publications.every(
(row) =>
row.idempotencyKey ===
`interaction:${assignedAgent.id}:${row.endpointId}`,
),
).toBe(true);
});
it("rolls back an interaction publication if its callback tokens cannot be stored", async () => {
const fixture = await seedBoundIssue(["telegram"]);
const interaction = await issueThreadInteractionService(db).create(
{ id: fixture.issueId, companyId: fixture.companyId },
{ kind: "request_confirmation", continuationPolicy: "wake_assignee", payload: { version: 1, prompt: "Proceed?" } },
{ agentId: fixture.agentId },
);
expect(await publicationsForInteraction(fixture.companyId, interaction.id)).toHaveLength(1);
await db.delete(chatActions).where(eq(chatActions.companyId, fixture.companyId));
await db.delete(chatPublications).where(eq(chatPublications.companyId, fixture.companyId));
const failTokens = (insert: typeof db.insert): typeof db.insert =>
((table: Parameters<typeof db.insert>[0]) => {
if (table === chatActions) throw new Error("Token write failed");
return insert(table);
}) as typeof db.insert;
const failingDb: Parameters<typeof enqueueIssueInteractionChatPublications>[0] = {
select: db.select.bind(db),
update: db.update.bind(db),
insert: failTokens(db.insert.bind(db)),
transaction: callback => db.transaction(async tx => {
tx.insert = failTokens(tx.insert.bind(tx));
return callback(tx);
}),
};
await expect(enqueueIssueInteractionChatPublications(failingDb, interaction))
.rejects.toThrow("Token write failed");
expect(await publicationsForInteraction(fixture.companyId, interaction.id)).toEqual([]);
});
it("delivers native checkbox reviews in Slack", async () => {
const fixture = await seedBoundIssue();
const interaction = await issueThreadInteractionService(db).create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "request_checkbox_confirmation",
payload: {
version: 1,
prompt: "Choose deployment regions",
options: [{ id: "us", label: "US" }],
},
},
{ agentId: fixture.agentId },
);
const publications = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(publications).toHaveLength(1);
expect(publications[0].endpointId).toBe(fixture.endpointIds.slack);
expect(publications[0].payload.card).toMatchObject({
selection: { multiple: true, options: [{ label: "US" }] },
actions: [{ type: "callback", label: "Reject" }],
});
await markInteractionCardsPublished(fixture.companyId, interaction.id);
await issueThreadInteractionService(db).rejectInteraction(
{ id: fixture.issueId, companyId: fixture.companyId }, interaction.id, {}, { userId: "board-user" },
);
const settled = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(settled).toHaveLength(2);
expect(settled.find(row => row.id !== publications[0].id)?.payload.card?.body).toBe("Rejected");
expect(settled.find(row => row.id !== publications[0].id)?.payload.card?.actions).toBeUndefined();
});
it.each([
{ kind: "suggest_tasks" as const, payload: { version: 1 as const, tasks: [{ clientKey: "docs", title: "Review the migration", description: "Verify preserved configuration" }] }, expected: "Review the migration" },
{ kind: "request_item_verdicts" as const, payload: { version: 1 as const, prompt: "Review each change", items: [{ id: "docs", label: "Migration notes", description: "Verify preserved configuration" }] }, expected: "Migration notes" },
])("delivers and settles $kind reviews in Slack", async ({ kind, payload, expected }) => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
const issue = { id: fixture.issueId, companyId: fixture.companyId };
const interaction = await service.create(issue, { kind, payload } as Parameters<typeof service.create>[1], { agentId: fixture.agentId });
const [original] = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(original?.endpointId).toBe(fixture.endpointIds.slack);
expect(original?.payload.card?.body).toContain(expected);
expect(original?.payload.card?.body).toContain("Verify preserved configuration");
expect(original?.payload.card?.actions).toEqual(kind === "request_item_verdicts" ? [{ type: "callback", actionId: expect.any(String), label: "Review decisions", style: "primary" }] : [{ type: "callback", actionId: expect.any(String), label: "Create task", style: "primary" }, { type: "callback", actionId: expect.any(String), label: "Dismiss", style: "default" }, { type: "link", label: "Revise in Paperclip", url: expect.stringContaining(fixture.issueId) }]);
await markInteractionCardsPublished(fixture.companyId, interaction.id);
if (kind === "request_item_verdicts") {
await service.submitItemVerdicts(issue, interaction.id, { verdicts: [{ id: "docs", verdict: "approve" }] }, { userId: "board-user" });
} else {
await service.rejectInteraction(issue, interaction.id, {}, { userId: "board-user" });
}
const settled = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(settled).toHaveLength(2);
expect(settled.find(row => row.id !== original.id)?.payload.card?.body).toBe(kind === "request_item_verdicts" ? "Migration notes: approve" : "Dismissed — no tasks created.");
expect(settled.find(row => row.id !== original.id)?.payload.card?.actions).toBeUndefined();
});
it("settles an item review superseded by a historical human comment", async () => {
const fixture = await seedBoundIssue(["slack"]);
const service = issueThreadInteractionService(db);
const issue = { id: fixture.issueId, companyId: fixture.companyId };
const interaction = await service.create(issue, { kind: "request_item_verdicts", payload: { version: 1, prompt: "Review these items", supersedeOnUserComment: true, items: [{ id: "docs", label: "Migration notes" }] } }, { agentId: fixture.agentId });
const [original] = await markInteractionCardsPublished(fixture.companyId, interaction.id);
await db.insert(issueComments).values({ companyId: fixture.companyId, issueId: fixture.issueId, authorUserId: "board-user", body: "Use the updated proposal instead.", createdAt: new Date(Date.now() + 1000) });
const expired = await service.expireRequestConfirmationsSupersededByHistoricalComments(issue);
expect(expired).toHaveLength(1);
expect(expired[0].status).toBe("expired");
const publications = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(publications).toHaveLength(2);
expect(publications.find(row => row.id !== original.id)?.payload.card?.body).toBe("Expired in Paperclip");
});
it.each(["decline", "reassign"] as const)("delivers, deduplicates and settles an addressed connection request on %s without a second wake owner", async outcome => {
const fixture = await seedBoundIssue();
const sourceRunId = randomUUID();
await db.insert(heartbeatRuns).values({ id: sourceRunId, companyId: fixture.companyId, agentId: fixture.agentId, status: "running", responsibleUserId: "board-user", contextSnapshot: { issueId: fixture.issueId } });
const service = issueThreadInteractionService(db);
const issue = { id: fixture.issueId, companyId: fixture.companyId };
const input = { sourceRunId, addresseeUserId: "board-user", idempotencyKey: "connection-slack", payload: { version: 1 as const, serviceSlug: "notion", serviceName: "Notion", requestingAgentId: fixture.agentId, requestingAgentName: "Codie", phase: "requested" as const } };
const interaction = await service.createConnectionIntent(issue, input);
await service.createConnectionIntent(issue, input);
const [original] = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(original?.payload.card?.body).toContain("Codie needs Notion");
expect(original?.payload.card?.actions).toEqual([{ type: "link", label: "Review connection", url: expect.stringContaining(fixture.issueId) }, { type: "callback", actionId: expect.any(String), label: "Not now", style: "default" }]);
expect(await publicationsForInteraction(fixture.companyId, interaction.id)).toHaveLength(1);
await markInteractionCardsPublished(fixture.companyId, interaction.id);
await expect(service.resolveConnectionIntent(issue, interaction.id, { version: 1, outcome: "declined" }, { userId: "different-user" })).rejects.toMatchObject({ status: 403 });
if (outcome === "decline") await service.resolveConnectionIntent(issue, interaction.id, { version: 1, outcome: "declined" }, { userId: "board-user" });
else await db.transaction(async tx => issueThreadInteractionService(tx as unknown as typeof db).expireConnectionIntentsForOwnershipChange(issue));
const settled = await publicationsForInteraction(fixture.companyId, interaction.id);
expect(settled).toHaveLength(2);
expect(settled.find(row => row.id !== original.id)?.payload.card?.body).toBe(outcome === "decline" ? "Declined: Notion. No access was granted." : "Expired in Paperclip");
expect((await db.select().from(chatActions).where(eq(chatActions.companyId, fixture.companyId))).filter(action => action.kind === "interaction_wakeup")).toEqual([]);
});
it("settles a card when the dispatcher claims it between the read and cancellation CAS", async () => {
const fixture = await seedBoundIssue(["slack"]);
const service = issueThreadInteractionService(db);
const interaction = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "priority",
prompt: "Which priority?",
selectionMode: "single",
allowOther: false,
options: [{ id: "high", label: "High" }],
},
],
},
},
{ agentId: fixture.agentId },
);
const [original] = await publicationsForInteraction(
fixture.companyId,
interaction.id,
);
if (!original) throw new Error("Expected an original publication");
await db
.update(issueThreadInteractions)
.set({
status: "answered",
result: {
version: 1,
answers: [{ questionId: "priority", optionIds: ["high"] }],
summaryMarkdown: null,
},
resolvedByUserId: "board-user",
resolvedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(issueThreadInteractions.id, interaction.id));
const answered = await service.getById(interaction.id);
if (!answered) throw new Error("Expected an answered interaction");
const raceDb = interceptAfterFirstSelect(async () => {
const claimed = await db
.update(chatPublications)
.set({ state: "streaming", updatedAt: new Date() })
.where(
and(
eq(chatPublications.id, original.id),
eq(chatPublications.state, "pending"),
),
)
.returning({ id: chatPublications.id });
expect(claimed).toEqual([{ id: original.id }]);
});
await enqueueTerminalIssueInteractionChatPublications(raceDb, answered);
await expect(
db
.select({ state: chatPublications.state })
.from(chatPublications)
.where(eq(chatPublications.id, original.id)),
).resolves.toEqual([{ state: "streaming" }]);
const publications = await publicationsForInteraction(
fixture.companyId,
interaction.id,
);
expect(publications).toEqual(
expect.arrayContaining([
expect.objectContaining({
idempotencyKey: `interaction-resolution:${interaction.id}:${fixture.endpointIds.slack}`,
state: "pending",
payload: expect.objectContaining({ text: "Answered: High." }),
}),
]),
);
});
it("settles an ambiguously delivered link-only card after an operator marks it delivered", async () => {
const fixture = await seedBoundIssue(["github"]);
const service = issueThreadInteractionService(db);
const interaction = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: { version: 1, prompt: "Ship the GitHub release?" },
},
{ agentId: fixture.agentId },
);
const [original] = await publicationsForInteraction(
fixture.companyId,
interaction.id,
);
if (!original) throw new Error("Expected a GitHub link-only card");
await db
.update(chatPublications)
.set({ state: "published", providerMessageId: null })
.where(eq(chatPublications.id, original.id));
await service.acceptInteraction(
{
id: fixture.issueId,
companyId: fixture.companyId,
projectId: null,
goalId: null,
},
interaction.id,
{},
{ userId: "board-user" },
);
await expect(
db
.select({
idempotencyKey: chatPublications.idempotencyKey,
payload: chatPublications.payload,
state: chatPublications.state,
})
.from(chatPublications)
.where(
eq(
chatPublications.idempotencyKey,
`interaction-resolution:${interaction.id}:${fixture.endpointIds.github}`,
),
),
).resolves.toEqual([
{
idempotencyKey: `interaction-resolution:${interaction.id}:${fixture.endpointIds.github}`,
payload: expect.objectContaining({
interactionId: interaction.id,
text: "Accepted.",
}),
state: "pending",
},
]);
});
it("retains question and answer context while redacting credentials in settled form cards", async () => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
const interaction = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "ask_user_questions",
payload: {
version: 1,
questions: [
{
id: "choice",
prompt: "Choose a tree",
selectionMode: "single",
allowOther: false,
options: [{ id: "cedar", label: "Cedar" }],
},
{
id: "label",
prompt: "Enter a label",
selectionMode: "single",
allowOther: true,
options: [
{ id: "text", label: "Write a label", freeText: true },
],
},
],
},
},
{ agentId: fixture.agentId },
);
await markInteractionCardsPublished(fixture.companyId, interaction.id);
await service.answerQuestions(
{ id: fixture.issueId, companyId: fixture.companyId },
interaction.id,
{
answers: [
{ questionId: "choice", optionIds: ["cedar"] },
{
questionId: "label",
optionIds: [],
otherText: "Runner conversion failed. password=do-not-publish-this",
},
],
},
{ userId: "board-user" },
);
const settlements = (
await publicationsForInteraction(fixture.companyId, interaction.id)
).filter((row) => row.state === "pending");
expect(settlements).toHaveLength(2);
for (const settlement of settlements) {
expect(settlement.payload).toMatchObject({
text: expect.stringContaining("Enter a label\n\nAnswered: Runner conversion failed."),
card: { kind: "question", body: expect.stringContaining("Choose a tree\n\nAnswered: Cedar") },
});
expect(settlement.payload.card?.actions ?? []).toHaveLength(0);
expect(JSON.stringify(settlement.payload)).not.toContain(
"do-not-publish-this",
);
expect(JSON.stringify(settlement.payload)).not.toContain(
"Answered in Paperclip",
);
}
});
it("settles UI answers and skips on every provider-native card with no live actions", async () => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
const questionInput = {
kind: "ask_user_questions" as const,
payload: {
version: 1 as const,
questions: [
{
id: "priority",
prompt: "Which priority?",
selectionMode: "single" as const,
allowOther: false,
options: [
{ id: "high", label: "High" },
{ id: "normal", label: "Normal" },
],
},
],
},
};
const answeredQuestion = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
questionInput,
{ agentId: fixture.agentId },
);
expect(
await markInteractionCardsPublished(
fixture.companyId,
answeredQuestion.id,
),
).toHaveLength(2);
const answered = await service.answerQuestions(
{ id: fixture.issueId, companyId: fixture.companyId },
answeredQuestion.id,
{ answers: [{ questionId: "priority", optionIds: ["high"] }] },
{ userId: "board-user" },
);
const answeredSettlements = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, fixture.companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
answeredQuestion.id,
),
eq(chatPublications.state, "pending"),
),
);
expect(answeredSettlements).toHaveLength(2);
expect(answeredSettlements).toEqual(
expect.arrayContaining(
Object.values(fixture.endpointIds).map((endpointId) =>
expect.objectContaining({
endpointId,
idempotencyKey: `interaction-resolution:${answeredQuestion.id}:${endpointId}`,
payload: expect.objectContaining({
text: "Answered: High.",
card: expect.objectContaining({
kind: "question",
title: "Which priority?",
body: "Answered: High.",
}),
}),
}),
),
),
);
expect(
answeredSettlements.every(
(row) => (row.payload.card?.actions ?? []).length === 0,
),
).toBe(true);
await expect(
enqueueTerminalIssueInteractionChatPublications(db, answered),
).resolves.toEqual([]);
await expect(
db
.select({ status: chatActions.status })
.from(chatActions)
.where(
eq(
sql<string>`${chatActions.payload}->>'interactionId'`,
answeredQuestion.id,
),
),
).resolves.toEqual([
{ status: "expired" },
{ status: "expired" },
{ status: "expired" },
{ status: "expired" },
]);
const skippedQuestion = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
questionInput,
{ agentId: fixture.agentId },
);
await markInteractionCardsPublished(
fixture.companyId,
skippedQuestion.id,
);
await service.skipInteraction(
{
id: fixture.issueId,
companyId: fixture.companyId,
status: "in_progress",
},
skippedQuestion.id,
{},
{ userId: "board-user" },
);
const skippedSettlements = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, fixture.companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
skippedQuestion.id,
),
eq(chatPublications.state, "pending"),
),
);
expect(skippedSettlements).toHaveLength(2);
expect(
skippedSettlements.every(
(row) => row.payload.card?.body === "Skipped in Paperclip.",
),
).toBe(true);
expect(
skippedSettlements.every(
(row) => (row.payload.card?.actions ?? []).length === 0,
),
).toBe(true);
await expect(
db
.select({ id: chatActions.id })
.from(chatActions)
.where(
and(
eq(chatActions.kind, "interaction_wakeup"),
eq(
sql<string>`${chatActions.payload}->>'interactionId'`,
skippedQuestion.id,
),
),
),
).resolves.toEqual([]);
});
it("settles native and link-only confirmation cards without inventing controls", async () => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
for (const outcome of ["accept", "reject"] as const) {
const interaction = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: { version: 1, prompt: `Should we ${outcome}?` },
},
{ agentId: fixture.agentId },
);
const originals = await markInteractionCardsPublished(
fixture.companyId,
interaction.id,
);
expect(originals).toHaveLength(2);
expect(
originals.find((row) => row.endpointId === fixture.endpointIds.slack)
?.payload.card?.actions,
).toEqual([
expect.objectContaining({ type: "callback", label: "Accept" }),
expect.objectContaining({ type: "callback", label: "Reject" }),
]);
expect(
originals.find(
(row) => row.endpointId === fixture.endpointIds.telegram,
)?.payload.card?.actions,
).toEqual([
expect.objectContaining({ type: "callback" }),
expect.objectContaining({ type: "callback" }),
]);
if (outcome === "accept") {
await service.acceptInteraction(
{
id: fixture.issueId,
companyId: fixture.companyId,
projectId: null,
goalId: null,
},
interaction.id,
{},
{ userId: "board-user" },
);
} else {
await service.rejectInteraction(
{ id: fixture.issueId, companyId: fixture.companyId },
interaction.id,
{},
{ userId: "board-user" },
);
}
const settlements = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, fixture.companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
interaction.id,
),
eq(chatPublications.state, "pending"),
),
);
expect(settlements).toHaveLength(2);
expect(settlements).toEqual(
expect.arrayContaining(
Object.values(fixture.endpointIds).map((endpointId) =>
expect.objectContaining({
endpointId,
idempotencyKey: `interaction-resolution:${interaction.id}:${endpointId}`,
payload: expect.objectContaining({
text: outcome === "accept" ? "Accepted." : "Rejected.",
card: expect.objectContaining({
kind: "confirmation",
title: `Should we ${outcome}?`,
body: outcome === "accept" ? "Accepted" : "Rejected",
}),
}),
}),
),
),
);
expect(
settlements.every(
(settlement) =>
(settlement.payload.card?.actions ?? []).length === 0,
),
).toBe(true);
await expect(
db
.select({
kind: chatActions.kind,
payload: chatActions.payload,
status: chatActions.status,
})
.from(chatActions)
.where(
and(
eq(chatActions.kind, "interaction_wakeup"),
eq(
sql<string>`${chatActions.payload}->>'interactionId'`,
interaction.id,
),
),
),
).resolves.toEqual([
{
kind: "interaction_wakeup",
payload: expect.objectContaining({
agentId: fixture.agentId,
interactionId: interaction.id,
interactionStatus: outcome === "accept" ? "accepted" : "rejected",
issueId: fixture.issueId,
requestedByActorId: "board-user",
requestedByActorType: "user",
}),
status: "issued",
},
]);
}
});
it("expires both modal action tokens when a form-backed question is answered", async () => {
const fixture = await seedBoundIssue(["slack"]);
const service = issueThreadInteractionService(db);
const interaction = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "priority",
prompt: "Which priority?",
selectionMode: "single",
allowOther: false,
options: [{ id: "high", label: "High" }],
},
{
id: "owner",
prompt: "Who owns it?",
selectionMode: "single",
allowOther: false,
options: [{ id: "agent", label: "Agent" }],
},
],
},
},
{ agentId: fixture.agentId },
);
await markInteractionCardsPublished(fixture.companyId, interaction.id);
await service.answerQuestions(
{ id: fixture.issueId, companyId: fixture.companyId },
interaction.id,
{
answers: [
{ questionId: "priority", optionIds: ["high"] },
{ questionId: "owner", optionIds: ["agent"] },
],
},
{ userId: "board-user" },
);
const actions = await db
.select({ kind: chatActions.kind, status: chatActions.status })
.from(chatActions)
.where(
eq(
sql<string>`${chatActions.payload}->>'interactionId'`,
interaction.id,
),
);
expect(actions).toEqual(
expect.arrayContaining([
{ kind: "question_form_open", status: "expired" },
{ kind: "question_form_submit", status: "expired" },
]),
);
expect(actions).toHaveLength(2);
});
it("cancels unsent native and link-only cards instead of publishing stale prompts", async () => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
const interaction = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "request_confirmation",
payload: { version: 1, prompt: "Proceed?" },
},
{ agentId: fixture.agentId },
);
await service.acceptInteraction(
{
id: fixture.issueId,
companyId: fixture.companyId,
projectId: null,
goalId: null,
},
interaction.id,
{},
{ userId: "board-user" },
);
const publications = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, fixture.companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
interaction.id,
),
),
);
expect(publications).toHaveLength(2);
expect(publications).toEqual(
expect.arrayContaining(
Object.values(fixture.endpointIds).map((endpointId) =>
expect.objectContaining({
endpointId,
idempotencyKey: `interaction:${interaction.id}:${endpointId}`,
state: "cancelled",
redactedError:
"Interaction was resolved before provider publication",
}),
),
),
);
expect(
publications.some((row) =>
row.payload.card?.actions?.some((action) => action.type === "callback"),
),
).toBe(true);
});
it("settles visible question cards when a newer interaction supersedes them", async () => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
const input = {
kind: "ask_user_questions" as const,
payload: {
version: 1 as const,
questions: [
{
id: "priority",
prompt: "Which priority?",
selectionMode: "single" as const,
allowOther: false,
options: [{ id: "high", label: "High" }],
},
],
},
};
const original = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
input,
{ agentId: fixture.agentId },
);
await markInteractionCardsPublished(fixture.companyId, original.id);
const replacement = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
input,
{ agentId: fixture.agentId },
);
await expect(service.getById(original.id)).resolves.toMatchObject({
status: "expired",
result: {
expirationReason: "superseded_by_newer_interaction",
supersededByInteractionId: replacement.id,
},
});
const publications = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, fixture.companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
original.id,
),
),
);
const settlements = publications.filter((row) =>
row.idempotencyKey.startsWith("interaction-resolution:"),
);
expect(settlements).toHaveLength(2);
expect(
settlements.every(
(row) =>
row.state === "pending" &&
row.payload.text === "Expired: replaced by a newer request." &&
row.payload.card?.body === "Expired: replaced by a newer request" &&
(row.payload.card.actions ?? []).length === 0,
),
).toBe(true);
});
it("settles live and historical human-comment supersessions", async () => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
const confirmation = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the release?",
supersedeOnUserComment: true,
},
},
{ agentId: fixture.agentId },
);
await markInteractionCardsPublished(fixture.companyId, confirmation.id);
const liveCommentId = randomUUID();
await service.expireRequestConfirmationsSupersededByComment(
{ id: fixture.issueId, companyId: fixture.companyId },
{
id: liveCommentId,
authorUserId: "board-user",
createdByRunId: null,
createdAt: new Date(Date.now() + 1_000),
},
{ userId: "board-user" },
);
const liveSettlements = await db
.select()
.from(chatPublications)
.where(
eq(
chatPublications.idempotencyKey,
`interaction-resolution:${confirmation.id}:${fixture.endpointIds.telegram}`,
),
);
expect(liveSettlements).toEqual([
expect.objectContaining({
payload: expect.objectContaining({
text: "Expired: superseded by a newer reply.",
card: expect.objectContaining({
body: "Expired: superseded by a newer reply",
}),
}),
}),
]);
expect(liveSettlements[0]?.payload.card?.actions ?? []).toHaveLength(0);
const question = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "ask_user_questions",
payload: {
version: 1,
supersedeOnUserComment: true,
questions: [
{
id: "priority",
prompt: "Which priority?",
selectionMode: "single",
allowOther: false,
options: [{ id: "high", label: "High" }],
},
],
},
},
{ agentId: fixture.agentId },
);
await markInteractionCardsPublished(fixture.companyId, question.id);
const questionCreatedAt = new Date("2026-09-05T12:00:00.000Z");
await db
.update(issueThreadInteractions)
.set({ createdAt: questionCreatedAt, updatedAt: questionCreatedAt })
.where(eq(issueThreadInteractions.id, question.id));
await db.insert(issueComments).values({
id: randomUUID(),
companyId: fixture.companyId,
issueId: fixture.issueId,
authorUserId: "board-user",
authorType: "user",
body: "Use the high-priority path.",
createdAt: new Date("2026-09-05T12:01:00.000Z"),
updatedAt: new Date("2026-09-05T12:01:00.000Z"),
});
await service.expireRequestConfirmationsSupersededByHistoricalComments({
id: fixture.issueId,
companyId: fixture.companyId,
});
const historicalSettlements = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, fixture.companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
question.id,
),
eq(chatPublications.state, "pending"),
),
);
expect(historicalSettlements).toHaveLength(2);
expect(
historicalSettlements.every(
(row) =>
row.payload.card?.body === "Expired: superseded by a newer reply" &&
(row.payload.card.actions ?? []).length === 0,
),
).toBe(true);
});
it("cancels stale-target prompts and settles terminal-issue expirations", async () => {
const fixture = await seedBoundIssue();
const service = issueThreadInteractionService(db);
const documentId = randomUUID();
const revisionId = randomUUID();
const nextRevisionId = randomUUID();
await db.insert(documents).values({
id: documentId,
companyId: fixture.companyId,
title: "Plan",
format: "markdown",
latestBody: "v1",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
companyId: fixture.companyId,
issueId: fixture.issueId,
documentId,
key: "plan",
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId: fixture.companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "v1",
});
const confirmation = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Apply this plan?",
target: {
type: "issue_document",
issueId: fixture.issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
},
{ agentId: fixture.agentId },
);
await service.expireStaleRequestConfirmationsForIssueDocument(
{ id: fixture.issueId, companyId: fixture.companyId },
{
id: documentId,
key: "plan",
latestRevisionId: nextRevisionId,
latestRevisionNumber: 2,
},
{ userId: "board-user" },
);
const stalePublications = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, fixture.companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
confirmation.id,
),
),
);
expect(stalePublications).toHaveLength(2);
expect(
stalePublications.every(
(row) =>
row.state === "cancelled" &&
row.redactedError ===
"Interaction was resolved before provider publication",
),
).toBe(true);
const question = await service.create(
{ id: fixture.issueId, companyId: fixture.companyId },
{
kind: "ask_user_questions",
payload: {
version: 1,
questions: [
{
id: "priority",
prompt: "Which priority?",
selectionMode: "single",
allowOther: false,
options: [{ id: "high", label: "High" }],
},
],
},
},
{ agentId: fixture.agentId },
);
await markInteractionCardsPublished(fixture.companyId, question.id);
await service.expirePendingInteractionsForTerminalIssue(
{
id: fixture.issueId,
companyId: fixture.companyId,
status: "done",
},
{ userId: "board-user" },
);
const closedSettlements = await db
.select()
.from(chatPublications)
.where(
and(
eq(chatPublications.companyId, fixture.companyId),
eq(
sql<string>`${chatPublications.payload}->>'interactionId'`,
question.id,
),
eq(chatPublications.state, "pending"),
),
);
expect(closedSettlements).toHaveLength(2);
expect(
closedSettlements.every(
(row) =>
row.payload.text === "Expired: task is closed." &&
row.payload.card?.body === "Expired: task is closed" &&
(row.payload.card.actions ?? []).length === 0,
),
).toBe(true);
});
},
);