Files
paperclip/server/src/__tests__/issue-thread-interactions-service.test.ts
T
Dotta c4269bab59 Add workflow interaction cancellation and issue cost summaries (#4862)
## Thinking Path

> - Paperclip coordinates work through issue-thread interactions, run
history, and cost telemetry.
> - Operators need workflow prompts to be cancellable and costs to be
visible at the issue level.
> - The earlier rollup mixed this workflow/cost work with database
backups, reliability recovery, thread scaling, and settings polish.
> - This pull request isolates the interaction and cost surfaces into a
reviewable slice.
> - The backend now supports cancelling pending question interactions
and summarizing issue-tree costs.
> - The UI component layer can render cancelled questions and interleave
activity with run ledger rows.

## What Changed

- Added `cancelled` as an issue-thread interaction status and result
shape for question interactions.
- Added the board-only `POST
/issues/:id/interactions/:interactionId/cancel` route and service
implementation.
- Added issue-tree cost summary support in the cost service and
`/issues/:id/cost-summary` API route.
- Extended shared cost exports and UI API/query keys for issue cost
summaries.
- Updated `IssueThreadInteractionCard` and `IssueRunLedger` components
for cancelled questions, issue cost surfaces, and activity/run
interleaving.
- Added focused server and component regression coverage.

## Verification

- `pnpm install --frozen-lockfile`
- `pnpm exec vitest run server/src/__tests__/costs-service.test.ts
server/src/__tests__/issue-thread-interaction-routes.test.ts
server/src/__tests__/issue-thread-interactions-service.test.ts
ui/src/components/IssueRunLedger.test.tsx`
- Result: 4 test files passed, 45 tests passed.
- UI screenshots not included because this PR updates reusable
components and API surfaces without wiring a new page-level layout.

## Risks

- Adds a new interaction terminal status; clients that switch
exhaustively on interaction status may need to handle `cancelled`.
- Issue-tree cost summaries use recursive issue traversal and should be
watched on unusually large issue trees.
- Page-level issue detail wiring is intentionally left to the board
QoL/issue-detail branch to keep this PR narrow.

> 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

- OpenAI Codex, GPT-5.5, code execution and GitHub CLI tool use, medium
reasoning effort.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-04-30 13:57:25 -05:00

961 lines
26 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import {
agents,
companies,
createDb,
documentRevisions,
documents,
goals,
heartbeatRuns,
issueDocuments,
instanceSettings,
issueRelations,
issueThreadInteractions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { issueService } from "../services/issues.js";
import { issueThreadInteractionService } from "../services/issue-thread-interactions.js";
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
describeEmbeddedPostgres("issueThreadInteractionService", () => {
let db!: ReturnType<typeof createDb>;
let issuesSvc!: ReturnType<typeof issueService>;
let interactionsSvc!: ReturnType<typeof issueThreadInteractionService>;
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
beforeAll(async () => {
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-issue-thread-interactions-");
db = createDb(tempDb.connectionString);
issuesSvc = issueService(db);
interactionsSvc = issueThreadInteractionService(db);
}, 20_000);
afterEach(async () => {
await db.delete(issueThreadInteractions);
await db.delete(issueDocuments);
await db.delete(documentRevisions);
await db.delete(documents);
await db.delete(issueRelations);
await db.delete(heartbeatRuns);
await db.delete(issues);
await db.delete(goals);
await db.delete(agents);
await db.delete(instanceSettings);
await db.delete(companies);
});
afterAll(async () => {
await tempDb?.cleanup();
});
it("accepts suggested tasks by creating a rooted issue tree under the current issue", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const assigneeAgentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Persist thread interactions",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: assigneeAgentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: 2,
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
assigneeAgentId,
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
],
},
}, {
userId: "local-board",
});
expect(created.status).toBe("pending");
const accepted = await interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.interaction.kind).toBe("suggest_tasks");
expect(accepted.interaction.status).toBe("accepted");
expect(accepted.interaction.result).toMatchObject({
version: 1,
createdTasks: [
expect.objectContaining({ clientKey: "root", parentIssueId: issueId }),
expect.objectContaining({ clientKey: "child" }),
],
});
expect(accepted.createdIssues).toEqual([
expect.objectContaining({
assigneeAgentId,
status: "todo",
}),
expect.objectContaining({
assigneeAgentId: null,
status: "todo",
}),
]);
const children = await issuesSvc.list(companyId, { parentId: issueId });
expect(children).toHaveLength(1);
expect(children[0]?.title).toBe("Create the root follow-up");
const nestedChildren = await issuesSvc.list(companyId, { parentId: children[0]!.id });
expect(nestedChildren).toHaveLength(1);
expect(nestedChildren[0]?.title).toBe("Create the nested follow-up");
expect(nestedChildren[0]?.requestDepth).toBe(4);
const listed = await interactionsSvc.listForIssue(issueId);
expect(listed).toHaveLength(1);
expect(listed[0]?.status).toBe("accepted");
await expect(interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
})).rejects.toThrow("Interaction has already been resolved");
const childrenAfterDuplicateAccept = await issuesSvc.list(companyId, { parentId: issueId });
expect(childrenAfterDuplicateAccept).toHaveLength(1);
});
it("accepts a selected subset of suggested tasks and records the skipped drafts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Selectively persist thread interactions",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
requestDepth: 2,
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
{
clientKey: "sibling",
title: "Create the sibling follow-up",
},
],
},
}, {
userId: "local-board",
});
const accepted = await interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedClientKeys: ["root"],
}, {
userId: "local-board",
});
expect(accepted.interaction.result).toMatchObject({
version: 1,
createdTasks: [
expect.objectContaining({ clientKey: "root", parentIssueId: issueId }),
],
skippedClientKeys: ["child", "sibling"],
});
const children = await issuesSvc.list(companyId, { parentId: issueId });
expect(children).toHaveLength(1);
expect(children[0]?.title).toBe("Create the root follow-up");
});
it("rejects partial acceptance when a selected task omits its selected-tree parent", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Validate selective acceptance",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "suggest_tasks",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
tasks: [
{
clientKey: "root",
title: "Create the root follow-up",
},
{
clientKey: "child",
parentClientKey: "root",
title: "Create the nested follow-up",
},
],
},
}, {
userId: "local-board",
});
await expect(
interactionsSvc.acceptSuggestedTasks({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {
selectedClientKeys: ["child"],
}, {
userId: "local-board",
}),
).rejects.toThrow("requires its parent");
});
it("persists validated answers for ask_user_questions interactions", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Persist question answers",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Question parent",
status: "todo",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
required: true,
options: [
{ id: "phase-1", label: "Phase 1" },
{ id: "phase-2", label: "Phase 2" },
],
},
{
id: "extras",
prompt: "Optional extras",
selectionMode: "multi",
options: [
{ id: "tests", label: "Tests" },
{ id: "docs", label: "Docs" },
],
},
],
},
}, {
userId: "local-board",
});
const answered = await interactionsSvc.answerQuestions({
id: issueId,
companyId,
}, created.id, {
answers: [
{ questionId: "scope", optionIds: ["phase-1"] },
{ questionId: "extras", optionIds: ["docs", "tests", "docs"] },
],
summaryMarkdown: "Ship Phase 1 with tests and docs.",
}, {
userId: "local-board",
});
expect(answered.status).toBe("answered");
expect(answered.result).toEqual({
version: 1,
answers: [
{ questionId: "scope", optionIds: ["phase-1"] },
{ questionId: "extras", optionIds: ["docs", "tests"] },
],
summaryMarkdown: "Ship Phase 1 with tests and docs.",
});
await expect(interactionsSvc.answerQuestions({
id: issueId,
companyId,
}, created.id, {
answers: [
{ questionId: "scope", optionIds: ["phase-2"] },
],
}, {
userId: "local-board",
})).rejects.toThrow("Interaction has already been resolved");
});
it("persists cancelled ask_user_questions interactions without answer data", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Cancel question answers",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Question parent",
status: "in_review",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "ask_user_questions",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [{
id: "scope",
prompt: "Choose the scope",
selectionMode: "single",
required: true,
options: [
{ id: "phase-1", label: "Phase 1" },
{ id: "phase-2", label: "Phase 2" },
],
}],
},
}, {
userId: "local-board",
});
const cancelled = await interactionsSvc.cancelQuestions({
id: issueId,
companyId,
}, created.id, {
reason: "Not needed anymore",
}, {
userId: "local-board",
});
expect(cancelled.status).toBe("cancelled");
expect(cancelled.result).toEqual({
version: 1,
answers: [],
cancelled: true,
cancellationReason: "Not needed anymore",
summaryMarkdown: null,
});
await expect(interactionsSvc.answerQuestions({
id: issueId,
companyId,
}, created.id, {
answers: [{ questionId: "scope", optionIds: ["phase-1"] }],
}, {
userId: "local-board",
})).rejects.toThrow("Interaction has already been resolved");
});
it("reuses the existing interaction when the same idempotency key is submitted twice", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
const runId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Interaction dedupe",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "CodexCoder",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(heartbeatRuns).values({
id: runId,
companyId,
agentId,
invocationSource: "manual",
status: "running",
startedAt: new Date("2026-04-20T12:00:00.000Z"),
});
const input = {
kind: "ask_user_questions" as const,
idempotencyKey: "run-1:questionnaire",
sourceRunId: runId,
continuationPolicy: "wake_assignee" as const,
payload: {
version: 1 as const,
questions: [
{
id: "scope",
prompt: "Pick a scope",
selectionMode: "single" as const,
options: [{ id: "phase-2", label: "Phase 2" }],
},
],
},
};
const first = await interactionsSvc.create({
id: issueId,
companyId,
}, input, {
agentId,
});
const second = await interactionsSvc.create({
id: issueId,
companyId,
}, input, {
agentId,
});
expect(second.id).toBe(first.id);
expect(second.sourceRunId).toBe(runId);
const rows = await db.select().from(issueThreadInteractions);
expect(rows).toHaveLength(1);
expect(rows[0]?.idempotencyKey).toBe("run-1:questionnaire");
});
it("accepts request_confirmation interactions without creating child issues", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Confirm a request",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Apply this plan?",
acceptLabel: "Apply",
rejectLabel: "Keep editing",
detailsMarkdown: "Creates follow-up work after acceptance.",
},
}, {
userId: "local-board",
});
expect(created.kind).toBe("request_confirmation");
expect(created.status).toBe("pending");
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.createdIssues).toEqual([]);
expect(accepted.interaction).toMatchObject({
kind: "request_confirmation",
status: "accepted",
result: {
version: 1,
outcome: "accepted",
},
resolvedByUserId: "local-board",
});
const requiresReason = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Decline only with a reason?",
rejectRequiresReason: true,
},
}, {
userId: "local-board",
});
await expect(interactionsSvc.rejectInteraction({
id: issueId,
companyId,
}, requiresReason.id, {}, {
userId: "local-board",
})).rejects.toThrow("A decline reason is required for this confirmation");
});
it("returns agent-authored request confirmations to the creating agent when a board user accepts", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const agentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Confirm a request",
level: "task",
status: "active",
});
await db.insert(agents).values({
id: agentId,
companyId,
name: "Senior Product Engineer",
role: "engineer",
status: "active",
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: {},
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Review the plan",
status: "in_review",
priority: "medium",
assigneeUserId: "local-board",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee_on_accept",
payload: {
version: 1,
prompt: "Approve this plan?",
acceptLabel: "Approve plan",
rejectLabel: "Ask for changes",
},
}, {
agentId,
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.continuationIssue).toEqual({
id: issueId,
assigneeAgentId: agentId,
assigneeUserId: null,
status: "todo",
});
const updatedIssue = (await db.select().from(issues)).find((issue) => issue.id === issueId);
expect(updatedIssue).toMatchObject({
id: issueId,
status: "todo",
assigneeAgentId: agentId,
assigneeUserId: null,
});
});
it("expires supersedable request confirmations when a user comments", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const commentId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Comment supersede",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
payload: {
version: 1,
prompt: "Proceed with the current draft?",
supersedeOnUserComment: true,
},
}, {
userId: "local-board",
});
const expired = await interactionsSvc.expireRequestConfirmationsSupersededByComment({
id: issueId,
companyId,
}, {
id: commentId,
authorUserId: "local-board",
}, {
userId: "local-board",
});
expect(expired).toHaveLength(1);
expect(expired[0]).toMatchObject({
id: created.id,
status: "expired",
result: {
version: 1,
outcome: "superseded_by_comment",
commentId,
},
resolvedByUserId: "local-board",
});
});
it("expires request confirmations when the watched issue document revision changes", async () => {
const companyId = randomUUID();
const goalId = randomUUID();
const issueId = randomUUID();
const documentId = randomUUID();
const revisionId = randomUUID();
const nextRevisionId = randomUUID();
await db.insert(companies).values({
id: companyId,
name: "Paperclip",
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
requireBoardApprovalForNewAgents: false,
});
await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: false });
await db.insert(goals).values({
id: goalId,
companyId,
title: "Document target confirmation",
level: "task",
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
goalId,
title: "Parent issue",
status: "in_progress",
priority: "medium",
});
await db.insert(documents).values({
id: documentId,
companyId,
title: "Plan",
format: "markdown",
latestBody: "v1",
latestRevisionId: revisionId,
latestRevisionNumber: 1,
});
await db.insert(issueDocuments).values({
companyId,
issueId,
documentId,
key: "plan",
});
await db.insert(documentRevisions).values({
id: revisionId,
companyId,
documentId,
revisionNumber: 1,
title: "Plan",
format: "markdown",
body: "v1",
});
const created = await interactionsSvc.create({
id: issueId,
companyId,
}, {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Apply the plan document?",
target: {
type: "issue_document",
issueId,
documentId,
key: "plan",
revisionId,
revisionNumber: 1,
},
},
}, {
userId: "local-board",
});
await db.insert(documentRevisions).values({
id: nextRevisionId,
companyId,
documentId,
revisionNumber: 2,
title: "Plan",
format: "markdown",
body: "v2",
});
await db.update(documents).set({
latestBody: "v2",
latestRevisionId: nextRevisionId,
latestRevisionNumber: 2,
});
const accepted = await interactionsSvc.acceptInteraction({
id: issueId,
companyId,
goalId,
projectId: null,
}, created.id, {}, {
userId: "local-board",
});
expect(accepted.interaction).toMatchObject({
id: created.id,
status: "expired",
payload: {
target: {
type: "issue_document",
key: "plan",
revisionId: nextRevisionId,
revisionNumber: 2,
},
},
result: {
version: 1,
outcome: "stale_target",
staleTarget: {
type: "issue_document",
key: "plan",
revisionId,
},
},
});
});
});