fix: preserve chat composer submissions and session state

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-11 17:20:12 -05:00
commit c0d2c2ed3e
37 changed files with 436 additions and 191 deletions

View File

@ -165,15 +165,12 @@ async function seedValidWorktreeSource(
principalId: userId,
status: "active",
});
await db.insert(issues).values({
id: issueId,
companyId,
title: "Representative seed issue",
status: "backlog",
priority: "medium",
issueNumber: 1,
identifier: "SEED-1",
});
// This helper also seeds an intentionally older schema. Current Drizzle
// insert builders include defaults for newly added columns absent there.
await db.$client`
insert into issues (id, company_id, title, status, priority, issue_number, identifier)
values (${issueId}, ${companyId}, 'Representative seed issue', 'backlog', 'medium', 1, 'SEED-1')
`;
await db.$client.end({ timeout: 5 });
return { companyId, issueId };
}

View File

@ -83,6 +83,12 @@ describePostgres("persistent agent chat migration", () => {
const legacyGuard = migrationSql.slice(migrationSql.lastIndexOf('ALTER TABLE "issues" ADD CONSTRAINT'))
.replace(' and "issues"."conversation_state" is not null', "");
await sql.unsafe(legacyGuard);
const legacyNullIds = [randomUUID(), randomUUID()];
for (const [index, status] of ["in_review", "in_progress"].entries()) {
await sql`INSERT INTO issues (id, company_id, title, assignee_agent_id, status, conversation_agent_id, conversation_user_id, conversation_state)
VALUES (${legacyNullIds[index]!}, ${row.companyId}, 'Legacy null state', ${row.agentId}, ${status}, ${row.agentId}, ${`legacy-null-${index}`}, NULL)`;
}
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${migrationHash}`;
expect(await inspectMigrations(database.connectionString)).toMatchObject({
status: "needsMigrations", pendingMigrations: [migrationFile],
@ -96,6 +102,9 @@ describePostgres("persistent agent chat migration", () => {
});
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);
const repaired = await sql`SELECT id, conversation_state FROM issues WHERE id IN ${sql(legacyNullIds)}`;
expect(repaired.find((item) => item.id === legacyNullIds[0])?.conversation_state).toBe("waiting");
expect(repaired.find((item) => item.id === legacyNullIds[1])?.conversation_state).toBe("active");
await assertConstraints(sql, row);
} finally {
await sql.end();

View File

@ -17,6 +17,10 @@ DO $$ BEGIN
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
-- The first development guard allowed NULL through SQL three-valued logic.
-- Recover the server-owned idle/active state before enforcing the stronger guard.
UPDATE "issues" SET "conversation_state" = CASE WHEN "status" = 'in_review' THEN 'waiting' ELSE 'active' END
WHERE "conversation_agent_id" IS NOT NULL AND "conversation_state" IS NULL;--> 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

File diff suppressed because one or more lines are too long

View File

@ -1708,10 +1708,11 @@
"repositoryUrls": {
"description": "Existing HTTPS GitHub repository URLs, including repos absent from the catalog.",
"items": {
"minLength": 1,
"maxLength": 2000,
"pattern": "^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$",
"type": "string"
},
"maxItems": 200,
"maxItems": 100,
"type": "array",
"uniqueItems": true
},

View File

@ -24,7 +24,7 @@
"prpVersion": 1,
"nativeExecutionVersion": 1,
"catalogVersion": 1,
"catalogSha256": "sha256:842a1515a5b549fcc5df7675f3a96471b2f1ca33f4699cc5dd2ecf6c4235f2ec",
"catalogSha256": "sha256:155849f666fffed8133d497c4323d42639eae7696699f9df049649f836e2edbc",
"driverContractVersion": 1,
"driverKind": "paperclip-deterministic",
"driverVersion": "1.0.0"

View File

@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
"sha256": "89641b73df452a5d03502bc151a81a68387ece129c8826e0572800c3b1c5265c",
"sha256": "43bda8e713605d690a5e755f2d47eaea012d28fef81bd7dc787a5f9cacc507a7",
"expectation": "accept",
"compatibilityCase": "canonical"
},

View File

@ -2,10 +2,16 @@ import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { serializeCapabilityGeneratedSemanticContracts } from "../dist/semantic-tools/provider-neutral.js";
import { PAPERCLIP_RUNNER_BUILD_METADATA } from "../dist/evals/build-metadata.js";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const outputPath = resolve(packageRoot, "generated/capability/semantic-tool-contracts.json");
const generated = serializeCapabilityGeneratedSemanticContracts();
// This is an explicitly seeded schema fixture, not retained live evidence.
// Keep its advertised catalog identity synchronized with the shipped contracts.
const fixturePath = resolve(packageRoot, "protocol/fixtures/evals/native-execution-seeded.json");
const fixture = JSON.parse(await readFile(fixturePath, "utf8"));
const fixtureCurrent = fixture.runner.catalogSha256 === PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256;
if (process.argv.includes("--check")) {
const current = await readFile(outputPath, "utf8").catch(() => "");
@ -13,7 +19,15 @@ if (process.argv.includes("--check")) {
process.stderr.write("semantic-tool-contracts.json is stale; run generate:semantic-contracts\n");
process.exitCode = 1;
}
if (!fixtureCurrent) {
process.stderr.write("native-execution-seeded.json catalog is stale; run generate:semantic-contracts and generate:protocol-manifest\n");
process.exitCode = 1;
}
} else {
await writeFile(outputPath, generated);
if (!fixtureCurrent) {
fixture.runner.catalogSha256 = PAPERCLIP_RUNNER_BUILD_METADATA.semanticCatalog.sha256;
await writeFile(fixturePath, `${JSON.stringify(fixture, null, 2)}\n`);
}
process.stdout.write(`wrote ${outputPath}\n`);
}

View File

@ -50,6 +50,11 @@
"description": "Company-visible task, agent, project, and goal discovery.",
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals"]
},
{
"id": "projects",
"description": "Project creation and authorized repository discovery through the live company/run authority.",
"operationIds": ["create_project", "list_project_repositories"]
},
{
"id": "delegation_dependencies",
"description": "Create delegated work and maintain dependency edges.",
@ -163,12 +168,12 @@
"legacyGroup": 5,
"name": "Search",
"owner": "optional discovery tools",
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals"],
"operationIds": ["search_tasks", "list_agents", "get_agent", "list_projects", "list_goals", "list_project_repositories"],
"controlPlaneOperationIds": [],
"realSurface": "company issue search and agent/project/goal list/get routes",
"mockStateDomains": ["company", "task", "actor", "project", "goal"],
"prpEvidence": "bounded redacted read projections through tool-result item events",
"gap": "Project and goal operations are scenario-only; every real service binding is unbound."
"gap": "Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher."
},
{
"id": "su",
@ -259,12 +264,12 @@
"legacyGroup": 13,
"name": "Reference files",
"owner": "optional domain tools + test-only escape hatch",
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api"],
"operationIds": ["list_cases", "upsert_case", "list_routines", "manage_routine", "list_company_skills", "sync_company_skills", "list_secret_metadata", "read_secret_value", "export_company", "administer_company", "generic_api_request", "search_api", "call_api", "create_project"],
"controlPlaneOperationIds": ["append_audit_record"],
"realSurface": "case, routine, company-skill, secret, portability, and administration services",
"realSurface": "project, case, routine, company-skill, secret, portability, and administration services",
"mockStateDomains": ["company", "cases", "routines", "skills", "secrets", "audit", "fault"],
"prpEvidence": "bounded domain projections, redacted broker receipts, company diffs, and audit references",
"gap": "These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
"gap": "Project creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage."
},
{
"id": "mh",

View File

@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface.
This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **43 reconciled semantic operations** (14 always, 29 optional), and **106 scenarios in 16 behavior groups**.
The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **45 reconciled semantic operations** (14 always, 31 optional), and **106 scenarios in 16 behavior groups**.
## Axis 1: PRP v1 event and command families
@ -87,13 +87,14 @@ Placement has exactly three outcomes:
`answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`.
### Optional operations (29) and grant groups (12)
### Optional operations (31) and grant groups (13)
Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive.
| Grant group | Operations | Required claims represented | Purpose |
| --- | --- | --- | --- |
| `discovery` | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals` | `discovery:agents:read`<br>`discovery:goals:read`<br>`discovery:projects:read`<br>`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
| `projects` | `create_project`<br>`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. |
| `delegation_dependencies` | `create_task`<br>`set_dependencies` | `delegation:tasks:create`<br>`dependencies:write` | Create delegated work and maintain dependency edges. |
| `governance` | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `governance:approvals:comment`<br>`governance:approvals:decide`<br>`governance:approvals:read`<br>`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
| `cases` | `list_cases`<br>`upsert_case` | `cases:read`<br>`cases:write` | Read and update case summaries without reusing issue-document authority. |
@ -118,7 +119,8 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `call_api` | `optional_agent_tool` | `api:call` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `company_write` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated PRP tool input/result and existing HTTP route authorization/activity records.<br>catalog PRP status: `bound` |
| `comment_on_approval` | `optional_agent_tool` | `governance:approvals:comment` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `governance` | `required` | no | `semantic_command:comment_on_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
| `control_workspace_service` | `optional_agent_tool` | `workspace:control` | `standard`<br>`skill_test` | `workspace_control` | `required` | no | `semantic_command:control_workspace_service` | `scenario` + `live`<br>`live_codex` | `unbound`<br>workspace service lifecycle event<br>catalog PRP status: `audit_pending` |
| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`<br>`live_codex` | `issues.createChild`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
| `create_project` | `optional_agent_tool` | none | `standard`<br>`skill_test` | `company_write` | `required` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`<br>`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`<br>`live_codex` | `issues.create / issues.createChild`<br>semantic-operation item event plus company-entity state diff and audit record<br>catalog PRP status: `bound` |
| `decide_approval` | `optional_agent_tool` | `governance:approvals:decide` | `standard`<br>`skill_test`<br>roles: `board`<br>`approver`<br>`security` | `governance` | `required` | no | `semantic_command:decide_approval` | `scenario` + `live`<br>`live_codex` | `unbound`<br>approval lifecycle plus governed-wait continuation and audit events<br>catalog PRP status: `audit_pending` |
| `export_company` | `optional_agent_tool` | `portability:export` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:portability.export` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
| `finish_task` | `always_agent_tool` | none | `standard`<br>`skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live`<br>`live_codex` | `unbound`<br>semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events<br>catalog PRP status: `audit_pending` |
@ -137,7 +139,8 @@ Grant groups are documentation/exposure bundles, not additional authority. The o
| `list_document_revisions` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_documents` | `always_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live`<br>`live_codex` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_goals` | `optional_agent_tool` | `discovery:goals:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.goals` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_project_repositories` | `optional_agent_tool` | none | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | inline/no mapping | `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`<br>`ask`<br>`planning`<br>`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` + `live`<br>`live_codex` | `PaperclipRunnerToolAuthority`<br>Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.<br>catalog PRP status: `bound` |
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`<br>`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`<br>`scenario_mock` | `unbound`<br>read projection surfaced via a tool-result item event; no control-plane state diff<br>catalog PRP status: `audit_pending` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`<br>`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`<br>`scenario_mock` | `unbound`<br>company admin/portability item event plus audit record<br>catalog PRP status: `audit_pending` |
@ -168,7 +171,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`co` — Checkout](#behavior-group-co-checkout) | control plane | none | `checkout_task` | POST /api/issues/:id/checkout and execution-lock services | `task`<br>`actor`<br>`run`<br>`idempotency`<br>`fault` | 6 | run preparation and issue-status decision evidence with checkout receipt | Intentionally no model tool; the production checkout receipt still needs the additive semantic-receipt envelope. |
| [`st` — Status](#behavior-group-st-status) | always tools + control-plane arbitration | `answer_status_question`<br>`finish_task`<br>`block_task`<br>`request_review` | `reconcile_run`<br>`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`<br>`comments`<br>`interactions`<br>`blockers`<br>`audit`<br>`run` | 8 | semantic operation receipt, work assessment, issue-status decision, and terminal causality | Production semantic binding and additive typed operation/conflict receipts remain unimplemented. |
| [`cm` — Comments](#behavior-group-cm-comments) | always tools | `get_task_history`<br>`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`<br>`comments`<br>`actor`<br>`idempotency`<br>`audit` | 6 | bounded read result or idempotent comment-write receipt plus audit reference | Active-task binding is unbound; cross-task comment mutation is deliberately outside V1. |
| [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals` | none | company issue search and agent/project/goal list/get routes | `company`<br>`task`<br>`actor`<br>`project`<br>`goal` | 4 | bounded redacted read projections through tool-result item events | Project and goal operations are scenario-only; every real service binding is unbound. |
| [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`<br>`list_agents`<br>`get_agent`<br>`list_projects`<br>`list_goals`<br>`list_project_repositories` | none | company issue search and agent/project/goal list/get routes | `company`<br>`task`<br>`actor`<br>`project`<br>`goal` | 4 | bounded redacted read projections through tool-result item events | Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher. |
| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task` | `route_wake` | company issue create, child issue, assignment, and wake services | `company`<br>`task`<br>`actor`<br>`blockers`<br>`wake`<br>`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. |
| [`bl` — Blockers](#behavior-group-bl-blockers) | always/optional tools + control plane | `block_task`<br>`set_dependencies` | `schedule_blocker_wake`<br>`route_wake` | issue relations, blocker projection, liveness validation, and blocker wake services | `task`<br>`blockers`<br>`wake`<br>`actor`<br>`audit`<br>`fault` | 5 | dependency diff, block receipt, attention routing, and issue-status decision | set_dependencies is production-bound for the active issue; block_task remains unbound, and cancelled-blocker receipts still need typed additive evidence. |
| [`dp` — Documents and plans](#behavior-group-dp-documents-and-plans) | always tools; restore optional; destructive lifecycle control-plane-only | `list_documents`<br>`read_document`<br>`list_document_revisions`<br>`write_document` | `append_audit_record` | issue document list/read/upsert/revision/restore/lock/unlock/delete routes | `task`<br>`documents`<br>`interactions`<br>`idempotency`<br>`audit`<br>`fault` | 3 | bounded reads and revision-safe write/conflict/denial receipts with revision lineage | restore_document_revision is an approved optional-tool gap; lock/unlock/delete are intentionally control-plane-only. |
@ -176,7 +179,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t
| [`ap` — Approvals](#behavior-group-ap-approvals) | optional governance tools + governed approver | `list_approvals`<br>`get_approval`<br>`get_approval_context`<br>`request_approval`<br>`decide_approval`<br>`comment_on_approval` | `route_wake`<br>`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`<br>`task`<br>`approvals`<br>`actor`<br>`wake`<br>`audit`<br>`idempotency` | 6 | governed semantic receipts, audit references, and attention/continuation linkage | Production binding and additive governed-action receipts are unbound; board-only authority stays outside grants. |
| [`ar` — Artifacts](#behavior-group-ar-artifacts) | always tools + artifact/work-product services | `register_deliverable` | `append_audit_record` | attachment upload and issue work-product routes | `task`<br>`artifacts`<br>`workProducts`<br>`workspace`<br>`audit`<br>`idempotency` | 4 | artifact/work-product reference and durable inspectability receipt; never binary bytes | Production upload/register composite and additive durable-reference receipt are unbound. |
| [`er` — Errors and critical rules](#behavior-group-er-errors-and-critical-rules) | runner/control plane + optional workspace/wake tools | `get_workspace_runtime`<br>`control_workspace_service`<br>`schedule_wake`<br>`inspect_operation_result` | `release_task`<br>`enforce_budget`<br>`persist_run`<br>`replay_run`<br>`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`<br>`budget`<br>`run`<br>`wake`<br>`audit`<br>`idempotency`<br>`fault` | 9 | runtime/workspace/attention/run lifecycle, typed denials, replay facts, and terminal causality | Budget stop reasons and semantic denial/conflict receipts require additive v1 envelopes; inspect_operation_result remains scenario-only. |
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api` | `append_audit_record` | case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | These operations are scenario-only except generic_api_request, which is test-only; broad administer_company is deferred and cannot claim product coverage. Production escape-hatch and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
| [`rf` — Reference files](#behavior-group-rf-reference-files) | optional domain tools + test-only escape hatch | `list_cases`<br>`upsert_case`<br>`list_routines`<br>`manage_routine`<br>`list_company_skills`<br>`sync_company_skills`<br>`list_secret_metadata`<br>`read_secret_value`<br>`export_company`<br>`administer_company`<br>`generic_api_request`<br>`search_api`<br>`call_api`<br>`create_project` | `append_audit_record` | project, case, routine, company-skill, secret, portability, and administration services | `company`<br>`cases`<br>`routines`<br>`skills`<br>`secrets`<br>`audit`<br>`fault` | 22 | bounded domain projections, redacted broker receipts, company diffs, and audit references | Project creation and API tools require the live server authority; generic_api_request is test-only and the remaining domain operations are scenario-only. Broad administer_company is deferred and cannot claim product coverage. Production API and paired dedicated-tool regressions are recorded separately in paperclip-evals/evals/runner-api-tools; the legacy scenario count is not evidence of that coverage. |
| [`mh` — Multi-hop](#behavior-group-mh-multi-hop) | composed semantic operations + control-plane continuation | `create_task`<br>`set_dependencies`<br>`request_human_input`<br>`request_approval`<br>`register_deliverable` | `route_wake`<br>`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`<br>`blockers`<br>`interactions`<br>`approvals`<br>`artifacts`<br>`wake`<br>`run`<br>`audit` | 4 | correlated operation receipts, state diffs, attention hops, work assessment, status decision, and terminal outcome | No generic transaction tool is allowed; shared mock/real conformance must prove each composed effect. |
| [`rs` — Restraint and no-call](#behavior-group-rs-restraint-and-no-call) | policy/exposure layer | `answer_status_question`<br>`read_secret_value`<br>`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`<br>`task`<br>`budget`<br>`secrets`<br>`audit`<br>`fault` | 3 | absence of forbidden effects plus typed policy denial/redaction receipts when a call is attempted | Typed redaction/authorization receipts need additive v1 evidence; generic_api_request is never a product fallback. |
| [`wk` — Wake situations](#behavior-group-wk-wake-situations) | control plane + always context/history tools | `get_task_context`<br>`get_task_history`<br>`schedule_wake` | `select_work`<br>`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`<br>`task`<br>`comments`<br>`interactions`<br>`approvals`<br>`blockers`<br>`run` | 8 | attention request routing/resolution plus resumed session/run causality | Production scheduling binding is unbound; control-plane routing remains non-callable. |
@ -453,10 +456,10 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston
### Catalog split and deliberate replacement
- Scenario/eval catalog: **37** operations.
- Live dispatcher catalog: **30** operations.
- Shared: **24**; union/canonical authority: **43**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_projects`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live-only: `call_api`, `get_agent`, `get_approval`, `get_approval_context`, `schedule_wake`, `search_api`.
- Live dispatcher catalog: **33** operations.
- Shared: **25**; union/canonical authority: **45**.
- Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`.
- Live-only: `call_api`, `create_project`, `get_agent`, `get_approval`, `get_approval_context`, `list_project_repositories`, `schedule_wake`, `search_api`.
- The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision.
- `generic_api_request` stays exported only for controlled tests and cannot be cited as real-surface, mock-parity, or PRP product coverage.

View File

@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
import Ajv2020 from "ajv/dist/2020.js";
import { describe, expect, it } from "vitest";
import { createTaskAction } from "../protocol-actions/create-task.js";
import { createProjectAction } from "../protocol-actions/create-project.js";
import {
PAPERCLIP_SEMANTIC_ACTION_CATALOG,
@ -19,6 +20,23 @@ const packageRoot = resolve(
);
describe("semantic action catalog", () => {
it("limits project repository URLs to HTTPS GitHub repository paths on both tool surfaces", () => {
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
for (const schema of [createProjectAction.live.descriptor.inputSchema, paperclipSemanticAction("create_project")!.inputSchema]) {
const validate = ajv.compile(schema);
const input = { name: "Project", idempotencyKey: "create-project-1" };
expect(validate({ ...input, repositoryUrls: ["https://github.com/org/repo", "https://github.com/org/other.git/"] })).toBe(true);
for (const url of [
"http://github.com/org/repo", "file:///etc/passwd", "data:text/plain,repo",
"https://localhost/org/repo", "https://127.0.0.1/org/repo", "https://10.0.0.1/org/repo",
"https://github.com.evil.test/org/repo", "https://token@github.com/org/repo",
"https://github.com:8443/org/repo", "https://github.com/org/repo?token=secret",
"https://github.com/org/repo#fragment", "https://github.com/org/repo/tree/main",
"https://github.com/../repo", "https://github.com/org/..",
]) expect(validate({ ...input, repositoryUrls: [url] }), url).toBe(false);
}
});
it("accepts project handoff receipts and preserves ordinary child task receipts", () => {
const ajv = new Ajv2020({ allErrors: true, allowUnionTypes: true, strict: true });
const validate = ajv.compile(createTaskAction.live.descriptor.outputSchema);

View File

@ -6,6 +6,7 @@ import type {
} from "./semantic-action-types.js";
import { searchApiAction } from "../protocol-actions/search-api.js";
import { callApiAction } from "../protocol-actions/call-api.js";
import { projectRepositoryUrlSchema } from "../protocol-actions/create-project.js";
const ALL_MODES = ["standard", "ask", "planning", "skill_test"] as const;
const WORK_MODES = ["standard", "planning", "skill_test"] as const;
@ -451,7 +452,10 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
inputSchema: object({
...idempotency, name: text("Project name.", 500), description: nullableText("Project outcome and context."),
repositoryIds: stringArray("Authorized repository IDs from list_project_repositories; may contain multiple repositories."),
repositoryUrls: stringArray("Existing HTTPS GitHub repository URLs, including repos absent from the catalog."),
repositoryUrls: {
type: "array", items: projectRepositoryUrlSchema, maxItems: 100, uniqueItems: true,
description: "Existing HTTPS GitHub repository URLs, including repos absent from the catalog.",
},
workspace: openObject, status: { enum: ["backlog", "planned", "in_progress", "completed", "cancelled"] },
goalId: nullableText("Goal ID."), goalIds: stringArray("Goal IDs."), leadAgentId: nullableText("Lead agent ID."),
targetDate: nullableText("Target date."), color: nullableText("Project color."), icon: nullableText("Project icon."),

View File

@ -466,13 +466,13 @@ describe("workflow reports and stress traceability", () => {
candidateFailures: 36,
});
expect(report.coverage).toMatchObject({
canonicalOperations: 43,
canonicalOperations: 45,
capabilityCases: 106,
workflows: 12,
stressFindings: 44,
stressExclusions: 1,
});
expect(report.coverage.operations).toHaveLength(43);
expect(report.coverage.operations).toHaveLength(45);
expect(report.coverage.composedWorkflows).toHaveLength(12);
expect(
report.coverage.operations.find(
@ -480,7 +480,7 @@ describe("workflow reports and stress traceability", () => {
)?.workflowIds.length,
).toBeGreaterThan(0);
expect(renderRunnerWorkflowMarkdown(report)).toContain(
"43 operations · 106 capability cases · 12 workflows",
"45 operations · 106 capability cases · 12 workflows",
);
expect(renderRunnerWorkflowJUnit(report)).toContain(
'tests="36" failures="36" skipped="0"',

View File

@ -1,3 +1,10 @@
/** Existing GitHub repository references, never arbitrary network/resource URIs. */
export const projectRepositoryUrlSchema = {
type: "string",
maxLength: 2000,
pattern: "^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$",
} as const;
/** Canonical project tool definition. */
export const createProjectAction = {
"id": "create_project",
@ -173,10 +180,7 @@ export const createProjectAction = {
},
"repositoryUrls": {
"type": "array",
"items": {
"type": "string",
"format": "uri"
},
"items": projectRepositoryUrlSchema,
"maxItems": 100,
"description": "Existing HTTPS GitHub repository URLs, including repos absent from the catalog."
}

View File

@ -181,7 +181,18 @@ export class CapabilitySemanticDispatcher {
return {
...createCapabilitySemanticPolicyContext(
context,
scenario,
{
...scenario,
// These descriptors belong to the server's authenticated project
// authority. This mock command port has no project/repository binding;
// it must neither advertise nor accept them merely for lacking claims.
denyOperations: [...new Set([
...(scenario.denyOperations ?? []),
"create_project" as const,
"list_project_repositories" as const,
"list_projects" as const,
])],
},
this.options.explicitClaims ?? context.capabilities,
),
runId,

View File

@ -53,7 +53,7 @@ describe("Capability semantic catalog and authorization", () => {
it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => {
const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId);
expect(new Set(names).size).toBe(names.length);
expect(names).toHaveLength(30);
expect(names).toHaveLength(33);
expect(names).toContain("get_task_context");
expect(names).toContain("finish_task");
expect(names).not.toContain("checkout_task");
@ -110,6 +110,15 @@ describe("Capability semantic catalog and authorization", () => {
const found = dispatcher.discoverTools(OPEN.identity.runId, "create child task approval secret admin");
expect(found.operations).toEqual([]);
expect(JSON.stringify(found.operations)).not.toMatch(/create_task|approval|secret|administer_company/);
const before = adapter.snapshot().revision;
for (const operationId of ["create_project", "list_project_repositories", "list_projects"] as const) {
expect(dispatcher.listTools(OPEN.identity.runId).map((tool) => tool.name)).not.toContain(operationId);
expect(await dispatcher.dispatch({
runId: OPEN.identity.runId, callId: `unbound-${operationId}`, operationId,
input: operationId === "create_project" ? { name: "Unbound", idempotencyKey: "unbound-project" } : {},
})).toMatchObject({ ok: false, denial: { code: "scenario_denied" } });
}
expect(adapter.snapshot().revision).toBe(before);
});
it("executes a granted optional operation through the mock port", async () => {

View File

@ -13,6 +13,7 @@ import { randomUUID } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
activityLog,
authUsers,
agents,
agentTaskSessions,
@ -236,6 +237,21 @@ const support = await getEmbeddedPostgresTestSupport();
).status,
).toBe(422);
const chatId = resolved[0].body.id;
for (const body of ["Hello", "/new"]) {
expect((await request(appFor(colleague)).post(`/api/issues/${chatId}/comments`)
.send({ body, clientRequestId: randomUUID() })).status).toBe(403);
}
expect((await request(appFor(colleague))
.post(`/api/companies/${companyId}/issues/${chatId}/attachments`)
.attach("file", Buffer.from("foreign upload"), "note.txt")).status).toBe(403);
const [planReview] = await db.insert(issueThreadInteractions).values({
companyId, issueId: chatId, kind: "request_confirmation", status: "pending",
continuationPolicy: "wake_assignee_on_accept", payload: { version: 1, prompt: "Hand off this plan?" },
}).returning();
expect((await request(appFor(colleague))
.post(`/api/issues/${chatId}/interactions/${planReview.id}/accept`).send({})).status).toBe(403);
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.id, planReview.id)))[0].status).toBe("pending");
const [pause] = await db.insert(issueTreeHolds).values({
companyId, rootIssueId: chatId, mode: "pause", status: "active",
createdByActorType: "user", createdByUserId: owner,
@ -245,9 +261,17 @@ const support = await getEmbeddedPostgresTestSupport();
.send({ body: "Continue working", clientRequestId: randomUUID() });
expect(blockedSend.status).toBe(409);
expect(await db.select().from(issueComments).where(eq(issueComments.issueId, chatId))).toHaveLength(0);
const reset = await request(app).post(`/api/issues/${chatId}/comments`)
.send({ body: "/new", clientRequestId: randomUUID() });
const resetRequest = { body: "/new", clientRequestId: randomUUID() };
const reset = await request(app).post(`/api/issues/${chatId}/comments`).send(resetRequest);
expect(reset.status).toBe(201);
const retriedResets = await Promise.all(Array.from({ length: 3 }, () =>
request(app).post(`/api/issues/${chatId}/comments`).send(resetRequest)));
expect(retriedResets.every((response) => response.status === 201 && response.body.id === reset.body.id)).toBe(true);
const addedEvents = await db.select().from(activityLog).where(and(
eq(activityLog.entityId, chatId), eq(activityLog.action, "issue.comment_added"),
));
expect(addedEvents).toHaveLength(1);
expect((await db.select().from(issueTreeHolds).where(eq(issueTreeHolds.id, pause.id)))[0].status).toBe("released");
const local = await request(appFor()).post(path);
expect(local.body.conversationUserId).toBe("local-board");
@ -577,7 +601,7 @@ const support = await getEmbeddedPostgresTestSupport();
runningProcesses.delete(active.id);
}
});
it("runs real process turns, processes /new without invocation, and leaves the chat idle", async () => {
it.each([false, true])("runs real process turns and resets without replaying pre-Stop queued input (queued=%s)", async (queuedBeforeStop) => {
const runtimeCompany = randomUUID();
const runtimeAgent = randomUUID();
await db
@ -585,7 +609,7 @@ const support = await getEmbeddedPostgresTestSupport();
.values({
id: runtimeCompany,
name: "Runtime chat",
issuePrefix: "RCHAT",
issuePrefix: queuedBeforeStop ? "RCHATQ" : "RCHAT",
requireBoardApprovalForNewAgents: false,
});
const generations: unknown[] = [];
@ -682,6 +706,19 @@ const support = await getEmbeddedPostgresTestSupport();
await db.insert(issueThreadInteractions).values({ companyId: runtimeCompany, issueId: chat.id,
kind: "ask_user_questions", status: "pending", title: "Old topic", payload: { version: 1, questions: [{ id: "old", prompt: "Old topic?", options: [{ id: "yes", label: "Yes" }], selectionMode: "single", required: true }], supersedeOnUserComment: false },
});
let stoppedQueuedWakeId: string | null = null;
if (queuedBeforeStop) {
const [pending] = await db.insert(issueComments).values({ companyId: runtimeCompany,
issueId: chat.id, authorUserId: "local-board", body: "Old topic queued before Stop",
}).returning();
const [stoppedQueuedWake] = await db.insert(agentWakeupRequests).values({ companyId: runtimeCompany, agentId: runtimeAgent,
source: "on_demand", reason: "issue_execution_deferred", status: "deferred_issue_execution",
requestedByActorType: "user", requestedByActorId: "local-board",
payload: { issueId: chat.id, commentId: pending.id,
_paperclipWakeContext: { issueId: chat.id, wakeReason: "issue_commented", wakeCommentId: pending.id, wakeCommentIds: [pending.id] } },
}).returning();
stoppedQueuedWakeId = stoppedQueuedWake.id;
}
await send("/new");
await waitIdle();
expect((await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, chat.id)))[0].status).toBe("expired");
@ -690,6 +727,11 @@ const support = await getEmbeddedPostgresTestSupport();
await send("A fresh idea");
await waitIdle();
expect(generations).toEqual([0, 1]);
if (stoppedQueuedWakeId) {
const [stoppedWake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, stoppedQueuedWakeId));
expect(stoppedWake).toMatchObject({ status: "cancelled", runId: null });
}
expect(
await heartbeat.wakeup(runtimeAgent, {
source: "automation",
@ -700,7 +742,7 @@ const support = await getEmbeddedPostgresTestSupport();
.select()
.from(issueComments)
.where(eq(issueComments.issueId, chat.id));
expect(history).toHaveLength(5);
expect(history).toHaveLength(queuedBeforeStop ? 6 : 5);
} finally {
await new Promise<void>((resolve) => listener.close(() => resolve()));
await rm(cwd, { recursive: true, force: true });

View File

@ -6710,13 +6710,20 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
await vi.waitFor(async () => expect((await heartbeat.getRun(next!.id))?.status).not.toBe("running"));
});
it.each(["dedicated deferred donor", "non-coalescing recipient"] as const)(
it.each(["dedicated deferred donor", "non-coalescing recipient", "persistent agent conversation"] as const)(
"does not adopt unrelated queued comments for a %s after Stop",
async (direction) => {
const { companyId, agentId, issueId, runId } = await seedRunFixture({
runtimeMode: "legacy",
agentStatus: "running",
});
const persistentConversation = direction === "persistent agent conversation";
if (persistentConversation) {
await instanceSettingsService(db).updateExperimental({ enableAgentChat: true });
await db.update(issues).set({
conversationAgentId: agentId, conversationUserId: "responsible-user", conversationState: "active",
}).where(eq(issues.id, issueId));
}
const heartbeat = heartbeatService(db);
const [pending, go] = await db
.insert(issueComments)
@ -6801,11 +6808,11 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
reason: "issue_commented",
requestedByActorType: "user",
requestedByActorId: "responsible-user",
...(dedicatedDonor ? {} : { allowRunCoalescing: false }),
...(direction === "non-coalescing recipient" ? { allowRunCoalescing: false } : {}),
payload: {
issueId,
commentId: go!.id,
...(dedicatedDonor
...(dedicatedDonor || persistentConversation
? {}
: { mutation: "interaction", ...interaction }),
},
@ -6813,12 +6820,12 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
issueId,
commentId: go!.id,
wakeReason: "issue_commented",
...(dedicatedDonor ? {} : interaction),
...(dedicatedDonor || persistentConversation ? {} : interaction),
},
});
expect(next).not.toBeNull();
expect(next?.contextSnapshot?.wakeCommentIds).toEqual([go!.id]);
if (!dedicatedDonor)
if (!dedicatedDonor && !persistentConversation)
expect(next?.contextSnapshot).toMatchObject(interaction);
const [retained] = await db
.select()
@ -6830,6 +6837,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
expect(retained?.payload).toEqual(deferredPayload);
} finally {
if (persistentConversation) await instanceSettingsService(db).updateExperimental({ enableAgentChat: false });
// Keep this fixture's parked donor from being scheduled during teardown.
await db
.update(agents)

View File

@ -1,3 +1,4 @@
import { currentConversationCommentCondition } from "../../../services/agent-conversations.js";
import { getExecutionBlocker } from "../../../services/execution-blocker.js";
import { and, asc, eq, inArray, isNull, notInArray, or, sql } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
@ -261,7 +262,7 @@ function buildTransaction(tx: Db, deps: WakeQueuePostgresAdapterDeps, db: Db, ru
const rows = await tx
.select({ id: issueComments.id, deletedAt: issueComments.deletedAt, createdByRunId: issueComments.createdByRunId })
.from(issueComments)
.where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), inArray(issueComments.id, queuedCommentIds)));
.where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), inArray(issueComments.id, queuedCommentIds), currentConversationCommentCondition()));
const targetsFinishingRunAgent = wakeAgentId === finishingRunAgentId;
const liveNonSelfCommentIds = queuedCommentIds.filter((commentId) => {
const row = rows.find((candidate) => candidate.id === commentId);

View File

@ -384,6 +384,50 @@ describe("releaseIssueExecution", () => {
expect(promotedContextSnapshot.acceptedPlanWakeRouting).toEqual({ targetAgentId: "agent-1" });
});
it.each(["done", "cancelled"])("cancels stale assignee continuations before claiming promotion on %s tasks", async (status) => {
const queue = [wakeCandidate({ agentId: ISSUE.assigneeAgentId!, requestedByActorType: "agent" })];
const transaction = createFakeTransaction({
findNextDeferredWake: vi.fn(async () => queue.shift() ?? null),
});
const release = createReleaseIssueExecution({
issueLock: createFakeIssueLock(createFakeHost(), transaction, { ...ISSUE, status }),
recovery: createFakeRecovery(),
});
const result = await release({ companyId: RUN.companyId, runId: RUN.id, now: new Date() });
expect(transaction.cancelDeferredWake).toHaveBeenCalledWith(expect.objectContaining({
wakeId: "wake-1",
reason: "Deferred execution wake no longer applies to a terminal task",
}));
expect(transaction.claimDeferredWakeForPromotion).not.toHaveBeenCalled();
expect(transaction.finalizePromotedWake).not.toHaveBeenCalled();
expect(result.outcome.kind).toBe("released");
});
it("reopens a completed task before promoting its assignee's human follow-up", async () => {
const queue = [wakeCandidate({
agentId: ISSUE.assigneeAgentId!,
requestedByActorType: "user",
deferredCommentIds: ["human-follow-up"],
})];
const transaction = createFakeTransaction({
findNextDeferredWake: vi.fn(async () => queue.shift() ?? null),
reopenIssue: vi.fn(async () => ({ ...ISSUE, status: "todo" })),
});
const release = createReleaseIssueExecution({
issueLock: createFakeIssueLock(createFakeHost(), transaction, { ...ISSUE, status: "done" }),
recovery: createFakeRecovery(),
});
const result = await release({ companyId: RUN.companyId, runId: RUN.id, now: new Date() });
expect(transaction.cancelDeferredWake).not.toHaveBeenCalled();
expect(transaction.reopenIssue).toHaveBeenCalledTimes(1);
expect(transaction.finalizePromotedWake).toHaveBeenCalledTimes(1);
expect(result.outcome.kind).toBe("promoted");
});
it("never reopens the issue when the promotion claim loses the race, and moves on to the next wake", async () => {
const doneIssue: IssueSnapshot = { ...ISSUE, status: "done" };
const queue = [

View File

@ -271,21 +271,8 @@ async function promoteDeferredWake(
postCommitEffects: PostCommitEffect[],
input: ReleaseIssueExecutionInput,
): Promise<ReleaseTransactionResult | null> {
// Claim the wake for promotion before any other write in this branch
// (design choice: claim first, then reopen). A reopen write, or its
// `issue_reopened` post-commit effect, must never survive a lost race on
// this compare-and-set. When the claim fails, a concurrent writer already
// changed the wake's status, so this candidate is gone; the caller moves
// on to the next one instead of ending the drain.
const claimedForPromotion = await ports.transaction.claimDeferredWakeForPromotion({
companyId: run.companyId,
wakeId: workingCandidate.id,
now: input.now,
});
if (!claimedForPromotion) return null;
let currentIssue = issue;
let shouldReopen = false;
if (
!workingCandidate.authorizedFailedChatRetry &&
workingCandidate.deferredCommentIds.length > 0 &&
@ -297,28 +284,56 @@ async function promoteDeferredWake(
finishingRunId: run.id,
commentIds: workingCandidate.deferredCommentIds,
});
const shouldReopen =
shouldReopen =
!selfAuthorship.allSelfAuthored &&
(workingCandidate.requestedByActorType === "user" ||
workingCandidate.wakeReason === "issue_reopened_via_comment");
if (shouldReopen) {
const reopened = await ports.transaction.reopenIssue({
companyId: run.companyId,
issueId: currentIssue.id,
}
// Agent continuations can outlive the work they addressed. Only a human
// reopen can revive assignee execution; other agents may still receive
// notifications about the closed task. Cancel before claiming promotion so
// the compare-and-set still sees the deferred wake.
if (
!shouldReopen &&
(currentIssue.status === "done" || currentIssue.status === "cancelled") &&
workingCandidate.agentId === currentIssue.assigneeAgentId
) {
await ports.transaction.cancelDeferredWake({
companyId: run.companyId,
wakeId: workingCandidate.id,
reason: "Deferred execution wake no longer applies to a terminal task",
now: input.now,
});
return null;
}
// Claim before reopening. A reopen write and its post-commit effect must
// never survive a lost race on this compare-and-set.
const claimedForPromotion = await ports.transaction.claimDeferredWakeForPromotion({
companyId: run.companyId,
wakeId: workingCandidate.id,
now: input.now,
});
if (!claimedForPromotion) return null;
if (shouldReopen) {
const reopened = await ports.transaction.reopenIssue({
companyId: run.companyId,
issueId: currentIssue.id,
runId: run.id,
});
if (reopened) {
postCommitEffects.push({
kind: "issue_reopened",
companyId: reopened.companyId,
agentId: invokableAgent.id,
runId: run.id,
issueId: reopened.id,
identifier: reopened.identifier,
reopenedFrom: currentIssue.status,
});
if (reopened) {
postCommitEffects.push({
kind: "issue_reopened",
companyId: reopened.companyId,
agentId: invokableAgent.id,
runId: run.id,
issueId: reopened.id,
identifier: reopened.identifier,
reopenedFrom: currentIssue.status,
});
currentIssue = reopened;
}
currentIssue = reopened;
}
}

View File

@ -5569,9 +5569,12 @@ export function issueRoutes(
async function getIssueThreadInteractionResolutionAuthorization(
req: Request,
res: Response,
issue: Parameters<typeof assertAgentIssueMutationAllowed>[2],
issue: Parameters<typeof assertAgentIssueMutationAllowed>[2] & { conversationAgentId?: string | null; conversationUserId?: string | null },
interactionId: string,
) {
if (issue.conversationAgentId && req.actor.type === "board" && req.actor.userId !== issue.conversationUserId) {
throw forbidden("Only the conversation owner can respond to chat interactions");
}
// Actor-only gates deliberately precede the interaction lookup. An actor
// outside the issue's trusted/watchdog scope must not learn whether an
// interaction id exists on that issue.
@ -16959,16 +16962,32 @@ export function issueRoutes(
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.actor.userId !== issue.conversationUserId) throw forbidden("Only the conversation owner can send messages or start a new session");
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,
const userId = req.actor.userId;
const publications: ActivityPublication[] = [];
const comment = await db.transaction(async (tx) => {
await tx.select({ id: issueRows.id }).from(issueRows).where(and(
eq(issueRows.id, issue.id), eq(issueRows.companyId, issue.companyId),
)).for("update");
const [existing] = await tx.select({ id: issueComments.id }).from(issueComments).where(and(
eq(issueComments.issueId, issue.id), eq(issueComments.authorUserId, userId),
eq(issueComments.clientRequestId, req.body.clientRequestId),
));
const saved = await svc.addComment(issue.id, req.body.body, { userId }, {
clientRequestId: req.body.clientRequestId, authorType: "user", attachmentIds: req.body.attachmentIds,
}, tx);
if (!existing) await logActivity(tx as unknown as Db, {
companyId: issue.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "issue.comment_added", entityType: "issue", entityId: issue.id,
details: { commentId: saved.id, identifier: issue.identifier },
}, publications);
return saved;
});
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 } });
for (const publication of publications) publishActivity(publication);
await issueReferencesSvc.syncComment(comment.id);
await deliverConversationComments(db, issue, heartbeat.wakeup);
res.status(201).json(comment);
@ -18132,6 +18151,9 @@ export function issueRoutes(
if (issue.conversationAgentId && req.actor.type === "board" && !(await instanceSettings.getExperimental()).enableAgentChat) {
throw notFound("Agent Chat is disabled");
}
if (issue.conversationAgentId && req.actor.type === "board" && req.actor.userId !== issue.conversationUserId) {
throw forbidden("Only the conversation owner can upload attachments");
}
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (
!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))

View File

@ -89,6 +89,21 @@ Create ordinary assigned tasks, never subtasks of this conversation. Give each t
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.`;
/** A reset keeps history visible, but parked input from a stopped session cannot become a new turn. */
export function currentConversationCommentCondition() {
return sql`not exists (
select 1 from ${issues} conversation_issue
join ${issueComments} conversation_boundary
on conversation_boundary.id = conversation_issue.conversation_boundary_comment_id
and conversation_boundary.company_id = conversation_issue.company_id
and conversation_boundary.issue_id = conversation_issue.id
where conversation_issue.id = ${issueComments.issueId}
and conversation_issue.company_id = ${issueComments.companyId}
and conversation_issue.conversation_agent_id is not null
and (${issueComments.createdAt}, ${issueComments.id}) < (conversation_boundary.created_at, conversation_boundary.id)
)`;
}
/** Runs under the normal issue execution lock, before any provider session is read. */
export async function prepareConversationTurn(
db: Db,

View File

@ -26579,7 +26579,7 @@ export function heartbeatService(
.then((rows) => rows[0]);
const pendingComments =
opts.allowRunCoalescing !== false &&
!isConversation(issue) && opts.allowRunCoalescing !== false &&
!(await getExecutionBlocker(tx as unknown as Db, issue.companyId, issue.id))
? await tx
.select()

View File

@ -12025,7 +12025,7 @@ export function issueService(db: Db) {
if (!issue) throw notFound("Issue not found");
if (issue.conversationAgentId && actor.userId && !(await instanceSettings.getExperimental()).enableAgentChat) {
if (issue.conversationAgentId && actor.userId && !(await instanceSettingsService(dbOrTx).getExperimental()).enableAgentChat) {
throw unprocessable("Agent Chat is disabled in Experimental settings");
}
const currentUserRedactionOptions = {

View File

@ -5053,10 +5053,12 @@ 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",
"uses provider turn completion without a semantic-result cutoff: chat=%s",
async (conversationMode) => {
state.execute.mockReset().mockImplementationOnce(async (options) => {
expect(options.semanticResultTerminalGraceMs).toBe(conversationMode ? 30_000 : undefined);
// The provider must finish streaming its reply after task tools return.
// A semantic-result grace timer would truncate that output.
expect(options).not.toHaveProperty("semanticResultTerminalGraceMs");
return {
result: { summary: "Reply completed" },
terminal: { runTerminalState: "succeeded" },

View File

@ -503,11 +503,10 @@ const recoveryFakeCodex = resolve(
bundle = createCapabilityRunnerdCodexTransport({
stateDirectory: root,
sourceCodexHome: home,
codexCommand: resolve(
import.meta.dirname,
"../../../../packages/paperclip-runner/runner/target/debug/fake-codex-app-server",
),
codexArgs: ["--state-file", join(scratch, "fake.json"), "--hold-turn"],
// The packaged runnerd does not imply local Rust test binaries exist.
// Reuse the credential-free provider fixture available in every checkout.
codexCommand: process.execPath,
codexArgs: [recoveryFakeCodex, join(scratch, "fake.json"), "16"],
prpIdentity: {
runId,
runnerInstanceId,

View File

@ -191,8 +191,10 @@ test("chat first open is read-only; concurrent first sends and retries share one
).toHaveLength(0);
const other = await context.newPage();
await other.goto(f.route);
await Promise.all([send(page, "First tab"), send(other, "Second tab")]);
await Promise.all([send(page, "Same first message"), send(other, "Same first message")]);
const issue = await idle(request, f.chatPath, 2);
const initialComments = await json(await request.get(`/api/issues/${issue.id}/comments`));
expect(initialComments.filter((comment: any) => !comment.authorAgentId && comment.body === "Same first message")).toHaveLength(2);
const resolved = await Promise.all(
Array.from({ length: 4 }, () =>
request.post(f.chatPath, { data: {} }).then(json),

View File

@ -602,6 +602,7 @@ interface IssueChatThreadProps {
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
) => Promise<void>;
onReviewConversation?: () => Promise<void>;
onCancelRun?: () => Promise<void>;

View File

@ -1713,6 +1713,18 @@ describe("TaskChatComposer", () => {
});
});
it("gives separate identical chat submissions separate receipt identities", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
render(<TaskChatComposer onAdd={onAdd} conversationMode workMode="standard" />);
typeText("Same message");
await act(async () => sendButton().click());
typeText("Same message");
await act(async () => sendButton().click());
expect(onAdd.mock.calls).toHaveLength(2);
expect(onAdd.mock.calls[0][4]).toEqual(expect.any(String));
expect(onAdd.mock.calls[1][4]).not.toBe(onAdd.mock.calls[0][4]);
});
describe("paused task takeover", () => {
it("allows only standalone /new to resume a paused conversation through the normal composer", async () => {
const onAdd = vi.fn().mockResolvedValue(undefined);
@ -1724,7 +1736,7 @@ describe("TaskChatComposer", () => {
typeText("/new");
expect(sendButton().disabled).toBe(false);
await act(async () => sendButton().click());
expect(onAdd).toHaveBeenCalledWith("/new", undefined, undefined);
expect(onAdd).toHaveBeenCalledWith("/new", undefined, undefined, undefined, expect.any(String));
});
it("preserves a typed draft and blocks sending until resume completes", async () => {

View File

@ -102,6 +102,7 @@ interface TaskChatComposerProps {
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
) => Promise<void> | void;
onStop?: () => Promise<void>;
stopPending?: boolean;
@ -970,7 +971,9 @@ export function TaskChatComposer({
.map((item) => item.attachmentId!),
),
];
if (attachmentIds.length > 0)
if (conversationMode)
await onAdd(fullBody, reopen, reassignment, attachmentIds.length ? attachmentIds : undefined, attemptId);
else if (attachmentIds.length > 0)
await onAdd(fullBody, reopen, reassignment, attachmentIds);
else await onAdd(fullBody, reopen, reassignment);
if (mountedTaskKey.current !== draftKey) return;

View File

@ -1,36 +1,8 @@
import { loadStructuredDraft, saveStructuredDraft } from "./composer-draft";
type PendingMessage = { body: string; id: string };
const pending = new Map<string, PendingMessage[]>();
const storageKey = (scope: string) => `paperclip:agent-chat-pending:${scope}`;
function read(scope: string): PendingMessage[] {
const stored = loadStructuredDraft<unknown>(
storageKey(scope),
pending.get(scope) ?? [],
);
return Array.isArray(stored)
? stored.filter(
(item): item is PendingMessage =>
typeof item?.body === "string" && typeof item?.id === "string",
)
: [];
}
function write(scope: string, messages: PendingMessage[]) {
pending.set(scope, messages);
saveStructuredDraft(storageKey(scope), messages);
}
/** Preserve retry identity alongside the draft across agent switches and reloads. */
export function chatMessageRequestId(scope: string, body: string): string {
const messages = read(scope);
const existing = messages.find((message) => message.body === body);
if (existing) return existing.id;
const id = crypto.randomUUID();
write(scope, [...messages.slice(-9), { body, id }]);
return id;
}
export function acknowledgeChatMessage(scope: string, id: string) {
write(
scope,
read(scope).filter((message) => message.id !== id),
);
/** Retire the legacy body-keyed retry cache; submission receipts now own identity. */
export function clearLegacyChatMessageRequests(scope: string) {
try {
localStorage.removeItem(`paperclip:agent-chat-pending:${scope}`);
} catch {
// Browser storage may be disabled.
}
}

View File

@ -22,6 +22,18 @@ describe("task draft upload receipts", () => {
contentPath: `/api/attachments/${id}/content`,
};
beforeEach(() => localStorage.clear());
it("keeps chat drafts and pending submission fences within the current tab", () => {
sessionStorage.clear();
const chatKey = "paperclip:agent-chat-draft:company:user:agent";
saveDraft(chatKey, "My chat draft");
saveDraftSubmission(chatKey, { attemptId: id, reviewed: false });
expect(loadDraft(chatKey)).toBe("My chat draft");
expect(loadDraftSubmission(chatKey)?.attemptId).toBe(id);
expect(localStorage.getItem(chatKey)).toBeNull();
expect(localStorage.getItem(`${chatKey}:submission:v1`)).toBeNull();
sessionStorage.clear();
expect(loadDraftSubmission(chatKey)).toBeNull();
});
it("retains a closed task-specific uncertainty marker and only settles the same attempt", () => {
saveDraftSubmission(key, { attemptId: id, reviewed: false });
expect(loadDraftSubmission(key)).toEqual({

View File

@ -1,18 +1,25 @@
/**
* Per-task composer draft persistence, shared by the chat composers.
*
* Draft text is kept in localStorage under the caller-provided key. All
* Ordinary task drafts use localStorage; agent chat drafts use tab-scoped
* sessionStorage under the caller-provided key. All
* access is guarded so disabled or full storage never throws into React.
* Empty drafts remove the text key. Uploaded receipt metadata has a separate,
* versioned task-keyed record; legacy text drafts remain plain strings.
*/
/** Debounce before a keystroke lands in localStorage. */
/** Debounce before a keystroke lands in browser storage. */
export const DRAFT_DEBOUNCE_MS = 800;
// Chat drafts and uncertain submissions belong to this browser tab. Sharing a
// submission fence across tabs prevents intentional concurrent conversation turns.
function draftStorage(draftKey: string): Storage {
return draftKey.startsWith("paperclip:agent-chat-draft:") ? sessionStorage : localStorage;
}
export function loadDraft(draftKey: string): string {
try {
return localStorage.getItem(draftKey) ?? "";
return draftStorage(draftKey).getItem(draftKey) ?? "";
} catch {
return "";
}
@ -27,23 +34,23 @@ export function saveDraft(draftKey: string, value: string, attemptId?: string) {
try {
if (!mayWriteDraft(draftKey, attemptId)) return;
if (value.trim()) {
localStorage.setItem(draftKey, value);
draftStorage(draftKey).setItem(draftKey, value);
} else {
localStorage.removeItem(draftKey);
draftStorage(draftKey).removeItem(draftKey);
}
} catch {
// Ignore localStorage failures.
// Ignore browser storage failures.
}
}
export function clearDraft(draftKey: string, attemptId?: string) {
try {
if (!mayWriteDraft(draftKey, attemptId)) return;
localStorage.removeItem(draftKey);
localStorage.removeItem(`${draftKey}:attachments:v1`);
localStorage.removeItem(`${draftKey}:submission:v1`);
draftStorage(draftKey).removeItem(draftKey);
draftStorage(draftKey).removeItem(`${draftKey}:attachments:v1`);
draftStorage(draftKey).removeItem(`${draftKey}:submission:v1`);
} catch {
// Ignore localStorage failures.
// Ignore browser storage failures.
}
}
@ -58,7 +65,7 @@ export function loadDraftSubmission(
draftKey: string,
): ComposerDraftSubmission | null {
try {
const raw = localStorage.getItem(`${draftKey}:submission:v1`);
const raw = draftStorage(draftKey).getItem(`${draftKey}:submission:v1`);
if (!raw || raw.length > 2_048) return null;
const record = JSON.parse(raw);
return record?.version === 1 &&
@ -82,7 +89,7 @@ export function saveDraftSubmission(
// An old completion/review must not replace a different retained intent.
// This is a local guard, not cross-tab atomicity or server idempotency.
if (!mayWriteDraft(draftKey, submission.attemptId)) return;
localStorage.setItem(
draftStorage(draftKey).setItem(
`${draftKey}:submission:v1`,
JSON.stringify({ version: 1, draftKey, ...submission }),
);
@ -94,7 +101,7 @@ export function saveDraftSubmission(
export function clearDraftSubmission(draftKey: string, attemptId: string) {
try {
if (loadDraftSubmission(draftKey)?.attemptId === attemptId)
localStorage.removeItem(`${draftKey}:submission:v1`);
draftStorage(draftKey).removeItem(`${draftKey}:submission:v1`);
} catch {
/* Disabled browser storage is supported in memory. */
}
@ -158,7 +165,7 @@ export function loadDraftAttachments(
draftKey: string,
): ComposerDraftAttachment[] {
try {
const raw = localStorage.getItem(`${draftKey}:attachments:v1`);
const raw = draftStorage(draftKey).getItem(`${draftKey}:attachments:v1`);
if (!raw || raw.length > 32_768) return [];
const value: unknown = JSON.parse(raw);
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
@ -178,18 +185,18 @@ export function saveDraftAttachments(draftKey: string, attachments: unknown) {
if (!mayWriteDraft(draftKey)) return;
const selected = draftAttachments(attachments);
if (selected.length)
localStorage.setItem(
draftStorage(draftKey).setItem(
`${draftKey}:attachments:v1`,
JSON.stringify({ version: 1, draftKey, attachments: selected }),
);
else localStorage.removeItem(`${draftKey}:attachments:v1`);
else draftStorage(draftKey).removeItem(`${draftKey}:attachments:v1`);
} catch {
/* Disabled/full browser storage must not break the composer. */
}
}
export function loadStructuredDraft<T>(draftKey: string, fallback: T): T {
try {
const value = localStorage.getItem(draftKey);
const value = draftStorage(draftKey).getItem(draftKey);
return value ? (JSON.parse(value) as T) : fallback;
} catch {
return fallback;
@ -198,8 +205,8 @@ export function loadStructuredDraft<T>(draftKey: string, fallback: T): T {
export function saveStructuredDraft(draftKey: string, value: unknown) {
try {
localStorage.setItem(draftKey, JSON.stringify(value));
draftStorage(draftKey).setItem(draftKey, JSON.stringify(value));
} catch {
// Ignore localStorage failures.
// Ignore browser storage failures.
}
}

View File

@ -1,8 +1,5 @@
import {
acknowledgeChatMessage,
chatMessageRequestId,
} from "./chat-message-request";
// @vitest-environment jsdom
import { clearLegacyChatMessageRequests } from "./chat-message-request";
import { describe, expect, it } from "vitest";
import {
orderChatAgents,
@ -48,16 +45,12 @@ describe("agent chat navigation and session markers", () => {
JSON.parse(localStorage.getItem("paperclip.recentAgentChats:a:user2")!),
).toEqual(["agent4"]);
});
it("retains message retry identity until the server acknowledges it", () => {
const id = chatMessageRequestId("company:user:agent", "Same draft");
expect(chatMessageRequestId("company:user:agent", "Same draft")).toBe(id);
expect(
chatMessageRequestId("company:other-user:agent", "Same draft"),
).not.toBe(id);
acknowledgeChatMessage("company:user:agent", id);
expect(chatMessageRequestId("company:user:agent", "Same draft")).not.toBe(
id,
);
it("removes legacy plaintext retry records", () => {
const scope = "company:user:agent";
const key = `paperclip:agent-chat-pending:${scope}`;
localStorage.setItem(key, JSON.stringify([{ body: "private text", id: "old" }]));
clearLegacyChatMessageRequests(scope);
expect(localStorage.getItem(key)).toBeNull();
});
it("renders a processed /new as a divider without discarding earlier messages", () => {
const comment = {

View File

@ -1458,6 +1458,30 @@ describe("IssueDetail", () => {
expect(ensureIssue).toHaveBeenCalledTimes(1);
});
it("retries the chosen initial chat mode after creation succeeded but mode persistence failed", async () => {
mockIssuesApi.addComment.mockClear();
mockIssuesApi.update.mockClear();
const agent = createAgent();
const canonical = createIssue({ conversationAgentId: agent.id, conversationUserId: "user-1", conversationState: "waiting", status: "in_review", workMode: "standard" });
const ensureIssue = vi.fn().mockResolvedValue(canonical);
mockIssuesApi.update.mockRejectedValueOnce(new Error("Mode save failed")).mockResolvedValue({ ...canonical, workMode: "ask" });
mockIssuesApi.addComment.mockResolvedValue(createIssueComment({ body: "Research only" }));
const renderChat = async (issue: Issue | null) => {
await act(async () => root.render(<QueryClientProvider client={queryClient}><TaskDetailSurface conversation={{ agent, issue, ensureIssue }} /></QueryClientProvider>));
await flushReact();
return mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { onWorkModeChange: (mode: string) => Promise<void>; onAdd: (body: string) => Promise<void> };
};
let props = await renderChat(null);
await act(async () => props.onWorkModeChange("ask"));
props = mockIssueChatThreadRender.mock.calls.at(-1)?.[0];
await act(async () => { await expect(props.onAdd("Research only")).rejects.toThrow("Mode save failed"); });
expect(mockIssuesApi.addComment).not.toHaveBeenCalled();
props = await renderChat(canonical);
await act(async () => props.onAdd("Research only"));
expect(mockIssuesApi.update).toHaveBeenNthCalledWith(2, canonical.id, { workMode: "ask" });
expect(mockIssuesApi.addComment).toHaveBeenCalledOnce();
});
it("opens artifact cards in the shared gallery at the selected image without duplicating attachments", async () => {
mockIssuesApi.get.mockResolvedValue(createIssue());
mockIssuesApi.listAttachments.mockResolvedValue([

View File

@ -1,4 +1,4 @@
import { acknowledgeChatMessage, chatMessageRequestId } from "@/lib/chat-message-request";
import { clearLegacyChatMessageRequests } from "@/lib/chat-message-request";
import { agentChatDraft } from "@/lib/agent-chat-draft";
import { Settings as ChatSettings } from "lucide-react";
import { agentDetailHref } from "./agent-detail-navigation";
@ -1272,6 +1272,7 @@ type IssueDetailChatTabProps = {
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
) => Promise<void>;
onReviewConversation: () => Promise<void>;
onImageUpload: (file: File) => Promise<string>;
@ -2303,7 +2304,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
>
<EmailThreadProvider companyId={companyId} issueId={issueId}>
<ThreadComponent
key={issueId}
key={conversationMode ? draftKey : issueId}
{...(!classicTaskInterfaceEnabled ? { creationActivity: resolvedActivity } : {})}
initialHistoryPending={!!issueId && (
initialHistoryPending ||
@ -2843,7 +2844,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const issueId = conversation ? conversation.issue?.id : routeIssueId;
const [draftWorkMode, setDraftWorkMode] = useState<IssueWorkMode>("standard");
const draftIssue = useMemo(() => conversation ? agentChatDraft(conversation.agent, draftWorkMode) : undefined, [conversation?.agent, draftWorkMode]);
const messageRequestIds = useRef(new Map<string, string>());
const pendingDraftWorkMode = useRef<IssueWorkMode | null>(null);
const { companies, selectedCompanyId } = useCompany();
// Classic Task Interface remains the sole task-chat-vs-pre-chat switch from
// master. Streamlined UI only layers the new task-detail presentation onto
@ -2975,7 +2976,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const resolveWritableIssueId = async () => {
if (!conversation) return issueId!;
const resolved = await conversation.ensureIssue();
if (!conversation.issue && draftWorkMode !== resolved.workMode) await issuesApi.update(resolved.id, { workMode: draftWorkMode });
const requestedMode = pendingDraftWorkMode.current;
if (requestedMode !== null && requestedMode !== resolved.workMode) {
await issuesApi.update(resolved.id, { workMode: requestedMode });
}
pendingDraftWorkMode.current = null;
return resolved.id;
};
// A cached header seed can paint during navigation, but must not redirect
@ -4387,25 +4392,11 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
});
const addComment = useMutation({
mutationFn: ({
body,
reopen,
interrupt,
attachmentIds,
}: {
body: string;
reopen?: boolean;
interrupt?: boolean;
attachmentIds?: string[];
mutationFn: async ({ body, reopen, interrupt, attachmentIds, clientRequestId }: {
body: string; reopen?: boolean; interrupt?: boolean; attachmentIds?: string[]; clientRequestId?: string;
}) => {
const chatScope = issue?.conversationAgentId ? `${issue.companyId}:${currentUserId}:${issue.conversationAgentId}` : null;
const requestId = chatScope ? chatMessageRequestId(chatScope, body) : messageRequestIds.current.get(body) ?? crypto.randomUUID();
messageRequestIds.current.set(body, requestId);
return resolveWritableIssueId().then(id => issuesApi.addComment(id, body, reopen, interrupt, attachmentIds, requestId)).then(comment => {
messageRequestIds.current.delete(body);
if (chatScope) acknowledgeChatMessage(chatScope, requestId);
return comment;
});
if (issue?.conversationAgentId) clearLegacyChatMessageRequests(`${issue.companyId}:${currentUserId}:${issue.conversationAgentId}`);
return issuesApi.addComment(await resolveWritableIssueId(), body, reopen, interrupt, attachmentIds, clientRequestId ?? crypto.randomUUID());
},
onMutate: async ({ body, reopen, interrupt }) => {
// Start cache cancellation immediately but do not put it in front of the
@ -6227,6 +6218,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
clientRequestId?: string,
) => {
if (reassignment) {
await addCommentAndReassign.mutateAsync({
@ -6237,7 +6229,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
});
return;
}
await addComment.mutateAsync({ body, reopen, attachmentIds });
await addComment.mutateAsync({ body, reopen, attachmentIds, clientRequestId });
},
[addComment, addCommentAndReassign],
);
@ -7920,7 +7912,7 @@ export function TaskDetailSurface({ conversation, tasksTab }: { tasksTab?: TaskS
const currentMode: IssueWorkMode =
issue.workMode ?? "standard";
if (currentMode === nextMode) return;
if (conversation && !conversation.issue) { setDraftWorkMode(nextMode); return; }
if (conversation && (!conversation.issue || pendingDraftWorkMode.current !== null)) { pendingDraftWorkMode.current = nextMode; setDraftWorkMode(nextMode); return; }
return updateIssue
.mutateAsync({ workMode: nextMode })
.then(() => undefined);