fix: preserve chat composer submissions and session state
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
commit
c0d2c2ed3e
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -160,7 +160,7 @@
|
|||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "89641b73df452a5d03502bc151a81a68387ece129c8826e0572800c3b1c5265c",
|
||||
"sha256": "43bda8e713605d690a5e755f2d47eaea012d28fef81bd7dc787a5f9cacc507a7",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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."),
|
||||
|
|
|
|||
|
|
@ -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"',
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -602,6 +602,7 @@ interface IssueChatThreadProps {
|
|||
reopen?: boolean,
|
||||
reassignment?: CommentReassignment,
|
||||
attachmentIds?: string[],
|
||||
clientRequestId?: string,
|
||||
) => Promise<void>;
|
||||
onReviewConversation?: () => Promise<void>;
|
||||
onCancelRun?: () => Promise<void>;
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue