feat: add persistent agent chats with project handoff and E2E coverage

This commit is contained in:
Dotta 2026-09-11 13:07:13 -05:00
parent 5cb4f061dd
commit 75b9b07077
142 changed files with 100840 additions and 278 deletions

View File

@ -357,3 +357,7 @@ pnpm secrets:migrate-inline-env --apply
```
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
### Persistent agent conversations
Migrations `0271_round_pete_wisdom.sql` and `0272_agent_chat_state_guard.sql` add conversation identity/state and session generation/boundary columns to `issues`, plus idempotent client request IDs and processed session-boundary generations to `issue_comments`. The company/agent/user unique index resolves concurrent first writes to one issue. A check constraint preserves the assigned-agent identity and prevents terminal conversation status. Comment request IDs are unique per issue and user. There is no separate chat/message store. Provider sessions continue to use `agent_task_sessions`; `/new` removes only the matching conversation session, and session writers fence stale generations against the issue row.

View File

@ -160,3 +160,17 @@ Paperclips core identity is a **control plane for autonomous AI companies**,
9. **Thin core, rich edges**
Put optional chat, knowledge, and special surfaces into plugins/extensions rather than bloating the control plane.
### Experimental persistent agent conversations
Agent Chat is an opt-in core task presentation (`enableAgentChat`, off by default). Each person has one persistent task-backed conversation per agent and company, with ordinary company task visibility. The shared task composer, transcript, tools, files, and document panel remain the interaction surface. Agents clarify goals and hand substantial execution to linked, assigned tasks; a reply ends a turn without completing the conversation. `/new` starts fresh provider context in the same conversation while preserving visible history and artifacts. Healthy idle conversations wait for a message and do not count as unfinished execution work. See `doc/plans/2026-09-10-agent-chat.md` for the implementation contract.
### Agent chat project handoff (2026-09-11)
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.

View File

@ -1561,3 +1561,25 @@ Export/import behavior in V1:
- import supports preview (dry-run) before apply
- import preview reports skill-policy and legacy-grant mappings before apply and rejects unknown policy schema versions
- GitHub imports warn on unpinned refs instead of blocking
### Experimental task-backed agent chat (2026-09-10)
`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
`GET /api/companies/:companyId/chats/:agentRef` reads an existing conversation or null. `POST` atomically resolves its issue on first send/upload. Existing issue comment, attachment, document, interaction, and run APIs apply thereafter. User chat comments require an idempotent UUID `clientRequestId`. Conversation delivery preserves comment order through the existing issue execution queue; the durable comment outbox repairs the commit-to-enqueue crash window.
The server owns conversation state: `waiting` plus `in_review` denotes a healthy idle conversation, and `active` denotes an unanswered or executing turn. Successful replies settle a turn; they do not finish the issue. Idle containers are excluded from execution-work counts, ordinary task lists, timer work, and recovery invocations. Failed/unanswered turns retain normal handling. Child completion never wakes or completes the conversation. Search and direct task access preserve history.
Standalone `/new` is an ordered queue command with no model response. It advances a durable session generation and boundary comment, resets only this issue's provider context, and preserves the issue ID and history. Generation checks reject stale context writes and replies. Fresh replay excludes earlier messages and summaries. The shared transcript renders a session divider.
Chat prompts retain agent instructions and tools while directing clarification and task creation. Substantial execution belongs to linked, assigned ordinary issues. Ask mode remains non-mutating. Feature disablement prevents new turns and resets while retaining data and lifecycle protection; already-running turns may settle normally.
### Agent chat project handoff (2026-09-11)
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.

View File

@ -272,6 +272,8 @@ All agent communication flows through the **task system**.
There is no separate messaging or chat system. Tasks are the communication channel. This keeps all context attached to the work it relates to and creates a natural audit trail.
Experimental Agent Chat presents one persistent task per person and agent as a simplified conversation. It retains the task composer, transcript, tools, attachments, documents, and existing Subtasks panel, with ordinary company visibility. New execution tasks are ordinary project tasks, not children of the conversation. Idle conversations wait for a message without entering execution-task work queues. Agents clarify goals here and create assigned tasks for substantial execution. `/new` resets provider context at an ordered session boundary within the same task while preserving visible history. `enableAgentChat` is disabled by default; the V1 lifecycle and rollout contract is specified in `SPEC-implementation.md`.
### Implications
- An agent's "inbox" is: tasks assigned to them + comments on tasks they're involved in
@ -543,3 +545,13 @@ Things Paperclip explicitly does **not** do:
7. **Atomic ownership.** Single assignee per task. Atomic checkout prevents conflicts.
8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted.
9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core.
### Agent chat project handoff (2026-09-11)
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.

View File

@ -0,0 +1,109 @@
# Persistent agent chat, backed by tasks
Date: 2026-09-10
Status: Implemented behind `enableAgentChat`; verification recorded in the implementation handoff.
## Contract
Each person has one persistent conversation with each agent in a company. A conversation is an ordinary issue with fixed `conversationAgentId` and `conversationUserId`, a matching agent assignee, and a unique company/agent/user identity. The authenticated board actor supplies the user identity (`local-board` in local trusted mode). Company task authorization still applies: these are separate conversations, not private messages.
Opening an unused conversation performs a read. First send or upload resolves its backing issue atomically through `POST /api/companies/:companyId/chats/:agentRef`. `GET` on the same path returns the existing issue or null. Comments, documents, files, interactions, runs, and subscriptions use the existing task APIs. User chat comments require a stable `clientRequestId`; retries return the same comment. Comment rows also provide a durable delivery outbox, serialized across servers before admission to the normal execution queue.
Idle conversations are `in_review` with server-owned `conversationState: waiting`. A new message makes the conversation active. A successful run parks it only after a durable agent response, with no later pending message. Failed or unanswered turns retain ordinary error handling. Finalizers, assignment recovery, liveness classification, timer eligibility, and work counts distinguish conversations from execution tasks. Completion or reassignment cannot terminate or transfer the container. Child completion does not wake the conversation.
## Session boundaries
Standalone `/new` is an ordinary queued user comment. The shared composer offers it as a slash command. It executes at a turn boundary without invoking the provider, increments `conversationSessionGeneration`, and records `conversationBoundaryCommentId` plus the generation on the command comment. Processing a retried command is idempotent. Only the matching agent/task provider session is deleted; agent-wide state and other task sessions are untouched.
A board-authored `/new` also releases pause holds rooted at the chat without waking the stopped turn. Dispatch admits the verified reset even when the previous turn has a no-replay recovery disposition; after the boundary, that old disposition remains auditable but does not block a fresh session. Pending clarification questions from the old session expire, including questions configured to survive ordinary comments. The reset run adds no empty-response notice.
Provider-session writes and run-authored replies check the generation. Cancelled conversation runs cannot issue mutating API calls, post late replies, or restore provider sessions. Fresh prompt replay is bounded to nondeleted comments after the boundary and before the current wake comment, with source-trust sanitization. Automatic task continuation summaries are omitted for conversations. History, artifacts, plans, and linked tasks retain their IDs and remain available for explicit inspection. The shared transcript renders processed command comments as session dividers.
## Agent policy
`server/src/services/agent-conversations.ts` owns the chat directive. The task prompt includes it on initial turns, retries, resumed turns, and fresh sessions. It asks the agent to clarify material gaps, then create and assign ordinary project tasks with outcomes, context, copied plans, and acceptance criteria before claiming they exist. It explicitly overrides ordinary completion and accepted-plan execution instructions for the container. Ask mode stays non-mutating; plan mode supports clarification and planning. Normal tools, approvals, budgets, assignment, and execution policies continue to apply.
## Shared production composition
`TaskDetailSurface` in `IssueDetail.tsx` is the shared controller and surface. `AgentChat.tsx` resolves the canonical task and provides an ephemeral view model before the first write. It does not implement a second transcript, composer, file panel, or run controller. The chat presentation hides task metadata and the seeded description bubble, uses task breadcrumb typography with agent avatar/name and a configuration-page gear, and defaults the existing task side panel to artifacts/plans rather than Properties.
Company-prefixed `chats/:agentRef` routes open the current person's conversation. Direct task URLs remain supported. The existing Agents roster provides a Chat action. The shared sidebar lists starred agents alphabetically, then four recent unstarred conversations, without a divider. Stars appear on hover or keyboard focus. Existing resource memberships store stars; company/user-scoped recent-navigation storage records conversation visits only. The gear goes to agent runtime configuration; See all agents goes to `/agents/all`.
## Rollout
`enableAgentChat` defaults to false in the shared feature catalog, validator, server settings, and Experimental settings UI. Navigation, resolution, new messages, and reset commands are gated. Turning the flag off preserves data, allows already-running turns to settle, and prevents new chat execution. Lifecycle protection is independent of flag state; task links remain readable under normal authorization.
## Verification
Database and route tests cover concurrent canonical creation, independent users with ordinary company visibility, client retry identity, cross-company denial, local identity, ordered concurrent delivery, separate queued resets, generation fences, replay boundaries, idle recovery classification, disabled admission, and child wake suppression. Shared composer/sidebar/settings tests and Storybook fixtures cover the production composition. Storybook scenarios include first conversation, returning, working, paused, failed send, long history, session boundary, disabled feature, light theme, and ordinary task comparison.
Required handoff checks: targeted tests; token gates; Storybook build; repository typecheck, tests, and build; browser checks of first send, session divider, stars, switching, drafts, configuration, roster, and disabled states. Fixture navigation is not a claim of a live provider evaluation: task creation quality remains prompt-guided and should be observed during the experimental rollout.
### Implementation verification — 2026-09-10
- Repository typecheck (`pnpm -r typecheck`), production build (`pnpm build`), token gates, and Storybook build passed.
- Final UI suite: 563 files, 5,622 tests passed. The shared controller/live-update regression pass covers first send and upload, preservation of agent routes, read-only unused conversations, personal live-update resolution, and durable session-divider refresh.
- General server lane: 8,345 passed and 38 skipped initially; the four failures (a stale module loaded during editing and three socket disconnects) passed in a fresh 68-test rerun. The conversation suite also executes a real process adapter: two ordinary turns invoke it twice, `/new` invokes it zero times, and each answered turn returns to idle.
- All 144 serialized route suites were exercised. The skill-route socket failure and queued-comment fixture cleanup failures passed in a 70-test rerun. Queue test cleanup now clears its full company-scoped foreign-key closure rather than ignoring failed deletes.
- Shared, database, CLI, adapter, skills-catalog, and plugin project suites passed. The CLI migration test exposed and verified the cloned-database constraint upgrade fix. Adapter suites that exceeded the default five-second timeout passed with capped workers and a 30-second test timeout. Tests ran against isolated temporary homes and databases; repository-standard unsupported integration cases remained skipped.
- Browser checks used the production composition with fixture APIs: first send, retry and draft retention, agent switching, stars, configuration/roster navigation, linked subtasks, paused-agent controls, disabled navigation, long history, and `/new` preserving earlier messages and plans. Live-update unit tests cover the socket/cache behavior independently of Storybook fixtures. No live-model task-handoff quality evaluation was performed.
The broad `pnpm test:run` attempt was followed by isolated group/file reruns for the failures above; this is not a claim that the initial monolithic command exited successfully. The experiment remains off by default.
## September 11 reset regression verification
The initial live demo checked an idle reset but missed Stop followed by `/new`. In the reported Claude run, dispatch cancelled both the reset and follow-up before reset processing; the provider generation stayed at zero, and the cancelled old turn posted a late reply. Regression coverage now includes pause plus a prior no-replay recovery disposition, reset and immediate follow-up queue order, cancelled-run write rejection, expiring persistent clarification questions, and suppressing empty reset-run transcript notices.
Live Codex and Claude checks confirmed fresh context after pause → `/new` → follow-up. An additional Claude check stopped an actively streaming turn containing a unique code word, reset, and asked for that word without history inspection. Claude reported it was absent; the chat returned to waiting.
### Agent chat project handoff (2026-09-11)
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
Chat instructions require selecting a suitable project, reusing an existing one where appropriate. The project requirement is prompt-only; ordinary projectless tasks remain supported. New parent relationships beneath conversation tasks are rejected by task services, including direct API creation and reparenting. Existing children remain readable/editable and can be moved elsewhere. The Subtasks panel is unchanged.
The `create_project` runtime tool uses the normal project API with durable idempotency. `list_projects` and `list_project_repositories` support selection. Multiple `repositoryIds` select authorized catalog entries; multiple HTTPS GitHub `repositoryUrls` register existing repositories absent from the catalog. IDs and URLs may be combined, but cannot accompany an explicit `workspace`. URLs do not create repositories on GitHub or grant credentials. Execution uses normal repository access rules. Repository IDs are revalidated against the authenticated run's responsible user and connection grants. Agents should consider proper available repositories, clarify material ambiguity, and use repository-free projects when appropriate for non-code work.
Confirmed project creation appears as a durable card in the shared task transcript, including selected repository links. Tasks are linked inline. Failed creation never produces a success card. Tool evals cover planning/handoff, project/repository selection, retries, permission and mode denials, and ordinary delegation regressions using the production chat directive.
### Project handoff verification (September 11)
The real-server tool tests cover concurrent project retries, task/plan atomic creation, ordinary child delegation, import/reparenting rejection under conversations, mode restrictions, cancellation, repository URL normalization, and committed project cards. The ordinary task review-path guard now exempts conversations; the server owns their waiting state after a successful reply. The chat directive explicitly tells agents to reply and end their turn without inventing a reviewer or changing status.
Five focused Codex live evals passed: existing-project reuse, new project/task handoff, multiple repository URLs, plan-only drafting, and authorized repository discovery. Four provider-free contract evals passed for retries, missing access, Ask-mode denial, and persisted handoff plans. The companion harness has 30 passing tests. The qualified Claude eval profile could not start on macOS without its explicit eval credential (it additionally requires Linux x64); it was not bypassed.
A separate local Claude agent drafted and revised a chat plan, created Garden Club Demo through the dedicated project tool, and created normal assigned task AGE-7 with its initial plan. The plan was persisted at 16:47:51.426 UTC before execution started at 16:47:51.492 UTC; the task completed with an output document and the original chat plan remained. Local Codex created Repository URL Demo with two URLs absent from its catalog; both appeared on the inline card and project configuration. The card persisted across reload and `/new`. Light and dark production-composition stories were inspected in the browser.
The broad general-server run reported 8,362 passing tests and two failures from pre-fix modules cached before the review guard and explicit-workspace card changes. The fresh current-source API run passed all 21 tests across four files, including both regressions. Remaining repository groups are verified separately so the initial monolithic exit is not represented as a clean pass.
The full UI lane passed 5,626 tests and the CLI passed 484. The shared and skills-catalog projects passed. The remaining database/adapter/plugin group passed 2,365 tests; a migration startup failure passed alone (1 test), after reducing workers to avoid embedded-Postgres contention. Existing unsupported integration tests remained skipped.
Both serialized server shards are now verified: all 144 suites passed across their final runs/resumed segments. An outdated project-route mock and the new MCP transport's missing OpenAPI inventory entry were corrected; embedded-Postgres startup failures passed in isolated retries. The API catalog now includes the task-run-only MCP transport and points project discovery/creation to their dedicated tools; its focused suite passed 824 tests. The catalog census has 792 operations (555 authored REST contract cases).
Repository-wide typecheck and build, Storybook build, and token gates passed. The final API metadata change also passed server typecheck/build. Two follow-up Codex live cases passed with the final directive, and all 11 retained deterministic/live artifacts passed the stronger persisted-state scoring, including detection of unintended tasks created through API fallback. These results do not turn the earlier failed monolithic test command into a clean run.
### 2026-09-11: E2E regression coverage
Persistent conversations now have dedicated `tests/e2e/agent-chat.spec.ts`
coverage using a deterministic process adapter against a disposable real server.
The authenticated suite additionally checks separate canonical chats, personal
stars/recency, shared company visibility, and cross-company denial for two people.
The runner catalog registers `agent-chat`: six scenarios on four local
Codex/Claude profiles (24 paid cells). See `tests/runner-e2e/README.md` for launch
commands, credential preflight, evidence, and reset/child-run accounting.
Browser testing identified a company-cache shape mismatch in chat live updates
and a dropped reset marker in compact run summaries. Preserve the shared cache
contract and `conversationReset` summary field so messages refresh live and reset
boundaries do not render empty model-completion notices.
Local acceptance on 2026-09-11: all 20 deterministic chat scenarios and two
existing repository browser scenarios passed against a fresh test instance.
The new authenticated two-person scenario passed independently. Runner fixture
checks passed (121 tests), and the live-update/run-summary regression checks
passed (46 tests). Repository typecheck, build, Storybook build, and token gates
passed. Paid Codex and Claude smoke attempts failed credential preflight because
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY` were unavailable; the 24-cell matrix is
registered but has no claimed paid passing coverage from this run.

View File

@ -1,5 +1,5 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { readFileSync, realpathSync } from "node:fs";
import path from "node:path";
import { and, eq, gt, isNull } from "drizzle-orm";
import { createDb } from "../src/client.js";
@ -31,13 +31,28 @@ async function main() {
database?: {
mode?: string;
embeddedPostgresPort?: number;
embeddedPostgresDataDir?: string;
connectionString?: string;
};
};
// The server can select another port when the configured one is occupied.
// Bind bootstrap to this data directory's running process, never another instance.
let embeddedPort: number | undefined;
if (config.database?.mode !== "postgres") {
const dataDir = config.database?.embeddedPostgresDataDir;
if (!dataDir) throw new Error("Embedded bootstrap requires its configured data directory");
const pidLines = readFileSync(path.join(dataDir, "postmaster.pid"), "utf8").split(/\r?\n/);
if (realpathSync(pidLines[1] ?? "") !== realpathSync(dataDir)) throw new Error("Embedded bootstrap data directory does not match the running postmaster");
const postmasterPid = Number(pidLines[0]);
if (!Number.isInteger(postmasterPid) || postmasterPid <= 1) throw new Error("Invalid embedded postmaster PID");
process.kill(postmasterPid, 0);
embeddedPort = Number(pidLines[3]);
if (!Number.isInteger(embeddedPort) || embeddedPort < 1 || embeddedPort > 65535) throw new Error("Invalid running embedded database port");
}
const dbUrl =
config.database?.mode === "postgres"
? config.database.connectionString
: `postgres://paperclip:paperclip@127.0.0.1:${config.database?.embeddedPostgresPort ?? 54329}/paperclip`;
: `postgres://paperclip:paperclip@127.0.0.1:${embeddedPort}/paperclip`;
if (!dbUrl) {
throw new Error(`Could not resolve database connection from ${configPath}`);
}

View File

@ -0,0 +1,18 @@
ALTER TABLE "issue_comments" ADD COLUMN "client_request_id" text;--> statement-breakpoint
ALTER TABLE "issue_comments" ADD COLUMN "conversation_session_generation" integer;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "conversation_agent_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "conversation_user_id" text;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "conversation_state" text;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "conversation_session_generation" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE "issues" ADD COLUMN "conversation_boundary_comment_id" uuid;--> statement-breakpoint
ALTER TABLE "issues" ADD CONSTRAINT "issues_conversation_agent_id_agents_id_fk" FOREIGN KEY ("conversation_agent_id") REFERENCES "public"."agents"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "issues_conversation_identity_idx" ON "issues" USING btree ("company_id","conversation_agent_id","conversation_user_id");--> statement-breakpoint
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_client_request_uq" UNIQUE("issue_id","author_user_id","client_request_id");--> statement-breakpoint
ALTER TABLE "issues" ADD CONSTRAINT "issues_conversation_identity_check" CHECK ((
"issues"."conversation_agent_id" is null and "issues"."conversation_user_id" is null and "issues"."conversation_state" is null
) or (
"issues"."conversation_agent_id" is not null and "issues"."conversation_user_id" is not null
and "issues"."assignee_agent_id" = "issues"."conversation_agent_id" and "issues"."assignee_agent_id" is not null
and "issues"."assignee_user_id" is null and "issues"."conversation_state" in ('active', 'waiting')
and "issues"."status" not in ('done', 'cancelled')
));

View File

@ -0,0 +1,10 @@
ALTER TABLE "issues" DROP CONSTRAINT IF EXISTS "issues_conversation_identity_check";--> statement-breakpoint
ALTER TABLE "issues" ADD CONSTRAINT "issues_conversation_identity_check" CHECK ((
"issues"."conversation_agent_id" is null and "issues"."conversation_user_id" is null and "issues"."conversation_state" is null
) or (
"issues"."conversation_agent_id" is not null and "issues"."conversation_user_id" is not null
and "issues"."assignee_agent_id" = "issues"."conversation_agent_id" and "issues"."assignee_agent_id" is not null
and "issues"."assignee_user_id" is null and "issues"."conversation_state" is not null
and "issues"."conversation_state" in ('active', 'waiting')
and "issues"."status" not in ('done', 'cancelled')
));

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1884,6 +1884,20 @@
"when": 1788973120697,
"tag": "0270_harsh_queen_noir",
"breakpoints": true
},
{
"idx": 271,
"version": "7",
"when": 1789074294113,
"tag": "0271_round_pete_wisdom",
"breakpoints": true
},
{
"idx": 272,
"version": "7",
"when": 1789075988240,
"tag": "0272_agent_chat_state_guard",
"breakpoints": true
}
]
}

View File

@ -5,7 +5,7 @@ import type {
IssueCommentPresentation,
SourceTrustMetadata,
} from "@paperclipai/shared";
import { pgTable, uuid, text, timestamp, index, jsonb, unique } from "drizzle-orm/pg-core";
import { pgTable, uuid, text, timestamp, index, jsonb, unique, integer } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { issues } from "./issues.js";
import { agents } from "./agents.js";
@ -30,6 +30,8 @@ export const issueComments = pgTable(
derivedAuthorAgentId: uuid("derived_author_agent_id").references(() => agents.id, { onDelete: "set null" }),
derivedCreatedByRunId: uuid("derived_created_by_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
derivedAuthorSource: text("derived_author_source").$type<IssueCommentDerivedAuthorSource>(),
clientRequestId: text("client_request_id"),
conversationSessionGeneration: integer("conversation_session_generation"),
body: text("body").notNull(),
presentation: jsonb("presentation").$type<IssueCommentPresentation | null>(),
metadata: jsonb("metadata").$type<IssueCommentMetadata | null>(),
@ -43,6 +45,7 @@ export const issueComments = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
clientRequestUq: unique("issue_comments_client_request_uq").on(table.issueId, table.authorUserId, table.clientRequestId),
companyIdUq: unique("issue_comments_company_id_uq").on(table.companyId, table.id),
issueIdx: index("issue_comments_issue_idx").on(table.issueId),
companyIdx: index("issue_comments_company_idx").on(table.companyId),

View File

@ -11,6 +11,7 @@ import {
uniqueIndex,
unique,
bigint,
check,
} from "drizzle-orm/pg-core";
import { agents } from "./agents.js";
import { projects } from "./projects.js";
@ -26,6 +27,12 @@ export const issues = pgTable(
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id),
// Conversation identity and session boundaries are owned by the server.
conversationAgentId: uuid("conversation_agent_id").references(() => agents.id),
conversationUserId: text("conversation_user_id"),
conversationState: text("conversation_state").$type<"active" | "waiting">(),
conversationSessionGeneration: integer("conversation_session_generation").notNull().default(0),
conversationBoundaryCommentId: uuid("conversation_boundary_comment_id"),
projectId: uuid("project_id").references(() => projects.id),
projectWorkspaceId: uuid("project_workspace_id").references(() => projectWorkspaces.id, { onDelete: "set null" }),
goalId: uuid("goal_id").references(() => goals.id),
@ -83,6 +90,16 @@ export const issues = pgTable(
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
conversationIdentityIdx: uniqueIndex("issues_conversation_identity_idx").on(table.companyId, table.conversationAgentId, table.conversationUserId),
conversationIdentityCheck: check("issues_conversation_identity_check", sql`(
${table.conversationAgentId} is null and ${table.conversationUserId} is null and ${table.conversationState} is null
) or (
${table.conversationAgentId} is not null and ${table.conversationUserId} is not null
and ${table.assigneeAgentId} = ${table.conversationAgentId} and ${table.assigneeAgentId} is not null
and ${table.assigneeUserId} is null and ${table.conversationState} is not null
and ${table.conversationState} in ('active', 'waiting')
and ${table.status} not in ('done', 'cancelled')
)`),
companyIdUq: unique("issues_company_id_uq").on(table.companyId, table.id),
companyStatusIdx: index("issues_company_status_idx").on(table.companyId, table.status),
companyHarnessKindIdx: index("issues_company_harness_kind_idx").on(table.companyId, table.harnessKind),

File diff suppressed because one or more lines are too long

View File

@ -1556,9 +1556,210 @@
{
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"description": "Create one child task under the active task.",
"description": "Inspect available company projects before selecting a project for new work.",
"effect": "read",
"inputSchema": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object"
},
"operationId": "list_projects",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [
"discovery:projects:read"
],
"schema": "paperclip.semantic-action.v1",
"title": "List projects",
"version": 1
},
{
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"description": "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
"effect": "read",
"inputSchema": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object"
},
"operationId": "list_project_repositories",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [],
"schema": "paperclip.semantic-action.v1",
"title": "List available repositories",
"version": 1
},
{
"allowedModes": [
"standard",
"skill_test"
],
"description": "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
"effect": "write",
"inputSchema": {
"additionalProperties": false,
"properties": {
"archivedAt": {
"description": "Archive timestamp.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"color": {
"description": "Project color.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"description": {
"description": "Project outcome and context.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"env": {
"additionalProperties": true,
"type": "object"
},
"executionWorkspacePolicy": {
"additionalProperties": true,
"type": "object"
},
"goalId": {
"description": "Goal ID.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"goalIds": {
"description": "Goal IDs.",
"items": {
"minLength": 1,
"type": "string"
},
"maxItems": 200,
"type": "array",
"uniqueItems": true
},
"icon": {
"description": "Project icon.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"idempotencyKey": {
"description": "Caller-stable retry key.",
"maxLength": 240,
"minLength": 1,
"type": "string"
},
"leadAgentId": {
"description": "Lead agent ID.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"name": {
"description": "Project name.",
"maxLength": 500,
"minLength": 1,
"type": "string"
},
"repositoryIds": {
"description": "Authorized repository IDs from list_project_repositories; may contain multiple repositories.",
"items": {
"minLength": 1,
"type": "string"
},
"maxItems": 200,
"type": "array",
"uniqueItems": true
},
"repositoryUrls": {
"description": "Existing HTTPS GitHub repository URLs, including repos absent from the catalog.",
"items": {
"minLength": 1,
"type": "string"
},
"maxItems": 200,
"type": "array",
"uniqueItems": true
},
"status": {
"enum": [
"backlog",
"planned",
"in_progress",
"completed",
"cancelled"
]
},
"targetDate": {
"description": "Target date.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"workspace": {
"additionalProperties": true,
"type": "object"
}
},
"required": [
"idempotencyKey",
"name"
],
"type": "object"
},
"operationId": "create_project",
"outputSchema": {
"additionalProperties": true,
"type": "object"
},
"placement": "optional",
"requiredClaims": [],
"schema": "paperclip.semantic-action.v1",
"title": "Create project",
"version": 1
},
{
"allowedModes": [
"standard",
"skill_test"
],
"description": "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution.",
"effect": "write",
"inputSchema": {
"additionalProperties": false,
@ -1595,6 +1796,14 @@
"minLength": 1,
"type": "string"
},
"initialPlan": {
"description": "Relevant markdown plan to persist on the new task before it starts.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"priority": {
"enum": [
"critical",
@ -1603,8 +1812,16 @@
"low"
]
},
"projectId": {
"description": "Project identifier for the new task.",
"maxLength": 20000,
"type": [
"string",
"null"
]
},
"title": {
"description": "Child task title.",
"description": "Task title.",
"maxLength": 500,
"minLength": 1,
"type": "string"
@ -1671,7 +1888,7 @@
"delegation:tasks:create"
],
"schema": "paperclip.semantic-action.v1",
"title": "Create child task",
"title": "Create task",
"version": 1
},
{

View File

@ -7,10 +7,48 @@
"src/scenarios/scenario-plan.ts"
],
"counts": {
"actions": 43,
"actions": 45,
"legacyRequirements": 106
},
"actions": [
{
"id": "create_project",
"ownership": "optional_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:create_project",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::create_project has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:create_project"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::create_project has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "list_project_repositories",
"ownership": "optional_agent_tool",
"surfaces": [
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:list_project_repositories",
"contractOwner": "src/catalog/protocol-action-contracts.test.ts::list_project_repositories has a schema-valid canonical example and every declared projection",
"legacyBehavioralCases": [],
"deterministicCases": [
"protocol-action:list_project_repositories"
],
"legacyRequirementCases": [],
"deterministicOwners": [
"src/catalog/protocol-action-contracts.test.ts::list_project_repositories has a schema-valid canonical example and every declared projection",
"src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity"
]
},
{
"id": "search_api",
"ownership": "optional_agent_tool",
@ -913,7 +951,8 @@
"id": "list_projects",
"ownership": "optional_agent_tool",
"surfaces": [
"scenario"
"scenario",
"live"
],
"legacyAliases": [],
"contractCase": "protocol-action:list_projects",

View File

@ -21,7 +21,7 @@ export const CAPABILITY_CANONICAL_OPERATIONS: readonly CapabilityCanonicalOperat
.sort((left, right) => left.operationId.localeCompare(right.operationId)),
);
const byId = new Map(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => [operation.operationId, operation]));
if (byId.size !== 43) throw new Error(`expected 43 canonical semantic operations, found ${byId.size}`);
if (byId.size !== PAPERCLIP_PROTOCOL_ACTIONS.length) throw new Error("Duplicate canonical semantic operation ID");
export function capabilityCanonicalOperation(operationId: string): CapabilityCanonicalOperation | undefined { return byId.get(operationId); }
export function capabilityCanonicalOperationsForSurface(surface: CapabilityCatalogSurface): readonly CapabilityCanonicalOperation[] { return CAPABILITY_CANONICAL_OPERATIONS.filter((operation) => operation.surfaces.includes(surface)); }
export function capabilityCanonicalOperationIds(): readonly string[] { return CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId); }

View File

@ -20,16 +20,18 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("pins the reconciled op-set relationship between the two catalogs", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.scenarioCount).toBe(37);
expect(summary.liveCount).toBe(30);
expect(summary.sharedCount).toBe(24);
expect(summary.unionCount).toBe(43);
expect(summary.liveCount).toBe(33);
expect(summary.sharedCount).toBe(25);
expect(summary.unionCount).toBe(45);
// Any operation added to or removed from either catalog without a
// reconciliation decision changes these exact sets and fails the gate.
expect(summary.liveOnly).toEqual([
"call_api",
"create_project",
"get_agent",
"get_approval",
"get_approval_context",
"list_project_repositories",
"schedule_wake",
"search_api",
]);
@ -40,7 +42,6 @@ describe("canonical semantic-catalog reconciliation authority", () => {
"list_cases",
"list_company_skills",
"list_goals",
"list_projects",
"list_routines",
"list_secret_metadata",
"manage_routine",
@ -51,7 +52,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("is the single source both catalogs derive their operation set from", () => {
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(43);
expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(45);
const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId));
// Neither catalog may contain an operation absent from the canonical source.
for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true);
@ -85,7 +86,7 @@ describe("canonical semantic-catalog reconciliation authority", () => {
});
it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => {
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(43);
expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(45);
for (const operation of CAPABILITY_CANONICAL_CATALOG) {
expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/);
expect(Array.isArray(operation.requiredClaims)).toBe(true);
@ -111,8 +112,8 @@ describe("canonical semantic-catalog reconciliation authority", () => {
it("classifies real binding status so generic_api_request is never product coverage", () => {
const summary = capabilityCatalogReconciliation();
expect(summary.byRealBindingStatus).toEqual({
live_codex: 29,
scenario_mock: 13,
live_codex: 32,
scenario_mock: 12,
test_only: 1,
});
expect(capabilityCanonicalOperation("generic_api_request")?.realBindingStatus).toBe("test_only");

View File

@ -23,7 +23,7 @@ describe("semantic action catalog", () => {
(action) => action.operationId,
);
expect(operationIds).toHaveLength(29);
expect(operationIds).toHaveLength(32);
expect(new Set(operationIds).size).toBe(operationIds.length);
expect(operationIds).not.toContain("generic_api_request");
expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true);

View File

@ -428,10 +428,41 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
),
outputSchema: operationReceipt,
}),
descriptor({
operationId: "list_projects",
title: "List projects",
requiredClaims: ["discovery:projects:read"],
description: "Inspect available company projects before selecting a project for new work.",
placement: "optional",
inputSchema: object({}),
}),
descriptor({
operationId: "list_project_repositories",
title: "List available repositories",
description: "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
placement: "optional",
inputSchema: object({}),
}),
descriptor({
operationId: "create_project",
title: "Create project",
description: "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
placement: "optional", effect: "write", allowedModes: STANDARD_MODE,
inputSchema: object({
...idempotency, name: text("Project name.", 500), description: nullableText("Project outcome and context."),
repositoryIds: stringArray("Authorized repository IDs from list_project_repositories; may contain multiple repositories."),
repositoryUrls: stringArray("Existing HTTPS GitHub repository URLs, including repos absent from the catalog."),
workspace: openObject, status: { enum: ["backlog", "planned", "in_progress", "completed", "cancelled"] },
goalId: nullableText("Goal ID."), goalIds: stringArray("Goal IDs."), leadAgentId: nullableText("Lead agent ID."),
targetDate: nullableText("Target date."), color: nullableText("Project color."), icon: nullableText("Project icon."),
env: openObject, executionWorkspacePolicy: openObject, archivedAt: nullableText("Archive timestamp."),
}, ["idempotencyKey", "name"]),
outputSchema: openObject,
}),
descriptor({
operationId: "create_task",
title: "Create child task",
description: "Create one child task under the active task.",
title: "Create task",
description: "Create an assigned task. In a conversation, create a project task with no parent; otherwise create a child of the active task. Include initialPlan to persist its plan before execution.",
placement: "optional",
effect: "write",
requiredClaims: ["delegation:tasks:create"],
@ -439,7 +470,9 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
inputSchema: object(
{
...idempotency,
title: text("Child task title.", 500),
title: text("Task title.", 500),
projectId: nullableText("Project identifier for the new task."),
initialPlan: nullableText("Relevant markdown plan to persist on the new task before it starts."),
description: nullableText("Child task description."),
assigneeActorId: nullableText("Optional actor assignee.", 200),
priority: { enum: ["critical", "high", "medium", "low"] },

View File

@ -23,6 +23,9 @@ export type PaperclipSemanticActionId =
| "get_workspace_runtime"
| "control_workspace_service"
| "set_dependencies"
| "create_project"
| "list_project_repositories"
| "list_projects"
| "create_task"
| "request_approval"
| "decide_approval"

View File

@ -0,0 +1,198 @@
/** Canonical project tool definition. */
export const createProjectAction = {
"id": "create_project",
"canonical": {
"operationId": "create_project",
"surfaces": [
"live"
],
"placement": "optional_agent_tool",
"optionalGroup": "discovery",
"requiredClaims": [],
"taskModes": [
"standard",
"skill_test"
],
"sideEffectClass": "company_write",
"idempotency": "required",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "PaperclipRunnerToolAuthority",
"prpEvidence": "Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.",
"prpBindingStatus": "bound",
"legacyAliases": []
},
"documentation": {
"title": "Create project",
"description": "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
"note": null
},
"examples": {
"call": {
"operationId": "create_project",
"input": {
"name": "Example",
"repositoryIds": [
"1",
"2"
],
"idempotencyKey": "example"
}
},
"success": {
"ok": true,
"operationId": "create_project",
"result": {}
}
},
"live": {
"order": 43,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "create_project",
"version": 1,
"title": "Create project",
"description": "Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.",
"effect": "write",
"requiredClaims": [],
"allowedModes": [
"standard",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {
"idempotencyKey": {
"type": "string",
"description": "Caller-stable retry key.",
"minLength": 1,
"maxLength": 240
},
"name": {
"type": "string",
"description": "Project name.",
"minLength": 1,
"maxLength": 500
},
"description": {
"type": [
"string",
"null"
],
"description": "Project outcome and context.",
"maxLength": 20000
},
"repositoryIds": {
"type": "array",
"description": "Authorized repository IDs from list_project_repositories; may contain multiple repositories.",
"items": {
"type": "string",
"minLength": 1
},
"maxItems": 200,
"uniqueItems": true
},
"workspace": {
"type": "object",
"additionalProperties": true
},
"status": {
"enum": [
"backlog",
"planned",
"in_progress",
"completed",
"cancelled"
]
},
"goalId": {
"type": [
"string",
"null"
],
"description": "Goal ID.",
"maxLength": 20000
},
"goalIds": {
"type": "array",
"description": "Goal IDs.",
"items": {
"type": "string",
"minLength": 1
},
"maxItems": 200,
"uniqueItems": true
},
"leadAgentId": {
"type": [
"string",
"null"
],
"description": "Lead agent ID.",
"maxLength": 20000
},
"targetDate": {
"type": [
"string",
"null"
],
"description": "Target date.",
"maxLength": 20000
},
"color": {
"type": [
"string",
"null"
],
"description": "Project color.",
"maxLength": 20000
},
"icon": {
"type": [
"string",
"null"
],
"description": "Project icon.",
"maxLength": 20000
},
"env": {
"type": "object",
"additionalProperties": true
},
"executionWorkspacePolicy": {
"type": "object",
"additionalProperties": true
},
"archivedAt": {
"type": [
"string",
"null"
],
"description": "Archive timestamp.",
"maxLength": 20000
},
"repositoryUrls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
},
"maxItems": 100,
"description": "Existing HTTPS GitHub repository URLs, including repos absent from the catalog."
}
},
"required": [
"idempotencyKey",
"name"
],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
},
"exposure": "optional"
}
},
"scenario": null
} as const;

View File

@ -20,7 +20,7 @@ export const createTaskAction = {
"idempotency": "required",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "issues.createChild",
"realServiceBinding": "issues.create / issues.createChild",
"prpEvidence": "semantic-operation item event plus company-entity state diff and audit record",
"prpBindingStatus": "bound",
"legacyAliases": [
@ -28,8 +28,8 @@ export const createTaskAction = {
]
},
"documentation": {
"title": "Create child task",
"description": "Create one durable standard child under the active task.",
"title": "Create task",
"description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.",
"note": null
},
"examples": {
@ -76,8 +76,8 @@ export const createTaskAction = {
"schema": "paperclip.semantic-tool.v1",
"operationId": "create_task",
"version": 1,
"title": "Create child task",
"description": "Create one durable standard child under the active task. Use only when a real ownership, parallelism, dependency, review, or lifecycle boundary justifies delegation.",
"title": "Create task",
"description": "Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.",
"exposure": "optional",
"requiredClaims": [
"delegation:tasks:create"
@ -134,6 +134,21 @@ export const createTaskAction = {
},
"maxItems": 200,
"uniqueItems": true
},
"projectId": {
"type": [
"string",
"null"
],
"description": "Project ID for the task."
},
"initialPlan": {
"type": [
"string",
"null"
],
"maxLength": 200000,
"description": "Relevant markdown plan saved on the new task before execution starts."
}
},
"required": [
@ -184,13 +199,38 @@ export const createTaskAction = {
"task": {
"type": "object",
"properties": {
"id": { "type": "string", "minLength": 1 },
"identifier": { "type": ["string", "null"] },
"parentId": { "type": "string", "minLength": 1 },
"status": { "type": "string", "minLength": 1 },
"assigneeActorId": { "type": ["string", "null"] }
"id": {
"type": "string",
"minLength": 1
},
"identifier": {
"type": [
"string",
"null"
]
},
"parentId": {
"type": "string",
"minLength": 1
},
"status": {
"type": "string",
"minLength": 1
},
"assigneeActorId": {
"type": [
"string",
"null"
]
}
},
"required": ["id", "identifier", "parentId", "status", "assigneeActorId"],
"required": [
"id",
"identifier",
"parentId",
"status",
"assigneeActorId"
],
"additionalProperties": false
}
},

View File

@ -1,3 +1,5 @@
import { createProjectAction } from "./create-project.js";
import { listProjectRepositoriesAction } from "./list-project-repositories.js";
import { searchApiAction } from "./search-api.js";
import { callApiAction } from "./call-api.js";
import { administerCompanyAction } from "./administer-company.js";
@ -44,6 +46,8 @@ import { writeDocumentAction } from "./write-document.js";
import { deepFreezeProtocolAction } from "./freeze.js";
export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([
createProjectAction,
listProjectRepositoriesAction,
searchApiAction,
callApiAction,
administerCompanyAction,

View File

@ -0,0 +1,73 @@
/** Canonical project tool definition. */
export const listProjectRepositoriesAction = {
"id": "list_project_repositories",
"canonical": {
"operationId": "list_project_repositories",
"surfaces": [
"live"
],
"placement": "optional_agent_tool",
"optionalGroup": "discovery",
"requiredClaims": [],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",
"idempotency": "none",
"disabledByDefault": false,
"realBindingStatus": "live_codex",
"realServiceBinding": "PaperclipRunnerToolAuthority",
"prpEvidence": "Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.",
"prpBindingStatus": "bound",
"legacyAliases": []
},
"documentation": {
"title": "List available repositories",
"description": "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
"note": null
},
"examples": {
"call": {
"operationId": "list_project_repositories",
"input": {}
},
"success": {
"ok": true,
"operationId": "list_project_repositories",
"result": {}
}
},
"live": {
"order": 44,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "list_project_repositories",
"version": 1,
"title": "List available repositories",
"description": "List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.",
"effect": "read",
"requiredClaims": [],
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
},
"exposure": "optional"
}
},
"scenario": null
} as const;

View File

@ -1,10 +1,11 @@
/** Canonical definition and documentation for `list_projects`. */
/** Canonical project discovery definition. */
export const listProjectsAction = {
"id": "list_projects",
"canonical": {
"operationId": "list_projects",
"surfaces": [
"scenario"
"scenario",
"live"
],
"placement": "optional_agent_tool",
"optionalGroup": "discovery",
@ -13,22 +14,23 @@ export const listProjectsAction = {
],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",
"idempotency": "none",
"disabledByDefault": false,
"realBindingStatus": "scenario_mock",
"realServiceBinding": "unbound",
"prpEvidence": "read projection surfaced via a tool-result item event; no control-plane state diff",
"prpBindingStatus": "audit_pending",
"legacyAliases": [],
"note": "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet."
"realBindingStatus": "live_codex",
"realServiceBinding": "PaperclipRunnerToolAuthority",
"prpEvidence": "Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.",
"prpBindingStatus": "bound",
"legacyAliases": []
},
"documentation": {
"title": "List Projects",
"description": "List Projects through the Capability discovery capability set.",
"note": "Scenario/eval-only discovery via mock extension; no live dispatcher binding yet."
"title": "List projects",
"description": "Inspect available company projects before selecting a project for new work.",
"note": null
},
"examples": {
"call": {
@ -38,18 +40,40 @@ export const listProjectsAction = {
"success": {
"ok": true,
"operationId": "list_projects",
"result": {
"schema": "paperclip.capability.tool-result.v1",
"ok": true,
"operationId": "list_projects",
"operationResultId": "example",
"value": "example",
"commandResult": "example",
"authorization": "example"
}
"result": {}
}
},
"live": {
"order": 45,
"descriptor": {
"schema": "paperclip.semantic-tool.v1",
"operationId": "list_projects",
"version": 1,
"title": "List projects",
"description": "Inspect available company projects before selecting a project for new work.",
"effect": "read",
"requiredClaims": [
"discovery:projects:read"
],
"allowedModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"inputSchema": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
},
"outputSchema": {
"type": "object",
"additionalProperties": true
},
"exposure": "optional"
}
},
"live": null,
"scenario": {
"order": 16,
"descriptor": {
@ -104,6 +128,8 @@ export const listProjectsAction = {
],
"taskModes": [
"standard",
"ask",
"planning",
"skill_test"
],
"sideEffectClass": "read",

View File

@ -27,7 +27,7 @@ const NAMESPACE: Readonly<Record<CapabilitySemanticOperationId, string>> = Objec
report_progress: "active_task", answer_status_question: "active_task", write_document: "documents",
request_human_input: "documents", register_deliverable: "documents", finish_task: "active_task",
block_task: "active_task", request_review: "active_task", search_tasks: "discovery",
list_agents: "discovery", get_agent: "discovery", create_task: "delegation",
list_agents: "discovery", get_agent: "discovery", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
set_dependencies: "delegation", list_approvals: "governance", get_approval: "governance",
get_approval_context: "governance", request_approval: "governance",
decide_approval: "governance", comment_on_approval: "governance",

View File

@ -36,7 +36,7 @@ const NAMESPACE: Readonly<Record<PaperclipSemanticActionId, string>> =
get_workspace_runtime: "workspace",
control_workspace_service: "workspace",
set_dependencies: "delegation",
create_task: "delegation",
create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects",
request_approval: "governance",
decide_approval: "governance",
comment_on_approval: "governance",

View File

@ -40,6 +40,9 @@ export type CapabilitySemanticOperationId =
| "get_workspace_runtime"
| "control_workspace_service"
| "set_dependencies"
| "create_project"
| "list_project_repositories"
| "list_projects"
| "create_task"
| "request_approval"
| "decide_approval"

View File

@ -115,6 +115,13 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableAgentChat: {
title: "Agent Chat",
description: "Persistent task-backed conversations that clarify goals and hand work off to tasks.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableConferenceRoomChat: {
title: "Conference Room Chat",
description:

View File

@ -64,6 +64,7 @@ export interface InstanceExperimentalSettings {
enableApps: boolean;
enablePipelines: boolean;
enableCases: boolean;
enableAgentChat: boolean;
enableConferenceRoomChat: boolean;
enableClassicTaskInterface: boolean;
enableIssuePlanDecompositions: boolean;

View File

@ -784,6 +784,11 @@ export interface IssueChangeReceiptEntry {
export type IssueChanges = Record<string, IssueChangeReceiptEntry>;
export interface Issue {
conversationAgentId?: string | null;
conversationUserId?: string | null;
conversationState?: "active" | "waiting" | null;
conversationSessionGeneration?: number;
conversationBoundaryCommentId?: string | null;
activeRun?: { id: string; status: string; agentId: string; invocationSource: string;
triggerDetail: string | null; startedAt: Date | string | null; finishedAt: Date | string | null;
createdAt: Date | string; execution?: ExecutionProjection } | null;
@ -948,6 +953,8 @@ export type IssueCommentDerivedAuthorSource =
| "run_log_comment_post";
export interface IssueComment {
clientRequestId?: string | null;
conversationSessionGeneration?: number | null;
id: string;
companyId: string;
issueId: string;

View File

@ -52,6 +52,7 @@ export const instanceExperimentalSettingsSchema = z.object({
enableApps: z.boolean().default(true),
enablePipelines: z.boolean().default(false),
enableCases: z.boolean().default(false),
enableAgentChat: z.boolean().default(false),
enableConferenceRoomChat: z.boolean().default(false),
enableClassicTaskInterface: z.boolean().default(false),
enableIssuePlanDecompositions: z.boolean().default(false),

View File

@ -534,6 +534,7 @@ function requireBlockedStatusForUnblockDescriptor(
}
const createIssueDuplicateGuardSchema = {
initialPlan: z.string().min(1).max(200000).optional().nullable(),
idempotencyKey: z.string().trim().min(1).max(255).optional().nullable(),
allowDuplicate: z.boolean()
.describe("Bypasses recent-title duplicate detection; idempotency keys always replay their original issue")
@ -732,6 +733,7 @@ export const issueCommentMetadataSchema = z.object({
export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
export const addIssueCommentSchema = z.object({
clientRequestId: z.string().uuid().optional(),
body: multilineTextSchema.pipe(z.string().min(1)),
onBehalfOfUserId: z.string().trim().min(1).optional().nullable(),
authorType: issueCommentAuthorTypeSchema.optional(),

View File

@ -117,9 +117,11 @@ const projectFields = {
};
export const createProjectSchema = z.object({
idempotencyKey: z.string().trim().min(1).max(255).optional(),
...projectFields,
workspace: createProjectWorkspaceSchema.optional(),
repositoryIds: z.array(z.string().regex(/^\d+$/)).optional(),
repositoryUrls: z.array(z.string().url().max(2000)).max(100).optional(),
});
export type CreateProject = z.infer<typeof createProjectSchema>;

View File

@ -1,3 +1,4 @@
import { AGENT_CHAT_DIRECTIVE } from "../server/src/services/agent-conversations.js";
/** JSONL worker for the companion paperclip-evals API suite. Never selects cases or retries. */
import { createHash, randomUUID } from "node:crypto";
import { createReadStream, realpathSync } from "node:fs";
@ -65,7 +66,7 @@ try {
const isOpenRouter = OPENROUTER_MODELS.has(request.model);
const provider = isOpenRouter ? "opencode" : request.model === "claude-sonnet-5" ? "acpx" : "codex";
let providerVersion: string | null = null;
const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true, connectionScenario: request.connectionScenario });
const fixture = await server.fixture({ mode: request.mode, apiToolsEnabled: request.arm !== "baseline", reset: true, conversation: request.conversation === true, connectionScenario: request.connectionScenario });
const initialState = await fixture.snapshot();
const substitutions = Object.fromEntries(Object.entries(fixture).filter(([, value]) => typeof value === "string"));
const expand = (value: any): any => typeof value === "string" ? value.replace(/\{\{(\w+)\}\}/g, (_, key) => String(substitutions[key] ?? (() => { throw new Error(`Unknown fixture variable ${key}`); })())) : Array.isArray(value) ? value.map(expand) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, expand(entry)])) : value;
@ -129,7 +130,7 @@ try {
completionContract: { revision: "runner-api-eval-v1", criterionIds: ["objective"] },
config: { ...createSkilllessCodexThreadConfig(fixture.workspace), model_reasoning_effort: "low" },
permissions: "paperclip-runner-workspace-only", runtimeWorkspaceRoots: [fixture.workspace], approvalPolicy: "never",
baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use.") + "\n" + CONNECTION_INTENT_AGENT_GUIDANCE,
baseInstructions: "You are operating a disposable real Paperclip company. Use the provided tools to do the user's task. Do not use shell, network, skills, or credentials. Stop when the requested work is verified. " + (request.arm === "baseline" ? "" : "Prefer available dedicated tools. Only use search_api and call_api when no dedicated tool supports the required operation or parameters. Do not search before ordinary dedicated tool use.") + "\n" + CONNECTION_INTENT_AGENT_GUIDANCE + (request.conversation ? "\n" + AGENT_CHAT_DIRECTIVE : ""),
dynamicTools: definitions, experimentalRawEvents: true, persistExtendedHistory: true,
});
if (request.preflight) {

View File

@ -158,6 +158,7 @@ describeEmbeddedPostgres("activity service", () => {
enormousBlob: "x".repeat(256_000),
},
resultJson: {
conversationReset: true,
billing_type: "metered",
total_cost_usd: 0.42,
stopReason: "timeout",
@ -197,6 +198,7 @@ describeEmbeddedPostgres("activity service", () => {
total_cost_usd: 0.42,
});
expect(runs[0]?.resultJson).toEqual({
conversationReset: true,
billingType: "metered",
billing_type: "metered",
costUsd: 0.42,

View File

@ -0,0 +1,744 @@
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { applyRunnerGoalPrpEvent } from "../services/runner-goals.js";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ensureHumanRoleDefaultGrants } from "../services/principal-access-compatibility.js";
import express from "express";
import request from "supertest";
import { issueRoutes } from "../routes/issues.js";
import { errorHandler } from "../middleware/index.js";
import { actorMiddleware } from "../middleware/auth.js";
import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
authUsers,
agents,
agentTaskSessions,
agentWakeupRequests,
companyMemberships,
companies,
createDb,
heartbeatRuns,
issueComments,
issueTreeHolds,
issueThreadInteractions,
issueRecoveryActions,
issues,
} from "@paperclipai/db";
import {
getEmbeddedPostgresTestSupport,
startEmbeddedPostgresTestDatabase,
} from "./helpers/embedded-postgres.js";
import { issueService } from "../services/issues.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import {
AGENT_CHAT_DIRECTIVE,
conversationNativeDecision,
deliverConversationComments,
conversationReplay,
isWaitingConversation,
prepareConversationTurn,
settleConversationTurn,
undeliveredConversationComments,
} from "../services/agent-conversations.js";
import { classifyIssueGraphLiveness } from "../services/recovery/issue-graph-liveness.js";
import { runningProcesses } from "../adapters/index.js";
import {
buildPaperclipTaskMarkdown,
buildPaperclipWakePayload,
heartbeatService,
} from "../services/heartbeat.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)(
"persistent agent conversations",
() => {
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let companyId: string;
let agentId: string;
beforeAll(async () => {
database = await startEmbeddedPostgresTestDatabase(
"paperclip-agent-conversations-",
);
db = createDb(database.connectionString);
companyId = randomUUID();
agentId = randomUUID();
await db
.insert(authUsers)
.values({
id: "local-board",
name: "Local Board",
email: "local@paperclip.test",
createdAt: new Date(),
updatedAt: new Date(),
})
.onConflictDoNothing();
await db
.insert(companies)
.values({
id: companyId,
name: "Chats",
issuePrefix: "CHAT",
requireBoardApprovalForNewAgents: false,
});
await db
.insert(agents)
.values({
id: agentId,
companyId,
name: "Planner",
role: "engineer",
status: "idle",
adapterType: "process",
});
await instanceSettingsService(db).updateExperimental({
enableAgentChat: true,
});
}, 90000);
afterAll(async () => {
await db?.$client.end({ timeout: 0 });
await database?.cleanup();
});
const create = (user = randomUUID()) =>
issueService(db).create(companyId, {
title: "Conversation",
conversationAgentId: agentId,
conversationUserId: user,
assigneeAgentId: agentId,
status: "in_review",
conversationState: "waiting",
});
async function runFor(issueId: string, commentId: string, overrides = {}) {
return (
await db
.insert(heartbeatRuns)
.values({
companyId,
agentId,
status: "running",
contextSnapshot: {
issueId,
taskKey: issueId,
wakeCommentId: commentId,
commentId,
...overrides,
},
})
.returning()
)[0]!;
}
it("atomically resolves one task per person and agent and excludes it from ordinary lists", async () => {
const user = randomUUID();
expect(
await issueService(db).getConversation(companyId, agentId, user),
).toBeNull();
const results = await Promise.all(
Array.from({ length: 6 }, () => create(user)),
);
expect(new Set(results.map((issue) => issue.id)).size).toBe(1);
expect((await create()).id).not.toBe(results[0].id);
expect(
(await issueService(db).list(companyId)).some(
(issue) => issue.id === results[0].id,
),
).toBe(false);
expect(
(
await issueService(db).list(companyId, { q: results[0].identifier! })
).some((issue) => issue.id === results[0].id),
).toBe(true);
expect(
(await issueService(db).getById(results[0].id))?.conversationUserId,
).toBe(user);
await expect(
issueService(db).update(results[0].id, { status: "done" }),
).rejects.toThrow(/conversation/i);
await expect(
issueService(db).update(results[0].id, { assigneeAgentId: null }),
).rejects.toThrow(/conversation/i);
});
it("resolves through authenticated company routes, keeps opens read-only, and derives ownership", async () => {
const appFor = (userId?: string, allowed = true) => {
const app = express();
app.use(express.json());
if (!userId)
app.use(actorMiddleware(db, { deploymentMode: "local_trusted" }));
else
app.use((req, _res, next) => {
req.actor = {
type: "board",
source: "session",
userId,
companyIds: allowed ? [companyId] : [],
};
next();
});
app.use("/api", issueRoutes(db, {} as never));
app.use(errorHandler);
return app;
};
const path = `/api/companies/${companyId}/chats/${agentId}`;
const owner = randomUUID();
const colleague = randomUUID();
const app = appFor(owner);
for (const userId of [owner, colleague]) {
await db
.insert(companyMemberships)
.values({
companyId,
principalType: "user",
principalId: userId,
status: "active",
membershipRole: "operator",
});
await ensureHumanRoleDefaultGrants(db, {
companyId,
principalId: userId,
membershipRole: "operator",
grantedByUserId: null,
});
}
expect((await request(app).get(path)).body).toBeNull();
expect(
await issueService(db).getConversation(companyId, agentId, owner),
).toBeNull();
const resolved = await Promise.all([
request(app).post(path).send({ conversationUserId: "spoof" }),
request(app).post(path),
]);
expect(resolved.every((response) => response.status === 200)).toBe(true);
expect(resolved[0].body.id).toBe(resolved[1].body.id);
expect(resolved[0].body.conversationUserId).toBe(owner);
expect(
(
await request(appFor(colleague)).get(
`/api/issues/${resolved[0].body.id}`,
)
).status,
).toBe(200);
expect(
(await request(appFor(randomUUID(), false)).get(path)).status,
).toBe(403);
expect(
(
await request(app)
.post(`/api/issues/${resolved[0].body.id}/comments`)
.send({ body: "Hello" })
).status,
).toBe(422);
const local = await request(appFor()).post(path);
expect(local.body.conversationUserId).toBe("local-board");
await instanceSettingsService(db).updateExperimental({
enableAgentChat: false,
});
expect((await request(app).get(path)).status).toBe(404);
expect(
(await request(app).get(`/api/issues/${resolved[0].body.id}`)).status,
).toBe(200);
await instanceSettingsService(db).updateExperimental({
enableAgentChat: true,
});
});
it("deduplicates concurrent message retries and preserves recoverable delivery", async () => {
const issue = await create();
const clientRequestId = randomUUID();
const messages = await Promise.all(
Array.from({ length: 4 }, () =>
issueService(db).addComment(
issue.id,
"Help scope a project",
{ userId: "local-board" },
{ clientRequestId },
),
),
);
expect(new Set(messages.map((message) => message.id)).size).toBe(1);
expect(
await undeliveredConversationComments(db, companyId, issue.id),
).toHaveLength(1);
await expect(
issueService(db).addComment(
issue.id,
"Different",
{ userId: "local-board" },
{ clientRequestId },
),
).rejects.toThrow(/different content/);
});
it("resets only this task, keeps history, and fences stale replies and retries", async () => {
const issue = await create();
const other = await create();
const before = await issueService(db).addComment(
issue.id,
"Old session secret context",
{ userId: "local-board" },
);
const oldRun = await runFor(issue.id, before.id);
await prepareConversationTurn(db, oldRun);
for (const target of [issue, other])
await db
.insert(agentTaskSessions)
.values({
companyId,
agentId,
adapterType: "process",
taskKey: target.id,
sessionDisplayId: "old-session",
});
const command = await issueService(db).addComment(issue.id, "/new", {
userId: "local-board",
});
const resetRun = await runFor(issue.id, command.id);
expect((await prepareConversationTurn(db, resetRun)).reset).toBe(true);
expect(
(
await prepareConversationTurn(
db,
(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, resetRun.id))
)[0]!,
)
).reset,
).toBe(true);
const [current] = await db
.select()
.from(issues)
.where(eq(issues.id, issue.id));
expect(current.conversationSessionGeneration).toBe(1);
expect(current.conversationBoundaryCommentId).toBe(command.id);
expect(
await db
.select()
.from(agentTaskSessions)
.where(eq(agentTaskSessions.taskKey, issue.id)),
).toHaveLength(0);
expect(
await db
.select()
.from(agentTaskSessions)
.where(eq(agentTaskSessions.taskKey, other.id)),
).toHaveLength(1);
expect(
await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, issue.id)),
).toHaveLength(2);
await expect(
issueService(db).addComment(issue.id, "Late old reply", {
agentId,
runId: oldRun.id,
}),
).rejects.toThrow(/earlier session/);
await expect(
prepareConversationTurn(
db,
(
await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, oldRun.id))
)[0]!,
),
).rejects.toThrow(/older turn/);
expect(await applyRunnerGoalPrpEvent(db, { companyId, agentId, issueId: issue.id, adapterType: "process" }, {
eventType: "session.capabilities.updated", sourceRunId: oldRun.id, sourceSeq: 500, payload: {},
})).toBeNull();
expect(await db.select().from(agentTaskSessions).where(eq(agentTaskSessions.taskKey, issue.id))).toHaveLength(0);
const next = await issueService(db).addComment(
issue.id,
"Fresh context",
{ userId: "local-board" },
);
expect(
await conversationReplay(db, companyId, issue.id, next.id),
).not.toContain("Old session");
const payload = await buildPaperclipWakePayload({
db, companyId,
contextSnapshot: { issueId: issue.id, conversationMode: true, wakeCommentId: next.id },
continuationSummary: { key: "summary", title: null, body: "Old session summary", updatedAt: new Date() },
issueSummary: { ...issue, description: "Old session description" },
});
expect(JSON.stringify(payload)).not.toContain("Old session");
expect(payload?.planReviewContext).toBeNull();
expect(payload?.documentReviewContext).toBeNull();
const later = await issueService(db).addComment(
issue.id,
"Following turn",
{ userId: "local-board" },
);
expect(
await conversationReplay(db, companyId, issue.id, later.id),
).toContain("Fresh context");
expect(
await conversationReplay(db, companyId, issue.id, next.id),
).not.toContain("Following turn");
});
it("keeps concurrent delivery and multiple resets in separate ordered queue entries", async () => {
const issue = await create();
const first = await issueService(db).addComment(issue.id, "First", {
userId: "local-board",
});
const active = await runFor(issue.id, first.id);
await prepareConversationTurn(db, active);
await db
.update(issues)
.set({ executionRunId: active.id, executionLockedAt: new Date() })
.where(eq(issues.id, issue.id));
runningProcesses.set(active.id, {
child: {} as never,
graceSec: 0,
processGroupId: null,
});
try {
const commands = [];
for (const body of ["Before reset", "/new", "/new", "After reset"])
commands.push(
await issueService(db).addComment(
issue.id,
body,
{ userId: "local-board" },
{ clientRequestId: randomUUID() },
),
);
const heartbeat = heartbeatService(db);
await Promise.all(
Array.from({ length: 3 }, () =>
deliverConversationComments(db, issue, heartbeat.wakeup),
),
);
const wakes = await db
.select()
.from(agentWakeupRequests)
.where(eq(agentWakeupRequests.agentId, agentId));
const queued = wakes.filter(
(wake) =>
(wake.payload as Record<string, unknown>)?.issueId === issue.id,
);
expect(queued).toHaveLength(4);
expect(
queued.every((wake) => wake.status === "deferred_issue_execution"),
).toBe(true);
expect(
queued.map(
(wake) => (wake.payload as Record<string, unknown>).commentId,
),
).toEqual(commands.map((comment) => comment.id));
expect(
await undeliveredConversationComments(db, companyId, issue.id),
).toHaveLength(0);
for (const command of commands.slice(1, 3)) {
const reset = await runFor(issue.id, command.id);
await prepareConversationTurn(db, reset);
}
const [current] = await db
.select()
.from(issues)
.where(eq(issues.id, issue.id));
expect(current.conversationSessionGeneration).toBe(2);
expect(current.conversationBoundaryCommentId).toBe(commands[2].id);
await instanceSettingsService(db).updateExperimental({
enableAgentChat: false,
});
expect(
await heartbeat.wakeup(agentId, {
contextSnapshot: {
issueId: issue.id,
wakeCommentId: commands[3].id,
},
}),
).toBeNull();
await instanceSettingsService(db).updateExperimental({
enableAgentChat: true,
});
} finally {
runningProcesses.delete(active.id);
}
});
it("runs real process turns, processes /new without invocation, and leaves the chat idle", async () => {
const runtimeCompany = randomUUID();
const runtimeAgent = randomUUID();
await db
.insert(companies)
.values({
id: runtimeCompany,
name: "Runtime chat",
issuePrefix: "RCHAT",
requireBoardApprovalForNewAgents: false,
});
const generations: unknown[] = [];
const app = express();
app.use(express.json());
app.post("/respond", async (req, res) => {
const [run] = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.id, req.body.runId));
generations.push(run.contextSnapshot?.conversationSessionGeneration);
await issueService(db).addComment(
String(run.contextSnapshot?.issueId),
"What outcome should the task deliver?",
{ agentId: runtimeAgent, runId: run.id },
);
res.json({ ok: true });
});
const listener = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => listener.once("listening", resolve));
const address = listener.address() as { port: number };
const cwd = await mkdtemp(join(tmpdir(), "chat-runtime-"));
const script = `fetch("http://127.0.0.1:${address.port}/respond", {method:"POST", headers:{"content-type":"application/json"}, body:JSON.stringify({runId:process.env.PAPERCLIP_RUN_ID})}).then(async r=>{if(!r.ok){console.error(r.status,await r.text());process.exitCode=1}})`;
await db
.insert(agents)
.values({
id: runtimeAgent,
companyId: runtimeCompany,
name: "Conversation runtime",
role: "engineer",
status: "idle",
adapterType: "process",
adapterConfig: {
command: process.execPath,
args: ["-e", script],
cwd,
},
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } },
});
const chat = await issueService(db).create(runtimeCompany, {
title: "Runtime chat",
conversationAgentId: runtimeAgent,
conversationUserId: "local-board",
assigneeAgentId: runtimeAgent,
conversationState: "waiting",
status: "in_review",
});
const heartbeat = heartbeatService(db);
const send = async (body: string) => {
await issueService(db).addComment(
chat.id,
body,
{ userId: "local-board" },
{ clientRequestId: randomUUID() },
);
await deliverConversationComments(db, chat, heartbeat.wakeup);
};
const waitIdle = async () => {
for (let i = 0; i < 160; i += 1) {
const current = await issueService(db).getById(chat.id);
if (isWaitingConversation(current) && !current?.executionRunId)
return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, runtimeAgent));
throw new Error(
JSON.stringify(
runs.map((run) => ({
status: run.status,
error: run.error,
result: run.resultJson,
})),
),
);
};
try {
await send("Help clarify an idea");
await waitIdle();
expect(generations).toEqual([0]);
// Stop leaves a pause and a no-replay recovery disposition. /new must
// get through dispatch, release the chat pause, and reset in queue order.
await db.insert(issueTreeHolds).values({ companyId: runtimeCompany,
rootIssueId: chat.id, mode: "pause", status: "active", createdByActorType: "user",
createdByUserId: "local-board", releasePolicy: { strategy: "manual", note: "leaf_pause" },
});
await db.insert(issueRecoveryActions).values({ companyId: runtimeCompany,
sourceIssueId: chat.id, kind: "active_run_watchdog", ownerType: "board",
cause: "uncertain_provider_action", status: "resolved", fingerprint: randomUUID(),
evidence: { automaticRecovery: { replay: "blocked" } }, nextAction: "Do not replay the stopped turn.",
});
await db.insert(issueThreadInteractions).values({ companyId: runtimeCompany, issueId: chat.id,
kind: "ask_user_questions", status: "pending", title: "Old topic", payload: { version: 1, questions: [{ id: "old", prompt: "Old topic?", options: [{ id: "yes", label: "Yes" }], selectionMode: "single", required: true }], supersedeOnUserComment: false },
});
await send("/new");
await waitIdle();
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, chat.id)))[0].status).toBe("expired");
expect((await db.select().from(issueTreeHolds).where(eq(issueTreeHolds.rootIssueId, chat.id)))[0].status).toBe("released");
expect(generations).toEqual([0]);
await send("A fresh idea");
await waitIdle();
expect(generations).toEqual([0, 1]);
expect(
await heartbeat.wakeup(runtimeAgent, {
source: "automation",
contextSnapshot: { issueId: chat.id },
}),
).toBeNull();
const history = await db
.select()
.from(issueComments)
.where(eq(issueComments.issueId, chat.id));
expect(history).toHaveLength(5);
} finally {
await new Promise<void>((resolve) => listener.close(() => resolve()));
await rm(cwd, { recursive: true, force: true });
}
}, 30000);
it("rejects late replies and session events from cancelled conversation turns", async () => {
const chat = await create();
const message = await issueService(db).addComment(chat.id, "Old topic", { userId: "local-board" });
const run = await runFor(chat.id, message.id);
await prepareConversationTurn(db, run);
await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, run.id));
const previousSecret = process.env.PAPERCLIP_AGENT_JWT_SECRET;
process.env.PAPERCLIP_AGENT_JWT_SECRET = "test-conversation-cancellation-secret";
try {
const app = express();
app.use(actorMiddleware(db, { deploymentMode: "local_trusted" }));
app.post("/mutate", (_req, res) => res.sendStatus(204));
const token = createLocalAgentJwt(agentId, companyId, "process", run.id)!;
expect((await request(app).post("/mutate").set("Authorization", `Bearer ${token}`)).status).toBe(403);
} finally {
if (previousSecret === undefined) delete process.env.PAPERCLIP_AGENT_JWT_SECRET;
else process.env.PAPERCLIP_AGENT_JWT_SECRET = previousSecret;
}
await expect(issueService(db).addComment(chat.id, "Late old reply", { agentId, runId: run.id }))
.rejects.toThrow(/cancelled/);
expect(await applyRunnerGoalPrpEvent(db, { companyId, agentId, issueId: chat.id, adapterType: "process" }, {
eventType: "session.capabilities.updated", sourceRunId: run.id, sourceSeq: 500, payload: {},
})).toBeNull();
});
it("only parks answered turns and preserves idle across recovery classification", async () => {
const issue = await create();
const message = await issueService(db).addComment(
issue.id,
"Which goal matters?",
{ userId: "local-board" },
);
const run = await runFor(issue.id, message.id);
const prepared = await prepareConversationTurn(db, run);
const succeeded = {
...run,
contextSnapshot: prepared.context,
status: "succeeded",
};
expect(await settleConversationTurn(db, succeeded)).toBe(false);
await issueService(db).addComment(
issue.id,
"What outcome should the task deliver?",
{ agentId, runId: run.id },
);
expect(await settleConversationTurn(db, succeeded)).toBe(true);
const [idle] = await db
.select()
.from(issues)
.where(eq(issues.id, issue.id));
expect(isWaitingConversation(idle)).toBe(true);
expect(
classifyIssueGraphLiveness({
issues: [idle],
relations: [],
agents: [],
}),
).toEqual([]);
await instanceSettingsService(db).updateExperimental({
enableAgentChat: false,
});
await expect(
issueService(db).addComment(issue.id, "/new", {
userId: "local-board",
}),
).rejects.toThrow(/disabled/);
expect(
isWaitingConversation(
(await db.select().from(issues).where(eq(issues.id, issue.id)))[0],
),
).toBe(true);
await instanceSettingsService(db).updateExperimental({
enableAgentChat: true,
});
const child = await issueService(db).create(companyId, { title: "Execute work", status: "done" });
await db.update(issues).set({ parentId: issue.id }).where(eq(issues.id, child.id));
expect(
await issueService(db).getWakeableParentAfterChildCompletion(issue.id, {
issueId: child.id,
summary: "Finished",
}),
).toBeNull();
});
},
);
describe("chat prompt policy", () => {
it.each(["standard", "ask", "planning"])(
"keeps handoff instructions in %s, including accepted plans and resumes",
(workMode) => {
const prompt = buildPaperclipTaskMarkdown({
issue: {
id: "chat",
identifier: null,
title: "Chat",
workMode,
conversationAgentId: "agent",
description: "Pre-boundary summary that must not replay",
},
acceptedPlanContinuation: true,
includeDescription: true,
acceptedPlan: { revisionId: "old-approved-plan" },
});
expect(prompt).toContain(AGENT_CHAT_DIRECTIVE);
expect(prompt).not.toContain("Pre-boundary summary");
expect(prompt).not.toContain("old-approved-plan");
expect(prompt).not.toContain("Implement the accepted plan on this issue");
expect(prompt).toContain("Create and link each task before claiming it exists");
},
);
});
describe("native conversation finalization", () => {
it("does not require execution completion or schedule a continuation after a successful chat turn", () => {
const decision = {
policyVersion: "paperclip.native-status-arbiter.v1",
statusAction: "in_progress",
toStatus: "in_progress",
reasonCode: "completion_evidence_incomplete",
unblockDescriptor: null,
effects: [
{
kind: "enqueue_continuation",
continuationKind: "same_agent",
summary: "Finish",
idempotencyKey: "next",
agentId: "agent",
},
],
} as Parameters<typeof conversationNativeDecision>[0]["decision"];
const input = {
conversation: true,
terminalState: "succeeded",
workspaceFinalizeStatus: "succeeded",
hasGovernanceGate: false,
priorStatus: "in_progress" as const,
decision,
};
expect(conversationNativeDecision(input)).toMatchObject({
statusAction: "preserve",
effects: [],
});
expect(
conversationNativeDecision({ ...input, hasGovernanceGate: true }),
).toBe(decision);
expect(
conversationNativeDecision({ ...input, terminalState: "failed" }),
).toBe(decision);
expect(conversationNativeDecision({ ...input, conversation: false })).toBe(
decision,
);
});
});

View File

@ -0,0 +1,129 @@
import { callProjectTool } from "../services/project-tools.js";
import { createLocalAgentJwt } from "../agent-auth-jwt.js";
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { eq } from "drizzle-orm";
import { issues, heartbeatRuns } from "@paperclipai/db";
import { startRunnerApiTestServer } from "./helpers/runner-api-server.js";
import { issueService } from "../services/issues.js";
import { documentService } from "../services/documents.js";
import { activityService } from "../services/activity.js";
import { getEmbeddedPostgresTestSupport } from "./helpers/embedded-postgres.js";
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("chat project tool handoff", () => {
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(); }, 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 call = (fixture: Awaited<ReturnType<typeof server.fixture>>, tool: string, args: Record<string, unknown>) => fixture.authority.execute({ tool, arguments: args, callId: randomUUID() });
it("allows a conversation reply to enter review without manufacturing a review interaction", async () => {
const f = await server.fixture({ conversation: true });
const token = createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!;
const response = await fetch(`${server.apiUrl}/api/issues/${f.issueId}`, {
method: "PATCH", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ status: "in_review", comment: "The plan is ready for our next discussion." }),
});
const result = await response.json();
expect(response.status, JSON.stringify(result)).toBe(200);
expect(result.status).toBe("in_review");
});
it("creates an ordinary project task and its plan atomically, retaining the conversation plan", async () => {
const f = await server.fixture({ conversation: true });
await documentService(server.db).upsertIssueDocument({ issueId: f.issueId, key: "plan", format: "markdown", body: "Full discussion plan" });
const input = { title: "Implement the clarified outcome", projectId: f.projectId, initialPlan: "# Execution plan\n\nBuild and verify the outcome.", idempotencyKey: "handoff" };
const first = await call(f, "create_task", input) as any;
const again = await call(f, "create_task", input) as any;
expect(again.task.id).toBe(first.task.id);
expect(first.task.parentId).toBeNull();
const [task] = await server.db.select().from(issues).where(eq(issues.id, first.task.id));
expect(task).toMatchObject({ projectId: f.projectId, assigneeAgentId: f.agentId, status: "todo" });
expect((await documentService(server.db).getIssueDocumentByKey(task.id, "plan"))?.body).toBe(input.initialPlan);
expect((await documentService(server.db).getIssueDocumentByKey(f.issueId, "plan"))?.body).toBe("Full discussion plan");
await expect(call(f, "create_task", { ...input, title: "Different" })).rejects.toThrow(/idempotency/);
});
it("rejects creation, child helpers, and reparenting under a chat, while retaining legacy children", async () => {
const f = await server.fixture({ conversation: true });
const svc = issueService(server.db);
await expect(svc.create(f.companyId, { title: "Invalid", parentId: f.issueId })).rejects.toThrow(/cannot have new subtasks/);
await expect(svc.createChild(f.issueId, { title: "Invalid" })).rejects.toThrow(/cannot have new subtasks/);
await expect(svc.importIssues(f.companyId, [{
id: randomUUID(), ref: "imported", title: "Imported child", parentId: f.issueId,
projectId: null, projectWorkspaceId: null, description: null, assigneeAgentId: null,
status: "backlog", priority: "medium", billingCode: null, assigneeAdapterOverrides: null,
executionWorkspaceSettings: null, labelIds: [], monitorNotes: null, monitorScheduledBy: null,
}])).rejects.toThrow(/cannot have new subtasks/);
const ordinary = await svc.create(f.companyId, { title: "Ordinary" });
await expect(svc.update(ordinary.id, { parentId: f.issueId })).rejects.toThrow(/cannot have new subtasks/);
await server.db.update(issues).set({ parentId: f.issueId }).where(eq(issues.id, ordinary.id));
expect(await svc.update(ordinary.id, { title: "Legacy edited", parentId: f.issueId })).toMatchObject({ title: "Legacy edited" });
expect(await svc.update(ordinary.id, { parentId: null })).toMatchObject({ parentId: null });
});
it("hands off through the same API used by Claude/Codex MCP with the plan present on return", async () => {
const f = await server.fixture({ conversation: true });
const result = await callProjectTool({ name: "create_task", arguments: { title: "MCP handoff", projectId: f.projectId, initialPlan: "# Plan\nImplement in the execution task.", idempotencyKey: "mcp" },
apiUrl: server.apiUrl, token: createLocalAgentJwt(f.agentId, f.companyId, "paperclip_runner", f.runId, f.responsibleUserId)!,
companyId: f.companyId, issueId: f.issueId, agentId: f.agentId, conversation: true });
expect(result).toMatchObject({ parentId: null, projectId: f.projectId, assigneeAgentId: f.agentId });
expect((await documentService(server.db).getIssueDocumentByKey(result.id, "plan"))?.body).toContain("Implement in the execution task");
});
it("retains ordinary child delegation and projectless task creation", async () => {
const f = await server.fixture();
const result = await call(f, "create_task", { title: "Delegate ordinary work", idempotencyKey: "child" }) as any;
expect(result.task.parentId).toBe(f.issueId);
expect(await issueService(server.db).create(f.companyId, { title: "No project needed" })).toMatchObject({ projectId: null });
});
it("creates a project once through the production API and records it on the source feed", async () => {
const f = await server.fixture({ conversation: true });
const input = { name: "New non-code project", description: "A well-scoped outcome", idempotencyKey: "project" };
const results = await Promise.all(Array.from({ length: 4 }, () => call(f, "create_project", input))) as any[];
const project = results[0];
expect(new Set(results.map(result => result.id)).size).toBe(1);
expect(project.id).toBeTruthy();
expect((await call(f, "create_project", input) as any).id).toBe(project.id);
const feed = await activityService(server.db).forIssue(f.issueId);
expect(feed.filter(event => event.action === "project.created")).toHaveLength(1);
expect(feed.find(event => event.action === "project.created")).toMatchObject({ entityId: project.id, runId: f.runId, details: { sourceIssueId: f.issueId } });
await expect(call(f, "create_project", { ...input, name: "Changed" })).rejects.toThrow(/different inputs/);
});
it("includes an explicit workspace repository in the committed project card", async () => {
const f = await server.fixture({ conversation: true });
const project = await call(f, "create_project", { name: "Workspace repo", workspace: { repoUrl: "https://github.com/example/web" }, idempotencyKey: "workspace" }) as any;
const feed = await activityService(server.db).forIssue(f.issueId);
expect(feed.find(event => event.entityId === project.id)?.details?.repositories).toEqual([
expect.objectContaining({ url: "https://github.com/example/web" }),
]);
});
it("registers multiple previously unknown GitHub URLs and deduplicates equivalent URLs", async () => {
const f = await server.fixture({ conversation: true });
const project = await call(f, "create_project", { name: "Across repos", repositoryUrls: ["https://github.com/example/web.git", "https://github.com/example/api", "https://github.com/example/web/"], idempotencyKey: "urls" }) as any;
expect(project.workspaces.map((w: any) => w.repoUrl).sort()).toEqual(["https://github.com/example/api", "https://github.com/example/web"]);
expect(project.workspaces.filter((w: any) => w.isPrimary)).toHaveLength(1);
await expect(call(f, "create_project", { name: "Invalid", repositoryUrls: ["https://github.com/example/api"], workspace: { repoUrl: "https://github.com/example/web" }, idempotencyKey: "conflict" })).rejects.toThrow(/either workspace/);
await expect(call(f, "create_project", { name: "Invalid", repositoryUrls: ["https://user:password@github.com/example/api"], idempotencyKey: "credentials" })).rejects.toThrow(/without credentials/);
});
it("allows planning documents while denying project/task creation in Plan and Ask mode", async () => {
for (const mode of ["planning", "ask"] as const) {
const f = await server.fixture({ conversation: true, mode });
await expect(call(f, "create_project", { name: "No", idempotencyKey: "no" })).rejects.toThrow(/mode_denied/);
await expect(call(f, "create_task", { title: "No", idempotencyKey: "no" })).rejects.toThrow(/mode_denied/);
if (mode === "planning") await call(f, "write_document", { key: "plan", title: "Plan", body: "Clarify and plan here", idempotencyKey: "plan" });
}
});
it("rejects invented repository IDs and cancelled runs without creating a project", async () => {
const f = await server.fixture({ conversation: true });
await expect(call(f, "create_project", { name: "Missing repo", repositoryIds: ["999999"], idempotencyKey: "missing" })).rejects.toThrow(/repository.*available/);
await server.db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId));
await expect(call(f, "create_project", { name: "Cancelled", idempotencyKey: "cancelled" })).rejects.toThrow();
});
});

View File

@ -98,6 +98,15 @@ vi.mock("../services/index.js", () => ({
workProductService: () => ({}),
}));
vi.mock("../services/activity-log.js", async () => ({
...await vi.importActual<typeof import("../services/activity-log.js")>("../services/activity-log.js"),
persistActivity: async (db: unknown, input: unknown) => {
await mockLogActivity(db, input);
return { activity: { id: "activity" }, publication: null };
},
publishActivity: vi.fn(),
}));
vi.mock("../services/environments.js", () => ({
environmentService: () => mockEnvironmentService,
}));
@ -131,7 +140,7 @@ let issueServer: Server | null = null;
function createProjectApp() {
projectServer ??= buildApp((expressApp) => {
expressApp.use("/api", projectRoutes({} as any));
expressApp.use("/api", projectRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any));
}).listen(0);
return projectServer;
}

View File

@ -4,7 +4,7 @@ import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { eq, sql } from "drizzle-orm";
import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, issueRelations, issueThreadInteractions, connectionIntentDeliveries, toolApplications, toolConnections, toolConnectionInstalls, connectionGrants, toolCatalogEntries, toolProfiles, toolProfileBindings } from "@paperclipai/db";
import { agents, authUsers, companies, companyMemberships, createDb, heartbeatRuns, issues, projects, projectWorkspaces, activityLog, issueComments, assets, goals, approvals, documents, documentRevisions, issueDocuments, issueRelations, issueThreadInteractions, connectionIntentDeliveries, toolApplications, toolConnections, toolConnectionInstalls, connectionGrants, toolCatalogEntries, toolProfiles, toolProfileBindings } from "@paperclipai/db";
import { documentService } from "../../services/documents.js";
import { connectionIntentService } from "../../services/connection-intents.js";
import { initializeRunIdentity } from "../../services/run-identity.js";
@ -42,7 +42,7 @@ export async function startRunnerApiTestServer() {
setupRunnerPrpWebSocketServer(http, { apiUrl });
return {
db, root, apiUrl, storage,
async fixture(options: { mode?: "standard" | "ask" | "planning"; apiToolsEnabled?: boolean; reset?: boolean; connectionScenario?: RunnerConnectionScenario } = {}) {
async fixture(options: { mode?: "standard" | "ask" | "planning"; apiToolsEnabled?: boolean; reset?: boolean; conversation?: boolean; connectionScenario?: RunnerConnectionScenario } = {}) {
if (options.connectionScenario !== undefined && !CONNECTION_SCENARIOS.includes(options.connectionScenario)) throw new Error(`Unknown connection eval scenario: ${String(options.connectionScenario)}`);
// This DB is created inside this helper, never supplied by a caller. Paid
// paired runs reset it between attempts so modeled IDs and data match.
@ -56,7 +56,7 @@ export async function startRunnerApiTestServer() {
const foreignCompanyId = id("foreign-company"), foreignProjectId = id("foreign-project");
const projectWorkspaceId = id("workspace"), artifactId = id("artifact"), binaryArtifactId = id("binary-artifact"), goalId = id("goal");
const blockerId = id("blocker"), approvalId = id("approval");
const responsibleUserId = options.connectionScenario ? id("responsible-user") : null;
const responsibleUserId = options.connectionScenario || options.conversation ? id("responsible-user") : null;
const workspace = await mkdtemp(join(root, "workspace-"));
await writeFile(join(workspace, "sample.txt"), "API escape hatch fixture\n");
await db.insert(companies).values([
@ -78,8 +78,8 @@ export async function startRunnerApiTestServer() {
const saved = await storage.putFile({ companyId, namespace: "eval", originalFilename: filename, contentType, body });
await db.insert(assets).values({ id, companyId, ...saved, createdByAgentId: agentId });
}
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId, issueNumber: 1, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-1", title: "Verify runner API tools", description: "Fixture marker: amber-fox.", status: "in_progress", workMode: options.mode ?? "standard", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId, runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } });
await db.insert(issues).values({ id: issueId, companyId, projectId, projectWorkspaceId, issueNumber: 1, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-1", ...(options.conversation ? { conversationAgentId: agentId, conversationUserId: responsibleUserId, conversationState: "active" as const } : {}), title: "Verify runner API tools", description: "Fixture marker: amber-fox.", status: "in_progress", workMode: options.mode ?? "standard", assigneeAgentId: agentId });
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", responsibleUserId, runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId, ...(options.conversation ? { conversationSessionGeneration: 0 } : {}) } });
await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId));
if (responsibleUserId) await initializeRunIdentity(db, { companyId, runId, issueId, responsibleUserId, cause: "instruction" });
await db.insert(issues).values({ id: blockerId, companyId, projectId, issueNumber: 2, identifier: "E" + companyId.replaceAll("-", "").slice(0, 8) + "-2", title: "Dependency gate", description: "Complete before shipping.", status: "todo", assigneeAgentId: agentId });
@ -129,6 +129,7 @@ export async function startRunnerApiTestServer() {
const binding = { companyId, agentId, issueId, runId, apiUrl, storage, apiToolsEnabled: options.apiToolsEnabled ?? true };
return {
...binding, projectId, projectWorkspaceId, artifactId, binaryArtifactId, goalId, blockerId, approvalId, foreignCompanyId, foreignProjectId, workspace,
conversation: options.conversation ?? false,
connectionScenario: options.connectionScenario ?? null, responsibleUserId, userId: responsibleUserId, sourceRunId: runId,
customConnectionService, foreignConnectionService, pendingInteractionId,
initialInteractionIds: pendingInteractionId ? [pendingInteractionId] : [],
@ -136,12 +137,15 @@ export async function startRunnerApiTestServer() {
async snapshot() {
return {
issues: await db.select().from(issues).where(eq(issues.companyId, companyId)),
issueDocuments: await db.select().from(issueDocuments).where(eq(issueDocuments.companyId, companyId)),
projectWorkspaces: await db.select().from(projectWorkspaces).where(eq(projectWorkspaces.companyId, companyId)),
projects: await db.select().from(projects).where(eq(projects.companyId, companyId)),
activity: await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)),
comments: await db.select().from(issueComments).where(eq(issueComments.companyId, companyId)),
assets: await db.select().from(assets).where(eq(assets.companyId, companyId)),
goals: await db.select().from(goals).where(eq(goals.companyId, companyId)),
approvals: await db.select().from(approvals).where(eq(approvals.companyId, companyId)),
documentRevisions: await db.select().from(documentRevisions).where(eq(documentRevisions.companyId, companyId)),
documents: await db.select().from(documents).where(eq(documents.companyId, companyId)),
issueRelations: await db.select().from(issueRelations).where(eq(issueRelations.companyId, companyId)),
connectionInteractions: await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.companyId, companyId)),

View File

@ -31,6 +31,7 @@ describe("instance settings service", () => {
enableStreamlinedLeftNavigation: true,
enableStreamlinedUi: true,
enableApps: true,
enableAgentChat: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
enableExternalObjects: false,

View File

@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import express from "express";
import request from "supertest";
import { eq } from "drizzle-orm";
import { eq, sql } from "drizzle-orm";
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import {
agentWakeupRequests,
@ -10,7 +10,6 @@ import {
companies,
companyMemberships,
createDb,
heartbeatRunEvents,
heartbeatRuns,
issueComments,
issues,
@ -52,17 +51,9 @@ describeEmbeddedPostgres("issue queued-comment routes", () => {
}, 30_000);
afterEach(async () => {
await db.update(issues).set({ executionRunId: null }).catch(() => undefined);
await db.update(agentWakeupRequests).set({ runId: null }).catch(() => undefined);
await db.delete(activityLog).catch(() => undefined);
await db.delete(issueComments).catch(() => undefined);
await db.delete(heartbeatRunEvents).catch(() => undefined);
await db.delete(heartbeatRuns).catch(() => undefined);
await db.delete(agentWakeupRequests).catch(() => undefined);
await db.delete(issues).catch(() => undefined);
await db.delete(companyMemberships).catch(() => undefined);
await db.delete(agents).catch(() => undefined);
await db.delete(companies).catch(() => undefined);
// Runs can create additional company-scoped rows. Clear their full FK
// closure rather than silently leaving fixtures behind after a delete fails.
await db.execute(sql`TRUNCATE TABLE "companies" CASCADE`);
});
afterAll(async () => {

View File

@ -54,6 +54,7 @@ const apiPrefixes: Record<string, string> = {
"plugin-ui-static.ts": "/api",
"plugins.ts": "/api",
"projects.ts": "/api",
"project-tools.ts": "/api",
"resource-memberships.ts": "/api",
"remote-agent-profiles.ts": "/api",
"routines.ts": "/api",
@ -188,6 +189,8 @@ describe("openapi routes", () => {
AgentBearerAuth: { type: "http", scheme: "bearer" },
});
expect(res.body.paths["/api/health"].get.security).toEqual([]);
expect(res.body.paths["/api/mcp/project-tools"].post.security).toEqual([{ AgentRunAuth: [] }]);
expect(res.body.paths["/api/mcp/project-tools"].post["x-paperclip-authorization"]).toEqual({ actor: "agent", heartbeatBound: true, taskBound: true });
expect(res.body.paths["/mcp/gateways/{gatewayPublicId}"].post.security).toEqual([]);
expect(res.body.paths["/api/mcp/gateways/{gatewayPublicId}"]).toBeUndefined();
expect(res.body.paths["/api/companies"].get.parameters).toContainEqual({

View File

@ -52,6 +52,11 @@ vi.mock("../services/workspace-runtime.js", () => ({
}));
function registerModuleMocks() {
vi.doMock("../services/activity-log.js", async () => ({
...await vi.importActual<typeof import("../services/activity-log.js")>("../services/activity-log.js"),
persistActivity: async (db: unknown, input: unknown) => { await mockLogActivity(db, input); return { activity: { id: "activity" }, publication: null }; },
publishActivity: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
@ -92,7 +97,7 @@ async function createApp(routeType: "project" | "goal") {
const { projectRoutes } = await vi.importActual<typeof import("../routes/projects.js")>(
"../routes/projects.js",
);
app.use("/api", projectRoutes({} as any));
app.use("/api", projectRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any));
} else {
const { goalRoutes } = await vi.importActual<typeof import("../routes/goals.js")>(
"../routes/goals.js",

View File

@ -54,6 +54,11 @@ vi.mock("../services/workspace-runtime.js", () => ({
}));
function registerModuleMocks() {
vi.doMock("../services/activity-log.js", async () => ({
...await vi.importActual<typeof import("../services/activity-log.js")>("../services/activity-log.js"),
persistActivity: async (db: unknown, input: unknown) => { await mockLogActivity(db, input); return { activity: { id: "activity" }, publication: null }; },
publishActivity: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
@ -98,7 +103,7 @@ async function createApp() {
};
next();
});
app.use("/api", projectRoutes({} as any));
app.use("/api", projectRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any));
app.use(errorHandler);
return app;
}

View File

@ -72,6 +72,11 @@ vi.mock("../services/workspace-runtime.js", () => ({
}));
function registerModuleMocks() {
vi.doMock("../services/activity-log.js", async () => ({
...await vi.importActual<typeof import("../services/activity-log.js")>("../services/activity-log.js"),
persistActivity: async (db: unknown, input: unknown) => { await mockLogActivity(db, input); return { activity: { id: "activity" }, publication: null }; },
publishActivity: vi.fn(),
}));
vi.doMock("../telemetry.js", () => ({
getTelemetryClient: mockGetTelemetryClient,
}));
@ -122,7 +127,7 @@ async function createApp() {
next();
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
app.use("/api", projectRoutes({} as any));
app.use("/api", projectRoutes({ transaction: async (effect: (tx: unknown) => unknown) => effect({}) } as any));
app.use(errorHandler);
return app;
}

View File

@ -1,3 +1,4 @@
import { projectToolRoutes } from "./routes/project-tools.js";
import { toolActionDeliveryService } from "./services/tool-action-delivery.js";
import express, { Router, type Request as ExpressRequest } from "express";
import { createServer as createHttpServer, type Server as HttpServer } from "node:http";
@ -518,6 +519,7 @@ export async function createApp(
}),
);
api.use(assetRoutes(db, opts.storageService));
api.use(projectToolRoutes(db));
api.use(projectRoutes(db));
api.use(caseRoutes(db, opts.storageService));
api.use(issueTreeControlRoutes(db));

View File

@ -381,9 +381,15 @@ export function actorMiddleware(db: Db, opts: ActorMiddlewareOptions): RequestHa
}
const [identityRun] = await db.select({ activeIdentityContextId: heartbeatRuns.activeIdentityContextId,
responsibleUserId: heartbeatRuns.responsibleUserId, status: heartbeatRuns.status }).from(heartbeatRuns).where(and(
responsibleUserId: heartbeatRuns.responsibleUserId, status: heartbeatRuns.status,
contextSnapshot: heartbeatRuns.contextSnapshot }).from(heartbeatRuns).where(and(
eq(heartbeatRuns.id, claims.run_id), eq(heartbeatRuns.companyId, claims.company_id), eq(heartbeatRuns.agentId, claims.sub),
));
if (identityRun?.status === "cancelled" && identityRun.contextSnapshot?.conversationMode === true
&& !["GET", "HEAD", "OPTIONS"].includes(req.method)) {
_res.status(403).json({ error: "This conversation turn was cancelled", code: "conversation_turn_cancelled" });
return;
}
if (identityRun?.activeIdentityContextId && identityRun.status === "running") {
const captured = await captureRunIdentity(db, { companyId: claims.company_id, agentId: claims.sub, runId: claims.run_id });
identityRun.activeIdentityContextId = captured.context?.id ?? null;

View File

@ -1,11 +1,12 @@
import { EXECUTION_RECONCILIATION_CAUSES } from "@paperclipai/shared";
import { and, asc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
import { and, asc, eq, gt, gte, inArray, lte, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
agentWakeupRequests,
agents,
heartbeatRuns,
issueRecoveryActions,
issueComments,
issues,
} from "@paperclipai/db";
import { ISSUE_DISPOSITION_REPAIR_RETRY_REASON } from "@paperclipai/shared";
@ -870,12 +871,29 @@ export function createPostgresRunDispatchAdapter(
const contextSnapshot = parseObject(run.contextSnapshot);
const issueId = readNonEmptyString(contextSnapshot.issueId);
if (!issueId) return { issueId: null, decision: { stale: false as const } };
// A verified /new is a context-only command, not a replay of uncertain
// execution. Let it reach the ordered reset handler. After the boundary,
// old recovery records remain auditable but cannot restart the old turn.
const [conversation] = await tx.select().from(issues).where(and(
eq(issues.id, issueId), eq(issues.companyId, run.companyId),
));
const commentId = deriveCommentId(contextSnapshot);
const [comment] = commentId ? await tx.select().from(issueComments).where(and(
eq(issueComments.id, commentId), eq(issueComments.issueId, issueId),
eq(issueComments.companyId, run.companyId),
)) : [];
const resetCommand = !!(conversation?.conversationAgentId && comment?.authorUserId
&& !comment.deletedAt && comment.body.trim() === "/new");
const [boundary] = conversation?.conversationBoundaryCommentId
? await tx.select().from(issueComments).where(eq(issueComments.id, conversation.conversationBoundaryCommentId)) : [];
const [recovery] = await tx.select({ id: issueRecoveryActions.id, nextAction: issueRecoveryActions.nextAction })
.from(issueRecoveryActions).where(and(
eq(issueRecoveryActions.companyId, run.companyId), eq(issueRecoveryActions.sourceIssueId, issueId),
or(inArray(issueRecoveryActions.status, ["active", "escalated"]),
sql`${issueRecoveryActions.evidence}->'automaticRecovery'->>'replay' = 'blocked'`),
inArray(issueRecoveryActions.cause, [...EXECUTION_RECONCILIATION_CAUSES]),
resetCommand ? sql`false` : boundary
? gt(issueRecoveryActions.createdAt, boundary.createdAt) : undefined,
)).limit(1);
if (recovery) return { issueId, decision: { stale: true as const,
errorCode: "execution_reconciliation_required" as const, reason: recovery.nextAction,

View File

@ -1,3 +1,4 @@
import { deliverConversationComments, isConversation } from "../services/agent-conversations.js";
import { issueRecoveryActionReadModel } from "../services/issue-recovery-actions.js";
import { requiresExecutionReconciliation } from "@paperclipai/shared";
import { validateExecutionReconciliation, markExecutionReconciliation } from "../services/execution-recovery-resolution.js";
@ -3766,6 +3767,8 @@ export function issueRoutes(
async function assertInReviewReviewPath(input: {
existing: {
conversationAgentId?: string | null;
conversationUserId?: string | null;
id: string;
companyId: string;
status: string;
@ -3783,6 +3786,9 @@ export function issueRoutes(
const nextStatus = typeof input.updateFields.status === "string"
? input.updateFields.status
: input.existing.status;
// Conversations wait for the next message; successful run finalization owns
// the waiting state. They do not need an execution-task review assignment.
if (isConversation(input.existing) && !input.reviewInteractionId) return null;
if (input.existing.status === "in_review" || nextStatus !== "in_review") return null;
if (input.actorType !== "agent" && !input.reviewInteractionId) return null;
@ -5659,7 +5665,7 @@ export function issueRoutes(
async function buildQueuedCommentQueue(input: {
executor: IssueQueueDb;
issue: { id: string; companyId: string; assigneeAgentId: string | null };
issue: { id: string; companyId: string; assigneeAgentId: string | null; conversationAgentId?: string | null };
activeRun: Awaited<ReturnType<typeof resolveActiveIssueRun>>;
actor: ReturnType<typeof getActorInfo>;
queueState?: IssueQueueState | null;
@ -5700,6 +5706,7 @@ export function issueRoutes(
if (protocol === "paperclip_runner_v1" && (!steeringRun || comments.length === 0)) {
steeringDisposition = "temporarily_unavailable";
}
if (input.issue.conversationAgentId) steeringDisposition = "unsupported";
return {
issueId: input.issue.id,
queueId: wake?.id ?? null,
@ -10264,6 +10271,9 @@ export function issueRoutes(
onBehalfOfUserId: _requestedOnBehalfOfUserId,
...updateFields
} = req.body;
if (existing.conversationAgentId && req.actor.type === "board" && commentBody) {
throw unprocessable("Send conversation messages through the comments endpoint with a clientRequestId");
}
if (deferWakeForGoal === true && (
!normalizedAssigneeAgentId ||
Object.keys(req.body).some((key) => !["assigneeAgentId", "assigneeUserId", "deferWakeForGoal"].includes(key))
@ -12331,6 +12341,7 @@ export function issueRoutes(
const commentId = req.params.commentId as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (issue.conversationAgentId) throw conflict("Conversation messages are processed in order at turn boundaries");
const actor = getActorInfo(req);
const steeringIdentity = await reserveSteeredIdentity(db, {
companyId: issue.companyId, runId: req.body.targetRunId, issueId: issue.id, messageId: commentId,
@ -13656,10 +13667,53 @@ export function issueRoutes(
res.json(bundle);
});
// Resolving an unused chat is read-only. POST is used only by first send/upload.
for (const method of ["get", "post"] as const) {
router[method]("/companies/:companyId/chats/:agentRef", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
if (req.actor.type !== "board" || !req.actor.userId) throw forbidden("Board user access required");
if (!(await instanceSettings.getExperimental()).enableAgentChat) throw notFound("Agent Chat is disabled");
const resolved = await agentsSvc.resolveByReference(companyId, req.params.agentRef as string);
if (resolved.ambiguous) throw conflict("Agent reference is ambiguous");
if (!resolved.agent) throw notFound("Agent not found");
const agent = resolved.agent;
const existing = await svc.getConversation(companyId, agent.id, req.actor.userId);
if (existing && !(await assertIssueReadAllowed(req, res, existing))) return;
if (existing || method === "get") { res.json(existing); return; }
const issue = await svc.create(companyId, {
title: `Chat with ${agent.name}`, assigneeAgentId: agent.id,
conversationAgentId: agent.id, conversationUserId: req.actor.userId,
conversationState: "waiting", status: "in_review", createdByUserId: req.actor.userId,
});
await logActivity(db, { companyId, actorType: "user", actorId: req.actor.userId,
action: "issue.conversation_opened", entityType: "issue", entityId: issue.id,
details: { agentId: agent.id } });
res.json(issue);
});
}
router.post("/issues/:id/comments", validate(addIssueCommentSchema), async (req, res) => {
const id = req.params.id as string;
const issue = await getAccessibleResource(req, res, svc.getById(id), "Issue not found");
if (!issue) return;
if (issue.conversationAgentId && req.actor.type === "board") {
if (!(await instanceSettings.getExperimental()).enableAgentChat) throw notFound("Agent Chat is disabled");
if (!req.actor.userId) throw forbidden("Board user access required");
if (!req.body.clientRequestId) throw unprocessable("Chat messages require a clientRequestId for safe retries");
if (!(await assertAgentIssueCommentAllowed(req, res, issue))) return;
const actor = getActorInfo(req);
const comment = await svc.addComment(issue.id, req.body.body, { userId: req.actor.userId }, {
clientRequestId: req.body.clientRequestId, authorType: "user",
});
await logActivity(db, { companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "issue.comment_added", entityType: "issue", entityId: issue.id,
details: { commentId: comment.id, identifier: issue.identifier } });
await issueReferencesSvc.syncComment(comment.id);
await deliverConversationComments(db, issue, heartbeat.wakeup);
res.status(201).json(comment);
return;
}
if (req.actor.type === "agent" && req.body.onBehalfOfUserId != null) {
await auditAgentIssueCommentAttributionSpoof({
db,
@ -14573,6 +14627,9 @@ export function issueRoutes(
res.status(422).json({ error: "Issue does not belong to company" });
return;
}
if (issue.conversationAgentId && req.actor.type === "board" && !(await instanceSettings.getExperimental()).enableAgentChat) {
throw notFound("Agent Chat is disabled");
}
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;

View File

@ -793,6 +793,7 @@ function registerCurrentRoute(input: {
type OpenApiAuthLevel =
| "public"
| "agent_run"
| "runtime_tools"
| "authenticated"
| "board"
@ -801,6 +802,7 @@ type OpenApiAuthLevel =
const BOARD_SESSION_AUTH_SCHEME = "BoardSessionAuth";
const BOARD_API_KEY_AUTH_SCHEME = "BoardApiKeyAuth";
const AGENT_BEARER_AUTH_SCHEME = "AgentBearerAuth";
const AGENT_RUN_AUTH_SCHEME = "AgentRunAuth";
const RUNTIME_TOOLS_BEARER_AUTH_SCHEME = "RuntimeToolsBearerAuth";
function securityRequirement(name: string): Record<string, string[]> {
@ -1109,6 +1111,7 @@ function isBoardOnlyOperation(method: string, path: string) {
function resolveOperationAuthLevel(method: string, path: string): OpenApiAuthLevel {
const key = operationKey(method, path);
if (PUBLIC_OPERATIONS.has(key)) return "public";
if (key === "POST /api/mcp/project-tools") return "agent_run";
if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools";
if (INSTANCE_ADMIN_OPERATIONS.has(key)) return "instance_admin";
if (isBoardOnlyOperation(method, path) || experimentalApiMetadata[`${method.toUpperCase()} ${path}`]?.boardOnly) return "board";
@ -1156,6 +1159,12 @@ function applyDocumentFixups(document: any): any {
description:
"Scoped token bound to an active heartbeat run and presented in the Authorization bearer header. The GitHub credential endpoint requires the distinct github_credentials scope.",
},
[AGENT_RUN_AUTH_SCHEME]: {
type: "http",
scheme: "bearer",
bearerFormat: "Task-bound agent JWT",
description: "Paperclip-issued JWT bound to an active task run. Agent API keys, board sessions, and connection-only tokens are rejected.",
},
};
document.security = AUTHENTICATED_SECURITY;
@ -1164,6 +1173,8 @@ function applyDocumentFixups(document: any): any {
const authLevel = resolveOperationAuthLevel(method, path);
if (authLevel === "public") {
operation.security = [];
} else if (authLevel === "agent_run") {
operation.security = [securityRequirement(AGENT_RUN_AUTH_SCHEME)];
} else if (authLevel === "runtime_tools") {
operation.security = RUNTIME_TOOLS_SECURITY;
} else if (authLevel === "authenticated") {
@ -1177,6 +1188,8 @@ function applyDocumentFixups(document: any): any {
? { actor: "board", instanceAdmin: true }
: authLevel === "board"
? { actor: "board" }
: authLevel === "agent_run"
? { actor: "agent", heartbeatBound: true, taskBound: true }
: authLevel === "runtime_tools"
? { actor: "runtime_tools", heartbeatBound: true }
: authLevel === "authenticated"
@ -7521,6 +7534,20 @@ for (const route of [
// --- Connection intents ------------------------------------------------------
registerCurrentRoute({
method: "post",
path: "/api/mcp/project-tools",
tags: ["projects"],
summary: "Call project and task tools through the active task run's MCP transport",
body: z.object({
jsonrpc: z.literal("2.0"),
id: z.union([z.string(), z.number()]).nullable().optional(),
method: z.string(),
params: z.record(z.string(), z.unknown()).optional(),
}),
responses: { 200: r.ok(), 202: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict },
});
registerCurrentRoute({
method: "post",
path: "/runtime-tools/github/credentials",

View File

@ -0,0 +1,37 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import { projectToolContext } from "../services/project-tool-context.js";
import { callProjectTool, projectToolDefinitions } from "../services/project-tools.js";
import { assertCompanyAccess } from "./authz.js";
import { forbidden } from "../errors.js";
/** Mounted after actor middleware; connection-scoped tokens cannot authenticate here. */
export function projectToolRoutes(db: Db) {
const router = Router();
router.post("/mcp/project-tools", async (req, res) => {
const context = await projectToolContext(db, req.actor);
assertCompanyAccess(req, context.run.companyId);
const { id = null, method, params } = req.body;
const send = (result: unknown) => res.json({ jsonrpc: "2.0", id, result });
if (method === "initialize") return send({ protocolVersion: "2025-03-26", capabilities: { tools: { listChanged: false } }, serverInfo: { name: "paperclip-project-tools", version: "1" } });
if (method === "notifications/initialized") return res.status(202).end();
const definitions = projectToolDefinitions(context.issue.workMode, true);
if (method === "tools/list") return send({ tools: definitions });
if (method !== "tools/call") return res.json({ jsonrpc: "2.0", id, error: { code: -32601, message: "Method not found" } });
try {
if (!definitions.some(tool => tool.name === params?.name)) throw forbidden("Tool is unavailable in this mode");
const apiUrl = process.env.PAPERCLIP_API_URL;
if (!apiUrl) throw new Error("Paperclip API origin is unavailable");
const result = await callProjectTool({
name: params.name, arguments: params.arguments ?? {}, apiUrl,
token: req.header("authorization")!.replace(/^Bearer\s+/i, ""),
companyId: context.run.companyId, issueId: context.issue.id, agentId: context.run.agentId,
conversation: Boolean(context.issue.conversationAgentId),
});
return send({ content: [{ type: "text", text: JSON.stringify(result) }], structuredContent: result });
} catch (error) {
return send({ isError: true, content: [{ type: "text", text: error instanceof Error ? error.message : "Project tool failed" }] });
}
});
return router;
}

View File

@ -1,5 +1,10 @@
import { createHash } from "node:crypto";
import { and, eq, sql } from "drizzle-orm";
import { activityLog } from "@paperclipai/db";
import { projectToolContext } from "../services/project-tool-context.js";
import { persistActivity, publishActivity } from "../services/activity-log.js";
import { z } from "zod";
import { resolveProjectRepositorySelection } from "../services/project-repositories.js";
import { normalizeProjectRepositoryUrl, resolveProjectRepositorySelection } from "../services/project-repositories.js";
import { toolAccessService } from "../services/tool-access.js";
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclipai/db";
@ -47,10 +52,17 @@ export function projectRoutes(db: Db) {
const router = Router();
const svc = projectService(db);
async function repositoryViewer(req: Request) {
if (req.actor.type === "board") return { userId: req.actor.userId ?? null, localTrusted: req.actor.source === "local_implicit" };
const context = await projectToolContext(db, req.actor);
if (!context.userId) throw forbidden("Repository access requires a responsible user");
return context;
}
async function selectedRepositories(req: Request, companyId: string, ids: string[], existing: import("@paperclipai/shared").ProjectWorkspace[] = []) {
assertBoard(req);
const viewer = await repositoryViewer(req);
if (!ids.length) return [];
const available = await toolAccessService(db).listProjectRepositories(companyId, req.actor.userId ?? null, req.actor.source === "local_implicit");
const available = await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted);
return resolveProjectRepositorySelection(ids, available.repositories, existing);
}
const access = accessService(db);
@ -173,10 +185,10 @@ export function projectRoutes(db: Db) {
});
router.get("/companies/:companyId/project-repositories", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await toolAccessService(db).listProjectRepositories(companyId, req.actor.userId ?? null, req.actor.source === "local_implicit"));
const viewer = await repositoryViewer(req);
res.json(await toolAccessService(db).listProjectRepositories(companyId, viewer.userId, viewer.localTrusted));
});
router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => {
@ -226,7 +238,9 @@ export function projectRoutes(db: Db) {
repositoryIds?: string[];
};
const { workspace, repositoryIds, ...projectData } = req.body as CreateProjectPayload;
const { workspace, repositoryIds, repositoryUrls, idempotencyKey, ...projectData } = req.body as CreateProjectPayload & { idempotencyKey?: string; repositoryUrls?: string[] };
const runContext = req.actor.type === "agent" && req.actor.source === "agent_jwt" && req.actor.runId
? await projectToolContext(db, req.actor, true) : null;
await assertProjectEnvironmentSelection(
companyId,
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
@ -246,48 +260,65 @@ export function projectRoutes(db: Db) {
{ strictMode: strictSecretsMode, fieldPath: "env" },
);
}
if (workspace && repositoryIds) throw unprocessable("Use either workspace or repositoryIds when creating a project");
if (workspace && (repositoryIds || repositoryUrls)) throw unprocessable("Use either workspace or repositoryIds/repositoryUrls when creating a project");
const urlRepositories = (repositoryUrls ?? []).map(normalizeProjectRepositoryUrl);
const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null;
const project = repositories ? await svc.createWithRepositories(companyId, projectData, repositories) : await svc.create(companyId, projectData);
if (project.env) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,
{ targetType: "project", targetId: project.id },
project.env,
);
}
let createdWorkspaceId: string | null = null;
if (workspace) {
const createdWorkspace = await svc.createWorkspace(project.id, workspace);
if (!createdWorkspace) {
await svc.remove(project.id);
res.status(422).json({ error: "Invalid project workspace payload" });
return;
}
createdWorkspaceId = createdWorkspace.id;
}
const hydratedProject = workspace ? await svc.getById(project.id) : project;
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "project.created",
entityType: "project",
entityId: project.id,
details: {
name: project.name,
workspaceId: createdWorkspaceId,
envKeys: project.env ? Object.keys(project.env).sort() : [],
},
const fingerprint = createHash("sha256").update(JSON.stringify({ projectData, workspace, repositoryIds, repositoryUrls })).digest("hex");
const receiptKey = idempotencyKey ? `project:${companyId}:${actor.actorId}:${runContext?.issue.id ?? "board"}:${idempotencyKey}` : null;
const result = await db.transaction(async (tx) => {
if (receiptKey) {
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${receiptKey}, 0))`);
const [prior] = await tx.select().from(activityLog).where(and(
eq(activityLog.companyId, companyId), eq(activityLog.action, "project.created"),
sql`${activityLog.details}->>'idempotencyKey' = ${receiptKey}`,
));
if (prior) {
if (prior.details?.fingerprint !== fingerprint) throw conflict("Project idempotency key was used with different inputs");
const project = await projectService(tx as unknown as Db).getById(prior.entityId);
if (!project) throw conflict("Previously created project is no longer available");
return { project, publication: null, duplicate: true };
}
}
if (runContext) await projectToolContext(tx as unknown as Db, req.actor, true);
const service = projectService(tx as unknown as Db);
const project = repositories ? await service.createWithRepositories(companyId, projectData, repositories) : await service.create(companyId, projectData);
const attachedUrls = new Set((repositories ?? []).map(repo => repo.url.toLowerCase()));
const registeredUrls: typeof urlRepositories = [];
for (const repo of urlRepositories) {
if (attachedUrls.has(repo.url.toLowerCase())) continue;
attachedUrls.add(repo.url.toLowerCase());
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url });
registeredUrls.push(repo);
}
const createdWorkspace = workspace ? await service.createWorkspace(project.id, workspace) : null;
if (workspace && !createdWorkspace) throw unprocessable("Invalid project workspace payload");
const hydrated = await service.getById(project.id);
const activity = await persistActivity(tx as unknown as Db, {
companyId, actorType: actor.actorType, actorId: actor.actorId, agentId: actor.agentId,
runId: actor.runId, issueId: runContext?.issue.id,
action: "project.created", entityType: "project", entityId: project.id,
details: {
name: project.name, description: project.description, icon: project.icon,
sourceIssueId: runContext?.issue.id ?? null,
repositories: [...(repositories ?? []).map(repo => ({ id: repo.id, name: repo.fullName, url: repo.url })), ...registeredUrls.map(repo => ({ id: repo.url, name: repo.fullName, url: repo.url })),
...(createdWorkspace?.repoUrl ? [{ id: createdWorkspace.id, name: createdWorkspace.name, url: createdWorkspace.repoUrl }] : []),
],
workspaceId: createdWorkspace?.id ?? null,
envKeys: project.env ? Object.keys(project.env).sort() : [],
...(receiptKey ? { idempotencyKey: receiptKey, fingerprint } : {}),
},
});
return { project: hydrated ?? project, publication: activity.publication, duplicate: false };
});
if (result.publication) publishActivity(result.publication);
if (result.project.env) await secretsSvc.syncEnvBindingsForTarget?.(companyId, { targetType: "project", targetId: result.project.id }, result.project.env);
if (result.duplicate) { res.status(200).json(result.project); return; }
const telemetryClient = getTelemetryClient();
if (telemetryClient) {
trackProjectCreated(telemetryClient);
}
res.status(201).json(hydratedProject ?? project);
res.status(result.duplicate ? 200 : 201).json(result.project);
});
router.patch("/projects/:id", validate(updateProjectSchema), async (req, res) => {

View File

@ -86,6 +86,7 @@ export function activityService(db: Db) {
case
when ${heartbeatRuns.resultJson} is null then null
else jsonb_strip_nulls(jsonb_build_object(
'conversationReset', ${heartbeatRuns.resultJson} -> 'conversationReset',
'billingType', coalesce(${heartbeatRuns.resultJson} -> 'billingType', ${heartbeatRuns.resultJson} -> 'billing_type'),
'billing_type', coalesce(${heartbeatRuns.resultJson} -> 'billing_type', ${heartbeatRuns.resultJson} -> 'billingType'),
'costUsd', coalesce(
@ -370,9 +371,10 @@ export function activityService(db: Db) {
.select()
.from(activityLog)
.where(
and(
eq(activityLog.entityType, "issue"),
eq(activityLog.entityId, issueId),
or(
and(eq(activityLog.entityType, "issue"), eq(activityLog.entityId, issueId)),
and(eq(activityLog.action, "project.created"), sql`${activityLog.details}->>'sourceIssueId' = ${issueId}`,
sql`${activityLog.companyId} = (select company_id from issues where id = ${issueId})`),
),
)
.orderBy(desc(activityLog.createdAt)),

View File

@ -0,0 +1,479 @@
import {
persistActivity,
publishActivity,
type ActivityPublication,
} from "./activity-log.js";
import type { NativeStatusDecision } from "./native-runtime/status-arbiter.js";
import { and, desc, eq, isNull, sql } from "drizzle-orm";
import {
agentTaskSessions,
agentWakeupRequests,
heartbeatRuns,
issueComments,
issueTreeHolds,
issueThreadInteractions,
issues,
type Db,
} from "@paperclipai/db";
import { sanitizeQuarantinedCommentForHigherTrust } from "./source-trust.js";
export type ConversationIdentity = {
conversationAgentId?: string | null;
conversationUserId?: string | null;
conversationState?: string | null;
status?: string;
};
export function isConversation(
issue: ConversationIdentity | null | undefined,
): boolean {
return Boolean(issue?.conversationAgentId && issue.conversationUserId);
}
export function isWaitingConversation(
issue: ConversationIdentity | null | undefined,
): boolean {
return (
isConversation(issue) &&
issue?.conversationState === "waiting" &&
issue.status === "in_review"
);
}
export function isConversationReset(body: string): boolean {
return body.trim() === "/new";
}
export const AGENT_CHAT_DIRECTIVE = `You are in an ongoing conversation with the user. Help them clarify the outcome they want. Ask focused questions when missing information materially affects the task; when the request is already clear, do not require a ritual confirmation.
Research, clarify, and develop full plans here using the conversation's plan document. Revise the draft as the discussion develops. Planning alone does not create execution tasks. Put implementation and substantial execution into separate tasks.
Before handing off work, inspect available projects and repositories. Every task you create from this chat must belong to a suitable project. Reuse an appropriate existing project; otherwise use create_project. Consider all relevant available repositories and pass repositoryIds for one or multiple repositories when the work spans them. For existing GitHub repositories you can access that are absent from the catalog, pass their HTTPS repositoryUrls; this registers them with the project without creating remote GitHub repositories. You may combine known IDs and URLs and attach multiple repositories. Never invent repository IDs or substitute inaccessible repositories. Ask when the choice is materially ambiguous or required access is missing. Non-code projects may need no repository.
Create ordinary assigned tasks, never subtasks of this conversation. Give each task a clear outcome, context, acceptance criteria, project, and appropriate assignee. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. Preserve the original plan here. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists.
Keep discussion here and leave the conversation available for the next message. Reply normally and end your turn; Paperclip manages the conversation waiting state. Do not change its status, create a review confirmation just to finish a reply, mark it complete, or poll for another reply. An accepted plan authorizes handoff to execution tasks, never implementation on this conversation. Honor normal approvals. Ask mode is non-mutating. Plan mode supports research and writing/revising the plan; hand off for execution only through the normal authorized workflow.`;
/** Runs under the normal issue execution lock, before any provider session is read. */
export async function prepareConversationTurn(
db: Db,
run: typeof heartbeatRuns.$inferSelect,
) {
const context = { ...(run.contextSnapshot ?? {}) };
const issueId = typeof context.issueId === "string" ? context.issueId : null;
if (!issueId) return { context, reset: false, conversation: false };
let publication: ActivityPublication | null = null;
const result = await db.transaction(async (tx) => {
const [issue] = await tx
.select()
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.for("update");
if (!isConversation(issue))
return { context, reset: false, conversation: false };
const commentId =
typeof context.wakeCommentId === "string"
? context.wakeCommentId
: typeof context.commentId === "string"
? context.commentId
: null;
const [comment] = commentId
? await tx
.select()
.from(issueComments)
.where(
and(
eq(issueComments.id, commentId),
eq(issueComments.issueId, issueId),
eq(issueComments.companyId, run.companyId),
),
)
: [];
const reset = Boolean(
comment && comment.authorUserId && isConversationReset(comment.body),
);
let generation = issue.conversationSessionGeneration;
if (
typeof context.conversationSessionGeneration === "number" &&
context.conversationSessionGeneration !== generation
) {
throw new Error(
"Conversation session changed; this older turn cannot resume",
);
}
// The boundary lives on the command comment. A crash/retry reuses it instead of resetting twice.
if (reset && comment && comment.conversationSessionGeneration == null) {
generation += 1;
await tx
.update(issues)
.set({
conversationSessionGeneration: generation,
conversationBoundaryCommentId: comment.id,
updatedAt: new Date(),
})
.where(eq(issues.id, issue.id));
await tx
.update(issueComments)
.set({ conversationSessionGeneration: generation })
.where(eq(issueComments.id, comment.id));
// Questions from the previous session must not keep occupying the
// composer or wake the old topic, even if they survive normal comments.
const expiredQuestions = await tx.update(issueThreadInteractions).set({
status: "expired", resolvedAt: new Date(), updatedAt: new Date(),
resolvedByUserId: comment.authorUserId,
result: { version: 1, outcome: "withdrawn", reason: "New conversation session", answers: [], summaryMarkdown: null },
}).where(and(eq(issueThreadInteractions.companyId, issue.companyId),
eq(issueThreadInteractions.issueId, issue.id), eq(issueThreadInteractions.status, "pending"),
eq(issueThreadInteractions.kind, "ask_user_questions"))).returning({ id: issueThreadInteractions.id });
publication = (
await persistActivity(tx as unknown as Db, {
companyId: issue.companyId,
actorType: "system",
actorId: "conversation",
action: "issue.conversation_session_started",
entityType: "issue",
entityId: issue.id,
runId: run.id,
details: { generation, boundaryCommentId: comment.id, expiredInteractionIds: expiredQuestions.map((row) => row.id) },
})
).publication;
// Deliberately do not touch agentRuntimeState or sessions belonging to other tasks.
await tx
.delete(agentTaskSessions)
.where(
and(
eq(agentTaskSessions.companyId, issue.companyId),
eq(agentTaskSessions.agentId, issue.conversationAgentId!),
eq(agentTaskSessions.taskKey, issue.id),
),
);
}
await tx
.update(issues)
.set({
conversationState: "active",
status: "in_progress",
updatedAt: new Date(),
})
.where(eq(issues.id, issue.id));
const next = {
...context,
conversationSessionGeneration: generation,
conversationMode: true,
};
await tx
.update(heartbeatRuns)
.set({ contextSnapshot: next })
.where(eq(heartbeatRuns.id, run.id));
return { context: next, reset, conversation: true };
});
if (publication) publishActivity(publication);
return result;
}
/** Finalizers only park a turn with a durable response (or a processed /new). */
export async function settleConversationTurn(
db: Db,
run: typeof heartbeatRuns.$inferSelect,
) {
if (run.status !== "succeeded") return false;
const context = run.contextSnapshot ?? {};
const issueId = typeof context.issueId === "string" ? context.issueId : null;
if (!issueId) return false;
let publication: ActivityPublication | null = null;
const settled = await db.transaction(async (tx) => {
const [issue] = await tx
.select()
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, run.companyId)))
.for("update");
if (
!isConversation(issue) ||
(issue.executionRunId && issue.executionRunId !== run.id)
)
return false;
const [response] = await tx
.select({ id: issueComments.id })
.from(issueComments)
.where(
and(
eq(issueComments.issueId, issueId),
eq(issueComments.createdByRunId, run.id),
eq(issueComments.authorAgentId, issue.conversationAgentId!),
isNull(issueComments.deletedAt),
),
)
.limit(1);
if (!response && context.conversationReset !== true) return false;
if (
context.conversationSessionGeneration !==
issue.conversationSessionGeneration
)
return false;
// Messages arriving during the reply remain actionable, including the
// crash window between their comment commit and wake enqueue.
const wakeId =
typeof context.wakeCommentId === "string"
? context.wakeCommentId
: context.commentId;
const [wake] =
typeof wakeId === "string"
? await tx
.select()
.from(issueComments)
.where(eq(issueComments.id, wakeId))
: [];
const [pending] = wake
? await tx
.select({ id: issueComments.id })
.from(issueComments)
.where(
and(
eq(issueComments.issueId, issueId),
isNull(issueComments.deletedAt),
sql`${issueComments.authorUserId} is not null`,
sql`(${issueComments.createdAt}, ${issueComments.id}) > (select cursor.created_at, cursor.id from issue_comments cursor where cursor.id = ${wake.id}::uuid)`,
),
)
.limit(1)
: [];
const status = pending ? "in_progress" : "in_review";
const conversationState = pending ? "active" : "waiting";
if (
issue.status === status &&
issue.conversationState === conversationState
)
return true;
await tx
.update(issues)
.set({
status,
conversationState,
statusVersion: sql`${issues.statusVersion} + 1`,
completedAt: null,
cancelledAt: null,
updatedAt: new Date(),
})
.where(
and(
eq(issues.id, issueId),
sql`(${issues.executionRunId} is null or ${issues.executionRunId} = ${run.id})`,
),
);
publication = (
await persistActivity(tx as unknown as Db, {
companyId: issue.companyId,
actorType: "system",
actorId: "conversation",
action: "issue.updated",
entityType: "issue",
entityId: issue.id,
runId: run.id,
details: {
status,
conversationState,
conversationSessionGeneration: issue.conversationSessionGeneration,
},
})
).publication;
return true;
});
if (publication) publishActivity(publication);
return settled;
}
/** Fresh provider context uses only messages in this session, up to this turn. */
export async function conversationReplay(
db: Db,
companyId: string,
issueId: string,
wakeCommentId: string | null,
) {
const [issue] = await db
.select()
.from(issues)
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)));
if (!isConversation(issue)) return "";
const [boundary] = issue.conversationBoundaryCommentId
? await db
.select()
.from(issueComments)
.where(eq(issueComments.id, issue.conversationBoundaryCommentId))
: [];
const [wake] = wakeCommentId
? await db
.select()
.from(issueComments)
.where(
and(
eq(issueComments.id, wakeCommentId),
eq(issueComments.issueId, issueId),
),
)
: [];
const rows = await db
.select()
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
eq(issueComments.issueId, issueId),
isNull(issueComments.deletedAt),
boundary
? sql`(${issueComments.createdAt}, ${issueComments.id}) > (select cursor.created_at, cursor.id from issue_comments cursor where cursor.id = ${boundary.id}::uuid)`
: undefined,
wake
? sql`(${issueComments.createdAt}, ${issueComments.id}) < (select cursor.created_at, cursor.id from issue_comments cursor where cursor.id = ${wake.id}::uuid)`
: undefined,
),
)
.orderBy(desc(issueComments.createdAt), desc(issueComments.id))
.limit(40);
return rows
.reverse()
.map((row) =>
JSON.stringify({
author: row.authorAgentId ? "agent" : "user",
body: sanitizeQuarantinedCommentForHigherTrust(row).body.slice(0, 8000),
}),
)
.join("\n");
}
/** Comment rows form a durable outbox for the narrow commit-to-enqueue crash window. */
export async function undeliveredConversationComments(
db: Db,
companyId: string,
issueId: string,
) {
return db
.select()
.from(issueComments)
.where(
and(
eq(issueComments.companyId, companyId),
eq(issueComments.issueId, issueId),
isNull(issueComments.deletedAt),
sql`${issueComments.clientRequestId} is not null`,
sql`not exists (select 1 from ${agentWakeupRequests} where ${agentWakeupRequests.companyId} = ${companyId}
and ${agentWakeupRequests.idempotencyKey} = 'conversation-comment:' || ${issueComments.id}::text)`,
),
)
.orderBy(issueComments.createdAt, issueComments.id)
.limit(100);
}
/** A user's /new resumes this chat without replaying the stopped turn. */
export async function resumeConversationForReset(db: Db, comment: typeof issueComments.$inferSelect) {
if (!comment.authorUserId || !isConversationReset(comment.body)) return;
const publications: ActivityPublication[] = [];
await db.transaction(async (tx) => {
const [issue] = await tx.select().from(issues).where(and(
eq(issues.id, comment.issueId), eq(issues.companyId, comment.companyId),
)).for("update");
if (!isConversation(issue) || comment.conversationSessionGeneration != null) return;
const released = await tx.update(issueTreeHolds).set({
status: "released", releasedAt: new Date(), updatedAt: new Date(),
releasedByActorType: "user", releasedByUserId: comment.authorUserId,
releaseReason: "Resumed by /new", releaseMetadata: { commentId: comment.id, wakeAgents: false },
}).where(and(eq(issueTreeHolds.companyId, issue.companyId),
eq(issueTreeHolds.rootIssueId, issue.id), eq(issueTreeHolds.mode, "pause"),
eq(issueTreeHolds.status, "active"))).returning();
for (const hold of released) {
publications.push((await persistActivity(tx as unknown as Db, {
companyId: issue.companyId, actorType: "user", actorId: comment.authorUserId!,
action: "issue.tree_hold_released", entityType: "issue", entityId: issue.id,
details: { holdId: hold.id, mode: "pause", reason: "Resumed by /new", commentId: comment.id },
})).publication);
}
});
for (const publication of publications) publishActivity(publication);
}
/** Serialize durable outbox delivery across API servers; the normal wake queue owns execution. */
export async function deliverConversationComments(
db: Db,
issue: { id: string; companyId: string; conversationAgentId: string | null },
enqueue: (
agentId: string,
options: {
source: "on_demand";
triggerDetail: "manual";
reason: string;
idempotencyKey: string;
requestedByActorType: "user";
requestedByActorId: string | null;
payload: Record<string, unknown>;
contextSnapshot: Record<string, unknown>;
},
) => Promise<unknown>,
) {
if (!issue.conversationAgentId) return;
for (;;) {
const delivered = await db.transaction(async (tx) => {
// Contenders release their connection while waiting so concurrent sends
// cannot exhaust the pool needed by normal wake admission.
const locks = await tx.execute(
sql`select pg_try_advisory_xact_lock(hashtextextended(${"conversation-delivery:" + issue.id}, 0)) as acquired`,
);
if (!locks[0]?.acquired) return false;
for (const comment of await undeliveredConversationComments(
tx as unknown as Db,
issue.companyId,
issue.id,
)) {
await resumeConversationForReset(db, comment);
await enqueue(issue.conversationAgentId!, {
source: "on_demand",
triggerDetail: "manual",
reason: "issue_commented",
idempotencyKey: `conversation-comment:${comment.id}`,
requestedByActorType: "user",
requestedByActorId: comment.authorUserId,
payload: { issueId: issue.id, commentId: comment.id },
contextSnapshot: {
issueId: issue.id,
taskKey: issue.id,
commentId: comment.id,
wakeCommentId: comment.id,
wakeCommentIds: [comment.id],
source: "issue.comment",
},
});
}
return true;
});
if (delivered) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
/** Conversation turns do not need execution-task completion evidence or a continuation. */
export function conversationNativeDecision(input: {
conversation: boolean;
terminalState: unknown;
workspaceFinalizeStatus: string;
hasGovernanceGate: boolean;
priorStatus: NativeStatusDecision["toStatus"];
decision: NativeStatusDecision;
}): NativeStatusDecision {
if (
!input.conversation ||
input.terminalState !== "succeeded" ||
input.workspaceFinalizeStatus !== "succeeded" ||
input.hasGovernanceGate ||
input.decision.statusAction === "blocked" ||
input.decision.effects.some(
(effect) =>
effect.kind === "schedule_retry" ||
effect.kind === "record_finalization_error",
)
)
return input.decision;
return {
...input.decision,
statusAction: "preserve",
toStatus: input.priorStatus,
reasonCode: "conversation_turn_finished",
unblockDescriptor: null,
effects: [],
};
}

View File

@ -56,7 +56,7 @@ import {
BLOCKER_ATTENTION_MAX_NODES,
issueService,
} from "./issues.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { executionIssueCondition } from "./issue-visibility.js";
import { parseIssueExecutionState } from "./issue-execution-policy.js";
import { isProspectiveBlockedTransition } from "./routable-blocked.js";
import { evaluateAgentInvokability, type AgentOrgRow } from "./agent-invokability.js";
@ -846,7 +846,7 @@ async function issueSummaryMap(db: Db, companyId: string, issueIds: Array<string
eq(issues.projectWorkspaceId, projectWorkspaces.id),
eq(projectWorkspaces.companyId, companyId),
))
.where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), visibleIssueCondition()));
.where(and(eq(issues.companyId, companyId), inArray(issues.id, ids), executionIssueCondition()));
return new Map(rows.map((row) => [row.id, {
id: row.id,
companyId: row.companyId,
@ -1647,7 +1647,7 @@ export function attentionService(db: Db, serviceOptions: AttentionServiceOptions
updatedAt: issues.updatedAt,
})
.from(issues)
.where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), visibleIssueCondition()))
.where(and(eq(issues.companyId, companyId), eq(issues.status, "in_review"), executionIssueCondition()))
.orderBy(desc(issues.updatedAt), desc(issues.id));
const reviewIssueIds = reviewRows.map((row) => row.id);
const pendingReviewApprovalRows = reviewIssueIds.length === 0

View File

@ -3,7 +3,7 @@ import type { Db } from "@paperclipai/db";
import { agents, approvals, companies, costEvents, heartbeatRuns, issues } from "@paperclipai/db";
import { notFound } from "../errors.js";
import { budgetService } from "./budgets.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { executionIssueCondition } from "./issue-visibility.js";
const DASHBOARD_RUN_ACTIVITY_DAYS = 14;
@ -44,7 +44,7 @@ export function dashboardService(db: Db) {
const taskRows = await db
.select({ status: issues.status, count: sql<number>`count(*)` })
.from(issues)
.where(and(eq(issues.companyId, companyId), visibleIssueCondition()))
.where(and(eq(issues.companyId, companyId), executionIssueCondition()))
.groupBy(issues.status);
const pendingApprovals = await db

View File

@ -1,3 +1,4 @@
import { AGENT_CHAT_DIRECTIVE, conversationReplay, isConversation, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.js";
import { legacyExecutionNeedsReconciliation, terminalizeLegacyExecution } from "./legacy-execution-recovery.js";
import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
import { buildHeartbeatRunStatusLiveEventPayload } from "./heartbeat-run-status-payload.js";
@ -6930,7 +6931,8 @@ export async function buildPaperclipWakePayload(input: {
input.contextSnapshot.annotationCommentId,
);
const issueId = readNonEmptyString(input.contextSnapshot.issueId);
const continuationSummary = input.continuationSummary ?? null;
const conversationMode = input.contextSnapshot.conversationMode === true;
const continuationSummary = conversationMode ? null : input.continuationSummary ?? null;
const agentMessage = parseObject(
input.contextSnapshot[PAPERCLIP_AGENT_MESSAGE_KEY],
);
@ -6994,7 +6996,7 @@ export async function buildPaperclipWakePayload(input: {
const commentsById = new Map(
commentRows.map((comment) => [comment.id, comment]),
);
const issueDescription = issueSummary?.description ?? null;
const issueDescription = conversationMode ? null : issueSummary?.description ?? null;
const issueDescriptionTruncated =
issueDescription !== null &&
issueDescription.length > MAX_INLINE_WAKE_ISSUE_DESCRIPTION_CHARS;
@ -7147,7 +7149,7 @@ export async function buildPaperclipWakePayload(input: {
const checkboxSelection = parseObject(
input.contextSnapshot.checkboxSelection,
);
const planReviewContext = issueId
const planReviewContext = issueId && !conversationMode
? await buildPlanReviewContext({
db: input.db,
companyId: input.companyId,
@ -7158,7 +7160,7 @@ export async function buildPaperclipWakePayload(input: {
interactionId,
})
: null;
const documentReviewContext = issueId
const documentReviewContext = issueId && !conversationMode
? await buildDocumentReviewContext({
db: input.db,
companyId: input.companyId,
@ -7656,6 +7658,7 @@ export function buildPaperclipTaskMarkdown(input: {
identifier: string | null;
title: string;
workMode?: string | null;
conversationAgentId?: string | null;
description?: string | null;
} | null;
ancestors?: Array<{
@ -7696,7 +7699,7 @@ export function buildPaperclipTaskMarkdown(input: {
const ancestors = (input.ancestors ?? []).slice(0, 6);
const wakeComment = input.wakeComment ?? null;
const acceptedPlanContinuation =
!wakeComment &&
!issue?.conversationAgentId && !wakeComment &&
(input.acceptedPlanContinuation ||
(input.interaction?.kind === "request_confirmation" &&
input.interaction.status === "accepted" &&
@ -7712,7 +7715,9 @@ export function buildPaperclipTaskMarkdown(input: {
`- Issue: ${quoteTaskScalar(issue.identifier || issue.id)}`,
`- Title: ${quoteTaskScalar(issue.title)}`,
);
if (issue.workMode === "ask") {
if (issue.conversationAgentId) {
lines.push("", "Chat mode directive:", AGENT_CHAT_DIRECTIVE, `Current composer mode: ${issue.workMode ?? "standard"}.`);
} else if (issue.workMode === "ask") {
lines.push(
`- Work mode: ${quoteTaskScalar("ask")}`,
"",
@ -7762,7 +7767,7 @@ export function buildPaperclipTaskMarkdown(input: {
);
}
const description =
input.includeDescription === false ? "" : issue.description?.trim();
input.includeDescription === false || issue.conversationAgentId ? "" : issue.description?.trim();
if (description) {
lines.push("", "Issue description:", fenceTaskText(description));
}
@ -9072,6 +9077,11 @@ export function heartbeatService(
async function getIssueExecutionContext(companyId: string, issueId: string) {
return db
.select({
conversationAgentId: issues.conversationAgentId,
conversationUserId: issues.conversationUserId,
conversationState: issues.conversationState,
conversationSessionGeneration: issues.conversationSessionGeneration,
conversationBoundaryCommentId: issues.conversationBoundaryCommentId,
id: issues.id,
identifier: issues.identifier,
title: issues.title,
@ -10950,14 +10960,15 @@ export function heartbeatService(
lastRunId: string | null;
lastError: string | null;
}) {
const existing = await getTaskSession(
input.companyId,
input.agentId,
input.adapterType,
input.taskKey,
);
return db.transaction(async (tx) => {
const [issue] = await tx.select().from(issues).where(and(sql`${issues.id}::text = ${input.taskKey}`, eq(issues.companyId, input.companyId))).for("update");
if (isConversation(issue)) {
const [run] = input.lastRunId ? await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, input.lastRunId)) : [];
if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return null;
}
const existing = await tx.select().from(agentTaskSessions).where(and(eq(agentTaskSessions.companyId, input.companyId), eq(agentTaskSessions.agentId, input.agentId), eq(agentTaskSessions.adapterType, input.adapterType), eq(agentTaskSessions.taskKey, input.taskKey))).then((rows) => rows[0] ?? null);
if (existing) {
return db
return tx
.update(agentTaskSessions)
.set({
sessionParamsJson: input.sessionParamsJson,
@ -10971,7 +10982,7 @@ export function heartbeatService(
.then((rows) => rows[0] ?? null);
}
return db
return tx
.insert(agentTaskSessions)
.values({
companyId: input.companyId,
@ -10985,12 +10996,13 @@ export function heartbeatService(
})
.returning()
.then((rows) => rows[0] ?? null);
});
}
async function clearTaskSessions(
companyId: string,
agentId: string,
opts?: { taskKey?: string | null; adapterType?: string | null },
opts?: { taskKey?: string | null; adapterType?: string | null; expectedRunId?: string },
) {
const conditions = [
eq(agentTaskSessions.companyId, companyId),
@ -11003,11 +11015,16 @@ export function heartbeatService(
conditions.push(eq(agentTaskSessions.adapterType, opts.adapterType));
}
return db
.delete(agentTaskSessions)
.where(and(...conditions))
.returning()
.then((rows) => rows.length);
return db.transaction(async (tx) => {
if (opts?.taskKey && opts.expectedRunId) {
const [issue] = await tx.select().from(issues).where(sql`${issues.id}::text = ${opts.taskKey}`).for("update");
if (isConversation(issue)) {
const [run] = await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, opts.expectedRunId));
if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return 0;
}
}
return tx.delete(agentTaskSessions).where(and(...conditions)).returning().then((rows) => rows.length);
});
}
async function ensureRuntimeState(agent: typeof agents.$inferSelect) {
@ -11364,6 +11381,7 @@ export function heartbeatService(
const issueId = readNonEmptyString(context.issueId);
if (!issueId) return;
if (isWaitingConversation(await getIssueExecutionContext(run.companyId, issueId))) return;
const [issue, agent] = await Promise.all([
db
@ -11583,6 +11601,7 @@ export function heartbeatService(
const issueId =
readNonEmptyString(context.issueId) ?? readNonEmptyString(context.taskId);
if (!issueId) return;
if (isWaitingConversation(await getIssueExecutionContext(run.companyId, issueId))) return;
if (
readNonEmptyString(context.goalControlRequestId) ||
context.resumeSessionGoalHeartbeat === true
@ -11896,6 +11915,7 @@ export function heartbeatService(
readNonEmptyString(contextSnapshot.issueId) ??
readNonEmptyString(contextSnapshot.taskId);
if (!issueId) return;
if (isWaitingConversation(await getIssueExecutionContext(run.companyId, issueId))) return;
const issue = await db
.select({
@ -14916,6 +14936,7 @@ export function heartbeatService(
isNull(issues.assigneeUserId),
isNull(issues.hiddenAt),
inArray(issues.status, [...TIMER_ACTIONABLE_ISSUE_STATUSES]),
isNull(issues.conversationAgentId),
),
)
.limit(1)
@ -17528,6 +17549,30 @@ export function heartbeatService(
return;
}
const dispatchIssueId = readNonEmptyString(parseObject(run.contextSnapshot).issueId);
const resumingAdmittedConversationTurn = !!runOptions.nativeLeaseOwner
&& typeof run.contextSnapshot?.conversationSessionGeneration === "number";
if (dispatchIssueId && isConversation(await getIssueExecutionContext(run.companyId, dispatchIssueId))
&& !resumingAdmittedConversationTurn && !(await instanceSettings.getExperimental()).enableAgentChat) {
await setRunStatus(run.id, "cancelled", { finishedAt: new Date(), error: "Agent Chat is disabled", errorCode: "agent_chat_disabled" });
await setWakeupStatus(run.wakeupRequestId, "cancelled", { finishedAt: new Date() });
await releaseIssueExecutionAndPromote((await getRun(run.id))!, { suppressImmediateRecovery: true });
await finalizeAgentStatus(agent.id, "cancelled");
return;
}
const preparedConversation = await prepareConversationTurn(db, run);
run = { ...run, contextSnapshot: preparedConversation.context };
if (preparedConversation.reset) {
const contextSnapshot = { ...preparedConversation.context, conversationReset: true };
await setRunStatus(run.id, "succeeded", { finishedAt: new Date(), contextSnapshot, resultJson: { conversationReset: true }, issueCommentStatus: "not_applicable" });
await setWakeupStatus(run.wakeupRequestId, "completed", { finishedAt: new Date() });
const resetRun = (await getRun(run.id))!;
await settleConversationTurn(db, resetRun);
await appendRunEvent(resetRun, { eventType: "lifecycle", stream: "system", level: "info", message: "New conversation session" });
await releaseIssueExecutionAndPromote(resetRun, { suppressImmediateRecovery: true });
await finalizeAgentStatus(agent.id, "succeeded");
return;
}
const runtime = await ensureRuntimeState(agent);
const context = parseObject(run.contextSnapshot);
const providerTraceRequested =
@ -17723,7 +17768,7 @@ export function heartbeatService(
)
.then((rows) => rows[0] ?? null)
: null;
const acceptedPlanContinuationWake = issueContext
const acceptedPlanContinuationWake = issueContext && !isConversation(issueContext)
? readNonEmptyString(context.workspaceRefreshReason) ===
"accepted_plan_confirmation" ||
(issueContext.workMode === "planning" &&
@ -17841,6 +17886,12 @@ export function heartbeatService(
taskKey,
)
: null;
if (isConversation(issueContext)) {
delete context.resumeSessionParams;
delete context.resumeSessionDisplayId;
delete context.executionContinuation;
delete context.paperclipContinuationSummary;
}
const taskSessionDecodedParams = normalizeSessionParams(
sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null),
);
@ -17874,6 +17925,7 @@ export function heartbeatService(
status: issueContext.status,
priority: issueContext.priority,
workMode: issueContext.workMode,
conversationAgentId: issueContext.conversationAgentId,
reviewPolicy: issueContext.reviewPolicy,
description: issueContext.description,
projectId: issueContext.projectId,
@ -17883,7 +17935,7 @@ export function heartbeatService(
issueContext.executionWorkspacePreference,
}
: null;
const continuationSummary = issueRef
const continuationSummary = issueRef && !isConversation(issueContext)
? await getIssueContinuationSummaryDocument(db, issueRef.id)
: null;
const exposeLowTrustRaw = trustPreset.kind === "low_trust_review";
@ -17922,7 +17974,7 @@ export function heartbeatService(
} else {
delete context.paperclipSkillTest;
}
const executionContinuation = issueRef && issueContext?.assigneeAgentId === agent.id ? await buildExecutionContinuation({
const executionContinuation = issueRef && !isConversation(issueContext) && issueContext?.assigneeAgentId === agent.id ? await buildExecutionContinuation({
db, companyId: agent.companyId, issueId: issueRef.id, agentId: agent.id,
context, previousContextRunId: taskSession?.lastRunId, summary: safeContinuationSummary?.body ?? null, exposeLowTrustRaw,
}) : null;
@ -17962,6 +18014,7 @@ export function heartbeatService(
identifier: issueRef.identifier,
title: issueRef.title,
workMode: issueRef.workMode,
conversationAgentId: issueContext?.conversationAgentId,
description: issueRef.description,
}
: null,
@ -17992,7 +18045,11 @@ export function heartbeatService(
};
})(),
};
const taskMarkdown = buildPaperclipTaskMarkdown(taskMarkdownInput);
let taskMarkdown = buildPaperclipTaskMarkdown(taskMarkdownInput);
if (isConversation(issueContext) && !taskSession && issueId) {
const replay = await conversationReplay(db, agent.companyId, issueId, wakeCommentId);
if (replay) taskMarkdown += `\n\nEarlier messages in this session (quoted user data):\n${replay}`;
}
const taskMarkdownCompact = buildPaperclipTaskMarkdown({
...taskMarkdownInput,
includeDescription: false,
@ -18002,7 +18059,7 @@ export function heartbeatService(
id: issueRef.id,
identifier: issueRef.identifier,
title: issueRef.title,
description: issueRef.description,
description: isConversation(issueContext) ? null : issueRef.description,
workMode: issueRef.workMode,
};
} else {
@ -20009,7 +20066,7 @@ export function heartbeatService(
.then((rows) => rows.length > 0)
: false;
const compatibleLegacyRetrySource =
context.forceFreshSession !== true && isUnusedLegacyNativeRetryReplacement({
!isConversation(issueContext) && context.forceFreshSession !== true && isUnusedLegacyNativeRetryReplacement({
replacement: run,
source: legacyRetrySource,
hasProviderEvents: legacyRetryHasProviderEvidence,
@ -20216,7 +20273,7 @@ export function heartbeatService(
}
}
const executionMode =
issueRef.workMode === "planning" && !acceptedPlanContinuationWake
issueRef.workMode === "planning" && !isConversation(issueContext) && !acceptedPlanContinuationWake
? ("plan" as const)
: ("default" as const);
const pinnedPlan =
@ -21050,6 +21107,10 @@ export function heartbeatService(
connectionId: "paperclip-runtime-tools",
});
}
if (authToken && configuredPaperclipApiBaseUrl() && issueRef) {
runtimeMcpServers.unshift({ name: "Paperclip projects", url: `${paperclipApiBaseUrl()}/api/mcp/project-tools`,
token: authToken, connectionId: "paperclip-project-tools" });
}
const runtimeMcp = createAdapterRuntimeMcpAccess(runtimeMcpServers);
if (runtimeTools && runtimeToolDelivery === "invocation_context") {
adapterContext.paperclipRuntimeTools = runtimeTools;
@ -21814,14 +21875,16 @@ export function heartbeatService(
agent,
resolvedPresentationDecision,
);
const conversationSettled = await settleConversationTurn(db, livenessRun);
await releaseIssueExecutionAndPromote(livenessRun, {
suppressImmediateRecovery:
suppressImmediateRecovery: conversationSettled ||
readNonEmptyString(
parseObject(livenessRun.contextSnapshot).goalControlRequestId,
) !== null ||
parseObject(livenessRun.contextSnapshot)
.resumeSessionGoalHeartbeat === true,
});
if (!conversationSettled) {
await handleRunLivenessContinuation(livenessRun);
await handleIssueReviewPathDisposition(livenessRun);
await handleSuccessfulRunHandoff(
@ -21834,6 +21897,7 @@ export function heartbeatService(
: livenessRun,
agent,
);
}
if (
outcome === "succeeded" &&
issueId &&
@ -21914,6 +21978,7 @@ export function heartbeatService(
await clearTaskSessions(agent.companyId, agent.id, {
taskKey,
adapterType: agent.adapterType,
expectedRunId: finalizedRun.id,
});
} else {
await upsertTaskSession({
@ -23618,6 +23683,14 @@ export function heartbeatService(
let agent = await getAgent(agentId);
if (!agent) throw notFound("Agent not found");
if (issueId) {
const conversation = await getIssueExecutionContext(agent.companyId, issueId);
if (isConversation(conversation)) {
if (agent.id !== conversation!.conversationAgentId) return null;
if (!(await instanceSettings.getExperimental()).enableAgentChat) return null;
if (!wakeCommentId && isWaitingConversation(conversation) && !hasInteractionContinuationWakeContext(enrichedContextSnapshot)) return null;
}
}
if (agent.adapterType === "paperclip_runner") {
const oldConfig = parseObject(agent.adapterConfig);
const nextConfig = normalizeLegacyRunnerProvider(oldConfig);
@ -23996,6 +24069,9 @@ export function heartbeatService(
id: issues.id,
companyId: issues.companyId,
identifier: issues.identifier,
conversationAgentId: issues.conversationAgentId,
conversationUserId: issues.conversationUserId,
conversationState: issues.conversationState,
status: issues.status,
projectId: issues.projectId,
projectWorkspaceId: issues.projectWorkspaceId,
@ -24673,6 +24749,7 @@ export function heartbeatService(
: activeExecutionRun;
if (
!isConversation(issue) &&
isSameExecutionAgent &&
!shouldDeferFollowupWake &&
!shouldQueueFollowupForRunningWake &&
@ -24738,7 +24815,7 @@ export function heartbeatService(
.limit(1)
.then((rows) => rows[0] ?? null);
if (existingDeferred) {
if (existingDeferred && !isConversation(issue)) {
const existingDeferredPayload = parseObject(
existingDeferred.payload,
);

View File

@ -234,6 +234,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableApps: true,
enablePipelines: parsed.data.enablePipelines ?? false,
enableCases: parsed.data.enableCases ?? false,
enableAgentChat: parsed.data.enableAgentChat ?? false,
enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false,
enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false,
enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false,
@ -272,6 +273,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
enableApps: true,
enablePipelines: false,
enableCases: false,
enableAgentChat: false,
enableConferenceRoomChat: false,
enableClassicTaskInterface: false,
enableIssuePlanDecompositions: false,

View File

@ -250,6 +250,7 @@ export async function refreshIssueContinuationSummary(input: {
db
.select({
id: issues.id,
conversationAgentId: issues.conversationAgentId,
identifier: issues.identifier,
title: issues.title,
description: issues.description,
@ -262,7 +263,7 @@ export async function refreshIssueContinuationSummary(input: {
getIssueContinuationSummaryDocument(db, issueId),
]);
if (!issue) return null;
if (!issue || issue.conversationAgentId) return null;
const body = buildContinuationSummaryMarkdown({
issue,
run,

View File

@ -714,6 +714,12 @@ export function issueTreeControlService(db: Db) {
preview: IssueTreeControlPreview;
resumedPauseHoldIds?: string[];
}> {
if (input.mode === "cancel") {
const [conversation] = await db.select({ id: issues.id }).from(issues).where(and(
eq(issues.id, rootIssueId), eq(issues.companyId, companyId), sql`${issues.conversationAgentId} is not null`,
));
if (conversation) throw unprocessable("Stop the active reply instead of cancelling the persistent conversation");
}
const holdReleasePolicy = normalizeReleasePolicy(input.releasePolicy);
const holdPreview = await preview(companyId, rootIssueId, {
mode: input.mode,

View File

@ -8,3 +8,8 @@ export function visibleIssueCondition(): SQL {
export function visibleIssueSql(alias = "issues") {
return `"${alias}"."hidden_at" IS NULL AND "${alias}"."harness_kind" IS NULL`;
}
/** Work queues and execution totals omit persistent conversation containers. */
export function executionIssueCondition(): SQL {
return and(visibleIssueCondition(), isNull(issues.conversationAgentId))!;
}

View File

@ -1,3 +1,4 @@
import { documentService } from "./documents.js";
import { executionProjectionsForRuns } from "./execution-projection.js";
import type { ExecutionProjection } from "@paperclipai/shared";
import { Buffer } from "node:buffer";
@ -704,7 +705,16 @@ type IssueUserContextInput = {
};
type ProjectGoalReader = Pick<Db, "select">;
type DbReader = Pick<Db, "select">;
/** Conversation containers cannot acquire new child edges, even with the experiment disabled. */
async function assertExecutionTaskParent(db: Db, companyId: string, parentId?: string | null) {
if (!parentId) return;
const [parent] = await db.select({ conversationAgentId: issues.conversationAgentId })
.from(issues).where(and(eq(issues.id, parentId), eq(issues.companyId, companyId)));
if (parent?.conversationAgentId) throw unprocessable("Conversations cannot have new subtasks; create a task in a project instead");
}
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
initialPlan?: string | null;
labelIds?: string[];
blockedByIssueIds?: string[];
inheritExecutionWorkspaceFromIssueId?: string | null;
@ -3126,6 +3136,9 @@ async function listIssueReviewAttentionMap(
assigneeUserId: issue.assigneeUserId,
createdByAgentId: issue.createdByAgentId,
createdByUserId: issue.createdByUserId,
conversationAgentId: issue.conversationAgentId,
conversationUserId: issue.conversationUserId,
conversationState: issue.conversationState,
executionPolicy: issue.executionPolicy,
executionState: issue.executionState,
monitorNextCheckAt: issue.monitorNextCheckAt,
@ -3232,6 +3245,11 @@ async function listIssueReviewAttentionMap(
}
const issueListSelect = {
conversationAgentId: issues.conversationAgentId,
conversationUserId: issues.conversationUserId,
conversationState: issues.conversationState,
conversationSessionGeneration: issues.conversationSessionGeneration,
conversationBoundaryCommentId: issues.conversationBoundaryCommentId,
id: issues.id,
companyId: issues.companyId,
projectId: issues.projectId,
@ -4004,6 +4022,9 @@ async function listIssueBlockedInboxAttentionMap(
assigneeUserId: issue.assigneeUserId,
createdByAgentId: issue.createdByAgentId,
createdByUserId: issue.createdByUserId,
conversationAgentId: issue.conversationAgentId,
conversationUserId: issue.conversationUserId,
conversationState: issue.conversationState,
executionPolicy: issue.executionPolicy,
executionState: issue.executionState,
monitorNextCheckAt: issue.monitorNextCheckAt,
@ -5653,6 +5674,7 @@ export function issueService(db: Db) {
}
const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()];
if (!filters?.q?.trim()) conditions.push(isNull(issues.conversationAgentId));
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(filters?.assigneeAgentId);
assertValidAssigneeAgentFilter(assigneeAgentFilter);
const limit = typeof filters?.limit === "number" && Number.isFinite(filters.limit)
@ -5913,6 +5935,7 @@ export function issueService(db: Db) {
}
const conditions = [eq(issues.companyId, companyId), visibleIssueCondition()];
if (!filters?.q?.trim()) conditions.push(isNull(issues.conversationAgentId));
const statuses = parseStatusFilter(filters?.status);
if (statuses.length === 1) conditions.push(eq(issues.status, statuses[0]!));
else if (statuses.length > 1) conditions.push(inArray(issues.status, statuses));
@ -6712,6 +6735,7 @@ export function issueService(db: Db) {
const parent = await db
.select({
id: issues.id,
conversationAgentId: issues.conversationAgentId,
assigneeAgentId: issues.assigneeAgentId,
status: issues.status,
companyId: issues.companyId,
@ -6719,7 +6743,7 @@ export function issueService(db: Db) {
.from(issues)
.where(eq(issues.id, parentIssueId))
.then((rows) => rows[0] ?? null);
if (!parent || !parent.assigneeAgentId || ["backlog", "done", "cancelled"].includes(parent.status)) {
if (!parent || parent.conversationAgentId || !parent.assigneeAgentId || ["backlog", "done", "cancelled"].includes(parent.status)) {
return null;
}
@ -6794,6 +6818,7 @@ export function issueService(db: Db) {
.where(eq(issues.id, parentIssueId))
.then((rows) => rows[0] ?? null);
if (!parent) throw notFound("Parent issue not found");
await assertExecutionTaskParent(db, parent.companyId, parent.id);
const idempotencyKey = data.idempotencyKey?.trim();
if (idempotencyKey) {
@ -7172,8 +7197,13 @@ export function issueService(db: Db) {
});
},
getConversation: async (companyId: string, agentId: string, userId: string) => db.select().from(issues).where(and(
eq(issues.companyId, companyId), eq(issues.conversationAgentId, agentId), eq(issues.conversationUserId, userId),
)).then((rows) => rows[0] ?? null),
create: async (companyId: string, data: IssueCreateInput) => {
const {
initialPlan,
labelIds: inputLabelIds,
blockedByIssueIds,
inheritExecutionWorkspaceFromIssueId,
@ -7207,6 +7237,18 @@ export function issueService(db: Db) {
throw unprocessable("in_progress issues require an assignee");
}
return db.transaction(async (tx) => {
await assertExecutionTaskParent(tx as unknown as Db, companyId, issueData.parentId);
if (issueData.conversationAgentId && issueData.conversationUserId) {
const identity = `conversation:${companyId}:${issueData.conversationAgentId}:${issueData.conversationUserId}`;
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${identity}, 0))`);
const [existing] = await tx.select().from(issues).where(and(eq(issues.companyId, companyId),
eq(issues.conversationAgentId, issueData.conversationAgentId), eq(issues.conversationUserId, issueData.conversationUserId)));
if (existing) {
const [enriched] = await withIssueLabels(tx, [existing]);
const [withRelations] = await withIssueRelationSummaries(companyId, [enriched], tx);
return withRelations;
}
}
const idempotencyKey = rawIdempotencyKey?.trim() || null;
const normalizedTitle = normalizeCreateIssueTitle(issueData.title);
if (allowDuplicate === false) {
@ -7503,6 +7545,13 @@ export function issueService(db: Db) {
tx,
);
}
if (initialPlan?.trim()) {
await documentService(tx as unknown as Db).upsertIssueDocument({
issueId: issue.id, key: "plan", title: "Plan", format: "markdown", body: initialPlan,
createdByAgentId: issueData.createdByAgentId, createdByUserId: issueData.createdByUserId,
createdByRunId: actorRunId,
});
}
const [enriched] = await withIssueLabels(tx, [issue]);
const [withRelations] = await withIssueRelationSummaries(companyId, [enriched], tx);
return withRelations;
@ -7599,6 +7648,7 @@ export function issueService(db: Db) {
let counter = base;
for (const row of rows) {
await assertExecutionTaskParent(tx as unknown as Db, companyId, row.parentId);
counter += 1;
const issueNumber = counter;
const identifier = `${company.issuePrefix}-${issueNumber}`;
@ -7798,6 +7848,17 @@ export function issueService(db: Db) {
.where(eq(issues.id, id))
.then((rows: Array<typeof issues.$inferSelect>) => rows[0] ?? null);
if (!existing) return null;
if (data.parentId !== undefined && data.parentId !== existing.parentId) {
await assertExecutionTaskParent(dbOrTx, existing.companyId, data.parentId);
}
if (existing.conversationAgentId) {
if ((data.assigneeAgentId !== undefined && data.assigneeAgentId !== existing.conversationAgentId)
|| data.assigneeUserId || data.conversationAgentId !== undefined || data.conversationUserId !== undefined
|| data.conversationState !== undefined || data.conversationSessionGeneration !== undefined
|| data.conversationBoundaryCommentId !== undefined || data.status === "done" || data.status === "cancelled") {
throw unprocessable("Conversation identity is fixed; finish the reply instead of completing or reassigning the conversation");
}
}
const {
labelIds: nextLabelIds,
@ -9002,10 +9063,11 @@ export function issueService(db: Db) {
authorizationReason?: string | null;
sourceTrust?: typeof issueComments.$inferInsert.sourceTrust;
createdAt?: Date | string | null;
clientRequestId?: string;
},
dbOrTx: any = db,
): Promise<IssueComment> {
if (dbOrTx === db && actor.runId) {
if (dbOrTx === db && (actor.runId || actor.userId)) {
return db.transaction(async (tx) => {
// Serialize run-authored comments on the issue so a provider retry
// cannot publish the same visible result twice. This needs no schema
@ -9020,17 +9082,36 @@ export function issueService(db: Db) {
});
}
const issue = await dbOrTx
.select({ companyId: issues.companyId })
.select({ companyId: issues.companyId, conversationAgentId: issues.conversationAgentId })
.from(issues)
.where(eq(issues.id, issueId))
.then((rows: Array<{ companyId: string }>) => rows[0] ?? null);
.then((rows: Array<{ companyId: string; conversationAgentId: string | null }>) => rows[0] ?? null);
if (!issue) throw notFound("Issue not found");
if (issue.conversationAgentId && actor.userId && !(await instanceSettings.getExperimental()).enableAgentChat) {
throw unprocessable("Agent Chat is disabled in Experimental settings");
}
const currentUserRedactionOptions = {
enabled: (await instanceSettings.getGeneral()).censorUsernameInLogs,
};
const redactedBody = redactCurrentUserText(body, currentUserRedactionOptions);
if (actor.userId && options?.clientRequestId) {
const [existing] = await dbOrTx.select().from(issueComments).where(and(eq(issueComments.issueId, issueId),
eq(issueComments.authorUserId, actor.userId), eq(issueComments.clientRequestId, options.clientRequestId)));
if (existing) {
if (existing.body !== redactedBody) throw conflict("Message request ID was already used for different content");
return existing;
}
}
if (issue.conversationAgentId && actor.runId) {
const [run] = await dbOrTx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, actor.runId));
const [current] = await dbOrTx.select().from(issues).where(eq(issues.id, issueId));
if (run?.status === "cancelled") throw conflict("This conversation turn was cancelled; it cannot post a reply");
if (run?.contextSnapshot?.conversationSessionGeneration !== current.conversationSessionGeneration) {
throw conflict("Conversation session changed; this reply belongs to an earlier session");
}
}
const authorType = issueCommentAuthorTypeSchema.parse(
options?.authorType ?? (actor.agentId ? "agent" : actor.userId ? "user" : "system"),
);
@ -9103,6 +9184,7 @@ export function issueService(db: Db) {
authorType,
createdByRunId,
body: redactedBody,
clientRequestId: options?.clientRequestId ?? null,
presentation,
metadata,
sourceTrust: options?.sourceTrust ?? null,
@ -9110,6 +9192,9 @@ export function issueService(db: Db) {
})
.returning();
if (issue.conversationAgentId && actor.userId) {
await dbOrTx.update(issues).set({ conversationState: "active" }).where(eq(issues.id, issueId));
}
// Update issue's updatedAt so comment activity is reflected in recency sorting
await dbOrTx
.update(issues)

View File

@ -1,3 +1,4 @@
import { conversationNativeDecision, isConversation } from "../agent-conversations.js";
import { randomUUID } from "node:crypto";
import { and, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@ -563,7 +564,7 @@ export async function finalizeNativeRun(input: {
runId: run.id,
}),
]);
const decision = resolveNativeFinalizerStatus({
const proposedDecision = resolveNativeFinalizerStatus({
assessment,
terminalState: terminalState as "succeeded" | "failed" | "cancelled",
workspaceFinalizeStatus: input.workspaceFinalizeStatus,
@ -576,6 +577,11 @@ export async function finalizeNativeRun(input: {
agentId: run.agentId,
priorIssueStatus: authoritativeStatus(authoritativeIssue.status),
});
const decision = conversationNativeDecision({
conversation: isConversation(authoritativeIssue), terminalState,
workspaceFinalizeStatus: input.workspaceFinalizeStatus, hasGovernanceGate: !!governanceGate,
priorStatus: authoritativeStatus(authoritativeIssue.status), decision: proposedDecision,
});
const assessmentRow = await recordNativeWorkAssessment({
db: input.db,
companyId: run.companyId,

View File

@ -63,9 +63,9 @@ describe("PaperclipRunnerToolAuthority", () => {
it("advertises only real bindings and reads the bound task", async () => {
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
expect(authority.definitions()).toHaveLength(18);
expect(authority.definitions()).toHaveLength(21);
expect(authority.definitions().map((tool) => tool.name)).toEqual(expect.arrayContaining([
"connections_search", "connection_request",
"connections_search", "connection_request", "create_project", "list_projects", "list_project_repositories",
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"create_task", "set_dependencies",

View File

@ -1,3 +1,4 @@
import { callProjectTool } from "../project-tools.js";
import { resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
import { connectionIntentService } from "../connection-intents.js";
import { RUNTIME_CONNECTION_TOOL_DEFINITIONS } from "../connection-tool-definitions.js";
@ -40,7 +41,7 @@ const IMPLEMENTED_OPERATIONS = new Set([
"search_api", "call_api",
"get_task_context", "get_task_history", "search_tasks", "report_progress",
"request_human_input",
"create_task", "set_dependencies",
"create_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects",
"list_documents", "read_document", "list_document_revisions", "write_document",
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
]);
@ -154,6 +155,16 @@ export class PaperclipRunnerToolAuthority {
}
const input = record(call.arguments);
switch (call.tool) {
case "create_project":
case "list_project_repositories":
case "list_projects": {
const apiUrl = this.binding.apiUrl ?? process.env.PAPERCLIP_API_URL;
const token = createLocalAgentJwt(this.binding.agentId, this.binding.companyId, context.actor.adapterType, this.binding.runId, context.run.responsibleUserId);
if (!apiUrl || !token) throw new Error("Project tool authentication is unavailable");
return callProjectTool({ name: call.tool, arguments: input, apiUrl, token,
companyId: this.binding.companyId, issueId: this.binding.issueId, agentId: this.binding.agentId,
conversation: Boolean(context.issue.conversationAgentId) });
}
case "search_api": return searchRunnerApi(call.arguments);
case "call_api": return this.#callApi(call.callId, call.arguments);
case "get_task_context": return {
@ -492,10 +503,10 @@ export class PaperclipRunnerToolAuthority {
.update(canonicalJson(input))
.digest("hex");
let publication: Awaited<ReturnType<typeof persistActivity>>["publication"] | null = null;
const result = await this.#withMutationReceipt("create_task", idempotencyKey, input, async (tx) => {
const result = await this.#withMutationReceipt("create_task", idempotencyKey, input, async (tx, context) => {
const conversation = Boolean(context.issue.conversationAgentId);
const existingChild = await tx.select().from(issues).where(and(
eq(issues.companyId, this.binding.companyId),
eq(issues.parentId, this.binding.issueId),
eq(issues.originId, durableIdempotencyKey),
)).limit(1).then((rows) => rows[0] ?? null);
if (existingChild) {
@ -518,19 +529,21 @@ export class PaperclipRunnerToolAuthority {
};
}
let deduplicated = false;
const created = await issueService(tx).createChild(this.binding.issueId, {
const createInput = {
projectId: nullableProviderId(input.projectId),
initialPlan: nullableProviderId(input.initialPlan),
title: requiredString(input.title),
description: input.description === null || input.description === undefined
? null
: requiredString(input.description),
status: blockedByIssueIds.length > 0 ? "blocked" : "todo",
workMode: "standard",
status: blockedByIssueIds.length > 0 ? "blocked" as const : "todo" as const,
workMode: "standard" as const,
priority,
assigneeAgentId,
blockedByIssueIds,
blockParentUntilDone: false,
createdByAgentId: this.binding.agentId,
originKind: "manual",
originKind: "manual" as const,
originId: durableIdempotencyKey,
originRunId: this.binding.runId,
originIdentityContextId: identityContextId,
@ -540,8 +553,10 @@ export class PaperclipRunnerToolAuthority {
actorRunId: this.binding.runId,
idempotencyKey: durableIdempotencyKey,
onDeduplicated: () => { deduplicated = true; },
});
const child = created.issue;
};
const child = conversation
? await issueService(tx).create(this.binding.companyId, createInput)
: (await issueService(tx).createChild(this.binding.issueId, createInput)).issue;
if (deduplicated && child.originFingerprint !== inputFingerprint) {
throw new Error("paperclip_runner_tool_idempotency_conflict");
}
@ -565,7 +580,7 @@ export class PaperclipRunnerToolAuthority {
companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId,
agentId: this.binding.agentId, runId: this.binding.runId, issueId: child.id,
action: "issue.created", entityType: "issue", entityId: child.id,
details: { identifier: child.identifier, title: child.title, parentId: this.binding.issueId,
details: { identifier: child.identifier, title: child.title, parentId: child.parentId,
assigneeAgentId: child.assigneeAgentId, status: childStatus, source: "paperclip_runner_protocol" },
});
publication = activity.publication;
@ -582,6 +597,7 @@ export class PaperclipRunnerToolAuthority {
id: child.id,
identifier: child.identifier,
parentId: child.parentId,
projectId: child.projectId,
status: childStatus,
assigneeActorId: child.assigneeAgentId,
},
@ -605,7 +621,7 @@ export class PaperclipRunnerToolAuthority {
payload: {
issueId: childId,
mutation: "create_child",
parentIssueId: this.binding.issueId,
parentIssueId: task.parentId ?? null,
},
idempotencyKey: scheduledWakeIds[0]!,
requestedByActorType: "agent",
@ -613,7 +629,7 @@ export class PaperclipRunnerToolAuthority {
contextSnapshot: {
issueId: childId,
source: "paperclip_runner.create_task",
parentIssueId: this.binding.issueId,
parentIssueId: task.parentId ?? null,
},
});
}

View File

@ -42,6 +42,8 @@ function words(text: string): string[] {
}
function dedicatedTools(method: string, path: string): string[] {
if (/\/projects$/.test(path)) return method === "GET" ? ["list_projects"] : method === "POST" ? ["create_project"] : [];
if (/\/project-repositories$/.test(path) && method === "GET") return ["list_project_repositories"];
if (/\/issues\/\{[^}]+\}\/comments$/.test(path)) return method === "GET" ? ["get_task_history"] : ["report_progress"];
if (/\/issues\/\{[^}]+\}\/documents/.test(path)) return method === "DELETE" ? [] : method === "GET" ? ["list_documents", "read_document", "list_document_revisions"] : ["write_document"];
if (/\/issues$/.test(path)) return method === "GET" ? ["search_tasks"] : ["create_task"];

View File

@ -17,6 +17,9 @@ describe("runner API catalog", () => {
expect(runnerApiOperation("DELETE /api/issues/{id}/documents/{key}").authorization.actor).toBe("board");
expect(runnerApiOperation("DELETE /api/issues/{id}/documents/{key}").dedicatedTools).toEqual([]);
expect(runnerApiOperation(createProject).requestBody?.content["application/json"].schema.required).toContain("name");
expect(runnerApiOperation(createProject).dedicatedTools).toEqual(["create_project"]);
expect(runnerApiOperation(projects).dedicatedTools).toEqual(["list_projects"]);
expect(runnerApiOperation("GET /api/companies/{companyId}/project-repositories").dedicatedTools).toEqual(["list_project_repositories"]);
});
it.each(runnerApiCatalog().filter(operation => operation.transport === "rest"))("resolves the catalog route $operationId inside the bound origin", operation => {
const pathParams = Object.fromEntries(operation.parameters.filter(parameter => parameter.in === "path").map(parameter => [parameter.name, parameter.name === "companyId" ? context.companyId : "fixture-id"]));
@ -54,6 +57,7 @@ describe("runner API request boundary", () => {
expect(request).not.toHaveBeenCalled();
});
it.each([
"POST /api/mcp/project-tools",
"POST /api/agents/{id}/claude-login",
"POST /api/companies/{companyId}/adapters/{type}/login-sessions",
"POST /api/agents/me/connections/{connectionId}/start-authorization",

View File

@ -1059,6 +1059,11 @@ export async function commitNativeStatusDecision(input: {
eq(issues.companyId, input.companyId),
)).for("update").limit(1).then((rows) => rows[0] ?? null);
if (!issue) throw new NativeStatusRaceError();
// A completed model turn cannot close a persistent conversation. Preserve
// the task here; the response finalizer records its durable waiting state.
if (issue.conversationAgentId && input.decision.statusAction === "done") {
input = { ...input, decision: { ...input.decision, statusAction: "preserve", toStatus: issue.status as NativeStatusDecision["toStatus"], effects: [] } };
}
if (coordinator.phase === "committed" && coordinator.decisionId) {
if (input.supersedesCommittedDecisionId) {
if (

View File

@ -15,7 +15,7 @@ import { logger } from "../middleware/logger.js";
import { logActivity } from "./activity-log.js";
import { budgetService } from "./budgets.js";
import { issueService } from "./issues.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { executionIssueCondition } from "./issue-visibility.js";
import { withRecoveryContext } from "./recovery/status-only-context.js";
import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
@ -261,7 +261,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
eq(issues.originId, sourceIssueId),
visibleIssueCondition(),
executionIssueCondition(),
notInArray(issues.status, ["done", "cancelled"]),
),
)
@ -309,7 +309,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
eq(issues.companyId, companyId),
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
eq(issues.originId, sourceIssueId),
visibleIssueCondition(),
executionIssueCondition(),
sql`${issues.status} <> 'cancelled'`,
sql`${issues.createdAt} >= ${cutoff.toISOString()}::timestamptz`,
),
@ -333,7 +333,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND),
eq(issues.originId, sourceIssueId),
eq(issues.status, "done"),
visibleIssueCondition(),
executionIssueCondition(),
),
)
.orderBy(desc(issues.createdAt), desc(issues.id))
@ -848,7 +848,7 @@ export function productivityReviewService(db: Db, deps?: { enqueueWakeup?: Enque
.where(
and(
opts?.companyId ? eq(issues.companyId, opts.companyId) : undefined,
visibleIssueCondition(),
executionIssueCondition(),
isNull(issues.assigneeUserId),
inArray(issues.status, ["todo", "in_progress"]),
sql`${issues.assigneeAgentId} is not null`,

View File

@ -40,3 +40,19 @@ export function resolveProjectRepositorySelection(
throw unprocessable("A selected GitHub repository is no longer available. Refresh repositories and try again.");
});
}
/** Register an existing GitHub URL without assuming it is in the connection catalog.
* No fetch or credential sharing: execution uses the normal repository access policy.
*/
export function normalizeProjectRepositoryUrl(value: string): { fullName: string; url: string } {
let parsed: URL;
try { parsed = new URL(value); } catch { throw unprocessable("Repository URL must be an HTTPS GitHub repository URL"); }
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com" || parsed.port || parsed.username || parsed.password || parsed.search || parsed.hash) {
throw unprocessable("Repository URL must be an HTTPS GitHub repository URL without credentials, query, or fragment");
}
const path = parsed.pathname.replace(/\/$/, "").replace(/\.git$/, "");
if (!/^\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(path) || path.split("/").some(part => part === "." || part === "..")) {
throw unprocessable("Repository URL must identify a GitHub owner and repository");
}
return { fullName: path.slice(1), url: `https://github.com${path}` };
}

View File

@ -0,0 +1,28 @@
import type { Request } from "express";
import { and, eq } from "drizzle-orm";
import { issues, type Db } from "@paperclipai/db";
import { forbidden } from "../errors.js";
import { captureRunIdentity } from "./run-identity.js";
/** Resolve authority from the authenticated run, never caller-supplied user/task IDs. */
export async function projectToolContext(db: Db, actor: Request["actor"], write = false) {
if (actor.type !== "agent" || actor.source !== "agent_jwt" || !actor.runId || !actor.agentId || !actor.companyId) {
throw forbidden("Project tools require an authenticated agent run");
}
// Acquire task/run locks before checking mode and session generation. A reset,
// cancellation, or steering update cannot race a committing project mutation.
const identity = await captureRunIdentity(db, { companyId: actor.companyId, agentId: actor.agentId, runId: actor.runId });
const run = identity.run;
const snapshot = run.contextSnapshot ?? {};
const issueId = run.nativeIssueId ?? (typeof snapshot.issueId === "string" ? snapshot.issueId : null);
if (!issueId) throw forbidden("Project tools require a task-bound run");
const [issue] = await db.select().from(issues).where(and(eq(issues.id, issueId), eq(issues.companyId, actor.companyId)));
if (!issue) throw forbidden("Run task is unavailable");
if (issue.conversationAgentId && Number(snapshot.conversationSessionGeneration ?? 0) !== issue.conversationSessionGeneration) {
throw forbidden("Conversation session has changed");
}
if (write && !["standard", "skill_test"].includes(issue.workMode)) throw forbidden("Project creation is unavailable in Ask or Plan mode");
const userId = identity.run.responsibleUserId;
// local-board is a server-owned identity; never accepted from tool arguments.
return { run, issue, userId, localTrusted: userId === "local-board" };
}

View File

@ -0,0 +1,52 @@
import { createProjectSchema, createIssueSchema } from "@paperclipai/shared";
import { z } from "zod";
import { CAPABILITY_SEMANTIC_TOOL_CATALOG } from "../vendor/paperclip-runner/index.js";
import { badRequest } from "../errors.js";
export const PROJECT_TOOL_NAMES = ["create_project", "list_project_repositories", "list_projects"];
export function projectToolDefinitions(workMode: string, includeTask = false) {
return CAPABILITY_SEMANTIC_TOOL_CATALOG.filter(tool =>
(PROJECT_TOOL_NAMES.includes(tool.operationId) || includeTask && tool.operationId === "create_task")
&& tool.allowedModes.includes(workMode as "standard"),
).map(tool => ({ name: tool.operationId, description: tool.description,
inputSchema: tool.operationId === "create_project"
? z.toJSONSchema(createProjectSchema.extend({ idempotencyKey: z.string().min(1).max(255) }))
: tool.inputSchema,
}));
}
/** All transports use the normal authenticated API, including its validation and audit path. */
export async function callProjectTool(input: {
name: string; arguments: Record<string, unknown>; apiUrl: string; token: string;
companyId: string; issueId: string; agentId: string; conversation: boolean;
}) {
const args = input.arguments;
let path = `/companies/${input.companyId}/projects`;
let body: unknown;
if (input.name === "list_project_repositories") path = `/companies/${input.companyId}/project-repositories`;
else if (input.name === "list_projects") { /* read projects */ }
else if (input.name === "create_project") {
body = createProjectSchema.extend({ idempotencyKey: z.string().min(1).max(255) }).parse(args);
} else if (input.name === "create_task") {
const key = z.string().min(1).max(150).parse(args.idempotencyKey);
path = `/companies/${input.companyId}/issues`;
body = createIssueSchema.parse({
title: args.title, description: args.description, priority: args.priority,
projectId: args.projectId, initialPlan: args.initialPlan,
assigneeAgentId: args.assigneeActorId ?? input.agentId,
parentId: input.conversation ? null : input.issueId,
status: Array.isArray(args.blockedByTaskIds) && args.blockedByTaskIds.length ? "blocked" : "todo",
blockedByIssueIds: args.blockedByTaskIds,
idempotencyKey: `chat-handoff:${input.issueId}:${key}`,
});
} else throw badRequest("Unknown project tool");
const response = await fetch(`${input.apiUrl.replace(/\/+$/, "").replace(/\/api$/, "")}/api${path}`, {
method: body ? "POST" : "GET",
headers: { Authorization: `Bearer ${input.token}`, "Content-Type": "application/json" },
...(body ? { body: JSON.stringify(body) } : {}),
signal: AbortSignal.timeout(60_000),
});
const result = await response.json();
if (!response.ok) throw new Error(typeof result.error === "string" ? result.error : `Project tool failed (${response.status})`);
return input.name === "list_projects" ? { projects: result } : result;
}

View File

@ -369,7 +369,7 @@ async function attachListMetrics(
count: sql<number>`count(*)::int`,
})
.from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.projectId, projectIds)))
.where(and(eq(issues.companyId, companyId), inArray(issues.projectId, projectIds), isNull(issues.conversationAgentId)))
.groupBy(issues.projectId),
db
.select({

View File

@ -12,6 +12,9 @@ export type IssueLivenessState =
| "in_review_without_action_path";
export interface IssueLivenessIssueInput {
conversationAgentId?: string | null;
conversationUserId?: string | null;
conversationState?: string | null;
id: string;
companyId: string;
identifier: string | null;
@ -216,6 +219,9 @@ export function classifyIssueReviewPaths(
const nowMs = readDateMs(input.now ?? new Date()) ?? Date.now();
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
const paths: IssueReviewPathFact[] = [];
if (issue.conversationAgentId && issue.conversationUserId && issue.conversationState === "waiting") {
return [{ kind: "human_reviewer", ref: issue.conversationUserId, userId: issue.conversationUserId, agentId: null, since: null }];
}
if (issue.assigneeUserId) {
paths.push({

View File

@ -1,3 +1,5 @@
import { instanceSettingsService } from "../instance-settings.js";
import { isWaitingConversation, settleConversationTurn, deliverConversationComments } from "../agent-conversations.js";
import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
@ -2977,6 +2979,20 @@ export function recoveryService(
}
for (const issue of candidates) {
if (issue.conversationAgentId) {
const lastRun = await getLatestIssueRun(issue.companyId, issue.id);
if (lastRun?.status === "succeeded") {
if (await settleConversationTurn(db, (await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, lastRun.id)))[0]!)) {
const [current] = await db.select().from(issues).where(eq(issues.id, issue.id));
if (current) Object.assign(issue, current);
}
}
if (!(await instanceSettingsService(db).getExperimental()).enableAgentChat) { result.skipped += 1; continue; }
{
await deliverConversationComments(db, issue, deps.enqueueWakeup);
}
}
if (isWaitingConversation(issue)) { result.skipped += 1; continue; }
const executionState = issue.status === "in_review"
? parseIssueExecutionState(issue.executionState)
: null;

View File

@ -658,6 +658,11 @@ export async function applyRunnerGoalPrpEvent(
].includes(event.eventType)) return null;
const payload = asRecord(event.payload) ?? {};
const changed = await db.transaction(async (tx) => {
const [issue] = await tx.select().from(issues).where(and(eq(issues.id, binding.issueId), eq(issues.companyId, binding.companyId))).for("update");
if (issue?.conversationAgentId) {
const [run] = event.sourceRunId ? await tx.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, event.sourceRunId)) : [];
if (run?.status === "cancelled" || run?.contextSnapshot?.conversationSessionGeneration !== issue.conversationSessionGeneration) return null;
}
await tx.insert(agentTaskSessions).values({
companyId: binding.companyId,
agentId: binding.agentId,

View File

@ -7,7 +7,7 @@ import {
publishActivity,
type ActivityPublication,
} from "./activity-log.js";
import { visibleIssueCondition } from "./issue-visibility.js";
import { executionIssueCondition } from "./issue-visibility.js";
import {
executeIssuePostCommitActions,
issueService,
@ -41,7 +41,7 @@ export function stalledReviewDecisionService(db: Db) {
.where(and(
eq(issues.id, input.issueId),
eq(issues.companyId, input.companyId),
visibleIssueCondition(),
executionIssueCondition(),
))
.for("update")
.then((rows) => rows[0] ?? null);

View File

@ -11,7 +11,7 @@ import {
issues,
issueThreadInteractions,
} from "@paperclipai/db";
import { visibleIssueCondition } from "./issue-visibility.js";
import { executionIssueCondition } from "./issue-visibility.js";
// DTO types are shared with the UI via @paperclipai/shared so both sides consume
// one contract. Re-exported here for back-compat with existing server imports.
@ -206,7 +206,7 @@ export function workTimelineService(db: Db) {
const filterConditions = [
eq(issues.companyId, input.companyId),
visibleIssueCondition(),
executionIssueCondition(),
input.goalId ? eq(issues.goalId, input.goalId) : undefined,
input.projectId ? eq(issues.projectId, input.projectId) : undefined,
input.issueId ? eq(issues.id, input.issueId) : undefined,
@ -332,7 +332,7 @@ export function workTimelineService(db: Db) {
.where(
and(
eq(issues.companyId, input.companyId),
visibleIssueCondition(),
executionIssueCondition(),
inArray(issues.id, issueIds),
input.goalId ? eq(issues.goalId, input.goalId) : undefined,
input.projectId ? eq(issues.projectId, input.projectId) : undefined,

1043
tests/e2e/agent-chat.spec.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
// Upstream GitHub simulation only. Paperclip's discovery, secret resolution,
// responsible-user authorization, and project creation all remain real.
if (process.env.NODE_ENV === "test") {
const realFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url = new URL(
typeof input === "string" || input instanceof URL ? input : input.url,
);
const headers = new Headers(
init?.headers ?? (input instanceof Request ? input.headers : undefined),
);
if (
url.hostname === "api.github.com" &&
headers.get("authorization") === "Bearer paperclip-e2e-repository-fixture"
) {
if (url.pathname !== "/user/repos")
return Response.json(
{ error: "Unsupported fixture GitHub request" },
{ status: 422 },
);
return Response.json([
{ id: 101, full_name: "chat-fixture/frontend", private: false },
{ id: 102, full_name: "chat-fixture/backend", private: false },
]);
}
return realFetch(input, init);
};
}

View File

@ -0,0 +1,188 @@
// Deterministic provider: all effects use the real run-authenticated APIs/MCP transport.
// No DB writes, mocked Paperclip responses, provider calls, or outside workspaces.
const base = process.env.PAPERCLIP_API_URL;
const headers = {
Authorization: `Bearer ${process.env.PAPERCLIP_API_KEY}`,
"Content-Type": "application/json",
};
async function api(path, method = "GET", body) {
const response = await fetch(`${base}/api${path}`, {
method,
headers,
...(body ? { body: JSON.stringify(body) } : {}),
});
const data = await response.json();
if (!response.ok)
throw new Error(
`${method} ${path}: ${response.status} ${JSON.stringify(data)}`,
);
return data;
}
const run = await api(`/heartbeat-runs/${process.env.PAPERCLIP_RUN_ID}`);
const ctx = run.contextSnapshot;
const task = await api(`/issues/${ctx.issueId}`);
const comment = async (body) =>
api(`/issues/${task.id}/comments`, "POST", { body });
if (!task.conversationAgentId) {
const plan = await api(`/issues/${task.id}/documents/plan`);
await api(`/issues/${task.id}/documents/output`, "PUT", {
title: "Output",
format: "markdown",
body: `Execution received plan: ${plan.body}`,
});
await api(`/issues/${task.id}`, "PATCH", {
status: "done",
comment: "Execution finished with its initial plan.",
});
process.exit(0);
}
const comments = await api(`/issues/${task.id}/comments?order=asc`);
const current =
comments.find((c) => c.id === ctx.wakeCommentId) ??
comments.filter((c) => c.authorUserId).at(-1);
let command;
try {
command = JSON.parse(
current.body.startsWith("fixture:")
? Buffer.from(current.body.slice(8), "base64url").toString()
: current.body,
);
} catch {
command = { action: "reply", text: current.body };
}
if (
ctx.interactionKind === "request_confirmation" &&
ctx.interactionStatus === "accepted"
) {
const plan = await api(`/issues/${task.id}/documents/plan`);
command = {
action: "handoff",
plan: plan.body,
name: "Approved plan project",
key: ctx.interactionId,
};
}
if (ctx.interactionKind === "ask_user_questions")
command = { action: "reply", text: "Clarification received." };
const writePlan = async (body) => {
const documents = await api(`/issues/${task.id}/documents`);
const previous = documents.find((doc) => doc.key === "plan");
return api(`/issues/${task.id}/documents/plan`, "PUT", {
title: "Plan",
format: "markdown",
body,
baseRevisionId: previous?.latestRevisionId,
});
};
const mcp = async (name, args) => {
const result = await api("/mcp/project-tools", "POST", {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name, arguments: args },
});
if (result.result?.isError || result.error)
throw new Error(JSON.stringify(result));
return result.result.structuredContent;
};
console.log("Deterministic chat provider received a turn");
if (command.action === "hold") {
await comment("Provider is streaming and ready to stop.");
// Keep a real provider process alive so Stop exercises cancellation and tree holds.
setInterval(() => console.log("Streaming discussion"), 250);
} else if (command.action === "delayed") {
await comment("Turn started before feature disable.");
await new Promise((resolve) => setTimeout(resolve, 3000));
await comment("Active turn settled after feature disable.");
} else if (command.action === "project" || command.action === "handoff") {
try {
const args = {
name: command.name ?? "Fixture project",
repositoryUrls: command.urls,
repositoryIds: command.ids,
workspace: command.workspace,
idempotencyKey: command.key ?? current.id,
};
const project = command.projectId
? await api(`/projects/${command.projectId}`)
: command.direct
? await api(`/companies/${task.companyId}/projects`, "POST", args)
: await mcp("create_project", args);
const retry = command.projectId
? project
: await mcp("create_project", args);
if (retry.id !== project.id)
throw new Error("Project retry created a duplicate");
if (command.action === "handoff") {
const plan = command.plan ?? "# Plan\n\nWrite the welcome note.";
if (!ctx.interactionId) await writePlan(plan);
const tasks = [];
for (let index = 0; index < (command.split ? 2 : 1); index++) {
const input = {
title: `Execution ${index + 1}`,
projectId: project.id,
initialPlan: `${plan}\nPart ${index + 1}`,
idempotencyKey: `${current.id}-${index}`,
};
const child = await mcp("create_task", input);
const again = await mcp("create_task", input);
if (again.id !== child.id)
throw new Error("Task retry created a duplicate");
tasks.push(`[${child.identifier}](/issues/${child.id})`);
}
await comment(`Handed off: ${tasks.join(", ")}`);
} else await comment(`Project registered: ${project.name}`);
} catch (error) {
await comment(`Expected tool result: ${error.message}`);
}
} else if (command.action === "plan") {
const plan = await writePlan(command.text);
if (command.approval)
await api(`/issues/${task.id}/interactions`, "POST", {
kind: "request_confirmation",
continuationPolicy: "wake_assignee",
payload: {
version: 1,
prompt: "Hand this plan off to an assigned project task?",
acceptLabel: "Approve handoff",
rejectLabel: "Revise",
rejectRequiresReason: true,
target: {
type: "issue_document",
key: "plan",
revisionId: plan.latestRevisionId,
revisionNumber: plan.latestRevisionNumber,
},
},
});
await comment("The draft plan is ready for discussion.");
} else if (command.action === "question") {
await api(`/issues/${task.id}/interactions`, "POST", {
kind: "ask_user_questions",
idempotencyKey: current.id,
continuationPolicy: "wake_assignee",
payload: {
version: 1,
questions: [
{
id: "audience",
prompt: "Who is the welcome note for?",
selectionMode: "single",
required: true,
options: [
{ id: "garden", label: "Garden club" },
{ id: "book", label: "Book club" },
],
},
],
},
});
await comment("Please choose an audience.");
} else if (command.action === "history") {
for (let index = 0; index < 65; index++)
await comment(`History message ${String(index).padStart(2, "0")}`);
} else {
await comment(
`Reply generation ${ctx.conversationSessionGeneration}: ${command.text}`,
);
}

View File

@ -324,3 +324,62 @@ test.describe("Multi-user: authenticated mode", () => {
}
});
});
test("agent chats keep personal identity and ordinary company visibility", async ({ browser, page }) => {
test.setTimeout(120_000);
expect((await (await page.request.get(`${BASE}/api/health`)).json()).deploymentMode).toBe("authenticated");
await signUp(page, { ...ownerUser, email: `chat-${ownerUser.email}` });
const bootstrapToken = new URL(createBootstrapInvite()).pathname.split("/").at(-1);
expect((await sessionJsonRequest(page, `${BASE}/api/invites/${bootstrapToken}/accept`, { method: "POST", data: { requestType: "human" } })).ok).toBe(true);
const company = await createCompanyForSession(page, `Chat identity ${runId}`);
const companyPrefix = company.issuePrefix ?? company.id;
const invite = await sessionJsonRequest<{ inviteUrl: string }>(page, `${BASE}/api/companies/${company.id}/invites`, { method: "POST", data: { allowedJoinTypes: "human", humanRole: "operator" } });
expect(invite.ok).toBe(true);
const invited = await newPage(browser);
try {
await signUp(invited.page, { ...invitedUser, email: `chat-${invitedUser.email}` });
const inviteToken = new URL(invite.json!.inviteUrl, BASE).pathname.split("/").at(-1);
const joined = await sessionJsonRequest(invited.page, `${BASE}/api/invites/${inviteToken}/accept`, { method: "POST", data: { requestType: "human" } });
expect(joined.ok).toBe(true);
// Persistent chats are personal identities, not private messaging.
const originalFlags = await sessionJsonRequest<Record<string, boolean>>(page, `${BASE}/api/instance/settings/experimental`);
const enable = await sessionJsonRequest(page, `${BASE}/api/instance/settings/experimental`, { method: "PATCH", data: { enableAgentChat: true } });
expect(enable.ok).toBe(true);
try {
const createdAgent = await sessionJsonRequest<{ id: string }>(page, `${BASE}/api/companies/${company.id}/agents`, { method: "POST", data: {
name: "Personal chat identity", adapterType: "process",
adapterConfig: { command: process.execPath, args: ["-e", "process.exit(0)"] },
runtimeConfig: { heartbeat: { enabled: false } },
} });
expect(createdAgent.ok).toBe(true);
const agentId = createdAgent.json!.id;
const chatEndpoint = `${BASE}/api/companies/${company.id}/chats/${agentId}`;
await page.goto(`${BASE}/${companyPrefix}/chats/${agentId}`);
await invited.page.goto(`${BASE}/${companyPrefix}/chats/${agentId}`);
expect((await sessionJsonRequest(page, chatEndpoint)).json).toBeNull();
expect((await sessionJsonRequest(invited.page, chatEndpoint)).json).toBeNull();
const ownerChat = await sessionJsonRequest<{ id: string; conversationUserId: string }>(page, chatEndpoint, { method: "POST", data: {} });
const memberChat = await sessionJsonRequest<{ id: string; conversationUserId: string }>(invited.page, chatEndpoint, { method: "POST", data: {} });
expect(ownerChat.ok).toBe(true); expect(memberChat.ok).toBe(true);
expect(ownerChat.json!.id).not.toBe(memberChat.json!.id);
expect(ownerChat.json!.conversationUserId).not.toBe(memberChat.json!.conversationUserId);
expect((await sessionJsonRequest(invited.page, `${BASE}/api/issues/${ownerChat.json!.id}`)).ok).toBe(true);
expect((await sessionJsonRequest(page, `${BASE}/api/issues/${memberChat.json!.id}`)).ok).toBe(true);
await page.reload(); await invited.page.reload();
await page.getByRole("button", { name: "Star Personal chat identity", exact: true }).click();
await expect(page.getByRole("button", { name: "Unstar Personal chat identity", exact: true })).toBeAttached();
await expect(invited.page.getByRole("button", { name: "Star Personal chat identity", exact: true })).toBeAttached();
const ownerRecent = await page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip.recentAgentChats:")));
const memberRecent = await invited.page.evaluate(() => Object.keys(localStorage).filter(key => key.startsWith("paperclip.recentAgentChats:")));
expect(ownerRecent.some(key => key.endsWith(ownerChat.json!.conversationUserId))).toBe(true);
expect(memberRecent.some(key => key.endsWith(memberChat.json!.conversationUserId))).toBe(true);
const another = await createCompanyForSession(page, `Private company ${runId}`);
const forbidden = await sessionJsonRequest(invited.page, `${BASE}/api/companies/${another.id}/chats/${agentId}`);
expect([403, 404]).toContain(forbidden.status);
} finally {
const restore = await sessionJsonRequest(page, `${BASE}/api/instance/settings/experimental`, { method: "PATCH", data: { enableAgentChat: originalFlags.json!.enableAgentChat } });
expect(restore.ok).toBe(true);
}
} finally { await invited.context.close(); }
});

View File

@ -69,6 +69,7 @@ export default defineConfig({
env: {
...process.env,
NODE_ENV: "test",
NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${path.resolve(import.meta.dirname, "fixtures/agent-chat-github.mjs")}`,
PORT: String(PORT),
PAPERCLIP_OPEN_ON_LISTEN: "false",
PAPERCLIP_API_URL: BASE_URL,

View File

@ -154,3 +154,21 @@ pnpm test:e2e:runner -- --list
Then run the narrowest paid cell that exercises the fixture. A full matrix is a
manual or scheduled campaign, not a PR requirement.
## Persistent chat fixtures
`chat-cases.ts` defines the six-case `agent-chat` suite; `chat-flow.ts` drives the
production composer, plan revision/approval controls, questions, reset command,
and project cards. Keep its 24 local cells intentional. `expectedRunCount`
counts provider turns, including cancelled and handed-off task runs, but excludes
synthetic `/new` runs. Assertions must inspect all company runs because ordinary
issue lists exclude the source conversation. `assertChatHandoff` rejects missing
projects/plans, chat children, wrong assignees, and execution before plan commit.
Retained `chat-api-state.json`, `chat-handoff.json`, and plan-revision evidence
include persisted comments, session generations, run context and logs, project
workspaces, task documents, and ordering. They pass through the normal sanitizer.
Screenshots are allowlisted to the exact disposable agent chat. Cleanup cancels
all active runs in the isolated company, including handed-off work; usage from
failed and cancelled runs must not disappear from campaign totals.

View File

@ -72,7 +72,7 @@ pnpm test:e2e:runner -- --suite daytona-warm-continuity
pnpm test:e2e:runner -- --all
```
The catalog contains four suites. `core-compatibility` (**Core Runner
The catalog contains five suites. `core-compatibility` (**Core Runner
Compatibility**) is seven major runner profiles × local/Daytona × three
workflows: 42 cells. Its cases are:
@ -120,7 +120,44 @@ runner instance, PID, and process-start identity. Each turn is bounded to ten
minutes, the cell to thirty minutes, and cleanup explicitly deletes the
sandbox rather than waiting for Daytona's idle timeout.
The complete catalog is 68 cells (45 local and 23 Daytona) and 120 expected
`agent-chat` (**Persistent Agent Chat**) adds six workflows on `legacy-codex`,
`legacy-claude`, `runner-codex`, and `runner-acpx-claude`: **24 local cells**.
They cover continuity across server restart, fresh context after `/new`,
Stop/reset/resume, draft/revise/approve/plan handoff, clarification with existing
project reuse, and a new project with two repository URLs. Each cell opens the
production chat surface and resolves the backing issue through the chat API.
The source conversation must settle to `in_review` / `waiting`; handed-off
execution tasks must finish with their initial Plan and output documents.
Reset runs are retained separately from the 68 expected provider turns in this
suite. Cancelled turns and execution-task runs remain included in billing and
cleanup. The production chat directive is injected normally; fixtures do not
replace it with completion instructions. Daytona is excluded.
```bash
# Run these after deterministic checks, with the required provider keys set.
pnpm test:e2e:runner -- --id agent-chat.legacy-codex.local.continuity-restart
pnpm test:e2e:runner -- --id agent-chat.legacy-claude.local.continuity-restart
pnpm test:e2e:runner -- --suite agent-chat
```
The regular browser suite has deterministic process providers in
`tests/e2e/fixtures/agent-chat.mjs`. It exercises the real queue, APIs, database,
MCP project tools, and shared task UI without provider billing. Only upstream
GitHub discovery is simulated, scoped to a fixture-only credential; repository
permissions and mutations remain real. Run it with:
```bash
pnpm --filter @paperclipai/ui build
pnpm test:e2e tests/e2e/agent-chat.spec.ts
# Against a dedicated authenticated test instance configured per that suite:
pnpm test:e2e:multiuser-authenticated --grep 'agent chats'
```
Both suites save and restore experimental settings. Browser E2E always starts a
throwaway instance; never point the authenticated suite at the running demo.
Missing provider credentials fail paid preflight and are not passing coverage.
The complete catalog is 92 cells (69 local and 23 Daytona) and 188 expected
paid agent turns. Follow-up steps remain ordered within their cell; all other
cells are independent. Narrow selectors are strongly recommended while
developing fixtures.
@ -369,7 +406,7 @@ Set `RUNNER_E2E_AWS_ENABLED=true` to route paid cells to the repository-scoped
ephemeral AWS RunsOn fleet selected by
`runs-on/fleet=paperclip-public-pr-x64/env=public-ci`. Any other value uses the
proven GitHub-hosted `ubuntu-latest` target. Set `RUNNER_E2E_MAX_PARALLEL` to an
integer from 1100 on AWS (default 100); use at least 68 to run the current
integer from 1100 on AWS (default 100); use at least 92 to run the current
complete catalog in one wave. The fallback runner retains its 157 limit and
default of 32. Multi-turn steps are sequential inside their cell while
independent cells overlap. Artifacts and merged HTML/JUnit/normalized reports

View File

@ -41,10 +41,10 @@ describe("runner E2E catalog", () => {
expect(localIntegrityTasks).toHaveLength(2);
expect(openRouterBreadthTasks).toHaveLength(3);
expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([
42, 14, 10, 2,
24, 42, 14, 10, 2,
]);
expect(validateRunnerCatalog()).toHaveLength(68);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(68);
expect(validateRunnerCatalog()).toHaveLength(92);
expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(92);
expect(
runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"),
).toHaveLength(42);
@ -68,7 +68,7 @@ describe("runner E2E catalog", () => {
(total, execution) => total + execution.task.expectedRunCount,
0,
),
).toBe(120);
).toBe(188);
expect(
runnerTasks.find((task) => task.id === "plan-revise-accept")
?.attemptTimeoutMs,
@ -497,6 +497,7 @@ describe("runner E2E selectors", () => {
"local",
]);
expect(selectRunnerExecutions(options).map((entry) => entry.id)).toEqual([
...runnerMatrix.filter(entry => entry.suite.id === "agent-chat" && ["legacy-codex", "runner-codex"].includes(entry.profile.id)).map(entry => entry.id),
"core-compatibility.legacy-codex.local.message-marker",
"core-compatibility.legacy-codex.local.plan-revise-accept",
"core-compatibility.legacy-codex.local.ask-question",
@ -551,10 +552,10 @@ describe("runner E2E selectors", () => {
const jobs = buildMatrixJobs(
selectRunnerExecutions(parseRunnerSelectors(["--all"])),
);
expect(jobs).toHaveLength(68);
expect(jobs).toHaveLength(92);
expect(jobs.filter((job) => job.needsDaytona)).toHaveLength(23);
expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(45);
expect(new Set(jobs.map((job) => job.executionId)).size).toBe(68);
expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(69);
expect(new Set(jobs.map((job) => job.executionId)).size).toBe(92);
expect(
jobs.find(
(job) =>

View File

@ -1,3 +1,4 @@
import { chatTasks } from "./chat-cases.js";
import { createHash } from "node:crypto";
import { createAgentSchema } from "../../packages/shared/src/validators/agent.js";
import { createEnvironmentSchema } from "../../packages/shared/src/validators/environment.js";
@ -30,6 +31,7 @@ const SELECTABLE_GROUPS = [
"warm",
"core",
"breadth",
"chat",
] as const;
const SAMPLE_UUID = "11111111-1111-4111-8111-111111111111";
@ -68,8 +70,9 @@ function commonAgent(
"AGENTS.md": [
"You are running a paid Paperclip end-to-end acceptance fixture.",
"Follow the assigned task and its Paperclip work mode literally.",
"For standard and ask tasks, publish the requested visible answer and mark the task done.",
"For planning tasks, publish or revise the canonical Plan document and its revision-bound request_confirmation, then wait. Only implement after that exact plan is accepted.",
"In ongoing agent chats, follow the injected production chat directive; keep the conversation available after replying. The completion and implementation instructions below apply only to ordinary execution tasks.",
"For ordinary standard and ask tasks, publish the requested visible answer and mark the task done.",
"For ordinary planning tasks, publish or revise the canonical Plan document and its revision-bound request_confirmation, then wait. Only implement after that exact plan is accepted.",
"Invoke assigned tools only through the runtime's real tool-call channel. Never print XML, DSML, JSON, or other tool-call markup as assistant text.",
"Legacy adapters must use the public Paperclip API and the injected PAPERCLIP_API_URL, PAPERCLIP_API_KEY, PAPERCLIP_TASK_ID, and PAPERCLIP_RUN_ID values for comments, documents, interactions, and status changes.",
...(adapterType === "paperclip_runner"
@ -874,6 +877,14 @@ export const connectionReviewSuite: RunnerSuiteFixture = {
};
export const runnerSuites: readonly RunnerSuiteFixture[] = [
{
id: "agent-chat", label: "Persistent Agent Chat",
description: "Task-backed conversations, session resets, and project plan handoff.",
groups: ["chat"],
profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)),
environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 24,
definitionMetadata: { version: 1, resetRunsCountedSeparately: true },
},
...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []),
{
id: "core-compatibility",

View File

@ -0,0 +1,27 @@
import type { RunnerTaskFixture } from "./types.js";
export const CHAT_CASES = [
["continuity-restart", "Conversation continuity across restart", 3],
["new-session", "Fresh context within preserved history", 2],
["stop-new-resume", "Stop, reset, and resume", 3],
["plan-handoff", "Draft, revise, approve, and hand off a plan", 4],
["clarify-reuse", "Clarify and reuse an existing project", 3],
["multi-repository", "Create a project with multiple repository URLs", 2],
] as const;
export type ChatCase = (typeof CHAT_CASES)[number][0];
export const chatTasks: readonly RunnerTaskFixture[] = CHAT_CASES.map(
([id, label, expectedRunCount]) => ({
id,
label,
groups: ["chat"],
flow: "agent_chat",
workMode: "standard",
expectedRunCount, // Provider turns, including cancelled turns and handed-off work; reset runs are separate.
attemptTimeoutMs: { local: 15 * 60_000, daytona: 15 * 60_000 },
expectedTerminalState: { issue: "in_review", run: "succeeded" },
buildTitle: (nonce) => `Chat acceptance ${id} ${nonce}`,
buildPrompt: (nonce) => `Let's discuss ${nonce}.`,
buildVisibleMarker: (nonce) => `CHAT_${nonce}`,
buildMatchers: () => [{ kind: "issue_status", expected: "in_review" }],
}),
);

View File

@ -0,0 +1,113 @@
import { describe, it, expect } from "vitest";
import {
assertChatHandoff,
isResetRun,
type ChatIssue,
type ChatRun,
} from "./chat-flow.js";
import { runnerMatrix } from "./catalog.js";
import { isPublicRunnerScreenshotRoute } from "./screenshot-policy.js";
const source: ChatIssue = {
id: "chat",
companyId: "co",
title: "Chat",
status: "in_review",
assigneeAgentId: "agent",
};
const task: ChatIssue = {
...source,
id: "work",
parentId: null,
projectId: "project",
};
const plan = {
body: "# Relevant plan",
latestRevisionId: "revision",
updatedAt: "2026-09-11T10:00:00Z",
};
const run: ChatRun = {
id: "run",
companyId: "co",
agentId: "agent",
status: "succeeded",
startedAt: "2026-09-11T10:00:01Z",
};
describe("chat acceptance contracts", () => {
it("has exactly six workflows on the four chosen local profiles", () => {
const matrix = runnerMatrix.filter(
(cell) => cell.suite.id === "agent-chat",
);
expect(matrix).toHaveLength(24);
expect(new Set(matrix.map((cell) => cell.profile.id))).toEqual(
new Set([
"legacy-codex",
"legacy-claude",
"runner-codex",
"runner-acpx-claude",
]),
);
expect(new Set(matrix.map((cell) => cell.task.id)).size).toBe(6);
expect(
matrix.every(
(cell) =>
cell.environment.id === "local" &&
cell.task.expectedTerminalState.issue === "in_review",
),
).toBe(true);
});
it("rejects missing plan, chat children, wrong assignments, and execution before the plan", () => {
expect(() => assertChatHandoff(task, plan, [run], source)).not.toThrow();
for (const invalid of [
{ ...task, parentId: "chat" },
{ ...task, projectId: null },
{ ...task, assigneeAgentId: "other" },
])
expect(() => assertChatHandoff(invalid, plan, [run], source)).toThrow();
expect(() =>
assertChatHandoff(task, { ...plan, body: "" }, [run], source),
).toThrow();
expect(() =>
assertChatHandoff(
task,
{ ...plan, updatedAt: "2026-09-11T10:00:02Z" },
[run],
source,
),
).toThrow();
expect(() => assertChatHandoff(task, plan, [], source)).toThrow();
});
it("separates reset control runs from provider runs without treating failures as resets", () => {
expect(isResetRun(run)).toBe(false);
expect(isResetRun({ ...run, status: "failed" })).toBe(false);
expect(
isResetRun({ ...run, contextSnapshot: { conversationReset: true } }),
).toBe(true);
});
it("only publishes screenshots of the exact disposable chat", () => {
const target = {
issuePrefix: "E2E",
issueId: "chat",
issueIdentifier: null,
chatAgentId: "fixture-agent",
};
expect(
isPublicRunnerScreenshotRoute(
"http://127.0.0.1:3199/E2E/chats/fixture-agent",
target,
),
).toBe(true);
expect(
isPublicRunnerScreenshotRoute(
"http://127.0.0.1:3199/E2E/chats/another-agent",
target,
),
).toBe(false);
expect(
isPublicRunnerScreenshotRoute(
"https://example.com/E2E/chats/fixture-agent",
target,
),
).toBe(false);
});
});

View File

@ -0,0 +1,598 @@
import { expect, type Page } from "@playwright/test";
import type { RunnerApi } from "./api.js";
import type { LiveFixtureValues } from "./live-fixtures.js";
import type { MatrixExecution } from "./types.js";
// Public API observations only: this driver never fabricates provider results or writes DB state.
export interface ChatIssue {
id: string;
companyId: string;
title: string;
status: string;
identifier?: string;
conversationState?: string;
conversationSessionGeneration?: number;
conversationBoundaryCommentId?: string;
parentId?: string | null;
projectId?: string | null;
assigneeAgentId?: string | null;
}
export interface ChatRun {
id: string;
companyId: string;
agentId: string;
status: string;
runtimeMode?: string;
contextSnapshot?: Record<string, unknown>;
resultJson?: Record<string, unknown>;
sessionIdBefore?: string | null;
sessionIdAfter?: string | null;
startedAt?: string;
}
type Comment = {
id: string;
body: string;
authorAgentId?: string;
createdByRunId?: string;
conversationSessionGeneration?: number;
};
type Plan = { body: string; latestRevisionId: string; updatedAt: string };
export const isResetRun = (run: ChatRun) =>
run.contextSnapshot?.conversationReset === true ||
run.resultJson?.conversationReset === true;
export function assertChatHandoff(
task: ChatIssue,
plan: Plan,
runs: ChatRun[],
source: ChatIssue,
) {
expect(task.parentId).toBeNull();
expect(task.projectId).toBeTruthy();
expect(task.assigneeAgentId).toBe(source.assigneeAgentId);
expect(plan.body.trim()).not.toBe("");
expect(runs.length).toBeGreaterThan(0);
for (const run of runs) {
expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual(
Date.parse(run.startedAt!),
);
}
}
export async function sendChatMessage(page: Page, message: string) {
const composer = page.getByTestId("task-chat-composer-input").last();
await composer
.locator('[contenteditable="true"], textarea')
.first()
.fill(message);
await page.getByTestId("task-chat-composer-send").last().click();
}
export async function runChatFlow(input: {
page: Page;
api: RunnerApi;
fixtures: LiveFixtureValues;
execution: MatrixExecution;
nonce: string;
restart: () => Promise<void>;
observe: (issue: ChatIssue, runs: ChatRun[]) => void;
capture: (id: string, label: string, file: string) => Promise<void>;
evidence: (name: string, data: unknown) => Promise<void>;
}) {
const { page, api, fixtures: f, execution, nonce } = input;
const chatPath = `/api/companies/${f.company.id}/chats/${f.agent.id}`;
const route = `/${f.company.issuePrefix}/chats/${f.agent.id}`;
const marker = execution.task.buildVisibleMarker(nonce);
const caseId = execution.task.id;
let issue: ChatIssue;
let runs: ChatRun[] = [];
const settings = await api.get<Record<string, unknown>>(
"/api/instance/settings/experimental",
);
const allRuns = async () => {
const rows = await api.get<ChatRun[]>(
`/api/companies/${f.company.id}/heartbeat-runs?limit=100`,
);
return Promise.all(
rows.map((row) => api.get<ChatRun>(`/api/heartbeat-runs/${row.id}`)),
);
};
const tasks = () =>
api.get<ChatIssue[]>(`/api/companies/${f.company.id}/issues`);
const comments = async () =>
(
await api.get<Array<Comment & { createdAt: string }>>(
`/api/issues/${issue.id}/comments?order=asc`,
)
).sort(
(a, b) =>
a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id),
);
const idle = async (minimumProviderRuns: number) => {
await expect
.poll(
async () => {
const resolved = await api.get<ChatIssue | null>(chatPath);
if (!resolved) return false;
issue = resolved;
runs = await allRuns();
input.observe(issue, runs);
return (
runs.filter((run) => !isResetRun(run)).length >=
minimumProviderRuns &&
runs.every((run) => !["queued", "running"].includes(run.status)) &&
issue.status === "in_review" &&
issue.conversationState === "waiting"
);
},
{
timeout: 240_000,
intervals: [500, 1000, 2000],
message: "chat turn settles to waiting",
},
)
.toBe(true);
};
const turn = async (text: string, count: number) => {
await sendChatMessage(page, text);
await idle(count);
};
const noTasks = async () => expect(await tasks()).toHaveLength(0);
try {
await api.patch("/api/instance/settings/experimental", {
enableAgentChat: true,
enableClassicTaskInterface: false,
});
expect(await api.get(chatPath)).toBeNull();
await page.goto(route);
await expect(page.getByTestId("task-chat-composer-input")).toBeVisible();
expect(await api.get(chatPath)).toBeNull();
expect(await allRuns()).toHaveLength(0);
if (
["continuity-restart", "new-session", "stop-new-resume"].includes(caseId)
) {
const secret = `OLD_CONTEXT_${nonce}`;
await turn(
`For this conversation only, remember the phrase ${secret}. Just acknowledge briefly; no project or task is needed.`,
1,
);
const initialId = issue!.id;
const before = runs.filter((run) => !isResetRun(run))[0]!;
await noTasks();
await page.reload();
if (caseId === "continuity-restart") {
await turn(
"What phrase did I just ask you to remember? Reply with the phrase only.",
2,
);
expect(
(await comments()).filter((c) => c.authorAgentId).at(-1)?.body,
).toContain(secret);
const count = runs.length;
await input.restart();
await page.reload();
await idle(2);
expect(runs).toHaveLength(count);
await turn(
`We are done discussing it. Reply with ${marker} only; no further work.`,
3,
);
} else {
let cancelledId: string | undefined;
if (caseId === "stop-new-resume") {
await sendChatMessage(
page,
"Explain the history of gardening at length here, in 100 numbered paragraphs. This is discussion only; do not create work.",
);
await expect
.poll(
async () => {
runs = await allRuns();
const active = runs.find(
(run) => run.status === "running" && run.id !== before.id,
);
if (!active) return false;
const events = await api.get<Array<Record<string, unknown>>>(
`/api/heartbeat-runs/${active.id}/events?limit=1000`,
);
const log = await api.get<{ content?: string }>(
`/api/heartbeat-runs/${active.id}/log?limitBytes=65536`,
);
if (!(events.length || log.content?.length)) return false;
cancelledId = active.id;
return true;
},
{ timeout: 120_000 },
)
.toBe(true);
await page.getByTestId("task-chat-composer-stop").click();
await expect
.poll(
async () =>
(await api.get<ChatRun>(`/api/heartbeat-runs/${cancelledId}`))
.status,
)
.toBe("cancelled");
}
const oldComments = await comments();
await sendChatMessage(page, "/new");
await expect
.poll(
async () =>
(await api.get<ChatIssue>(chatPath))
.conversationSessionGeneration,
{ timeout: 30_000 },
)
.toBe(1);
await expect
.poll(async () => (await allRuns()).some(isResetRun))
.toBe(true);
await turn(
`Without reading older history or files, if you have a remembered phrase in your current context return it; otherwise reply exactly ${marker}. Do not look it up.`,
caseId === "new-session" ? 2 : 3,
);
const fresh = runs
.filter((run) => !isResetRun(run) && run.status === "succeeded")
.sort((a, b) => Date.parse(a.startedAt!) - Date.parse(b.startedAt!))
.at(-1)!;
expect(fresh.contextSnapshot?.conversationSessionGeneration).toBe(1);
expect(fresh.sessionIdBefore).toBeFalsy();
expect(
String(fresh.contextSnapshot?.paperclipTaskMarkdown ?? ""),
).not.toContain(secret);
if (before.sessionIdAfter && fresh.sessionIdAfter)
expect(fresh.sessionIdAfter).not.toBe(before.sessionIdAfter);
const replies = (await comments()).filter(
(c) => c.createdByRunId === fresh.id && c.authorAgentId,
);
expect(replies.map((c) => c.body).join("\n")).toContain(marker);
expect(replies.map((c) => c.body).join("\n")).not.toContain(secret);
const reset = runs.filter(isResetRun);
expect(reset).toHaveLength(1);
expect(
(await comments()).filter(
(c) => c.authorAgentId && c.createdByRunId === reset[0]!.id,
),
).toHaveLength(0);
expect(
(await comments()).filter((c) =>
oldComments.some((old) => old.id === c.id),
),
).toHaveLength(oldComments.length);
if (cancelledId)
expect(
(await comments()).filter((c) => c.createdByRunId === cancelledId),
).toEqual(
oldComments.filter((c) => c.createdByRunId === cancelledId),
);
await page.reload();
await expect(
page.getByText("New session", { exact: true }),
).toHaveCount(1);
}
expect(issue!.id).toBe(initialId);
await noTasks();
} else {
let existingProject: { id: string; name: string } | undefined;
if (caseId === "clarify-reuse") {
existingProject = await api.post(
`/api/companies/${f.company.id}/projects`,
{
name: `Garden ${nonce}`,
description: "Garden club welcome notes and event announcements.",
},
);
await turn(
"I need a welcome note for our club. Help me clarify what information you need before assigning the work.",
1,
);
await noTasks();
const questions = await api.get<
Array<{
status: string;
kind: string;
payload?: {
questions?: Array<{
selectionMode?: string;
answerMode?: string;
customAnswer?: { label?: string };
}>;
};
}>
>(`/api/issues/${issue!.id}/interactions`);
const pendingQuestions = questions.find(
(row) =>
row.status === "pending" && row.kind === "ask_user_questions",
)?.payload?.questions;
expect(
Boolean(pendingQuestions?.length) ||
(await comments()).some(
(c) => c.authorAgentId && c.body.includes("?"),
),
).toBe(true);
const clarification = `It is the garden club; use the existing Garden ${nonce} project. Make one assigned task for yourself to write a two-sentence welcome note. Include ${marker} in that note, save it as the output document, and finish that execution task. Please get it started now.`;
if (pendingQuestions?.length) {
expect(pendingQuestions.length).toBeLessThanOrEqual(3);
for (const [index, question] of pendingQuestions.entries()) {
const textInput = page
.getByTestId("question-text-answer-composer")
.last();
if (await textInput.isVisible()) {
await textInput
.locator('[contenteditable="true"],textarea')
.first()
.fill(clarification);
} else {
await page
.getByRole(
question.selectionMode === "multiple" ? "checkbox" : "radio",
{
name: question.customAnswer?.label ?? "Other",
exact: true,
},
)
.last()
.click();
await page
.getByTestId("question-other-answer-composer")
.last()
.locator('[contenteditable="true"],textarea')
.first()
.fill(clarification);
}
await page
.getByRole("button", {
name:
index === pendingQuestions.length - 1
? "Submit answers"
: "Next",
exact: true,
})
.last()
.click();
}
await idle(3);
} else await turn(clarification, 3);
} else if (caseId === "plan-handoff") {
await page.getByTestId("task-chat-composer-mode").click();
await page
.getByTestId("task-chat-composer-mode-menu")
.getByText("Plan mode", { exact: true })
.click();
await turn(
`Let's plan a two-sentence garden club welcome note. Write a plan in the plan panel, with the required phrase DRAFT_${nonce}. Do not create a project or task yet.`,
1,
);
const draft = await api.get<Plan>(
`/api/issues/${issue!.id}/documents/plan`,
);
expect(draft.body).toContain(`DRAFT_${nonce}`);
await noTasks();
await input.capture(
"chat-plan-draft",
"Draft plan in the conversation",
"chat-plan-draft.png",
);
const initialInteractions = await api.get<
Array<{
status: string;
kind: string;
payload?: {
target?: { revisionId?: string };
rejectLabel?: string;
};
}>
>(`/api/issues/${issue!.id}/interactions`);
const initialApproval = initialInteractions.find(
(row) =>
row.status === "pending" &&
row.kind === "request_confirmation" &&
row.payload?.target?.revisionId === draft.latestRevisionId,
);
expect(
initialApproval,
"draft has a revision-bound approval",
).toBeTruthy();
const reviseButton = page
.getByRole("button", {
name: initialApproval!.payload?.rejectLabel ?? "Reject",
exact: true,
})
.last();
await reviseButton.click();
await page
.getByTestId("plan-revision-composer")
.last()
.locator('[contenteditable="true"],textarea')
.first()
.fill(
`Revise the plan: replace DRAFT_${nonce} with ${marker}. The execution task should save the welcome note in its output document. Present this revised plan for approval; do not hand it off yet.`,
);
await reviseButton.click();
await idle(2);
const revised = await api.get<Plan>(
`/api/issues/${issue!.id}/documents/plan`,
);
expect(revised.body).toContain(marker);
expect(revised.body).not.toContain(`DRAFT_${nonce}`);
expect(revised.latestRevisionId).not.toBe(draft.latestRevisionId);
await noTasks();
const interactions = await api.get<
Array<{
id: string;
status: string;
kind: string;
payload?: {
target?: { revisionId?: string };
acceptLabel?: string;
};
}>
>(`/api/issues/${issue!.id}/interactions`);
const approval = interactions.find(
(row) =>
row.status === "pending" &&
row.kind === "request_confirmation" &&
row.payload?.target?.revisionId === revised.latestRevisionId,
);
expect(approval, "approval targets the revised plan").toBeTruthy();
await input.capture(
"chat-plan-revised",
"Revised plan before handoff",
"chat-plan-revised.png",
);
await page
.getByRole("button", {
name: approval!.payload?.acceptLabel ?? "Approve",
exact: true,
})
.last()
.click();
await idle(4);
await input.evidence("chat-plan-revisions.json", {
draft,
revised,
approval,
source: await api.get(`/api/issues/${issue!.id}/documents/plan`),
});
} else {
await turn(
`Create a project called Repository Discussion ${nonce} for work spanning https://github.com/octocat/Hello-World and https://github.com/octocat/Spoon-Knife. These existing public repositories are not in our catalog; register both URLs. Then make one assigned task for yourself to write a two-sentence description of the intended project in an output document, containing ${marker}, and complete that task. No code changes or remote repository creation are needed.`,
2,
);
}
const children = await tasks();
expect(children).toHaveLength(1);
const child = children[0]!;
await expect
.poll(
async () =>
(await api.get<ChatIssue>(`/api/issues/${child.id}`)).status,
{ timeout: 240_000 },
)
.toBe("done");
runs = await allRuns();
input.observe(issue!, runs);
const plan = await api.get<Plan>(
`/api/issues/${child.id}/documents/plan`,
);
const taskRuns = runs.filter(
(run) => run.contextSnapshot?.issueId === child.id,
);
assertChatHandoff(child, plan, taskRuns, issue!);
const output = await api.get<Plan>(
`/api/issues/${child.id}/documents/output`,
);
expect(output.body).toContain(marker);
expect(
(await comments())
.filter((c) => c.authorAgentId)
.map((c) => c.body)
.join("\n"),
).toMatch(new RegExp(`${child.id}|${child.identifier}`));
const projects = await api.get<
Array<{
id: string;
name: string;
workspaces: Array<{ repoUrl?: string }>;
}>
>(`/api/companies/${f.company.id}/projects`);
expect(projects).toHaveLength(1);
if (existingProject) {
expect(child.projectId).toBe(existingProject.id);
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(0);
} else {
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(1);
if (caseId === "multi-repository") {
expect(projects[0]!.workspaces.map((w) => w.repoUrl).sort()).toEqual(
[
"https://github.com/octocat/Hello-World",
"https://github.com/octocat/Spoon-Knife",
].sort(),
);
await expect(
page
.getByRole("article")
.getByRole("link", { name: "octocat/Hello-World" }),
).toBeVisible();
await expect(
page
.getByRole("article")
.getByRole("link", { name: "octocat/Spoon-Knife" }),
).toBeVisible();
} else
expect(projects[0]!.workspaces.filter((w) => w.repoUrl)).toHaveLength(
0,
);
await page.reload();
await expect(
page.getByRole("article", { name: /Project created:/ }),
).toHaveCount(1);
}
await input.evidence("chat-handoff.json", {
source: issue!,
task: child,
plan,
output,
projects,
});
}
await idle(execution.task.expectedRunCount);
expect(runs.filter((run) => !isResetRun(run))).toHaveLength(
execution.task.expectedRunCount,
);
for (const run of runs.filter((run) => !isResetRun(run))) {
expect(run.runtimeMode).toBe(execution.profile.expectedRuntimeMode);
expect(run.status).toBe(
caseId === "stop-new-resume" && run.status === "cancelled"
? "cancelled"
: "succeeded",
);
}
await input.evidence("chat-api-state.json", {
issue: issue!,
runs,
runGroups: {
resets: runs.filter(isResetRun).map((run) => run.id),
cancelled: runs
.filter((run) => run.status === "cancelled")
.map((run) => run.id),
conversation: runs
.filter(
(run) =>
!isResetRun(run) && run.contextSnapshot?.issueId === issue!.id,
)
.map((run) => run.id),
handoff: runs
.filter((run) => run.contextSnapshot?.issueId !== issue!.id)
.map((run) => run.id),
},
comments: await comments(),
activity: await api.get(`/api/issues/${issue!.id}/activity`),
runEvidence: await Promise.all(
runs.map(async (run) => ({
runId: run.id,
log: await api.get(
`/api/heartbeat-runs/${run.id}/log?limitBytes=1048576`,
),
events: await api.get(
`/api/heartbeat-runs/${run.id}/events?limit=1000`,
),
})),
),
});
await input.capture(
"final-state",
"Chat waiting after its verified workflow",
"final-state.png",
);
return { issue: issue!, runs };
} finally {
await api.patch("/api/instance/settings/experimental", {
enableAgentChat: settings.enableAgentChat,
enableClassicTaskInterface: settings.enableClassicTaskInterface,
});
}
}

View File

@ -38,10 +38,7 @@ describe("runner E2E Daytona image contract", () => {
"COPY packages/paperclip-runner ./packages/paperclip-runner",
);
expect(dockerfile).toContain(
"COPY packages/paperclip-eval-kernel/src ./packages/paperclip-eval-kernel/src",
);
expect(dockerfile).toContain(
"COPY packages/paperclip-runner/src ./packages/paperclip-runner/src",
"COPY packages ./packages",
);
expect(dockerfile).toContain(
"/opt/paperclip-runner/provider-pack/provider-pack.json",
@ -137,20 +134,21 @@ describe("runner E2E Daytona image contract", () => {
workflow.indexOf(`--format '{{json .Image}}'`),
);
const providerInstall = dockerfile.indexOf(
"RUN pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'",
"pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'",
);
const runnerSourceCopy = dockerfile.indexOf(
"COPY packages/paperclip-runner/src ./packages/paperclip-runner/src",
"COPY packages ./packages",
);
const providerRevisionArg = dockerfile.indexOf(
"ARG PAPERCLIP_RUNNER_SOURCE_REVISION",
);
const cliInstall = dockerfile.indexOf("RUN npm install -g");
const cliInstall = dockerfile.indexOf("npm install -g");
const finalMetadataArgs = dockerfile.lastIndexOf(
"ARG PAPERCLIP_RUNNER_CONTENT_ID",
);
expect(providerInstall).toBeGreaterThan(0);
expect(providerInstall).toBeLessThan(runnerSourceCopy);
expect(runnerSourceCopy).toBeGreaterThan(0);
expect(runnerSourceCopy).toBeLessThan(providerInstall);
expect(providerInstall).toBeLessThan(providerRevisionArg);
expect(cliInstall).toBeGreaterThan(0);
expect(cliInstall).toBeLessThan(finalMetadataArgs);

View File

@ -195,8 +195,8 @@ describe("runner E2E campaign history", () => {
expect(index).toContain("Runner E2E campaigns");
expect(index).toContain("complete-green");
expect(index).toContain("complete-red");
expect(index).toContain("68/68 passed");
expect(index).toContain("67/68 passed");
expect(index).toContain("92/92 passed");
expect(index).toContain("91/92 passed");
expect(index).toContain("Open report&nbsp;→");
expect(index).toContain(
"campaigns/complete-red/public-images/campaign-summary.png",

View File

@ -1,3 +1,4 @@
import { runChatFlow } from "./chat-flow.js";
import { randomBytes } from "node:crypto";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import path from "node:path";
@ -628,6 +629,7 @@ for (const execution of executions) {
const isReviewedFixtureScreenshotRoute = () =>
isPublicRunnerScreenshotRoute(page.url(), {
chatAgentId: execution.task.flow === "agent_chat" ? fixtures?.agent.id : undefined,
issuePrefix: fixtures?.company.issuePrefix,
issueId: issue?.id,
issueIdentifier: issue?.identifier,
@ -670,10 +672,10 @@ for (const execution of executions) {
};
const cancelActiveRunsForCleanup = async () => {
if (!issue) return;
const cleanupIssueId = issue.id;
if (!issue && !(execution.task.flow === "agent_chat" && fixtures)) return;
const cleanupIssueId = issue?.id;
const runs = await api.get<RunRecord[]>(
`/api/issues/${cleanupIssueId}/runs`,
execution.task.flow === "agent_chat" && fixtures ? `/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100` : `/api/issues/${cleanupIssueId}/runs`,
);
const activeRunIds = [
...new Set(
@ -694,7 +696,7 @@ for (const execution of executions) {
await pollUntil({
label: `cleanup cancellation for issue ${cleanupIssueId}`,
deadlineAt: Date.now() + 45_000,
load: () => api.get<RunRecord[]>(`/api/issues/${cleanupIssueId}/runs`),
load: () => api.get<RunRecord[]>(execution.task.flow === "agent_chat" && fixtures ? `/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100` : `/api/issues/${cleanupIssueId}/runs`),
accept: (currentRuns) =>
currentRuns
.filter((run) => activeIds.has(run.id))
@ -704,7 +706,16 @@ for (const execution of executions) {
};
const captureFailureApiState = async () => {
if (!fixtures || !issue) return;
if (!fixtures) return;
if (execution.task.flow === "agent_chat" && !issue) {
const companyRuns = await api.get<RunRecord[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
selectedRuns = await Promise.all(companyRuns.map(run => api.get<RunRecord>(`/api/heartbeat-runs/${run.id}`)));
// Settings are already restored on failure, so chat resolution may be
// gated. Direct task access still permits evidence and usage capture.
const sourceId = selectedRuns.map(run => record(run.contextSnapshot).issueId).find(id => typeof id === "string");
if (typeof sourceId === "string") issue = await api.get<IssueRecord>(`/api/issues/${sourceId}`);
}
if (!issue) return;
const capture = async <T>(operation: () => Promise<T>) =>
operation().catch((error) => ({
evidenceCaptureError:
@ -715,7 +726,9 @@ for (const execution of executions) {
capture(() => api.get<IssueRecord>(`/api/issues/${issue!.id}`)),
capture(() =>
api.get<RunRecord[]>(
`/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
execution.task.flow === "agent_chat"
? `/api/companies/${fixtures!.company.id}/heartbeat-runs?limit=100`
: `/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
),
),
capture(() =>
@ -730,7 +743,7 @@ for (const execution of executions) {
),
]);
const taskRuns = Array.isArray(listedRuns)
? matchingRuns(listedRuns, "id" in currentIssue ? currentIssue : issue)
? execution.task.flow === "agent_chat" ? listedRuns : matchingRuns(listedRuns, "id" in currentIssue ? currentIssue : issue)
: [];
const detailedRuns = await Promise.all(
taskRuns.map((candidate) =>
@ -846,6 +859,17 @@ for (const execution of executions) {
secrets,
);
if (execution.task.flow === "agent_chat") {
const chat = await runChatFlow({
page, api, fixtures, execution, nonce,
restart: () => restartIsolatedPaperclipServer({ api, requestId: `chat-${nonce}`, deadlineAt: startedAtMs + deadlineMs }),
observe: (chatIssue, chatRuns) => { issue = chatIssue; selectedRuns = chatRuns; },
capture: captureScreenshot,
evidence: (name, data) => writeSanitizedJson(snapshotsDir, name, data, secrets),
});
issue = chat.issue; selectedRuns = chat.runs;
matcherResults = [{ matcher: { kind: "issue_status", expected: "in_review" }, passed: true, detail: "Chat workflow and durable handoff/session assertions passed" }];
} else {
const issuePrefix = fixtures.company.issuePrefix;
if (!issuePrefix)
throw new Error(
@ -911,7 +935,9 @@ for (const execution of executions) {
const [currentIssue, runs, comments, interactions] = await Promise.all([
api.get<IssueRecord>(`/api/issues/${issue!.id}`),
api.get<RunRecord[]>(
`/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
execution.task.flow === "agent_chat"
? `/api/companies/${fixtures!.company.id}/heartbeat-runs?limit=100`
: `/api/companies/${fixtures!.company.id}/heartbeat-runs?agentId=${fixtures!.agent.id}&limit=20`,
),
api.get<CommentRecord[]>(
`/api/issues/${issue!.id}/comments?order=asc`,
@ -2377,6 +2403,7 @@ for (const execution of executions) {
`Runtime invariant failure: ${invariantFailures.join("; ")}`,
);
}
}
} catch (error) {
primaryError = error;
try {
@ -2439,6 +2466,11 @@ for (const execution of executions) {
});
try {
await cancelActiveRunsForCleanup();
if (execution.task.flow === "agent_chat") {
const companyRuns = await api.get<RunRecord[]>(`/api/companies/${fixtures.company.id}/heartbeat-runs?limit=100`);
selectedRuns = await Promise.all(companyRuns.map(run => api.get<RunRecord>(`/api/heartbeat-runs/${run.id}`)));
await writeSanitizedJson(snapshotsDir, "chat-final-run-ledger.json", selectedRuns, secrets);
}
await fixtures.teardown();
cleanup = "passed";
} catch (error) {

View File

@ -1,6 +1,7 @@
export const PUBLIC_RUNNER_SCREENSHOT_MARKER = "public-runner-fixture" as const;
export interface PublicRunnerScreenshotTarget {
chatAgentId?: string;
issuePrefix: string | null | undefined;
issueId: string | null | undefined;
issueIdentifier: string | null | undefined;
@ -15,10 +16,12 @@ export function isPublicRunnerScreenshotRoute(
const candidate = new URL(url);
const issueReference = target.issueIdentifier ?? target.issueId;
const expectedPath = `/${encodeURIComponent(target.issuePrefix)}/issues/${encodeURIComponent(issueReference)}`;
const chatPath = target.chatAgentId ? `/${encodeURIComponent(target.issuePrefix)}/chats/${encodeURIComponent(target.chatAgentId)}` : null;
return (
candidate.protocol === "http:" &&
candidate.hostname === "127.0.0.1" &&
(candidate.pathname === expectedPath ||
(candidate.pathname === chatPath ||
candidate.pathname === expectedPath ||
candidate.pathname === `${expectedPath}/`)
);
} catch {

View File

@ -10,6 +10,7 @@ export type RunnerGeneration = "legacy" | "native";
export type RunnerEnvironmentId = "local" | "daytona";
export type RunnerTaskWorkMode = "standard" | "planning" | "ask";
export type RunnerTaskFlow =
| "agent_chat"
| "governed_tool_review"
| "single_turn"
| "plan_revision_acceptance"
@ -122,7 +123,7 @@ export interface RunnerTaskFixture {
expectedRunCount: number;
attemptTimeoutMs: Readonly<Record<RunnerEnvironmentId, number>>;
expectedTerminalState: {
issue: "done";
issue: "done" | "in_review";
run: "succeeded";
};
buildTitle(nonce: string): string;

View File

@ -36,6 +36,7 @@ import { Workspaces } from "./pages/Workspaces";
import { Issues } from "./pages/Issues";
import { Search } from "./pages/Search";
import { IssueDetail } from "./pages/IssueDetail";
import { AgentChat } from "./pages/AgentChat";
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
import { Routines } from "./pages/Routines";
import { Learnings, PipelineItemDetail, PipelineItemLegacyRedirect, Pipelines, ReviewQueue } from "./pages/Pipelines";
@ -289,6 +290,7 @@ function boardRoutes(streamlinedUiEnabled: boolean) {
<Route path="issues/backlog" element={<Navigate to="/issues" replace />} />
<Route path="issues/done" element={<Navigate to="/issues" replace />} />
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="chats/:agentRef" element={<AgentChat />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
{import.meta.env.DEV ? (
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />

13
ui/src/api/agentChats.ts Normal file
View File

@ -0,0 +1,13 @@
import type { Issue } from "@paperclipai/shared";
import { api } from "./client";
export const agentChatsApi = {
get: (companyId: string, agentRef: string) =>
api.get<Issue | null>(
`/companies/${companyId}/chats/${encodeURIComponent(agentRef)}`,
),
ensure: (companyId: string, agentRef: string) =>
api.post<Issue>(
`/companies/${companyId}/chats/${encodeURIComponent(agentRef)}`,
{},
),
};

View File

@ -339,11 +339,12 @@ export const issuesApi = {
allowSharing?: boolean;
},
) => api.post<FeedbackVote>(`/issues/${id}/feedback-votes`, data),
addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean) =>
addComment: (id: string, body: string, reopen?: boolean, interrupt?: boolean, clientRequestId?: string) =>
api.post<IssueComment>(
`/issues/${id}/comments`,
{
body,
...(clientRequestId ? { clientRequestId } : {}),
...(reopen === undefined ? {} : { reopen }),
...(interrupt === undefined ? {} : { interrupt }),
},

Some files were not shown because too many files have changed in this diff Show More