feat(server): add experimental task-backed agent conversations
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
63a69d0414
commit
64c9edc29f
|
|
@ -386,3 +386,7 @@ pnpm secrets:migrate-inline-env --apply
|
|||
```
|
||||
|
||||
Hosted AWS provider notes live in [SECRETS-AWS-PROVIDER.md](./SECRETS-AWS-PROVIDER.md).
|
||||
|
||||
### Persistent agent conversations
|
||||
|
||||
Migration `0273_agent_chat.sql` adds 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.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
buildPaperclipEnv,
|
||||
buildRuntimeToolsEnv,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
isPaperclipExternalChatContractTurn,
|
||||
isPaperclipExternalChatQuestionResponseTurn,
|
||||
isPaperclipExternalChatTurn,
|
||||
|
|
@ -86,6 +87,9 @@ describe("runtime connection tool delivery", () => {
|
|||
expect(DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE).toContain(
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
);
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).toContain(CONNECTION_INTENT_AGENT_GUIDANCE);
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("Execution contract:");
|
||||
expect(DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE).not.toContain("child issues");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -908,6 +912,30 @@ describe("runChildProcess", () => {
|
|||
});
|
||||
|
||||
describe("renderPaperclipWakePrompt", () => {
|
||||
it("leaves conversation disposition and accepted-plan handoff to the injected chat policy", () => {
|
||||
const payload = {
|
||||
reason: "issue_commented",
|
||||
issue: { id: "chat", workMode: "planning", status: "in_progress" },
|
||||
interactionKind: "request_confirmation",
|
||||
interactionStatus: "accepted",
|
||||
comments: [],
|
||||
commentWindow: { requestedCount: 0, includedCount: 0, missingCount: 0 },
|
||||
fallbackFetchNeeded: false,
|
||||
};
|
||||
const ordinary = renderPaperclipWakePrompt(payload, { resumedSession: true });
|
||||
expect(ordinary).toContain("Execution contract:");
|
||||
expect(ordinary).toContain("Create child issues from the approved plan");
|
||||
for (const resumedSession of [false, true]) {
|
||||
const chat = renderPaperclipWakePrompt(payload, {
|
||||
resumedSession, conversationMode: true, includeExecutionContract: true,
|
||||
});
|
||||
expect(chat).not.toContain("Execution contract:");
|
||||
expect(chat).not.toContain("clear final disposition");
|
||||
expect(chat).not.toContain("Create child issues");
|
||||
expect(chat).not.toContain("you may create child implementation issues");
|
||||
}
|
||||
});
|
||||
|
||||
const ordinaryExternalChatWake = {
|
||||
reason: "External chat message received",
|
||||
externalChatProvider: " GitHub ",
|
||||
|
|
|
|||
|
|
@ -229,6 +229,18 @@ export const DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE = [
|
|||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
// Chat behavior is supplied centrally by the server's task-context markdown.
|
||||
// Keep the ordinary task's completion/delegation contract out of this template.
|
||||
export const DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE = [
|
||||
"You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip conversation using the supplied chat mode directive.",
|
||||
"Use available tools and assigned skills as needed; respect budget, pause/cancel, approval gates, and company boundaries.",
|
||||
"Prefer the smallest verification that proves the action. Use PAPERCLIP_SCRATCH_DIR / PAPERCLIP_RUN_SCRATCH_DIR for temporary scratch files.",
|
||||
"After 2 consecutive failures of the same control-plane write, stop retrying that write for the rest of the turn. Report the failure honestly; never claim an unconfirmed mutation succeeded.",
|
||||
"Never create probe or throwaway issue-thread interactions. Every interaction must carry a real, answerable prompt; withdraw one you no longer need.",
|
||||
"",
|
||||
CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
].join("\n");
|
||||
|
||||
export const WATCHDOG_DEFAULT_MANDATE = [
|
||||
"You are running as a task watchdog, not as the original deliverable worker.",
|
||||
"Your mission is to keep the watched issue tree moving by verifying stopped work, not by trusting agent claims.",
|
||||
|
|
@ -2180,6 +2192,9 @@ function renderPaperclipWakePromptBody(
|
|||
options: {
|
||||
resumedSession?: boolean;
|
||||
includeExecutionContract?: boolean;
|
||||
// Conversation policy arrives in the server-owned task markdown. Generic
|
||||
// task disposition and child-delegation instructions conflict with it.
|
||||
conversationMode?: boolean;
|
||||
nativeWakeReaderAvailable?: boolean;
|
||||
// Set by adapters whose prompt already carries the task-context markdown
|
||||
// (the authoritative, uncapped brief) so the description is not delivered
|
||||
|
|
@ -2203,8 +2218,8 @@ function renderPaperclipWakePromptBody(
|
|||
// The heartbeat prompt template already carries the execution contract on
|
||||
// fresh sessions; only resume deltas (which replace the template) and
|
||||
// template-less adapters need the wake-payload copy.
|
||||
const includeExecutionContract =
|
||||
resumedSession || options.includeExecutionContract === true;
|
||||
const includeExecutionContract = options.conversationMode !== true &&
|
||||
(resumedSession || options.includeExecutionContract === true);
|
||||
const hasWakeCommentBatch =
|
||||
normalized.comments.length > 0 ||
|
||||
normalized.includedCount > 0 ||
|
||||
|
|
@ -2499,7 +2514,7 @@ function renderPaperclipWakePromptBody(
|
|||
lines.push(`- checkbox selection ids: ${selectedOptionIds}`);
|
||||
lines.push(`- checkbox selection options: ${selectedOptions}`);
|
||||
}
|
||||
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog) {
|
||||
if (normalized.issue?.workMode === "planning" && !normalized.taskWatchdog && options.conversationMode !== true) {
|
||||
const hasWakeComments = normalized.comments.length > 0;
|
||||
const acceptedPlanContinuation =
|
||||
!hasWakeComments &&
|
||||
|
|
@ -2646,7 +2661,7 @@ function renderPaperclipWakePromptBody(
|
|||
"",
|
||||
"Open plan comments to incorporate:",
|
||||
"These open plan annotations are user feedback. Resolved annotations were intentionally omitted.",
|
||||
"Read this before revising the plan or creating child issues from an accepted plan.",
|
||||
"Read this before revising the plan or acting on an accepted plan.",
|
||||
);
|
||||
if (context.latestRevisionNumber || context.latestRevisionId) {
|
||||
lines.push(
|
||||
|
|
@ -2654,9 +2669,10 @@ function renderPaperclipWakePromptBody(
|
|||
);
|
||||
}
|
||||
if (context.interaction) {
|
||||
lines.push(
|
||||
`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`,
|
||||
);
|
||||
lines.push(`- interaction: ${context.interaction.kind ?? "unknown"} ${context.interaction.status ?? "unknown"}`);
|
||||
if (context.interaction.status === "rejected") {
|
||||
lines.push("The user requested changes to this plan. Revise it using the feedback below; this is not approval to implement or hand off execution tasks. In Ask mode, discuss the requested changes without mutating documents or tasks.");
|
||||
}
|
||||
if (context.interaction.result) {
|
||||
const result = context.interaction.result;
|
||||
lines.push(
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ import {
|
|||
shapePaperclipWorkspaceEnvForExecution,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import { buildSkillLibraryManifestMarkdown } from "@paperclipai/adapter-utils/skill-library-manifest";
|
||||
import {
|
||||
|
|
@ -428,7 +429,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const effort = asString(config.effort, "");
|
||||
const chrome = asBoolean(config.chrome, false);
|
||||
|
|
@ -845,6 +848,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession: Boolean(sessionId),
|
||||
conversationMode: context.conversationMode === true,
|
||||
// The task-context markdown is the authoritative brief on this lane; keep
|
||||
// the wake prompt's description copy out so the prompt carries it once.
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
const promptBundleKey =
|
||||
readNonEmptyString(record.promptBundleKey) ??
|
||||
readNonEmptyString(record.prompt_bundle_key);
|
||||
const mcpServerIdentity = readNonEmptyString(record.mcpServerIdentity);
|
||||
const workspaceId = readNonEmptyString(record.workspaceId) ?? readNonEmptyString(record.workspace_id);
|
||||
const repoUrl = readNonEmptyString(record.repoUrl) ?? readNonEmptyString(record.repo_url);
|
||||
const repoRef = readNonEmptyString(record.repoRef) ?? readNonEmptyString(record.repo_ref);
|
||||
|
|
@ -89,6 +90,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
|
|
@ -105,6 +107,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
const promptBundleKey =
|
||||
readNonEmptyString(params.promptBundleKey) ??
|
||||
readNonEmptyString(params.prompt_bundle_key);
|
||||
const mcpServerIdentity = readNonEmptyString(params.mcpServerIdentity);
|
||||
const workspaceId = readNonEmptyString(params.workspaceId) ?? readNonEmptyString(params.workspace_id);
|
||||
const repoUrl = readNonEmptyString(params.repoUrl) ?? readNonEmptyString(params.repo_url);
|
||||
const repoRef = readNonEmptyString(params.repoRef) ?? readNonEmptyString(params.repo_ref);
|
||||
|
|
@ -112,6 +115,7 @@ export const sessionCodec: AdapterSessionCodec = {
|
|||
sessionId,
|
||||
...(cwd ? { cwd } : {}),
|
||||
...(promptBundleKey ? { promptBundleKey } : {}),
|
||||
...(mcpServerIdentity ? { mcpServerIdentity } : {}),
|
||||
...(workspaceId ? { workspaceId } : {}),
|
||||
...(repoUrl ? { repoUrl } : {}),
|
||||
...(repoRef ? { repoRef } : {}),
|
||||
|
|
|
|||
|
|
@ -45,9 +45,11 @@ import {
|
|||
readPaperclipIssueWorkModeFromContext,
|
||||
renderTemplate,
|
||||
renderPaperclipWakePrompt,
|
||||
selectPaperclipTaskMarkdown,
|
||||
isPaperclipRecoveryWakePayload,
|
||||
stringifyPaperclipWakePayload,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE,
|
||||
joinPromptSections,
|
||||
} from "@paperclipai/adapter-utils/server-utils";
|
||||
import {
|
||||
|
|
@ -587,7 +589,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
|
||||
const promptTemplate = asString(
|
||||
config.promptTemplate,
|
||||
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
context.conversationMode === true
|
||||
? DEFAULT_PAPERCLIP_CONVERSATION_PROMPT_TEMPLATE
|
||||
: DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
||||
);
|
||||
const command = asString(config.command, "codex");
|
||||
const model = asString(config.model, "");
|
||||
|
|
@ -1119,7 +1123,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
||||
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
||||
: "";
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) });
|
||||
const taskContextNote = selectPaperclipTaskMarkdown(context, { resumedSession: Boolean(sessionId) });
|
||||
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, {
|
||||
resumedSession: Boolean(sessionId),
|
||||
conversationMode: context.conversationMode === true,
|
||||
suppressIssueDescription: taskContextNote.length > 0,
|
||||
});
|
||||
const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0;
|
||||
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
||||
instructionsChars = promptInstructionsPrefix.length;
|
||||
|
|
@ -1202,6 +1211,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
wakePrompt,
|
||||
codexFallbackHandoffNote,
|
||||
sessionHandoffNote,
|
||||
taskContextNote,
|
||||
renderedPrompt,
|
||||
]);
|
||||
const promptMetrics = {
|
||||
|
|
@ -1210,6 +1220,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
|||
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
||||
wakePromptChars: wakePrompt.length,
|
||||
sessionHandoffChars: sessionHandoffNote.length,
|
||||
taskContextChars: taskContextNote.length,
|
||||
heartbeatPromptChars: renderedPrompt.length,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -104,12 +104,16 @@ describe("opencode remote execution", () => {
|
|||
const cleanupDirs: string[] = [];
|
||||
const originalOpenCodeAllowAllModels = process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
cleanupDirs.push(configHome);
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
if (originalOpenCodeAllowAllModels === undefined) {
|
||||
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,18 @@ function probeResult(overrides: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
describe("OpenCode local skill injection", () => {
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
configHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllEnvs();
|
||||
await fs.rm(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("injects runtime skills into the configured child HOME", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-configured-home-"));
|
||||
const processHome = path.join(root, "process-home");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
const {
|
||||
|
|
@ -71,8 +74,17 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|||
import { testEnvironment } from "./test.js";
|
||||
|
||||
describe("opencode remote environment diagnostics", () => {
|
||||
afterEach(() => {
|
||||
let configHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
||||
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
await rm(configHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("stages remote runtime config assets for sandbox hello probes", async () => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const migrationFile = "0273_agent_chat.sql";
|
||||
const migrationSql = await readFile(new URL(`./migrations/${migrationFile}`, import.meta.url), "utf8");
|
||||
const migrationHash = createHash("sha256").update(migrationSql).digest("hex");
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
afterEach(async () => {
|
||||
while (cleanups.length) await cleanups.pop()?.();
|
||||
});
|
||||
|
||||
async function seed(sql: postgres.Sql) {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const commentId = randomUUID();
|
||||
const userId = `chat-user-${randomUUID()}`;
|
||||
await sql`INSERT INTO companies (id, name, issue_prefix) VALUES (${companyId}, 'Chat migration', 'CHM')`;
|
||||
await sql`INSERT INTO agents (id, company_id, name, role, adapter_type) VALUES (${agentId}, ${companyId}, 'Chat agent', 'engineer', 'process')`;
|
||||
await sql`INSERT INTO "user" (id, name, email, email_verified, created_at, updated_at)
|
||||
VALUES (${userId}, 'Chat user', ${`${userId}@example.test`}, true, now(), now())`;
|
||||
await sql`INSERT INTO issues (id, company_id, title, assignee_agent_id, status,
|
||||
conversation_agent_id, conversation_user_id, conversation_state, conversation_session_generation, conversation_boundary_comment_id)
|
||||
VALUES (${issueId}, ${companyId}, 'Preserved chat', ${agentId}, 'in_review',
|
||||
${agentId}, ${userId}, 'waiting', 7, ${commentId})`;
|
||||
await sql`INSERT INTO issue_comments (id, company_id, issue_id, author_user_id, body, client_request_id, conversation_session_generation)
|
||||
VALUES (${commentId}, ${companyId}, ${issueId}, ${userId}, 'Preserved conversation history', 'first-message', 7)`;
|
||||
return { companyId, agentId, issueId, commentId, userId };
|
||||
}
|
||||
|
||||
async function assertConstraints(sql: postgres.Sql, row: Awaited<ReturnType<typeof seed>>) {
|
||||
for (const update of [
|
||||
{ conversation_state: null },
|
||||
{ status: "done" },
|
||||
{ status: "cancelled" },
|
||||
{ assignee_agent_id: null },
|
||||
{ conversation_user_id: null },
|
||||
]) {
|
||||
await expect(sql`UPDATE issues SET ${sql(update)} WHERE id = ${row.issueId}`)
|
||||
.rejects.toMatchObject({ code: "23514", constraint_name: "issues_conversation_identity_check" });
|
||||
}
|
||||
await expect(sql`INSERT INTO issues (company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
|
||||
VALUES (${row.companyId}, 'Duplicate conversation', ${row.agentId}, 'in_review', ${row.agentId}, ${row.userId}, 'waiting')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issues_conversation_identity_idx" });
|
||||
await expect(sql`INSERT INTO issue_comments (company_id, issue_id, author_user_id, body, client_request_id)
|
||||
VALUES (${row.companyId}, ${row.issueId}, ${row.userId}, 'Duplicate message', 'first-message')`)
|
||||
.rejects.toMatchObject({ code: "23505", constraint_name: "issue_comments_client_request_uq" });
|
||||
await sql`INSERT INTO issues (company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
|
||||
VALUES (${row.companyId}, 'Other person conversation', ${row.agentId}, 'in_review', ${row.agentId}, 'other-person', 'waiting')`;
|
||||
await sql`INSERT INTO issues (company_id, title, status) VALUES (${row.companyId}, 'Ordinary completed task', 'done')`;
|
||||
}
|
||||
|
||||
describePostgres("persistent agent chat migration", () => {
|
||||
it("applies to a fresh database and enforces conversation identity and message retry uniqueness", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-chat-migration-fresh-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
await assertConstraints(sql, await seed(sql));
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("replays over pre-release columns and constraints without losing history or weakening the state guard", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-chat-migration-replay-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const row = await seed(sql);
|
||||
const beforeIssue = await sql`SELECT * FROM issues WHERE id = ${row.issueId}`;
|
||||
const beforeComment = await sql`SELECT * FROM issue_comments WHERE id = ${row.commentId}`;
|
||||
// The original pre-release guard omitted the explicit state null check.
|
||||
// Keep every column, index and FK to model an already-upgraded development DB.
|
||||
await sql`ALTER TABLE issues DROP CONSTRAINT issues_conversation_identity_check`;
|
||||
const legacyGuard = migrationSql.slice(migrationSql.lastIndexOf('ALTER TABLE "issues" ADD CONSTRAINT'))
|
||||
.replace(' and "issues"."conversation_state" is not null', "");
|
||||
await sql.unsafe(legacyGuard);
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${migrationHash}`;
|
||||
expect(await inspectMigrations(database.connectionString)).toMatchObject({
|
||||
status: "needsMigrations", pendingMigrations: [migrationFile],
|
||||
});
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
// Exercise the SQL itself a second time, even with every new object present.
|
||||
await sql.begin(async (tx) => {
|
||||
for (const statement of migrationSql.split("--> statement-breakpoint")) {
|
||||
if (statement.trim()) await tx.unsafe(statement);
|
||||
}
|
||||
});
|
||||
expect(await sql`SELECT * FROM issues WHERE id = ${row.issueId}`).toEqual(beforeIssue);
|
||||
expect(await sql`SELECT * FROM issue_comments WHERE id = ${row.commentId}`).toEqual(beforeComment);
|
||||
await assertConstraints(sql, row);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
-- Idempotent for development instances that applied the pre-release chat migrations.
|
||||
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "client_request_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "issue_comments" ADD COLUMN IF NOT EXISTS "conversation_session_generation" integer;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_agent_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_user_id" text;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_state" text;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_session_generation" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "issues" ADD COLUMN IF NOT EXISTS "conversation_boundary_comment_id" uuid;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issues_conversation_agent_id_agents_id_fk' AND conrelid = 'issues'::regclass) THEN
|
||||
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;
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "issues_conversation_identity_idx" ON "issues" USING btree ("company_id","conversation_agent_id","conversation_user_id");--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'issue_comments_client_request_uq' AND conrelid = 'issue_comments'::regclass) THEN
|
||||
ALTER TABLE "issue_comments" ADD CONSTRAINT "issue_comments_client_request_uq" UNIQUE("issue_id","author_user_id","client_request_id");
|
||||
END IF;
|
||||
END $$;--> statement-breakpoint
|
||||
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
|
|
@ -1898,6 +1898,13 @@
|
|||
"when": 1789137216452,
|
||||
"tag": "0272_light_kate_bishop",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 273,
|
||||
"version": "7",
|
||||
"when": 1789163980231,
|
||||
"tag": "0273_agent_chat",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -123,6 +123,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:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface InstanceExperimentalSettings {
|
|||
enableChatConnectors: boolean;
|
||||
enablePipelines: boolean;
|
||||
enableCases: boolean;
|
||||
enableAgentChat: boolean;
|
||||
enableConferenceRoomChat: boolean;
|
||||
enableClassicTaskInterface: boolean;
|
||||
enableIssuePlanDecompositions: boolean;
|
||||
|
|
|
|||
|
|
@ -768,6 +768,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;
|
||||
|
|
@ -933,6 +938,8 @@ export type IssueCommentDerivedAuthorSource =
|
|||
| "run_log_comment_post";
|
||||
|
||||
export interface IssueComment {
|
||||
clientRequestId?: string | null;
|
||||
conversationSessionGeneration?: number | null;
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export const instanceExperimentalSettingsSchema = z.object({
|
|||
enableChatConnectors: z.boolean().default(false),
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -758,6 +758,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()
|
||||
|
|
@ -1014,6 +1015,7 @@ export const issueCommentMetadataSchema = z
|
|||
export type IssueCommentMetadata = z.infer<typeof issueCommentMetadataSchema>;
|
||||
|
||||
export const addIssueCommentSchema = z.object({
|
||||
clientRequestId: z.string().uuid().optional(),
|
||||
body: multilineTextSchema.pipe(z.string().min(1)),
|
||||
attachmentIds: issueCommentAttachmentIdsSchema.optional(),
|
||||
onBehalfOfUserId: z.string().trim().min(1).optional().nullable(),
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -37,6 +37,19 @@ describe("adapter session codecs", () => {
|
|||
expect(claudeSessionCodec.getDisplayId?.(serialized ?? null)).toBe("claude-session-1");
|
||||
});
|
||||
|
||||
it("preserves Claude MCP identity across persistence so resumed turns keep their context", () => {
|
||||
const params = {
|
||||
sessionId: "11111111-1111-4111-8111-111111111111",
|
||||
cwd: "/tmp/workspace",
|
||||
mcpServerIdentity: JSON.stringify([{
|
||||
name: "Paperclip projects",
|
||||
url: "http://localhost:3100/api/mcp/project-tools",
|
||||
connectionId: "paperclip-project-tools",
|
||||
}]),
|
||||
};
|
||||
expect(claudeSessionCodec.deserialize(claudeSessionCodec.serialize(params))).toEqual(params);
|
||||
});
|
||||
|
||||
it("preserves claude ACP session params for ACP lane resumes", () => {
|
||||
const parsed = claudeSessionCodec.deserialize({
|
||||
sessionKey: "paperclip:company:agent:task:fingerprint",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
claudeSessionCwdMatchesExecutionTarget,
|
||||
execute,
|
||||
resetClaudeCliCapabilitiesCacheForTests,
|
||||
sessionCodec,
|
||||
} from "@paperclipai/adapter-claude-local/server";
|
||||
|
||||
async function writeFailingClaudeCommand(
|
||||
|
|
@ -1156,6 +1157,14 @@ describe("claude execute", () => {
|
|||
},
|
||||
},
|
||||
context: {},
|
||||
runtimeMcp: {
|
||||
getServers: () => [{
|
||||
name: "Paperclip projects",
|
||||
url: "http://localhost:3100/api/mcp/project-tools",
|
||||
connectionId: "paperclip-project-tools",
|
||||
token: "run-jwt-token",
|
||||
}],
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
|
@ -1179,7 +1188,7 @@ describe("claude execute", () => {
|
|||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: first.sessionParams ?? null,
|
||||
sessionParams: sessionCodec.deserialize(sessionCodec.serialize(first.sessionParams ?? null)),
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
|
|
@ -1231,6 +1240,14 @@ describe("claude execute", () => {
|
|||
fallbackFetchNeeded: false,
|
||||
},
|
||||
},
|
||||
runtimeMcp: {
|
||||
getServers: () => [{
|
||||
name: "Paperclip projects",
|
||||
url: "http://localhost:3100/api/mcp/project-tools",
|
||||
connectionId: "paperclip-project-tools",
|
||||
token: "next-run-jwt-token",
|
||||
}],
|
||||
},
|
||||
authToken: "run-jwt-token",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -338,7 +338,14 @@ const SKIP_DIRS = new Set([
|
|||
"tmp",
|
||||
]);
|
||||
|
||||
const SKIP_PATH_PREFIXES = ["doc/logs/", "doc/plans/", "scripts/"];
|
||||
const SKIP_PATH_PREFIXES = [
|
||||
"doc/logs/",
|
||||
"doc/plans/",
|
||||
"scripts/",
|
||||
// Generated paid-run transcripts contain historical copies of instructions,
|
||||
// including escaped warning examples; they are not authored guidance.
|
||||
"tests/runner-e2e/results/",
|
||||
];
|
||||
|
||||
const SCAN_EXTENSIONS = new Set([
|
||||
".md",
|
||||
|
|
@ -366,6 +373,8 @@ function listGuidanceFiles(rootDir = repoRoot): string[] {
|
|||
if (entry.isSymbolicLink()) continue;
|
||||
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) {
|
||||
if (SKIP_DIRS.has(entry.name)) continue;
|
||||
if (SKIP_PATH_PREFIXES.some((prefix) => `${relPath}/`.startsWith(prefix))) continue;
|
||||
if (SKIP_DIRS.has(entry.name) || relPath === ".paperclip-runtime") continue;
|
||||
walk(path.join(absDir, entry.name), relPath);
|
||||
continue;
|
||||
|
|
@ -469,6 +478,28 @@ function scanForBrokenExecForm(): string[] {
|
|||
}
|
||||
|
||||
describe("paperclipai CLI invocation safety", () => {
|
||||
it("excludes generated runner evidence while preserving authored runner guidance", () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-guidance-"));
|
||||
const sourcePaths = [
|
||||
"doc/CLI.md",
|
||||
"tests/runner-e2e/README.md",
|
||||
"tests/runner-e2e/catalog.ts",
|
||||
];
|
||||
try {
|
||||
for (const relPath of [
|
||||
...sourcePaths,
|
||||
"tests/runner-e2e/results/campaign/attempt-1/snapshots/api-state.json",
|
||||
]) {
|
||||
const absPath = path.join(root, relPath);
|
||||
mkdirSync(path.dirname(absPath), { recursive: true });
|
||||
writeFileSync(absPath, "fixture");
|
||||
}
|
||||
expect(listGuidanceFiles(root).sort()).toEqual(sourcePaths.sort());
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("excludes root runtime recordings but still scans unsafe docs and source guidance", () => {
|
||||
const fixtureRoot = mkdtempSync(path.join(os.tmpdir(), "paperclip-cli-guidance-"));
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import { runChildProcess } from "@paperclipai/adapter-utils/server-utils";
|
||||
import { execute } from "@paperclipai/adapter-codex-local/server";
|
||||
import { buildPaperclipTaskMarkdown } from "../services/heartbeat.js";
|
||||
import { AGENT_CHAT_DIRECTIVE } from "../services/agent-conversations.js";
|
||||
|
||||
async function writeFakeCodexCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
|
|
@ -1210,7 +1212,7 @@ process.exit(1);
|
|||
}
|
||||
});
|
||||
|
||||
it("uses a compact wake delta instead of the full heartbeat prompt when resuming a session", async () => {
|
||||
it.each([{ conversationMode: false, resumedSession: true }, { conversationMode: true, resumedSession: true }, { conversationMode: true, resumedSession: false }])("retains current task policy (conversation=$conversationMode, resumed=$resumedSession)", async ({ conversationMode, resumedSession }) => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-codex-execute-resume-wake-"));
|
||||
const workspace = path.join(root, "workspace");
|
||||
const commandPath = path.join(root, "codex");
|
||||
|
|
@ -1224,6 +1226,14 @@ process.exit(1);
|
|||
process.env.HOME = root;
|
||||
await seedSharedCodexAuth(root);
|
||||
|
||||
const policy = conversationMode
|
||||
? buildPaperclipTaskMarkdown({
|
||||
issue: { id: "issue-1", title: "Chat", workMode: "planning", conversationAgentId: "agent-1" },
|
||||
interaction: { kind: "request_confirmation", status: "rejected" },
|
||||
planReview: { status: "rejected", reason: "Revise the final note." },
|
||||
includeDescription: false,
|
||||
})
|
||||
: "Current ordinary task policy";
|
||||
let invocationPrompt = "";
|
||||
let invocationNotes: string[] = [];
|
||||
let promptMetrics: Record<string, number> = {};
|
||||
|
|
@ -1240,7 +1250,7 @@ process.exit(1);
|
|||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: {
|
||||
sessionId: "codex-session-1",
|
||||
sessionId: resumedSession ? "codex-session-1" : null,
|
||||
cwd: workspace,
|
||||
},
|
||||
sessionDisplayId: null,
|
||||
|
|
@ -1254,9 +1264,12 @@ process.exit(1);
|
|||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
},
|
||||
promptTemplate: "Follow the paperclip heartbeat.",
|
||||
promptTemplate: conversationMode ? undefined : "Follow the paperclip heartbeat.",
|
||||
},
|
||||
context: {
|
||||
conversationMode,
|
||||
paperclipTaskMarkdown: `Full description that must not replay\n${policy}`,
|
||||
paperclipTaskMarkdownCompact: policy,
|
||||
issueId: "issue-1",
|
||||
taskId: "issue-1",
|
||||
wakeReason: "issue_commented",
|
||||
|
|
@ -1304,18 +1317,35 @@ process.exit(1);
|
|||
expect(result.errorMessage).toBeNull();
|
||||
|
||||
const capture = JSON.parse(await fs.readFile(capturePath, "utf8")) as CapturePayload;
|
||||
expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"]));
|
||||
expect(capture.prompt).toContain("## Paperclip Resume Delta");
|
||||
if (resumedSession) expect(capture.argv).toEqual(expect.arrayContaining(["resume", "codex-session-1", "-"]));
|
||||
else expect(capture.argv).not.toContain("resume");
|
||||
expect(capture.prompt).toContain(resumedSession ? "## Paperclip Resume Delta" : "## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("Second comment");
|
||||
expect(capture.prompt).toContain(policy);
|
||||
expect(invocationPrompt).toContain(policy);
|
||||
if (resumedSession) expect(capture.prompt).not.toContain("Full description that must not replay");
|
||||
else expect(capture.prompt).toContain("Full description that must not replay");
|
||||
expect(promptMetrics.taskContextChars).toBe(resumedSession ? policy.length : `Full description that must not replay\n${policy}`.length);
|
||||
if (conversationMode) {
|
||||
expect(invocationPrompt).toContain(AGENT_CHAT_DIRECTIVE);
|
||||
expect(invocationPrompt).toContain("baseRevisionId set to that latestRevisionId");
|
||||
expect(capture.prompt).not.toContain("Execution contract:");
|
||||
expect(capture.prompt).not.toContain("Use child issues");
|
||||
} else {
|
||||
expect(capture.prompt).toContain("Execution contract:");
|
||||
}
|
||||
expect(capture.prompt).not.toContain("Follow the paperclip heartbeat.");
|
||||
expect(capture.prompt).not.toContain("You are managed instructions.");
|
||||
expect(invocationPrompt).toContain("## Paperclip Resume Delta");
|
||||
expect(invocationNotes).toContain(
|
||||
"Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.",
|
||||
);
|
||||
expect(promptMetrics.instructionsChars).toBe(0);
|
||||
expect(promptMetrics.heartbeatPromptChars).toBe(0);
|
||||
if (resumedSession) {
|
||||
expect(capture.prompt).not.toContain("You are managed instructions.");
|
||||
expect(invocationPrompt).toContain("## Paperclip Resume Delta");
|
||||
expect(invocationNotes).toContain("Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.");
|
||||
expect(promptMetrics.instructionsChars).toBe(0);
|
||||
expect(promptMetrics.heartbeatPromptChars).toBe(0);
|
||||
} else {
|
||||
expect(capture.prompt).toContain("You are managed instructions.");
|
||||
expect(promptMetrics.heartbeatPromptChars).toBeGreaterThan(0);
|
||||
}
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
|
|
|
|||
|
|
@ -113,6 +113,41 @@ describeEmbeddedPostgres("documentService system issue documents", () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it("explains the revision guard and rejects missing or stale update revisions without changing the document", async () => {
|
||||
const { issueId } = await createIssueWithDocuments();
|
||||
const current = (await svc.getIssueDocumentByKey(issueId, "plan"))!;
|
||||
const update = {
|
||||
issueId,
|
||||
key: "plan",
|
||||
title: "Plan",
|
||||
format: "markdown" as const,
|
||||
body: "# Revised plan",
|
||||
};
|
||||
|
||||
await expect(svc.upsertIssueDocument(update)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: expect.stringContaining("set baseRevisionId to that latestRevisionId"),
|
||||
details: { currentRevisionId: current.latestRevisionId },
|
||||
});
|
||||
expect(await svc.getIssueDocumentByKey(issueId, "plan")).toMatchObject({
|
||||
body: current.body,
|
||||
latestRevisionId: current.latestRevisionId,
|
||||
});
|
||||
|
||||
const saved = await svc.upsertIssueDocument({ ...update, baseRevisionId: current.latestRevisionId });
|
||||
expect(saved.document.body).toBe(update.body);
|
||||
expect(saved.document.latestRevisionNumber).toBe(current.latestRevisionNumber + 1);
|
||||
await expect(svc.upsertIssueDocument({
|
||||
...update,
|
||||
body: "# Stale replacement",
|
||||
baseRevisionId: current.latestRevisionId,
|
||||
})).rejects.toMatchObject({ status: 409, message: "Document was updated by someone else" });
|
||||
expect(await svc.getIssueDocumentByKey(issueId, "plan")).toMatchObject({
|
||||
body: saved.document.body,
|
||||
latestRevisionId: saved.document.latestRevisionId,
|
||||
});
|
||||
});
|
||||
|
||||
it("locks and unlocks issue documents", async () => {
|
||||
const { issueId } = await createIssueWithDocuments();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1198,7 +1198,11 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
|||
}
|
||||
}, 120_000);
|
||||
|
||||
it("does not reopen a finished issue when the deferred comment wake came from another agent", async () => {
|
||||
it.each([
|
||||
{ caseName: "allows a non-assignee mention on completed work", targetAssignee: false, terminalStatus: "done" },
|
||||
{ caseName: "cancels an assignee continuation on completed work", targetAssignee: true, terminalStatus: "done" },
|
||||
{ caseName: "cancels an assignee continuation on cancelled work", targetAssignee: true, terminalStatus: "cancelled" },
|
||||
] as const)("$caseName without reopening an agent-commented task", async ({ targetAssignee, terminalStatus }) => {
|
||||
const gateway = await createControlledGatewayServer();
|
||||
const companyId = randomUUID();
|
||||
const assigneeAgentId = randomUUID();
|
||||
|
|
@ -1206,6 +1210,9 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
|||
const issueId = randomUUID();
|
||||
const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`;
|
||||
const heartbeat = heartbeatService(db);
|
||||
const targetAgentId = targetAssignee ? assigneeAgentId : mentionedAgentId;
|
||||
const commentingAgentId = targetAssignee ? mentionedAgentId : assigneeAgentId;
|
||||
const wakeReason = targetAssignee ? "issue_commented" : "issue_comment_mentioned";
|
||||
|
||||
try {
|
||||
await db.insert(companies).values({
|
||||
|
|
@ -1301,28 +1308,29 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
|||
.values({
|
||||
companyId,
|
||||
issueId,
|
||||
authorAgentId: assigneeAgentId,
|
||||
createdByRunId: firstRun?.id ?? null,
|
||||
authorAgentId: commentingAgentId,
|
||||
createdByRunId: targetAssignee ? null : firstRun?.id ?? null,
|
||||
body: "@Mentioned Agent please review after I finish",
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
const deferredRun = await heartbeat.wakeup(mentionedAgentId, {
|
||||
const deferredRun = await heartbeat.wakeup(targetAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_comment_mentioned",
|
||||
reason: wakeReason,
|
||||
payload: { issueId, commentId: comment.id },
|
||||
contextSnapshot: {
|
||||
issueId,
|
||||
taskId: issueId,
|
||||
commentId: comment.id,
|
||||
wakeCommentId: comment.id,
|
||||
wakeReason: "issue_comment_mentioned",
|
||||
wakeReason,
|
||||
...(targetAssignee ? { resumeIntent: true, followUpRequested: true } : {}),
|
||||
source: "comment.mention",
|
||||
},
|
||||
requestedByActorType: "agent",
|
||||
requestedByActorId: assigneeAgentId,
|
||||
requestedByActorId: commentingAgentId,
|
||||
});
|
||||
|
||||
expect(deferredRun).toBeNull();
|
||||
|
|
@ -1334,7 +1342,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
|||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.agentId, mentionedAgentId),
|
||||
eq(agentWakeupRequests.agentId, targetAgentId),
|
||||
eq(agentWakeupRequests.status, "deferred_issue_execution"),
|
||||
),
|
||||
)
|
||||
|
|
@ -1349,7 +1357,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
|||
await db
|
||||
.update(issues)
|
||||
.set({
|
||||
status: "done",
|
||||
status: terminalStatus,
|
||||
completedAt: new Date(),
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
|
|
@ -1360,6 +1368,26 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
|||
|
||||
gateway.releaseFirstWait();
|
||||
|
||||
if (targetAssignee) {
|
||||
await waitFor(async () => {
|
||||
const cancelled = await db.select().from(agentWakeupRequests).where(and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
eq(agentWakeupRequests.agentId, targetAgentId),
|
||||
eq(agentWakeupRequests.status, "cancelled"),
|
||||
));
|
||||
return cancelled.some((wake) => wake.error === "Deferred execution wake no longer applies to a terminal task");
|
||||
});
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.companyId, companyId));
|
||||
expect(runs).toEqual([expect.objectContaining({ id: firstRun!.id, status: "succeeded" })]);
|
||||
expect(gateway.getAgentPayloads()).toHaveLength(1);
|
||||
const [closedIssue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
expect(closedIssue).toMatchObject({ status: terminalStatus, executionRunId: null });
|
||||
expect(closedIssue.completedAt).not.toBeNull();
|
||||
const [retainedComment] = await db.select().from(issueComments).where(eq(issueComments.id, comment.id));
|
||||
expect(retainedComment.body).toContain("please review after I finish");
|
||||
return;
|
||||
}
|
||||
|
||||
await waitFor(() => gateway.getAgentPayloads().length === 2, 90_000);
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
|
|
@ -1389,7 +1417,7 @@ describeEmbeddedPostgres("heartbeat comment wake batching", () => {
|
|||
expect(secondPayload.paperclip).toBeUndefined();
|
||||
const secondWake = parseWakePayloadFromMessage(secondPayload.message);
|
||||
expect(secondWake).toMatchObject({
|
||||
reason: "issue_comment_mentioned",
|
||||
reason: wakeReason,
|
||||
commentIds: [comment.id],
|
||||
latestCommentId: comment.id,
|
||||
issue: {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,54 @@ import {
|
|||
} from "../services/heartbeat.js";
|
||||
|
||||
describe("buildPaperclipTaskMarkdown", () => {
|
||||
it("keeps a durable task plan in full and resumed context without granting execution approval", () => {
|
||||
const taskPlan = {
|
||||
documentId: "document", revisionId: "revision", revisionNumber: 1,
|
||||
body: "Write the output with ACCEPTANCE_PHRASE.\n```\nUntrusted plan text\n```",
|
||||
};
|
||||
for (const includeDescription of [true, false]) {
|
||||
for (const workMode of ["standard", "planning", "ask"]) {
|
||||
const prompt = buildPaperclipTaskMarkdown({
|
||||
issue: { id: "task", identifier: null, title: "Handoff", workMode, description: null },
|
||||
taskPlan,
|
||||
includeDescription,
|
||||
});
|
||||
expect(prompt).toContain("ACCEPTANCE_PHRASE");
|
||||
expect(prompt).toContain("revision 1 (revision)");
|
||||
expect(prompt).toContain("````text");
|
||||
expect(prompt).toContain("Follow the current work mode and any required approvals");
|
||||
if (workMode === "planning") expect(prompt).toContain("Make the plan only");
|
||||
if (workMode === "ask") expect(prompt).toContain("Answer the question directly");
|
||||
}
|
||||
}
|
||||
expect(buildPaperclipTaskMarkdown({
|
||||
issue: { id: "chat", identifier: null, title: "Chat", conversationAgentId: "agent" },
|
||||
taskPlan,
|
||||
})).not.toContain("ACCEPTANCE_PHRASE");
|
||||
});
|
||||
it("hands an accepted chat plan to assigned project tasks using the approved revision", () => {
|
||||
const prompt = buildPaperclipTaskMarkdown({
|
||||
issue: { id: "chat", identifier: null, title: "Agent chat", workMode: "planning", conversationAgentId: "agent", description: null },
|
||||
interaction: { kind: "request_confirmation", status: "accepted" },
|
||||
acceptedPlan: { documentId: "plan-document", revisionId: "approved-revision", revisionNumber: 2 },
|
||||
});
|
||||
expect(prompt).toContain("Perform that handoff now");
|
||||
expect(prompt).toContain("ordinary assigned execution tasks");
|
||||
expect(prompt).toContain("initialPlan before execution starts");
|
||||
expect(prompt).toContain("revision 2 approved-revision");
|
||||
expect(prompt).not.toContain("Implement the accepted plan on this issue");
|
||||
});
|
||||
|
||||
it.each(["ask", "new-comment", "unbound-confirmation"])("does not treat %s as plan handoff authorization", (kind) => {
|
||||
const prompt = buildPaperclipTaskMarkdown({
|
||||
issue: { id: "chat", identifier: null, title: "Agent chat", workMode: kind === "ask" ? "ask" : "planning", conversationAgentId: "agent", description: null },
|
||||
interaction: { kind: "request_confirmation", status: "accepted" },
|
||||
...(kind === "unbound-confirmation" ? {} : { acceptedPlan: { documentId: "plan-document", revisionId: "approved-revision", revisionNumber: 2 } }),
|
||||
...(kind === "new-comment" ? { wakeComment: { id: "later-comment", body: "Please revise it again first." } } : {}),
|
||||
});
|
||||
expect(prompt).not.toContain("Perform that handoff now");
|
||||
});
|
||||
|
||||
it("surfaces every coalesced wake comment in provider order", () => {
|
||||
const markdown = buildPaperclipTaskMarkdown({
|
||||
issue: {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { instanceSettingsService } from "../services/instance-settings.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { terminalizeLegacyExecution } from "../services/legacy-execution-recovery.js";
|
||||
import { issueService } from "../services/issues.js";
|
||||
|
|
@ -10609,6 +10610,38 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("does not recover a finished native chat while its response publication is pending", async () => {
|
||||
const { agentId, issueId, runId } = await seedStrandedIssueFixture({
|
||||
status: "in_progress",
|
||||
runStatus: "succeeded",
|
||||
livenessState: "advanced",
|
||||
resultJson: { finalizationReasonCode: "conversation_turn_finished" },
|
||||
});
|
||||
await instanceSettingsService(db).updateExperimental({ enableAgentChat: true });
|
||||
try {
|
||||
await db.update(issues).set({
|
||||
conversationAgentId: agentId,
|
||||
conversationUserId: "responsible-user",
|
||||
conversationState: "active",
|
||||
}).where(eq(issues.id, issueId));
|
||||
const result = await heartbeatService(db).reconcileStrandedAssignedIssues();
|
||||
expect(result.continuationRequeued).toBe(0);
|
||||
expect(result.escalated).toBe(0);
|
||||
const runs = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.agentId, agentId));
|
||||
expect(runs.map((run) => run.id)).toEqual([runId]);
|
||||
const wakes = await db.select().from(agentWakeupRequests)
|
||||
.where(eq(agentWakeupRequests.agentId, agentId));
|
||||
expect(wakes).toHaveLength(1);
|
||||
expect(wakes[0].reason).toBe("issue_assigned");
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
// No fabricated idle state: durable response publication still settles it.
|
||||
expect(issue.status).toBe("in_progress");
|
||||
expect(issue.conversationState).toBe("active");
|
||||
} finally {
|
||||
await instanceSettingsService(db).updateExperimental({ enableAgentChat: false });
|
||||
}
|
||||
});
|
||||
|
||||
it("does not turn a pre-adapter setup failure into a duplicate continuation run", async () => {
|
||||
const { companyId, agentId, issueId, runId } =
|
||||
await seedStrandedIssueFixture({
|
||||
|
|
|
|||
|
|
@ -448,6 +448,19 @@ describe("resolveHeartbeatRunResponse", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("persists a final assistant reply when the server completed a conversation turn", () => {
|
||||
expect(resolveHeartbeatRunResponse({
|
||||
conversationTurnFinished: true,
|
||||
resultJson: { nativeResult: { schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "yielded", summary: "Waiting for the next message." } },
|
||||
finalAgentMessage: { text: "Which project should own this?",
|
||||
sourceEventId: "chat-final-1", channel: "final" },
|
||||
})).toMatchObject({
|
||||
text: "Which project should own this?",
|
||||
decision: { commentAction: "create", sourceEventId: "chat-final-1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render a serialized semantic result as the final prose", () => {
|
||||
expect(
|
||||
resolveHeartbeatRunResponse({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -74,7 +74,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([
|
||||
|
|
@ -96,8 +96,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 });
|
||||
|
|
@ -147,6 +147,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] : [],
|
||||
|
|
@ -154,12 +155,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)),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ describe("instance settings service", () => {
|
|||
enableStreamlinedLeftNavigation: true,
|
||||
enableStreamlinedUi: true,
|
||||
enableApps: true,
|
||||
enableAgentChat: false,
|
||||
enableChatConnectors: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
companyMemberships,
|
||||
companySkills,
|
||||
createDb,
|
||||
heartbeatRunEvents,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issues,
|
||||
|
|
|
|||
|
|
@ -139,11 +139,15 @@ function makeIssue(status: "todo" | "done") {
|
|||
};
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await Promise.all([
|
||||
function loadAppModules() {
|
||||
return Promise.all([
|
||||
vi.importActual<typeof import("../middleware/index.js")>("../middleware/index.js"),
|
||||
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
|
||||
]);
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown>) {
|
||||
const [{ errorHandler }, { issueRoutes }] = await loadAppModules();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
|
|
@ -156,7 +160,7 @@ async function createApp(actor: Record<string, unknown>) {
|
|||
}
|
||||
|
||||
describe("issue telemetry routes", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("@paperclipai/shared/telemetry");
|
||||
vi.doUnmock("../telemetry.js");
|
||||
|
|
@ -197,7 +201,9 @@ describe("issue telemetry routes", () => {
|
|||
permissions: null,
|
||||
}]).then(onFulfilled, onRejected),
|
||||
}));
|
||||
});
|
||||
// Keep cold route imports in setup rather than the HTTP assertion timeout.
|
||||
await loadAppModules();
|
||||
}, 60_000);
|
||||
|
||||
it("emits task-completed telemetry with the agent role, adapter type, and model", async () => {
|
||||
mockAgentService.getById.mockResolvedValue({
|
||||
|
|
|
|||
|
|
@ -260,6 +260,13 @@ function createIssue(overrides: Record<string, unknown> = {}) {
|
|||
};
|
||||
}
|
||||
|
||||
function loadAppModules() {
|
||||
return Promise.all([
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
}
|
||||
|
||||
async function createApp(actor: Record<string, unknown> = {
|
||||
type: "board",
|
||||
userId: "local-board",
|
||||
|
|
@ -275,10 +282,7 @@ async function createApp(actor: Record<string, unknown> = {
|
|||
responsibleUserId: actor.onBehalfOfUserId ?? null,
|
||||
};
|
||||
}
|
||||
const [{ issueRoutes }, { errorHandler }] = await Promise.all([
|
||||
import("../routes/issues.js"),
|
||||
import("../middleware/index.js"),
|
||||
]);
|
||||
const [{ issueRoutes }, { errorHandler }] = await loadAppModules();
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
|
|
@ -305,7 +309,7 @@ async function resolveMockInteraction(
|
|||
}
|
||||
|
||||
describe.sequential("issue thread interaction routes", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
vi.doUnmock("../routes/issues.js");
|
||||
vi.doUnmock("../routes/authz.js");
|
||||
|
|
@ -577,7 +581,9 @@ describe.sequential("issue thread interaction routes", () => {
|
|||
mockCrossIssueInfluence.sourceIssueId = ISSUE_ID;
|
||||
mockCrossIssueInfluence.priorCount = 0;
|
||||
mockCrossIssueInfluence.inserted.length = 0;
|
||||
});
|
||||
// Keep cold route imports in setup rather than the HTTP assertion timeout.
|
||||
await loadAppModules();
|
||||
}, 60_000);
|
||||
|
||||
it("creates board-authored interactions", async () => {
|
||||
const app = await createApp();
|
||||
|
|
@ -1678,7 +1684,11 @@ describe.sequential("issue thread interaction routes", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("forces a fresh workspace-aware session when accepting a planning confirmation", async () => {
|
||||
it.each([
|
||||
{ label: "explicit", targetIssueId: ISSUE_ID },
|
||||
{ label: "omitted", targetIssueId: undefined },
|
||||
{ label: "null", targetIssueId: null },
|
||||
])("forces a fresh workspace-aware session when accepting a planning confirmation with $label issueId", async ({ targetIssueId }) => {
|
||||
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "planning" }));
|
||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||
interaction: {
|
||||
|
|
@ -1696,7 +1706,7 @@ describe.sequential("issue thread interaction routes", () => {
|
|||
prompt: "Approve this plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
...(targetIssueId !== undefined ? { issueId: targetIssueId } : {}),
|
||||
documentId: "document-plan",
|
||||
key: "plan",
|
||||
revisionId: "revision-plan",
|
||||
|
|
@ -1770,6 +1780,45 @@ describe.sequential("issue thread interaction routes", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("does not project an explicitly different issue's approved plan into the current issue wake", async () => {
|
||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||
interaction: {
|
||||
id: "interaction-other-plan",
|
||||
companyId: "company-1",
|
||||
issueId: ISSUE_ID,
|
||||
kind: "request_confirmation",
|
||||
status: "accepted",
|
||||
continuationPolicy: "wake_assignee_on_accept",
|
||||
sourceRunId: RUN_1,
|
||||
payload: {
|
||||
version: 1,
|
||||
prompt: "Approve the other issue's plan?",
|
||||
target: {
|
||||
type: "issue_document",
|
||||
issueId: OTHER_ISSUE_ID,
|
||||
key: "plan",
|
||||
revisionId: "other-revision",
|
||||
revisionNumber: 2,
|
||||
},
|
||||
},
|
||||
result: { version: 1, outcome: "accepted" },
|
||||
},
|
||||
createdIssues: [],
|
||||
});
|
||||
const response = await request(await createApp())
|
||||
.post(`/api/issues/${ISSUE_ID}/interactions/interaction-other-plan/accept`)
|
||||
.send({});
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1);
|
||||
const wake = mockHeartbeatService.wakeup.mock.calls[0]?.[1] as unknown as {
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
expect(wake.contextSnapshot).not.toHaveProperty("planReviewInteraction");
|
||||
expect(wake.payload).not.toHaveProperty("planReviewInteraction");
|
||||
expect(wake.contextSnapshot).not.toHaveProperty("forceFreshSession");
|
||||
});
|
||||
|
||||
it("forces a fresh workspace-aware session when accepting a plan document confirmation on a standard-work issue", async () => {
|
||||
mockIssueService.getById.mockResolvedValueOnce(createIssue({ workMode: "standard" }));
|
||||
mockInteractionService.acceptInteraction.mockResolvedValueOnce({
|
||||
|
|
|
|||
|
|
@ -2587,14 +2587,39 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("records superseding assessment lineage when a board transition has no native decision predecessor", async () => {
|
||||
it.each(["none", "same_run", "other_run"])("records run-scoped superseding assessment lineage with %s predecessor", async (predecessor) => {
|
||||
const fixture = corpus.fixtures.find((candidate) => candidate.mode === "native");
|
||||
if (!fixture) throw new Error("native corpus fixture missing");
|
||||
const seeded = await seedFixture(fixture);
|
||||
let priorDecisionId: string | null = null;
|
||||
if (predecessor !== "none") {
|
||||
let priorRunId = seeded.runId;
|
||||
let priorAssessmentId = seeded.assessmentId;
|
||||
if (predecessor === "other_run") {
|
||||
priorRunId = randomUUID();
|
||||
const [run] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId));
|
||||
await db.insert(heartbeatRuns).values({ ...run, id: priorRunId });
|
||||
const [result] = await db.select().from(nativeRunResults).where(eq(nativeRunResults.id, seeded.resultId!));
|
||||
const resultId = randomUUID();
|
||||
await db.insert(nativeRunResults).values({ ...result, id: resultId, runId: priorRunId });
|
||||
const [assessment] = await db.select().from(workAssessments).where(eq(workAssessments.id, seeded.assessmentId));
|
||||
priorAssessmentId = randomUUID();
|
||||
await db.insert(workAssessments).values({ ...assessment, id: priorAssessmentId, runId: priorRunId,
|
||||
resultId, inputDigest: `later-run:${priorRunId}` });
|
||||
}
|
||||
const [prior] = await db.insert(statusDecisions).values({
|
||||
companyId, issueId: seeded.issueId, runId: priorRunId, assessmentId: priorAssessmentId,
|
||||
decisionVersion: 1, policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
fromStatus: "in_progress", toStatus: "blocked", reasonCode: "prior_authoritative_decision",
|
||||
decisionJson: { statusAction: "blocked" }, decisionDigest: `prior:${seeded.issueId}`,
|
||||
applicationState: "applied", appliedAt: new Date(),
|
||||
}).returning();
|
||||
priorDecisionId = prior.id;
|
||||
}
|
||||
await db.update(issues).set({
|
||||
status: "blocked",
|
||||
statusVersion: 1,
|
||||
lastStatusDecisionId: null,
|
||||
lastStatusDecisionId: priorDecisionId,
|
||||
}).where(eq(issues.id, seeded.issueId));
|
||||
const supersedingAssessmentId = randomUUID();
|
||||
await db.insert(workAssessments).values({
|
||||
|
|
@ -2608,7 +2633,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
|||
triggerActorCompanyId: companyId,
|
||||
priorIssueStatus: "blocked",
|
||||
priorStatusVersion: 1,
|
||||
priorDecisionId: null,
|
||||
priorDecisionId,
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
assessmentJson: { reason: "board_transition_without_native_decision" },
|
||||
inputDigest: `board-transition-assessment:${seeded.issueId}`,
|
||||
|
|
@ -2623,7 +2648,7 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
|||
assessmentId: supersedingAssessmentId,
|
||||
priorStatus: "blocked",
|
||||
priorStatusVersion: 1,
|
||||
priorDecisionId: null,
|
||||
priorDecisionId,
|
||||
decision: {
|
||||
policyVersion: NATIVE_STATUS_ARBITER_POLICY_VERSION,
|
||||
statusAction: "preserve",
|
||||
|
|
@ -2634,7 +2659,9 @@ describe("P6-31 Section 18.13 executable status-authority corpus", () => {
|
|||
},
|
||||
});
|
||||
|
||||
expect(committed.decision.supersedesDecisionId).toBeNull();
|
||||
const [persistedDecision] = await db.select().from(statusDecisions)
|
||||
.where(eq(statusDecisions.id, committed.decision.id));
|
||||
expect(persistedDecision.supersedesDecisionId).toBe(priorDecisionId);
|
||||
await expect(db.select({
|
||||
supersedesAssessmentId: workAssessments.supersedesAssessmentId,
|
||||
}).from(workAssessments).where(eq(workAssessments.id, supersedingAssessmentId))).resolves.toEqual([
|
||||
|
|
|
|||
|
|
@ -56,6 +56,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",
|
||||
|
|
@ -253,12 +254,10 @@ describe("openapi routes", () => {
|
|||
AgentBearerAuth: { type: "http", scheme: "bearer" },
|
||||
});
|
||||
expect(res.body.paths["/api/health"].get.security).toEqual([]);
|
||||
expect(
|
||||
res.body.paths["/mcp/gateways/{gatewayPublicId}"].post.security,
|
||||
).toEqual([]);
|
||||
expect(
|
||||
res.body.paths["/api/mcp/gateways/{gatewayPublicId}"],
|
||||
).toBeUndefined();
|
||||
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({
|
||||
name: "scope",
|
||||
in: "query",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { projectToolRoutes } from "./routes/project-tools.js";
|
||||
import { emailChannelService } from "./services/email-channels.js";
|
||||
import { emailRoutes, emailWebhookRoutes } from "./routes/email.js";
|
||||
import { toolActionDeliveryService } from "./services/tool-action-delivery.js";
|
||||
|
|
@ -725,6 +726,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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
issueThreadInteractions,
|
||||
heartbeatRuns,
|
||||
issueRecoveryActions,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { ISSUE_DISPOSITION_REPAIR_RETRY_REASON } from "@paperclipai/shared";
|
||||
|
|
@ -897,7 +898,7 @@ export function createPostgresRunDispatchAdapter(
|
|||
const contextSnapshot = parseObject(run.contextSnapshot);
|
||||
const issueId = readNonEmptyString(contextSnapshot.issueId);
|
||||
if (!issueId) return { issueId: null, decision: { stale: false as const } };
|
||||
const recovery = await getExecutionBlocker(tx, run.companyId, issueId);
|
||||
const recovery = await getExecutionBlocker(tx, run.companyId, issueId, { conversationResetCommentId: deriveCommentId(contextSnapshot) });
|
||||
if (recovery) return { issueId, decision: { stale: true as const,
|
||||
errorCode: "execution_reconciliation_required" as const, reason: recovery.nextAction,
|
||||
details: { issueId, recoveryActionId: recovery.recoveryActionId },
|
||||
|
|
|
|||
|
|
@ -96,6 +96,9 @@ function toRunSnapshot(row: HeartbeatRunRow): RunSnapshot {
|
|||
|
||||
function toIssueSnapshot(row: IssueRow): IssueSnapshot {
|
||||
return {
|
||||
conversationAgentId: row.conversationAgentId,
|
||||
conversationUserId: row.conversationUserId,
|
||||
conversationState: row.conversationState,
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
identifier: row.identifier ?? "",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,9 @@ export type RunSnapshot = {
|
|||
};
|
||||
|
||||
export type IssueSnapshot = {
|
||||
conversationAgentId?: string | null;
|
||||
conversationUserId?: string | null;
|
||||
conversationState?: string | null;
|
||||
id: string;
|
||||
companyId: string;
|
||||
identifier: string;
|
||||
|
|
|
|||
|
|
@ -410,7 +410,10 @@ async function runReleaseRecoveryTail(
|
|||
input: ReleaseIssueExecutionInput,
|
||||
postCommitEffects: PostCommitEffect[],
|
||||
): Promise<ReleaseTransactionResult> {
|
||||
const suppressImmediateRecovery = input.suppressImmediateRecovery ?? false;
|
||||
const suppressImmediateRecovery = input.suppressImmediateRecovery === true || Boolean(
|
||||
issue.conversationAgentId && issue.conversationUserId &&
|
||||
issue.conversationState === "waiting" && issue.status === "in_review"
|
||||
);
|
||||
const isStrandedRecoveryOrigin =
|
||||
issue.originKind === STRANDED_ISSUE_RECOVERY_ORIGIN_KIND;
|
||||
const recoveryAgent = await transaction.findInvokableAgent({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { deliverConversationComments, isConversation } from "../services/agent-conversations.js";
|
||||
import { issueRecoveryActionReadModel } from "../services/issue-recovery-actions.js";
|
||||
import { getExecutionBlocker } from "../services/execution-blocker.js";
|
||||
import { requiresExecutionReconciliation } from "@paperclipai/shared";
|
||||
|
|
@ -835,7 +836,10 @@ function issueWriteAuthorizationReason(
|
|||
function readPlanConfirmationTargetForIssue(payload: unknown, issueId: string) {
|
||||
const target = readObject(readObject(payload).target);
|
||||
if (target.type !== "issue_document" || target.key !== "plan") return null;
|
||||
if (readNonEmptyString(target.issueId) !== issueId) return null;
|
||||
// The interaction contract makes issueId optional; null/omitted means the
|
||||
// issue containing this interaction, just as target snapshot validation does.
|
||||
const targetIssueId = target.issueId == null ? issueId : readNonEmptyString(target.issueId);
|
||||
if (targetIssueId !== issueId) return null;
|
||||
return {
|
||||
issueId,
|
||||
documentId: readNonEmptyString(target.documentId),
|
||||
|
|
@ -4477,6 +4481,8 @@ export function issueRoutes(
|
|||
|
||||
async function assertInReviewReviewPath(input: {
|
||||
existing: {
|
||||
conversationAgentId?: string | null;
|
||||
conversationUserId?: string | null;
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
|
|
@ -4491,12 +4497,13 @@ export function issueRoutes(
|
|||
actorRunId?: string | null;
|
||||
reviewInteractionId?: string;
|
||||
}) {
|
||||
const nextStatus =
|
||||
typeof input.updateFields.status === "string"
|
||||
? input.updateFields.status
|
||||
: input.existing.status;
|
||||
if (input.existing.status === "in_review" || nextStatus !== "in_review")
|
||||
return null;
|
||||
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;
|
||||
|
||||
const interactions = await issueThreadInteractionService(db).listForIssue(
|
||||
|
|
@ -6846,7 +6853,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;
|
||||
|
|
@ -6883,7 +6890,7 @@ export function issueRoutes(
|
|||
queuedCommentCount: comments.length,
|
||||
});
|
||||
const steeringDisposition: IssueQueuedCommentQueue["steeringDisposition"] =
|
||||
steering.kind !== "probe"
|
||||
input.issue.conversationAgentId ? "unsupported" : steering.kind !== "probe"
|
||||
? steering.kind
|
||||
: input.steeringDisposition
|
||||
?? (await getNativeSessionSteeringState(steering.steeringRunId)
|
||||
|
|
@ -12644,6 +12651,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 ||
|
||||
|
|
@ -15213,6 +15223,7 @@ export function issueRoutes(
|
|||
"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,
|
||||
|
|
@ -16907,6 +16918,32 @@ 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),
|
||||
|
|
@ -16919,6 +16956,24 @@ export function issueRoutes(
|
|||
"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;
|
||||
if (req.body.body.trim() !== "/new" && !(await assertBoardCommentNotPaused(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", attachmentIds: req.body.attachmentIds,
|
||||
});
|
||||
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,
|
||||
|
|
@ -18074,6 +18129,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))
|
||||
|
|
|
|||
|
|
@ -1208,11 +1208,17 @@ function registerCurrentRoute(input: {
|
|||
}
|
||||
|
||||
type OpenApiAuthLevel =
|
||||
"public" | "runtime_tools" | "authenticated" | "board" | "instance_admin";
|
||||
| "public"
|
||||
| "agent_run"
|
||||
| "runtime_tools"
|
||||
| "authenticated"
|
||||
| "board"
|
||||
| "instance_admin";
|
||||
|
||||
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[]> {
|
||||
|
|
@ -1559,6 +1565,7 @@ function resolveOperationAuthLevel(
|
|||
): 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 (
|
||||
|
|
@ -1611,6 +1618,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;
|
||||
|
||||
|
|
@ -1621,6 +1634,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") {
|
||||
|
|
@ -1634,6 +1649,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"
|
||||
|
|
@ -9681,6 +9698,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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,530 @@
|
|||
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"
|
||||
);
|
||||
}
|
||||
|
||||
/** Recovery for an older turn must not replace a reset or an answered chat. */
|
||||
export function isSupersededConversationRun(
|
||||
issue: ConversationIdentity & {
|
||||
conversationSessionGeneration?: number;
|
||||
executionRunId?: string | null;
|
||||
},
|
||||
run: { id: string; contextSnapshot: Record<string, unknown> | null },
|
||||
): boolean {
|
||||
if (!isConversation(issue)) return false;
|
||||
const generation = run.contextSnapshot?.conversationSessionGeneration;
|
||||
return (
|
||||
(typeof generation === "number" &&
|
||||
typeof issue.conversationSessionGeneration === "number" &&
|
||||
generation !== issue.conversationSessionGeneration) ||
|
||||
(typeof generation === "number" &&
|
||||
isWaitingConversation(issue) &&
|
||||
issue.executionRunId !== run.id)
|
||||
);
|
||||
}
|
||||
/** Execution tasks may link to a conversation, but never drive its turns.
|
||||
* Apply before enqueue, including while a reply is still running: waiting until
|
||||
* finalization is too late to prevent a deferred dependency follow-up.
|
||||
*/
|
||||
export function isConversationExecutionWake(
|
||||
issue: ConversationIdentity | null | undefined,
|
||||
reason: string | null | undefined,
|
||||
): boolean {
|
||||
return isConversation(issue) && (
|
||||
reason === "issue_blockers_resolved" ||
|
||||
reason === "issue_children_completed" ||
|
||||
reason === "issue_unblock_requested"
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
When the user asks to approve a plan before handoff, publish the plan and create a revision-bound approval card before ending the turn. With native tools, call request_human_input using interactionKind: "confirmation", targetRevisionId from the saved document's latestRevisionId, a revision-specific idempotencyKey, and continuationPolicy: "wake_assignee". Set payload.target to { type: "issue_document", key: "plan", revisionId: latestRevisionId }. Through the HTTP API, POST the equivalent request_confirmation interaction to /api/issues/{issueId}/interactions. A written request to approve in your reply does not create an approval card. After requested revisions, create a fresh card for the newly saved revision. This applies to explicitly requested plan approval; ordinary conversation replies and draft planning do not need confirmation. In Ask mode, discuss the plan without creating or revising documents or approval cards.
|
||||
|
||||
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. The direct HTTP equivalent is POST /api/companies/{companyId}/projects with name, repositoryIds and/or repositoryUrls arrays, and an idempotencyKey. Include all selected repositories in that creation; do not combine these arrays with workspace. 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. If using the HTTP API directly, POST /api/companies/{companyId}/issues with projectId, assigneeAgentId, status: "todo", initialPlan containing the relevant plan Markdown, and an idempotencyKey; omit parentId. Putting a plan in description does not create the task's plan document. Verify the new task's plan document before claiming the handoff is complete. 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. Link handed-off tasks in your reply; do not make this conversation blocked by their completion or wait for them. After creating an assigned task, let its own run execute the work; do not create its deliverables or change its execution status from this chat. 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, interaction, or 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);
|
||||
// Native question/plan waits use the durable interaction as the reply;
|
||||
// their terminal prose is deliberately not materialized as a comment.
|
||||
const [interaction] = !response && context.conversationReset !== true
|
||||
? await tx.select({ id: issueThreadInteractions.id })
|
||||
.from(issueThreadInteractions)
|
||||
.where(and(
|
||||
eq(issueThreadInteractions.companyId, run.companyId),
|
||||
eq(issueThreadInteractions.issueId, issueId),
|
||||
eq(issueThreadInteractions.sourceRunId, run.id),
|
||||
eq(issueThreadInteractions.createdByAgentId, issue.conversationAgentId!),
|
||||
eq(issueThreadInteractions.status, "pending"),
|
||||
))
|
||||
.limit(1)
|
||||
: [];
|
||||
if (!response && !interaction && 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: [],
|
||||
};
|
||||
}
|
||||
|
|
@ -55,7 +55,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";
|
||||
|
|
@ -844,7 +844,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,
|
||||
|
|
@ -1586,7 +1586,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ export function documentService(db: Db) {
|
|||
}
|
||||
|
||||
if (!input.baseRevisionId) {
|
||||
throw conflict("Document update requires baseRevisionId", {
|
||||
throw conflict("Document update requires baseRevisionId. GET the current document, read its body and latestRevisionId, then set baseRevisionId to that latestRevisionId when updating.", {
|
||||
currentRevisionId: existing.latestRevisionId,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { and, desc, eq, inArray, not, or, sql } from "drizzle-orm";
|
||||
import { and, desc, eq, gt, inArray, not, or, sql } from "drizzle-orm";
|
||||
import { conversationRecoveryActionPredicate, getConversationOwnershipBlocker } from "./conversation-continuation.js";
|
||||
import { z } from "zod";
|
||||
import { heartbeatRuns, issueRecoveryActions, type Db } from "@paperclipai/db";
|
||||
import { heartbeatRuns, issueComments, issues, issueRecoveryActions, type Db } from "@paperclipai/db";
|
||||
import { EXECUTION_RECONCILIATION_CAUSES, type ExecutionBlocker } from "@paperclipai/shared";
|
||||
|
||||
/** Resolved recovery bookkeeping can still carry an effective no-replay hold. */
|
||||
|
|
@ -14,13 +14,33 @@ export function executionBlockerPredicate() {
|
|||
);
|
||||
}
|
||||
|
||||
export async function getExecutionBlocker(db: Db, companyId: string, issueId: string): Promise<ExecutionBlocker | null> {
|
||||
export async function getExecutionBlocker(db: Db, companyId: string, issueId: string, options?: { conversationResetCommentId?: string | null }): Promise<ExecutionBlocker | null> {
|
||||
const [conversation] = await db.select({ agentId: issues.conversationAgentId,
|
||||
boundaryId: issues.conversationBoundaryCommentId }).from(issues).where(and(
|
||||
eq(issues.companyId, companyId), eq(issues.id, issueId),
|
||||
)).limit(1);
|
||||
// A persisted user /new is an ordered context command, not a retry of uncertain work.
|
||||
// The normal issue execution lock still serializes it behind any active turn.
|
||||
if (conversation?.agentId && options?.conversationResetCommentId) {
|
||||
const [command] = await db.select().from(issueComments).where(and(
|
||||
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
||||
eq(issueComments.id, options.conversationResetCommentId),
|
||||
)).limit(1);
|
||||
if (command?.authorUserId && !command.deletedAt && command.body.trim() === "/new") return null;
|
||||
}
|
||||
const [boundary] = conversation?.agentId && conversation.boundaryId
|
||||
? await db.select({ createdAt: issueComments.createdAt }).from(issueComments).where(and(
|
||||
eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId),
|
||||
eq(issueComments.id, conversation.boundaryId),
|
||||
)).limit(1) : [];
|
||||
|
||||
const ownership = await getConversationOwnershipBlocker(db, companyId, issueId);
|
||||
if (ownership) return { ...ownership, recoveryActionId: null };
|
||||
const [action] = await db.select().from(issueRecoveryActions).where(and(
|
||||
eq(issueRecoveryActions.companyId, companyId),
|
||||
eq(issueRecoveryActions.sourceIssueId, issueId),
|
||||
executionBlockerPredicate(),
|
||||
boundary ? gt(issueRecoveryActions.createdAt, boundary.createdAt) : undefined,
|
||||
)).orderBy(desc(issueRecoveryActions.updatedAt), desc(issueRecoveryActions.id)).limit(1);
|
||||
if (!action) return null;
|
||||
const parsedRunId = z.string().guid().safeParse(action.evidence.runId ?? action.evidence.sourceRunId);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
type ExecutionReconciliation,
|
||||
} from "@paperclipai/shared";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import { isSupersededConversationRun } from "./agent-conversations.js";
|
||||
|
||||
/** An operator records observed outcomes; this is not permission to blindly retry. */
|
||||
export async function validateExecutionReconciliation(input: {
|
||||
|
|
@ -463,6 +464,7 @@ export async function settleUnrecoverableExecutions(
|
|||
)
|
||||
return;
|
||||
const current =
|
||||
!isSupersededConversationRun(task, run) &&
|
||||
action.returnOwnerAgentId !== null &&
|
||||
task.assigneeAgentId === action.returnOwnerAgentId &&
|
||||
!["done", "cancelled"].includes(task.status) &&
|
||||
|
|
|
|||
|
|
@ -357,6 +357,8 @@ function decision(
|
|||
*/
|
||||
export function resolveHeartbeatRunResponse(input: {
|
||||
resultJson: Record<string, unknown> | null | undefined;
|
||||
/** Server-owned conversation finalization, after normal governance checks. */
|
||||
conversationTurnFinished?: boolean;
|
||||
existingComment?: { id: string; body?: string | null } | null;
|
||||
preferFinalResponseOverExistingComment?: boolean;
|
||||
externalChatResponseWakeSummaryAuthorized?: boolean;
|
||||
|
|
@ -519,7 +521,7 @@ export function resolveHeartbeatRunResponse(input: {
|
|||
// still emit terminal-looking prose while the control plane is yielding for
|
||||
// an interaction; keep that prose in activity and let the durable
|
||||
// interaction own the visible waiting state.
|
||||
if (hasYieldedSemanticResult(resultJson)) {
|
||||
if (hasYieldedSemanticResult(resultJson) && !input.conversationTurnFinished) {
|
||||
return {
|
||||
text: null,
|
||||
decision: decision("none", {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AGENT_CHAT_DIRECTIVE, conversationReplay, isConversation, isConversationExecutionWake, isWaitingConversation, prepareConversationTurn, settleConversationTurn } from "./agent-conversations.js";
|
||||
import { completeTerminatedRemoteNativeSessionCleanup } from "../vendor/paperclip-runner/index.js";
|
||||
import { remoteExecutionHasStopped, remoteTerminationReceipt, stoppedRemoteCleanupScopes } from "./remote-execution-termination.js";
|
||||
import { applyConnectorSkills, prepareConnectorSkillDelivery, resolveConnectorAssignments } from "./connector-runtime.js";
|
||||
|
|
@ -173,6 +174,7 @@ import {
|
|||
withQueuedCommentIdsInRunContext,
|
||||
} from "./issue-queued-comment-queue.js";
|
||||
import { documentService } from "./documents.js";
|
||||
import { getTaskPlanContext } from "./task-plan-context.js";
|
||||
import { managedAgentProfileService } from "./managed-agent-profiles.js";
|
||||
import { remoteAgentProfileService } from "./remote-agent-profiles.js";
|
||||
import {
|
||||
|
|
@ -7468,7 +7470,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],
|
||||
);
|
||||
|
|
@ -7533,7 +7536,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;
|
||||
|
|
@ -7762,18 +7765,22 @@ export async function buildPaperclipWakePayload(input: {
|
|||
const checkboxSelection = parseObject(
|
||||
input.contextSnapshot.checkboxSelection,
|
||||
);
|
||||
const planReviewContext = issueId
|
||||
// A resolved plan review is new user input, including in chat. Ordinary chat
|
||||
// wakes must still exclude historical plan context across /new boundaries.
|
||||
const resolvedPlanInteraction = interactionId && interactionKind === "request_confirmation" &&
|
||||
(interactionStatus === "accepted" || interactionStatus === "rejected");
|
||||
const planReviewContext = issueId && (!conversationMode || resolvedPlanInteraction)
|
||||
? await buildPlanReviewContext({
|
||||
db: input.db,
|
||||
companyId: input.companyId,
|
||||
issueId,
|
||||
issueWorkMode: issueSummary?.workMode ?? null,
|
||||
includeForIssueComment: commentIds.length > 0,
|
||||
includeForAnnotationDelta: annotationDeltas.length > 0,
|
||||
issueWorkMode: conversationMode ? null : issueSummary?.workMode ?? null,
|
||||
includeForIssueComment: !conversationMode && commentIds.length > 0,
|
||||
includeForAnnotationDelta: !conversationMode && annotationDeltas.length > 0,
|
||||
interactionId,
|
||||
})
|
||||
: null;
|
||||
const documentReviewContext = issueId
|
||||
const documentReviewContext = issueId && !conversationMode
|
||||
? await buildDocumentReviewContext({
|
||||
db: input.db,
|
||||
companyId: input.companyId,
|
||||
|
|
@ -8314,6 +8321,7 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
identifier: string | null;
|
||||
title: string;
|
||||
workMode?: string | null;
|
||||
conversationAgentId?: string | null;
|
||||
description?: string | null;
|
||||
} | null;
|
||||
ancestors?: Array<{
|
||||
|
|
@ -8346,12 +8354,22 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
kind?: string | null;
|
||||
status?: string | null;
|
||||
} | null;
|
||||
planReview?: {
|
||||
status?: string | null;
|
||||
reason?: string | null;
|
||||
} | null;
|
||||
acceptedPlan?: {
|
||||
documentId?: string | null;
|
||||
revisionId?: string | null;
|
||||
revisionNumber?: number | null;
|
||||
} | null;
|
||||
acceptedPlanContinuation?: boolean;
|
||||
taskPlan?: {
|
||||
documentId: string;
|
||||
revisionId: string;
|
||||
revisionNumber: number;
|
||||
body: string;
|
||||
} | null;
|
||||
externalChatProvider?: string | null;
|
||||
nativeRunner?: boolean;
|
||||
// false builds the compact variant used for resume deltas, where the session
|
||||
|
|
@ -8382,12 +8400,21 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
: null);
|
||||
const effectiveWakeComments =
|
||||
wakeComments.length > 0 ? wakeComments : wakeComment ? [wakeComment] : [];
|
||||
const rejectedPlan = input.planReview?.status === "rejected";
|
||||
const acceptedPlanContinuation =
|
||||
!wakeComment &&
|
||||
!rejectedPlan && !issue?.conversationAgentId && !wakeComment &&
|
||||
(input.acceptedPlanContinuation ||
|
||||
(input.interaction?.kind === "request_confirmation" &&
|
||||
input.interaction.status === "accepted" &&
|
||||
issue?.workMode === "planning"));
|
||||
const acceptedChatPlan = Boolean(
|
||||
!rejectedPlan && issue?.conversationAgentId &&
|
||||
issue.workMode !== "ask" &&
|
||||
!wakeComment &&
|
||||
input.interaction?.kind === "request_confirmation" &&
|
||||
input.interaction.status === "accepted" &&
|
||||
(input.acceptedPlan?.revisionId || input.acceptedPlanContinuation),
|
||||
);
|
||||
if (!issue && effectiveWakeComments.length === 0) return null;
|
||||
|
||||
const lines = [
|
||||
|
|
@ -8451,7 +8478,16 @@ 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"}.`);
|
||||
if (acceptedChatPlan) {
|
||||
lines.push(
|
||||
"",
|
||||
"Accepted chat plan directive:",
|
||||
"The user has approved the plan for handoff. Perform that handoff now: select or create a suitable project, then create the ordinary assigned execution tasks with the relevant approved plan in initialPlan before execution starts. Do not stop at acknowledging approval or ask for another confirmation. Keep the original plan here, link the created tasks, and leave this conversation available for discussion. Do not implement here or create subtasks of this conversation.",
|
||||
);
|
||||
}
|
||||
} else if (issue.workMode === "ask") {
|
||||
lines.push(
|
||||
`- Work mode: ${quoteTaskScalar("ask")}`,
|
||||
"",
|
||||
|
|
@ -8489,7 +8525,18 @@ export function buildPaperclipTaskMarkdown(input: {
|
|||
"Implement the accepted plan on this issue when the work is small and cohesive. Use the paperclip-converting-plans-to-tasks skill to decide whether decomposition is justified. Create the minimum child issue graph only for qualifying ownership, parallelism, dependency, review, or lifecycle boundaries. Do not create a child merely because a plan was accepted.",
|
||||
);
|
||||
}
|
||||
if (acceptedPlanContinuation && input.acceptedPlan?.revisionId) {
|
||||
if (rejectedPlan) {
|
||||
lines.push(
|
||||
"",
|
||||
"Rejected plan review directive:",
|
||||
"The user rejected the plan and requested changes. Revise the plan to address their feedback through the existing plan document and review workflow. In Ask mode, discuss the requested changes without mutating documents or tasks. This is not approval to implement or hand off execution tasks. Do not treat the issue's in_progress status as plan approval.",
|
||||
"When revising the plan, first GET /api/issues/{issueId}/documents/plan and read its body and latestRevisionId. PUT the revised document to the same endpoint with baseRevisionId set to that latestRevisionId. An existing document requires this concurrency guard; do not omit it or blindly retry a stale revision. Bind the new approval request to the revision returned by the successful update.",
|
||||
);
|
||||
if (input.planReview?.reason?.trim()) {
|
||||
lines.push("User's requested changes:", fenceTaskText(input.planReview.reason.trim()));
|
||||
}
|
||||
}
|
||||
if ((acceptedPlanContinuation || acceptedChatPlan) && input.acceptedPlan?.revisionId) {
|
||||
const revisionNumber = input.acceptedPlan.revisionNumber
|
||||
? ` revision ${input.acceptedPlan.revisionNumber}`
|
||||
: " revision";
|
||||
|
|
@ -8501,10 +8548,18 @@ 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));
|
||||
}
|
||||
if (!issue.conversationAgentId && input.taskPlan?.body.trim()) {
|
||||
lines.push(
|
||||
"",
|
||||
`Task plan document ${input.taskPlan.documentId}, revision ${input.taskPlan.revisionNumber} (${input.taskPlan.revisionId}):`,
|
||||
"Use this plan as assignment context, including its outcome and acceptance criteria. Follow the current work mode and any required approvals.",
|
||||
fenceTaskText(input.taskPlan.body.trim()),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (ancestors.length > 0) {
|
||||
lines.push("", "Authoritative parent / ancestor context:");
|
||||
|
|
@ -10128,6 +10183,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,
|
||||
|
|
@ -12062,14 +12122,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,
|
||||
|
|
@ -12083,7 +12144,7 @@ export function heartbeatService(
|
|||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
return db
|
||||
return tx
|
||||
.insert(agentTaskSessions)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
|
|
@ -12097,6 +12158,7 @@ export function heartbeatService(
|
|||
})
|
||||
.returning()
|
||||
.then((rows) => rows[0] ?? null);
|
||||
});
|
||||
}
|
||||
|
||||
async function clearTaskSessions(
|
||||
|
|
@ -12105,6 +12167,7 @@ export function heartbeatService(
|
|||
opts?: {
|
||||
taskKey?: string | null;
|
||||
adapterType?: string | null;
|
||||
expectedRunId?: string;
|
||||
includeIssueAliases?: boolean;
|
||||
},
|
||||
) {
|
||||
|
|
@ -12142,11 +12205,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) {
|
||||
|
|
@ -12560,6 +12628,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
|
||||
|
|
@ -12760,6 +12829,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
|
||||
|
|
@ -13077,6 +13147,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({
|
||||
|
|
@ -16143,6 +16214,7 @@ export function heartbeatService(
|
|||
isNull(issues.assigneeUserId),
|
||||
isNull(issues.hiddenAt),
|
||||
inArray(issues.status, [...TIMER_ACTIONABLE_ISSUE_STATUSES]),
|
||||
isNull(issues.conversationAgentId),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
|
|
@ -19188,6 +19260,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 authorizeFailedChatRetryExecution = () =>
|
||||
|
|
@ -19434,7 +19530,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" &&
|
||||
|
|
@ -19588,6 +19684,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),
|
||||
);
|
||||
|
|
@ -19621,6 +19723,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,
|
||||
|
|
@ -19630,7 +19733,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";
|
||||
|
|
@ -19669,19 +19772,10 @@ export function heartbeatService(
|
|||
} else {
|
||||
delete context.paperclipSkillTest;
|
||||
}
|
||||
const executionContinuation =
|
||||
issueRef && 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;
|
||||
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;
|
||||
context.executionContinuation = executionContinuation;
|
||||
const paperclipWakePayload = await buildPaperclipWakePayload({
|
||||
db,
|
||||
|
|
@ -19762,6 +19856,7 @@ export function heartbeatService(
|
|||
identifier: issueRef.identifier,
|
||||
title: issueRef.title,
|
||||
workMode: issueRef.workMode,
|
||||
conversationAgentId: issueContext?.conversationAgentId,
|
||||
description: issueRef.description,
|
||||
}
|
||||
: null,
|
||||
|
|
@ -19775,6 +19870,12 @@ export function heartbeatService(
|
|||
kind: readNonEmptyString(context.interactionKind),
|
||||
status: readNonEmptyString(context.interactionStatus),
|
||||
},
|
||||
planReview: paperclipWakePayload?.planReviewContext?.interaction
|
||||
? {
|
||||
status: paperclipWakePayload.planReviewContext.interaction.status,
|
||||
reason: paperclipWakePayload.planReviewContext.interaction.result?.reason,
|
||||
}
|
||||
: null,
|
||||
acceptedPlanContinuation:
|
||||
readNonEmptyString(context.workspaceRefreshReason) ===
|
||||
"accepted_plan_confirmation" &&
|
||||
|
|
@ -19796,9 +19897,23 @@ export function heartbeatService(
|
|||
};
|
||||
})(),
|
||||
};
|
||||
const taskMarkdown = buildPaperclipTaskMarkdown(taskMarkdownInput);
|
||||
const taskPlan = issueRef && !isConversation(issueContext)
|
||||
? await getTaskPlanContext({
|
||||
db,
|
||||
companyId: agent.companyId,
|
||||
issueId: issueRef.id,
|
||||
approvedRevisionId: taskMarkdownInput.acceptedPlan?.revisionId,
|
||||
exposeLowTrustRaw,
|
||||
})
|
||||
: null;
|
||||
let taskMarkdown = buildPaperclipTaskMarkdown({ ...taskMarkdownInput, taskPlan });
|
||||
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,
|
||||
taskPlan,
|
||||
includeDescription: false,
|
||||
});
|
||||
if (issueRef) {
|
||||
|
|
@ -19806,7 +19921,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 {
|
||||
|
|
@ -22008,8 +22123,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: nativeBootstrapHasProviderEvidence,
|
||||
|
|
@ -22231,7 +22345,7 @@ export function heartbeatService(
|
|||
}
|
||||
}
|
||||
const executionMode =
|
||||
issueRef.workMode === "planning" && !acceptedPlanContinuationWake
|
||||
issueRef.workMode === "planning" && !isConversation(issueContext) && !acceptedPlanContinuationWake
|
||||
? ("plan" as const)
|
||||
: ("default" as const);
|
||||
const pinnedPlan =
|
||||
|
|
@ -22278,6 +22392,7 @@ export function heartbeatService(
|
|||
`# ${issueRef.identifier ?? issueRef.id}: ${issueRef.title}`,
|
||||
wakePayload: context.paperclipWake,
|
||||
resumedSession,
|
||||
conversationMode: context.conversationMode === true,
|
||||
agentId: agent.id,
|
||||
workspace: {
|
||||
// Projectless paperclip_runner tasks still have a resolved local cwd. Bind that
|
||||
|
|
@ -22916,6 +23031,7 @@ export function heartbeatService(
|
|||
executePaperclipNativeSession({
|
||||
db,
|
||||
execution: nativeExecution,
|
||||
conversationMode: isConversation(issueContext),
|
||||
runnerInstanceId: nativeRunnerInstanceId,
|
||||
leaseOwner: runOptions.nativeLeaseOwner,
|
||||
restartRecovery: runOptions.nativeRestartRecovery,
|
||||
|
|
@ -23086,6 +23202,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;
|
||||
|
|
@ -23781,6 +23901,8 @@ export function heartbeatService(
|
|||
: null;
|
||||
const resolved = resolveHeartbeatRunResponse({
|
||||
resultJson: persistedResultJson,
|
||||
conversationTurnFinished: isConversation(issueContext) &&
|
||||
persistedResultJson?.finalizationReasonCode === "conversation_turn_finished",
|
||||
existingComment: existingRunComment,
|
||||
finalAgentMessage,
|
||||
preferFinalResponseOverExistingComment:
|
||||
|
|
@ -23927,14 +24049,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(
|
||||
|
|
@ -23947,6 +24071,7 @@ export function heartbeatService(
|
|||
: livenessRun,
|
||||
agent,
|
||||
);
|
||||
}
|
||||
if (
|
||||
outcome === "succeeded" &&
|
||||
issueId &&
|
||||
|
|
@ -24027,6 +24152,7 @@ export function heartbeatService(
|
|||
await clearTaskSessions(agent.companyId, agent.id, {
|
||||
taskKey,
|
||||
adapterType: agent.adapterType,
|
||||
expectedRunId: finalizedRun.id,
|
||||
});
|
||||
} else {
|
||||
await upsertTaskSession({
|
||||
|
|
@ -24846,6 +24972,15 @@ 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 (isConversationExecutionWake(conversation, reason ?? readNonEmptyString(enrichedContextSnapshot.wakeReason))) return null;
|
||||
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);
|
||||
|
|
@ -25463,6 +25598,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,
|
||||
|
|
@ -25650,6 +25788,7 @@ export function heartbeatService(
|
|||
const explicitContinuationRunId = randomUUID();
|
||||
const executionBlocker = await getExecutionBlocker(
|
||||
tx as unknown as Db, issue.companyId, issue.id,
|
||||
{ conversationResetCommentId: opts.requestedByActorType === "user" ? wakeCommentId : null },
|
||||
);
|
||||
// Prove eligibility without retiring the hold. Later gates can still
|
||||
// decline this wake; hold retirement and successor creation stay atomic.
|
||||
|
|
@ -26201,7 +26340,7 @@ export function heartbeatService(
|
|||
contextSnapshot: activeExecutionRun.contextSnapshot,
|
||||
wakeupRequestId: activeExecutionRun.wakeupRequestId,
|
||||
},
|
||||
allowRunCoalescing: opts.allowRunCoalescing,
|
||||
allowRunCoalescing: isConversation(issue) ? false : opts.allowRunCoalescing,
|
||||
durableReceipt: durableRequest
|
||||
? {
|
||||
id: durableRequest.id,
|
||||
|
|
|
|||
|
|
@ -235,6 +235,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableChatConnectors: parsed.data.enableChatConnectors ?? false,
|
||||
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,
|
||||
|
|
@ -274,6 +275,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta
|
|||
enableChatConnectors: false,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableAgentChat: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))!;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { documentService } from "./documents.js";
|
||||
import { createdFromIssueCondition } from "./issue-creation-origin.js";
|
||||
import { executionProjectionsForRuns } from "./execution-projection.js";
|
||||
import type { ExecutionProjection } from "@paperclipai/shared";
|
||||
|
|
@ -1857,8 +1858,17 @@ 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 DbTransaction = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||
type IssueCreateInput = Omit<typeof issues.$inferInsert, "companyId"> & {
|
||||
initialPlan?: string | null;
|
||||
labelIds?: string[];
|
||||
blockedByIssueIds?: string[];
|
||||
inheritExecutionWorkspaceFromIssueId?: string | null;
|
||||
|
|
@ -4609,6 +4619,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,
|
||||
|
|
@ -4763,6 +4776,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,
|
||||
|
|
@ -5705,6 +5723,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,
|
||||
|
|
@ -7762,6 +7783,7 @@ export function issueService(db: Db) {
|
|||
eq(issues.companyId, companyId),
|
||||
visibleIssueCondition(),
|
||||
];
|
||||
if (!filters?.q?.trim()) conditions.push(isNull(issues.conversationAgentId));
|
||||
if (filters?.afterId) conditions.push(gt(issues.id, filters.afterId));
|
||||
const assigneeAgentFilter = parseIssueAssigneeAgentFilter(
|
||||
filters?.assigneeAgentId,
|
||||
|
|
@ -8121,10 +8143,8 @@ export function issueService(db: Db) {
|
|||
return countBlockedInboxIssues(db, companyId, filters);
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(issues.companyId, companyId),
|
||||
visibleIssueCondition(),
|
||||
];
|
||||
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]!));
|
||||
|
|
@ -8994,6 +9014,7 @@ export function issueService(db: Db) {
|
|||
eq(issueRelations.companyId, blockerIssue.companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
eq(issueRelations.issueId, blockerIssueId),
|
||||
isNull(issues.conversationAgentId),
|
||||
),
|
||||
);
|
||||
if (candidates.length === 0) return [];
|
||||
|
|
@ -9042,6 +9063,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,
|
||||
|
|
@ -9049,11 +9071,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;
|
||||
}
|
||||
|
||||
|
|
@ -9141,6 +9159,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) {
|
||||
|
|
@ -9639,12 +9658,17 @@ 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,
|
||||
dbOrTx: Db | DbTransaction = db,
|
||||
) => {
|
||||
const {
|
||||
initialPlan,
|
||||
labelIds: inputLabelIds,
|
||||
blockedByIssueIds,
|
||||
inheritExecutionWorkspaceFromIssueId,
|
||||
|
|
@ -9686,6 +9710,18 @@ export function issueService(db: Db) {
|
|||
throw unprocessable("in_progress issues require an assignee");
|
||||
}
|
||||
const persist = async (tx: DbTransaction) => {
|
||||
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) {
|
||||
|
|
@ -10094,6 +10130,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,
|
||||
|
|
@ -10235,6 +10278,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}`;
|
||||
|
|
@ -10482,6 +10526,17 @@ export function issueService(db: Db) {
|
|||
.where(idPredicate)
|
||||
.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,
|
||||
|
|
@ -11939,10 +11994,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)) {
|
||||
const append = () =>
|
||||
db.transaction(async (tx) => {
|
||||
// Serialize run-authored comments on the issue so a provider retry
|
||||
|
|
@ -11962,13 +12018,16 @@ export function issueService(db: Db) {
|
|||
: append();
|
||||
}
|
||||
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 = {
|
||||
// Keep every read on the caller's transaction connection. Re-entering
|
||||
// the outer pool here can deadlock when concurrent transactions fill
|
||||
|
|
@ -11976,10 +12035,23 @@ export function issueService(db: Db) {
|
|||
enabled: (await instanceSettings.getGeneral({ db: dbOrTx }))
|
||||
.censorUsernameInLogs,
|
||||
};
|
||||
const redactedBody = redactCurrentUserText(
|
||||
body,
|
||||
currentUserRedactionOptions,
|
||||
);
|
||||
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"),
|
||||
|
|
@ -12190,6 +12262,7 @@ export function issueService(db: Db) {
|
|||
authorType,
|
||||
createdByRunId,
|
||||
body: redactedBody,
|
||||
clientRequestId: options?.clientRequestId ?? null,
|
||||
presentation,
|
||||
metadata,
|
||||
sourceTrust: options?.sourceTrust ?? null,
|
||||
|
|
@ -12395,6 +12468,9 @@ export function issueService(db: Db) {
|
|||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { heartbeatRuns, issueRecoveryActions, issues, type Db } from "@paperclip
|
|||
import { issueRecoveryActionService } from "./issue-recovery-actions.js";
|
||||
import { parseIssueExecutionState } from "./issue-execution-policy.js";
|
||||
import { executionFailureRetryCount } from "./execution-recovery-attempt.js";
|
||||
import { isSupersededConversationRun } from "./agent-conversations.js";
|
||||
|
||||
type Run = typeof heartbeatRuns.$inferSelect;
|
||||
export const LEGACY_RECOVERY_CAUSE = "legacy_execution_requires_reconciliation";
|
||||
|
|
@ -103,6 +104,7 @@ export async function terminalizeLegacyExecution(input: {
|
|||
review.currentParticipant?.type === "agent" && review.currentParticipant.agentId === run.agentId;
|
||||
if (
|
||||
task &&
|
||||
!isSupersededConversationRun(task, updated) &&
|
||||
(task.assigneeAgentId === run.agentId || isCurrentReviewer) &&
|
||||
!["done", "cancelled"].includes(task.status)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ export function buildNativeExecutionInput(input: {
|
|||
*/
|
||||
wakePayload?: unknown;
|
||||
resumedSession?: boolean;
|
||||
conversationMode?: boolean;
|
||||
agentId: string;
|
||||
workspace: {
|
||||
id: string;
|
||||
|
|
@ -155,6 +156,7 @@ export function buildNativeExecutionInput(input: {
|
|||
: input.wakePayload;
|
||||
const wakePrompt = renderPaperclipWakePrompt(wakePayload, {
|
||||
resumedSession: input.resumedSession === true,
|
||||
conversationMode: input.conversationMode === true,
|
||||
suppressIssueDescription: input.taskPrompt.trim().length > 0,
|
||||
nativeWakeReaderAvailable: true,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { conversationNativeDecision, isConversation } from "../agent-conversations.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray, isNotNull, isNull, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
|
|
@ -1154,7 +1155,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,
|
||||
|
|
@ -1175,6 +1176,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,
|
||||
|
|
|
|||
|
|
@ -245,6 +245,7 @@ import {
|
|||
nativeSessionFailureSourceCode,
|
||||
nativeSessionRecoveryProjection,
|
||||
nativeGovernedWaitResult,
|
||||
nativeConversationReplyResult,
|
||||
nativeToolsRefreshWaitResult,
|
||||
parseRemoteExecutableCandidate,
|
||||
buildRemoteCodexLauncherCommand,
|
||||
|
|
@ -3929,6 +3930,55 @@ describe("provider plan synchronization", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("native conversation replies", () => {
|
||||
const reply: PrpEvent = {
|
||||
schema: "paperclip.prp.event.v1", sourceInstanceId: "runner-1",
|
||||
sourceEventId: "runner-1:run-1:8", sourceSeq: 8, sourceKind: "runner",
|
||||
runId: "run-1", normalizedSessionId: "session-1", turnId: "turn-1",
|
||||
eventType: "item.completed", schemaVersion: 1, priority: 1,
|
||||
emittedAt: "2026-09-11T18:00:00.000Z",
|
||||
payload: { kind: "agentMessage", channel: "final", text: "Which project should own this?" },
|
||||
};
|
||||
const terminal = { ...reply, sourceEventId: "runner-1:run-1:9", sourceSeq: 9,
|
||||
eventType: "turn.completed", payload: { status: "completed" } } as PrpEvent;
|
||||
const input = { conversation: true, replyEvent: reply, terminalEvent: terminal,
|
||||
completionContract: { revision: "1", objective: "Ongoing conversation",
|
||||
criteria: [{ id: "objective", requirement: "Help the user" }] } };
|
||||
|
||||
it("yields an evidenced completed reply without claiming execution completion", () => {
|
||||
expect(nativeConversationReplyResult(input)).toMatchObject({
|
||||
reportedWorkDisposition: "yielded", summary: "Which project should own this?",
|
||||
completionClaim: { objectiveSatisfied: false, criteria: [{ status: "unknown" }] },
|
||||
evidence: [{ ref: "run-event:runner-1:run-1:8" }],
|
||||
continuation: { kind: "response_wake" }, attentionRequests: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["turn.failed", "turn.cancelled", "turn.interrupted"])(
|
||||
"does not reinterpret a %s provider turn as a chat reply", (eventType) => {
|
||||
expect(nativeConversationReplyResult({ ...input,
|
||||
terminalEvent: { ...terminal, eventType } as PrpEvent })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps ordinary execution tasks and absent or unfinished replies fail-closed", () => {
|
||||
expect(nativeConversationReplyResult({ ...input, conversation: false })).toBeNull();
|
||||
expect(nativeConversationReplyResult({ ...input, replyEvent: null })).toBeNull();
|
||||
for (const payload of [
|
||||
{ kind: "agentMessage", channel: "progress", text: "Still working" },
|
||||
{ kind: "agentMessage", channel: "final", text: " " },
|
||||
{ kind: "toolCall", channel: "final", text: "Tool result" },
|
||||
]) expect(nativeConversationReplyResult({ ...input, replyEvent: { ...reply, payload } })).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["runId", "turnId", "normalizedSessionId"] as const)(
|
||||
"rejects a final message from another %s", (key) => {
|
||||
expect(nativeConversationReplyResult({ ...input,
|
||||
replyEvent: { ...reply, [key]: "old-authority" } })).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("native governed waits", () => {
|
||||
it("yields to an existing tools-refresh wake without claiming completion or a human interaction", () => {
|
||||
const result = nativeToolsRefreshWaitResult({
|
||||
|
|
@ -5002,6 +5052,32 @@ describe("native session same-turn steering", () => {
|
|||
});
|
||||
|
||||
describe("native warm session supervision", () => {
|
||||
it.each([true, false])(
|
||||
"preserves chat reply grace for per-turn providers: chat=%s",
|
||||
async (conversationMode) => {
|
||||
state.execute.mockReset().mockImplementationOnce(async (options) => {
|
||||
expect(options.semanticResultTerminalGraceMs).toBe(conversationMode ? 30_000 : undefined);
|
||||
return {
|
||||
result: { summary: "Reply completed" },
|
||||
terminal: { runTerminalState: "succeeded" },
|
||||
turnId: "turn-grace",
|
||||
normalizedSessionId: execution.session.normalizedSessionId,
|
||||
providerSessionId: "provider-grace",
|
||||
driverKind: "test",
|
||||
driverVersion: "1",
|
||||
nativeEventCount: 1,
|
||||
highestContiguousSourceSeq: 1,
|
||||
};
|
||||
});
|
||||
await executePaperclipNativeSession({
|
||||
db: leaseDb(),
|
||||
execution,
|
||||
runnerInstanceId: "runner",
|
||||
conversationMode,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("persists agent-created goal continuity before a per-turn runner settles", async () => {
|
||||
const goalCheckpoint = {
|
||||
identity: { runId: execution.binding.runId, sessionId: "session" },
|
||||
|
|
|
|||
|
|
@ -886,6 +886,46 @@ export function nativeGovernedWaitResult(input: {
|
|||
};
|
||||
}
|
||||
|
||||
/** A completed chat reply yields to the next message without claiming task completion. */
|
||||
export function nativeConversationReplyResult(input: {
|
||||
conversation: boolean;
|
||||
terminalEvent: PrpEvent;
|
||||
replyEvent: PrpEvent | null;
|
||||
completionContract: NativeExecutionInput["completionContract"]["contract"];
|
||||
}): PrpStructuredRunResult | null {
|
||||
const reply = input.replyEvent;
|
||||
const payload = record(reply?.payload);
|
||||
const text = typeof payload.text === "string" ? payload.text.trim() : "";
|
||||
if (!input.conversation || input.terminalEvent.eventType !== "turn.completed" ||
|
||||
!reply || reply.eventType !== "item.completed" || payload.kind !== "agentMessage" ||
|
||||
payload.channel !== "final" || !text || reply.runId !== input.terminalEvent.runId ||
|
||||
reply.turnId !== input.terminalEvent.turnId ||
|
||||
reply.normalizedSessionId !== input.terminalEvent.normalizedSessionId) return null;
|
||||
const ref = `run-event:${reply.sourceEventId}`;
|
||||
return {
|
||||
schema: "paperclip.run_result.v1",
|
||||
reportedWorkDisposition: "yielded",
|
||||
summary: text.slice(0, 12_000),
|
||||
completionClaim: {
|
||||
contractRevision: input.completionContract.revision,
|
||||
objectiveSatisfied: false,
|
||||
criteria: input.completionContract.criteria.map((criterion) => ({
|
||||
criterionId: criterion.id, status: "unknown", evidenceRefs: [ref],
|
||||
})),
|
||||
remainingWork: [],
|
||||
},
|
||||
evidence: [{ ref }],
|
||||
verification: [],
|
||||
attentionRequests: [],
|
||||
artifacts: [],
|
||||
continuation: {
|
||||
kind: "response_wake",
|
||||
summary: "Wait for the next user message in this conversation.",
|
||||
idempotencyKey: `conversation-reply:${reply.sourceEventId}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge an asynchronous durable-interaction lookup to the runner package's
|
||||
* synchronous governed-wait boundary. Observations are single-use and bound
|
||||
|
|
@ -6615,6 +6655,8 @@ export async function executePaperclipNativeSession(input: {
|
|||
db: Db;
|
||||
execution: NativeExecutionInput;
|
||||
runnerInstanceId: string;
|
||||
/** Trusted task identity from the heartbeat orchestration. */
|
||||
conversationMode?: boolean;
|
||||
leaseOwner?: string;
|
||||
restartRecovery?: NativeRestartRecoveryClaim;
|
||||
onSpawn?: (meta: {
|
||||
|
|
@ -7156,6 +7198,7 @@ async function executePaperclipNativeSessionWithinScope(
|
|||
payload: event.payload,
|
||||
},
|
||||
);
|
||||
let completedConversationReply: PrpEvent | null = null;
|
||||
const controlPlane = new PaperclipControlPlanePort(
|
||||
input.db,
|
||||
{
|
||||
|
|
@ -7171,6 +7214,11 @@ async function executePaperclipNativeSessionWithinScope(
|
|||
},
|
||||
{
|
||||
onCommittedEvent: async (event) => {
|
||||
if (event.eventType === "item.completed" &&
|
||||
record(event.payload).kind === "agentMessage" &&
|
||||
record(event.payload).channel === "final") {
|
||||
completedConversationReply = event;
|
||||
}
|
||||
await projectSessionGoalEvent(event);
|
||||
providerUsageLimitObserved ||= nativeProviderUsageLimitFromEvent(event);
|
||||
const eventAtMs = Date.parse(event.emittedAt);
|
||||
|
|
@ -7659,12 +7707,25 @@ async function executePaperclipNativeSessionWithinScope(
|
|||
resolveGovernedWait: ({ event }) =>
|
||||
governedWaitObservation.consume(event),
|
||||
resolveMissingResult: async ({ terminalEvent }) => {
|
||||
// A model may correctly create a durable question/confirmation and
|
||||
// then end its provider turn without also invoking paperclip_finish.
|
||||
// Recover only completed turns with a pending interaction created by
|
||||
// this exact run; unrelated or failed turns still fail closed.
|
||||
// Governed waits take precedence over an ordinary chat reply.
|
||||
// Execution tasks still require their normal semantic finish.
|
||||
if (terminalEvent.eventType !== "turn.completed") return null;
|
||||
return resolvePendingGovernedWait();
|
||||
const governedWait = await resolvePendingGovernedWait();
|
||||
if (governedWait) return governedWait;
|
||||
const [conversation] = await input.db
|
||||
.select({ agentId: issues.conversationAgentId })
|
||||
.from(issues)
|
||||
.where(and(
|
||||
eq(issues.id, input.execution.binding.issueId),
|
||||
eq(issues.companyId, input.execution.binding.companyId),
|
||||
))
|
||||
.limit(1);
|
||||
return nativeConversationReplyResult({
|
||||
conversation: conversation?.agentId === input.execution.binding.agentId,
|
||||
terminalEvent,
|
||||
replyEvent: completedConversationReply,
|
||||
completionContract: input.execution.completionContract.contract,
|
||||
});
|
||||
},
|
||||
existingSession: existingWarmSession,
|
||||
persistedSession: persistedWarmSession,
|
||||
|
|
|
|||
|
|
@ -2536,7 +2536,7 @@ describe("buildNativeExecutionInput wake projection", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("places child completion summaries in the closed provider prompt", () => {
|
||||
it.each([false, true])("projects wake context with the appropriate execution contract (conversation=%s)", (conversationMode) => {
|
||||
const input = buildNativeExecutionInput({
|
||||
companyId,
|
||||
runId: currentRunId,
|
||||
|
|
@ -2572,6 +2572,7 @@ describe("buildNativeExecutionInput wake projection", () => {
|
|||
checkedOutByHarness: true,
|
||||
},
|
||||
resumedSession: true,
|
||||
conversationMode,
|
||||
agentId,
|
||||
workspace: {
|
||||
id: currentRunId,
|
||||
|
|
@ -2598,6 +2599,8 @@ describe("buildNativeExecutionInput wake projection", () => {
|
|||
runtimeContext: nativeRuntimeContextFixture(),
|
||||
});
|
||||
|
||||
expect(input.task.prompt.includes("Execution contract:")).toBe(!conversationMode);
|
||||
expect(input.task.prompt.includes("Use child issues")).toBe(!conversationMode);
|
||||
expect(input.task.prompt).toContain("## Paperclip Resume Delta");
|
||||
expect(input.task.prompt).toContain("reason: issue_children_completed");
|
||||
expect(input.task.prompt).toContain("DOT-147 Build utility (done)");
|
||||
|
|
|
|||
|
|
@ -90,11 +90,11 @@ describe("PaperclipRunnerToolAuthority", () => {
|
|||
issueId,
|
||||
runId,
|
||||
});
|
||||
expect(authority.definitions()).toHaveLength(22);
|
||||
expect(authority.definitions()).toHaveLength(25);
|
||||
expect(authority.definitions().map((tool) => tool.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"connections_search",
|
||||
"connection_request",
|
||||
"connection_request", "create_project", "list_project_repositories", "list_projects",
|
||||
"get_task_context",
|
||||
"get_task_history",
|
||||
"search_tasks",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { callProjectTool } from "../project-tools.js";
|
||||
import { isConnectorTool, executeConnectorTool, type ConnectorAssignment } from "../connector-runtime.js";
|
||||
import { resolveNativeRuntimeMcpSnapshot } from "./runtime-context.js";
|
||||
import { connectionIntentService } from "../connection-intents.js";
|
||||
|
|
@ -71,7 +72,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", "register_deliverable",
|
||||
"create_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable",
|
||||
"list_documents", "read_document", "list_document_revisions", "write_document",
|
||||
"list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context",
|
||||
]);
|
||||
|
|
@ -307,6 +308,16 @@ export class PaperclipRunnerToolAuthority {
|
|||
throw new Error("paperclip_runner_tool_mode_denied");
|
||||
}
|
||||
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 {
|
||||
|
|
@ -646,10 +657,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) {
|
||||
|
|
@ -672,19 +683,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,
|
||||
|
|
@ -694,8 +707,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");
|
||||
}
|
||||
|
|
@ -719,7 +734,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;
|
||||
|
|
@ -736,6 +751,7 @@ export class PaperclipRunnerToolAuthority {
|
|||
id: child.id,
|
||||
identifier: child.identifier,
|
||||
parentId: child.parentId,
|
||||
projectId: child.projectId,
|
||||
status: childStatus,
|
||||
assigneeActorId: child.assigneeAgentId,
|
||||
},
|
||||
|
|
@ -759,7 +775,7 @@ export class PaperclipRunnerToolAuthority {
|
|||
payload: {
|
||||
issueId: childId,
|
||||
mutation: "create_child",
|
||||
parentIssueId: this.binding.issueId,
|
||||
parentIssueId: task.parentId ?? null,
|
||||
},
|
||||
idempotencyKey: scheduledWakeIds[0]!,
|
||||
requestedByActorType: "agent",
|
||||
|
|
@ -767,7 +783,7 @@ export class PaperclipRunnerToolAuthority {
|
|||
contextSnapshot: {
|
||||
issueId: childId,
|
||||
source: "paperclip_runner.create_task",
|
||||
parentIssueId: this.binding.issueId,
|
||||
parentIssueId: task.parentId ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -45,22 +45,21 @@ describe("runner API catalog", () => {
|
|||
catalog.length,
|
||||
);
|
||||
expect(JSON.stringify(catalog)).not.toContain('"$ref"');
|
||||
expect(
|
||||
runnerApiOperation("GET /api/companies/{companyId}/decisions")
|
||||
.authorization.actor,
|
||||
).toBe("board");
|
||||
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("GET /api/companies/{companyId}/decisions").authorization.actor).toBe("board");
|
||||
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"]));
|
||||
const url = runnerApiUrl(operation, { operationId: operation.operationId, pathParams }, context, "https://paperclip.test");
|
||||
expect(url.origin).toBe("https://paperclip.test");
|
||||
expect(url.pathname).not.toContain("{");
|
||||
expect(operation.responses).toBeDefined();
|
||||
expect(operation.authorization.actor).toBeTruthy();
|
||||
});
|
||||
it.each(
|
||||
runnerApiCatalog().filter((operation) => operation.transport === "rest"),
|
||||
|
|
@ -135,6 +134,7 @@ describe("runner API request boundary", () => {
|
|||
},
|
||||
);
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -1559,6 +1559,11 @@ export async function commitNativeStatusDecision(input: {
|
|||
.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 (
|
||||
|
|
|
|||
|
|
@ -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}` };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { isWaitingConversation, settleConversationTurn, deliverConversationComments } from "../agent-conversations.js";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
|
|
@ -4194,12 +4196,24 @@ export function recoveryService(
|
|||
}
|
||||
|
||||
for (const issue of candidates) {
|
||||
const executionState =
|
||||
issue.status === "in_review"
|
||||
? parseIssueExecutionState(issue.executionState)
|
||||
: null;
|
||||
const pendingExecutionState =
|
||||
executionState?.status === "pending" ? executionState : null;
|
||||
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;
|
||||
const pendingExecutionState = executionState?.status === "pending" ? executionState : null;
|
||||
const currentParticipant = pendingExecutionState
|
||||
? pendingExecutionState.currentParticipant
|
||||
: null;
|
||||
|
|
@ -4228,6 +4242,18 @@ export function recoveryService(
|
|||
}
|
||||
|
||||
let latestRun = await getLatestIssueRun(issue.companyId, issue.id);
|
||||
// A native chat can finish between the earlier settlement read and this
|
||||
// fresh run read, before its response is materialized. Its trusted
|
||||
// finalizer owns that settlement; generic productive-work recovery must
|
||||
// not invent another conversation turn during the publication window.
|
||||
if (
|
||||
issue.conversationAgentId &&
|
||||
latestRun?.status === "succeeded" &&
|
||||
parseObject(latestRun.resultJson).finalizationReasonCode === "conversation_turn_finished"
|
||||
) {
|
||||
result.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const agent = await getAgent(agentId);
|
||||
const agentInvokable =
|
||||
|
|
@ -5188,6 +5214,7 @@ export function recoveryService(
|
|||
const queryCandidates = (afterIssueId: string | null) => {
|
||||
const filters = [
|
||||
eq(issues.status, "blocked"),
|
||||
isNull(issues.conversationAgentId),
|
||||
visibleIssueCondition(),
|
||||
sql`${issues.assigneeAgentId} is not null`,
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import {
|
||||
documentRevisions,
|
||||
documents,
|
||||
issueDocuments,
|
||||
issues,
|
||||
type Db,
|
||||
} from "@paperclipai/db";
|
||||
import { redactQuarantinedBodyForHigherTrust } from "./source-trust.js";
|
||||
|
||||
/** Read the task's durable plan before constructing any provider's assignment. */
|
||||
export async function getTaskPlanContext(input: {
|
||||
db: Db;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
approvedRevisionId?: string | null;
|
||||
exposeLowTrustRaw?: boolean;
|
||||
}) {
|
||||
const { db, companyId, issueId } = input;
|
||||
const plan = await db
|
||||
.select({
|
||||
documentId: documents.id,
|
||||
revisionId: documentRevisions.id,
|
||||
revisionNumber: documentRevisions.revisionNumber,
|
||||
body: documentRevisions.body,
|
||||
sourceTrust: documents.sourceTrust,
|
||||
})
|
||||
.from(issueDocuments)
|
||||
.innerJoin(issues, eq(issues.id, issueDocuments.issueId))
|
||||
.innerJoin(documents, eq(documents.id, issueDocuments.documentId))
|
||||
.innerJoin(
|
||||
documentRevisions,
|
||||
and(
|
||||
eq(documentRevisions.documentId, documents.id),
|
||||
input.approvedRevisionId
|
||||
? eq(documentRevisions.id, input.approvedRevisionId)
|
||||
: eq(documentRevisions.id, documents.latestRevisionId),
|
||||
),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.id, issueId),
|
||||
eq(issues.companyId, companyId),
|
||||
eq(issueDocuments.companyId, companyId),
|
||||
eq(documents.companyId, companyId),
|
||||
eq(documentRevisions.companyId, companyId),
|
||||
eq(issueDocuments.key, "plan"),
|
||||
isNull(issues.conversationAgentId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return plan && !input.exposeLowTrustRaw
|
||||
? redactQuarantinedBodyForHigherTrust(plan)
|
||||
: plan;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ function makeInstanceSettings({
|
|||
enableEnvironments,
|
||||
enableIsolatedWorkspaces: true,
|
||||
enableStreamlinedLeftNavigation: false,
|
||||
enableAgentChat: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableIssuePlanDecompositions: true,
|
||||
enableExperimentalFileViewer: false,
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload {
|
|||
enableChatConnectors: false,
|
||||
enablePipelines: false,
|
||||
enableCases: false,
|
||||
enableAgentChat: false,
|
||||
enableConferenceRoomChat: false,
|
||||
enableClassicTaskInterface: false,
|
||||
enableIssuePlanDecompositions: false,
|
||||
|
|
|
|||
Loading…
Reference in New Issue