diff --git a/cli/src/__tests__/worktree.test.ts b/cli/src/__tests__/worktree.test.ts
index 78815ebe36..e6b0505a7c 100644
--- a/cli/src/__tests__/worktree.test.ts
+++ b/cli/src/__tests__/worktree.test.ts
@@ -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 };
}
diff --git a/packages/db/src/agent-chat-migration.test.ts b/packages/db/src/agent-chat-migration.test.ts
index d27a42fd59..064a121fb9 100644
--- a/packages/db/src/agent-chat-migration.test.ts
+++ b/packages/db/src/agent-chat-migration.test.ts
@@ -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();
diff --git a/packages/db/src/migrations/0273_agent_chat.sql b/packages/db/src/migrations/0273_agent_chat.sql
index b5575bd7e4..b84afa4980 100644
--- a/packages/db/src/migrations/0273_agent_chat.sql
+++ b/packages/db/src/migrations/0273_agent_chat.sql
@@ -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
diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
index 30bc9e8336..3650245951 100644
--- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
+++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json
@@ -1 +1 @@
-[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a typed, durable interaction on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use exactly {version:1, questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]}; option keys are id/label, not value, and question choice cardinality is selectionMode, not type. For confirmation, payload may be {}. Keep all ids stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active mock task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active mock task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal mock task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active mock task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted mock actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted mock actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Mock actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search mock tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List mock approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one mock approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked mock tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task mock workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task mock workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Mock workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional mock service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed mock approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide a mock approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to a mock approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic mock continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"format":"uri","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}}]
+[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a typed, durable interaction on the active mock task.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use exactly {version:1, questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]}; option keys are id/label, not value, and question choice cardinality is selectionMode, not type. For confirmation, payload may be {}. Keep all ids stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register mock attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active mock task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active mock task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal mock task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active mock task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted mock actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted mock actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Mock actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search mock tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List mock approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one mock approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked mock tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task mock workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task mock workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Mock workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional mock service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed mock approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide a mock approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to a mock approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Mock approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic mock continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable mock command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Mock entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}}]
diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json
index 946b38de40..a08558336e 100644
--- a/packages/paperclip-runner/generated/semantic-action-catalog.json
+++ b/packages/paperclip-runner/generated/semantic-action-catalog.json
@@ -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
},
diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
index aef9364cae..a963f9949f 100644
--- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
+++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json
@@ -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"
diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json
index 954fc62fcf..eb9bbadd53 100644
--- a/packages/paperclip-runner/protocol/manifest.json
+++ b/packages/paperclip-runner/protocol/manifest.json
@@ -160,7 +160,7 @@
},
{
"path": "fixtures/evals/native-execution-seeded.json",
- "sha256": "89641b73df452a5d03502bc151a81a68387ece129c8826e0572800c3b1c5265c",
+ "sha256": "43bda8e713605d690a5e755f2d47eaea012d28fef81bd7dc787a5f9cacc507a7",
"expectation": "accept",
"compatibilityCase": "canonical"
},
diff --git a/packages/paperclip-runner/scripts/generate-semantic-contracts.mjs b/packages/paperclip-runner/scripts/generate-semantic-contracts.mjs
index 2f80ec1eb3..684e5a2bef 100644
--- a/packages/paperclip-runner/scripts/generate-semantic-contracts.mjs
+++ b/packages/paperclip-runner/scripts/generate-semantic-contracts.mjs
@@ -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`);
}
diff --git a/packages/paperclip-runner/spec/operation-groups/source.json b/packages/paperclip-runner/spec/operation-groups/source.json
index 19133ada3f..de088ece3a 100644
--- a/packages/paperclip-runner/spec/operation-groups/source.json
+++ b/packages/paperclip-runner/spec/operation-groups/source.json
@@ -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",
diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
index fb387a4759..7bd61080c9 100644
--- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
+++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md
@@ -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`
`list_agents`
`get_agent`
`list_projects`
`list_goals` | `discovery:agents:read`
`discovery:goals:read`
`discovery:projects:read`
`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. |
+| `projects` | `create_project`
`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. |
| `delegation_dependencies` | `create_task`
`set_dependencies` | `delegation:tasks:create`
`dependencies:write` | Create delegated work and maintain dependency edges. |
| `governance` | `list_approvals`
`get_approval`
`get_approval_context`
`request_approval`
`decide_approval`
`comment_on_approval` | `governance:approvals:comment`
`governance:approvals:decide`
`governance:approvals:read`
`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. |
| `cases` | `list_cases`
`upsert_case` | `cases:read`
`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`
`ask`
`planning`
`skill_test` | `company_write` | `none` | no | inline/no mapping | `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated PRP tool input/result and existing HTTP route authorization/activity records.
catalog PRP status: `bound` |
| `comment_on_approval` | `optional_agent_tool` | `governance:approvals:comment` | `standard`
`ask`
`planning`
`skill_test` | `governance` | `required` | no | `semantic_command:comment_on_approval` | `scenario` + `live`
`live_codex` | `unbound`
approval lifecycle plus governed-wait continuation and audit events
catalog PRP status: `audit_pending` |
| `control_workspace_service` | `optional_agent_tool` | `workspace:control` | `standard`
`skill_test` | `workspace_control` | `required` | no | `semantic_command:control_workspace_service` | `scenario` + `live`
`live_codex` | `unbound`
workspace service lifecycle event
catalog PRP status: `audit_pending` |
-| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`
`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`
`live_codex` | `issues.createChild`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` |
+| `create_project` | `optional_agent_tool` | none | `standard`
`skill_test` | `company_write` | `required` | no | inline/no mapping | `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.
catalog PRP status: `bound` |
+| `create_task` | `optional_agent_tool` | `delegation:tasks:create` | `standard`
`skill_test` | `company_write` | `required` | no | `semantic_command:create_task` | `scenario` + `live`
`live_codex` | `issues.create / issues.createChild`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` |
| `decide_approval` | `optional_agent_tool` | `governance:approvals:decide` | `standard`
`skill_test`
roles: `board`
`approver`
`security` | `governance` | `required` | no | `semantic_command:decide_approval` | `scenario` + `live`
`live_codex` | `unbound`
approval lifecycle plus governed-wait continuation and audit events
catalog PRP status: `audit_pending` |
| `export_company` | `optional_agent_tool` | `portability:export` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:portability.export` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` |
| `finish_task` | `always_agent_tool` | none | `standard`
`skill_test` | `task_write` | `required` | no | `semantic_command:finish_task` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
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`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document_revisions` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
| `list_documents` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:active_task_documents` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
| `list_goals` | `optional_agent_tool` | `discovery:goals:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:discovery.goals` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
-| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
+| `list_project_repositories` | `optional_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | inline/no mapping | `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.
catalog PRP status: `bound` |
+| `list_projects` | `optional_agent_tool` | `discovery:projects:read` | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `mock_extension:discovery.projects` | `scenario` + `live`
`live_codex` | `PaperclipRunnerToolAuthority`
Authenticated project tools, persisted projects and repository workspaces, and run-bound activity.
catalog PRP status: `bound` |
| `list_routines` | `optional_agent_tool` | `routines:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:routines.list` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
| `list_secret_metadata` | `optional_agent_tool` | `secrets:metadata:read` | `standard`
`skill_test` | `read` | `none` | no | `mock_extension:secrets.metadata` | `scenario`
`scenario_mock` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` |
| `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
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`
`actor`
`run`
`idempotency`
`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`
`finish_task`
`block_task`
`request_review` | `reconcile_run`
`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`
`comments`
`interactions`
`blockers`
`audit`
`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`
`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`
`comments`
`actor`
`idempotency`
`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`
`list_agents`
`get_agent`
`list_projects`
`list_goals` | none | company issue search and agent/project/goal list/get routes | `company`
`task`
`actor`
`project`
`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`
`list_agents`
`get_agent`
`list_projects`
`list_goals`
`list_project_repositories` | none | company issue search and agent/project/goal list/get routes | `company`
`task`
`actor`
`project`
`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`
`task`
`actor`
`blockers`
`wake`
`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`
`set_dependencies` | `schedule_blocker_wake`
`route_wake` | issue relations, blocker projection, liveness validation, and blocker wake services | `task`
`blockers`
`wake`
`actor`
`audit`
`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`
`read_document`
`list_document_revisions`
`write_document` | `append_audit_record` | issue document list/read/upsert/revision/restore/lock/unlock/delete routes | `task`
`documents`
`interactions`
`idempotency`
`audit`
`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`
`get_approval`
`get_approval_context`
`request_approval`
`decide_approval`
`comment_on_approval` | `route_wake`
`append_audit_record` | company approval, decision, issue-link, comment, and governed-action services | `company`
`task`
`approvals`
`actor`
`wake`
`audit`
`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`
`artifacts`
`workProducts`
`workspace`
`audit`
`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`
`control_workspace_service`
`schedule_wake`
`inspect_operation_result` | `release_task`
`enforce_budget`
`persist_run`
`replay_run`
`reconcile_run` | workspace runtime, monitor/recovery, budget, run persistence/replay, release, and terminal services | `workspace`
`budget`
`run`
`wake`
`audit`
`idempotency`
`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`
`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` | `append_audit_record` | case, routine, company-skill, secret, portability, and administration services | `company`
`cases`
`routines`
`skills`
`secrets`
`audit`
`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`
`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` | `append_audit_record` | project, case, routine, company-skill, secret, portability, and administration services | `company`
`cases`
`routines`
`skills`
`secrets`
`audit`
`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`
`set_dependencies`
`request_human_input`
`request_approval`
`register_deliverable` | `route_wake`
`reconcile_run` | delegation, dependency, interaction, approval, artifact, and terminal orchestration services | `task`
`blockers`
`interactions`
`approvals`
`artifacts`
`wake`
`run`
`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`
`read_secret_value`
`generic_api_request` | `enforce_budget` | task-mode, secret-broker, test-scope, pause, and budget policy checks | `actor`
`task`
`budget`
`secrets`
`audit`
`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`
`get_task_history`
`schedule_wake` | `select_work`
`route_wake` | wakeup requests, heartbeat context, comment/interaction/approval/blocker wake routing, and scheduled wake services | `wake`
`task`
`comments`
`interactions`
`approvals`
`blockers`
`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.
diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
index 48b2cbb9b0..213fa8d622 100644
--- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
+++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts
@@ -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);
diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
index 8b33ecf6ff..86c8cfb75a 100644
--- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
+++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts
@@ -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."),
diff --git a/packages/paperclip-runner/src/eval/workflow-evals.test.ts b/packages/paperclip-runner/src/eval/workflow-evals.test.ts
index 6ad8501468..58653ac45d 100644
--- a/packages/paperclip-runner/src/eval/workflow-evals.test.ts
+++ b/packages/paperclip-runner/src/eval/workflow-evals.test.ts
@@ -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"',
diff --git a/packages/paperclip-runner/src/protocol-actions/create-project.ts b/packages/paperclip-runner/src/protocol-actions/create-project.ts
index 53f0e11343..1b9141e746 100644
--- a/packages/paperclip-runner/src/protocol-actions/create-project.ts
+++ b/packages/paperclip-runner/src/protocol-actions/create-project.ts
@@ -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."
}
diff --git a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts
index 36af0db38d..0fbf36cfbf 100644
--- a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts
+++ b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts
@@ -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,
diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
index eb9d40e7ae..bc024a1f75 100644
--- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
+++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts
@@ -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 () => {
diff --git a/server/src/__tests__/agent-conversations.test.ts b/server/src/__tests__/agent-conversations.test.ts
index 5f64884c14..bce16757b7 100644
--- a/server/src/__tests__/agent-conversations.test.ts
+++ b/server/src/__tests__/agent-conversations.test.ts
@@ -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((resolve) => listener.close(() => resolve()));
await rm(cwd, { recursive: true, force: true });
diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts
index 2ffeab0ee3..561869c2aa 100644
--- a/server/src/__tests__/heartbeat-process-recovery.test.ts
+++ b/server/src/__tests__/heartbeat-process-recovery.test.ts
@@ -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)
diff --git a/server/src/modules/wake-queue/adapters/postgres.ts b/server/src/modules/wake-queue/adapters/postgres.ts
index 7e730a127e..28e7bbc7c3 100644
--- a/server/src/modules/wake-queue/adapters/postgres.ts
+++ b/server/src/modules/wake-queue/adapters/postgres.ts
@@ -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);
diff --git a/server/src/modules/wake-queue/application/use-cases.test.ts b/server/src/modules/wake-queue/application/use-cases.test.ts
index 3c4e355b1f..71de306851 100644
--- a/server/src/modules/wake-queue/application/use-cases.test.ts
+++ b/server/src/modules/wake-queue/application/use-cases.test.ts
@@ -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 = [
diff --git a/server/src/modules/wake-queue/application/use-cases.ts b/server/src/modules/wake-queue/application/use-cases.ts
index 4b63a422d5..b9817e53df 100644
--- a/server/src/modules/wake-queue/application/use-cases.ts
+++ b/server/src/modules/wake-queue/application/use-cases.ts
@@ -271,21 +271,8 @@ async function promoteDeferredWake(
postCommitEffects: PostCommitEffect[],
input: ReleaseIssueExecutionInput,
): Promise {
- // 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;
}
}
diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts
index ebf2cf3a2c..a3f7a34918 100644
--- a/server/src/routes/issues.ts
+++ b/server/src/routes/issues.ts
@@ -5569,9 +5569,12 @@ export function issueRoutes(
async function getIssueThreadInteractionResolutionAuthorization(
req: Request,
res: Response,
- issue: Parameters[2],
+ issue: Parameters[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))
diff --git a/server/src/services/agent-conversations.ts b/server/src/services/agent-conversations.ts
index d0b1bd3799..2c6eadfa8f 100644
--- a/server/src/services/agent-conversations.ts
+++ b/server/src/services/agent-conversations.ts
@@ -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,
diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts
index e5caee91df..c9b6e18c1f 100644
--- a/server/src/services/heartbeat.ts
+++ b/server/src/services/heartbeat.ts
@@ -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()
diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts
index d5560b7d4e..d15fb00a59 100644
--- a/server/src/services/issues.ts
+++ b/server/src/services/issues.ts
@@ -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 = {
diff --git a/server/src/services/native-runtime/native-session-executor.test.ts b/server/src/services/native-runtime/native-session-executor.test.ts
index 92e7026ab4..d4c68654ae 100644
--- a/server/src/services/native-runtime/native-session-executor.test.ts
+++ b/server/src/services/native-runtime/native-session-executor.test.ts
@@ -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" },
diff --git a/server/src/services/native-runtime/native-session-resume.test.ts b/server/src/services/native-runtime/native-session-resume.test.ts
index f0a348a8c4..07db32b2ff 100644
--- a/server/src/services/native-runtime/native-session-resume.test.ts
+++ b/server/src/services/native-runtime/native-session-resume.test.ts
@@ -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,
diff --git a/tests/e2e/agent-chat.spec.ts b/tests/e2e/agent-chat.spec.ts
index 7d6ad1d2c5..59c071fc9d 100644
--- a/tests/e2e/agent-chat.spec.ts
+++ b/tests/e2e/agent-chat.spec.ts
@@ -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),
diff --git a/ui/src/components/IssueChatThread.tsx b/ui/src/components/IssueChatThread.tsx
index 42f1e02911..49c49b34df 100644
--- a/ui/src/components/IssueChatThread.tsx
+++ b/ui/src/components/IssueChatThread.tsx
@@ -602,6 +602,7 @@ interface IssueChatThreadProps {
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
+ clientRequestId?: string,
) => Promise;
onReviewConversation?: () => Promise;
onCancelRun?: () => Promise;
diff --git a/ui/src/components/task-chat/TaskChatComposer.test.tsx b/ui/src/components/task-chat/TaskChatComposer.test.tsx
index 723ccd2432..c6b894ed0b 100644
--- a/ui/src/components/task-chat/TaskChatComposer.test.tsx
+++ b/ui/src/components/task-chat/TaskChatComposer.test.tsx
@@ -1713,6 +1713,18 @@ describe("TaskChatComposer", () => {
});
});
+ it("gives separate identical chat submissions separate receipt identities", async () => {
+ const onAdd = vi.fn().mockResolvedValue(undefined);
+ render();
+ 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 () => {
diff --git a/ui/src/components/task-chat/TaskChatComposer.tsx b/ui/src/components/task-chat/TaskChatComposer.tsx
index 9de78b136c..2b894a8cd9 100644
--- a/ui/src/components/task-chat/TaskChatComposer.tsx
+++ b/ui/src/components/task-chat/TaskChatComposer.tsx
@@ -102,6 +102,7 @@ interface TaskChatComposerProps {
reopen?: boolean,
reassignment?: CommentReassignment,
attachmentIds?: string[],
+ clientRequestId?: string,
) => Promise | void;
onStop?: () => Promise;
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;
diff --git a/ui/src/lib/chat-message-request.ts b/ui/src/lib/chat-message-request.ts
index be81f73866..be466c6c4a 100644
--- a/ui/src/lib/chat-message-request.ts
+++ b/ui/src/lib/chat-message-request.ts
@@ -1,36 +1,8 @@
-import { loadStructuredDraft, saveStructuredDraft } from "./composer-draft";
-
-type PendingMessage = { body: string; id: string };
-const pending = new Map();
-const storageKey = (scope: string) => `paperclip:agent-chat-pending:${scope}`;
-function read(scope: string): PendingMessage[] {
- const stored = loadStructuredDraft(
- 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.
+ }
}
diff --git a/ui/src/lib/composer-draft.test.ts b/ui/src/lib/composer-draft.test.ts
index 6d475ea9c8..215e8f8fcc 100644
--- a/ui/src/lib/composer-draft.test.ts
+++ b/ui/src/lib/composer-draft.test.ts
@@ -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({
diff --git a/ui/src/lib/composer-draft.ts b/ui/src/lib/composer-draft.ts
index e78eb49b8c..986398fafc 100644
--- a/ui/src/lib/composer-draft.ts
+++ b/ui/src/lib/composer-draft.ts
@@ -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(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(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.
}
}
diff --git a/ui/src/lib/recent-agent-chats.test.ts b/ui/src/lib/recent-agent-chats.test.ts
index 00b6df8d0a..c34030fc7d 100644
--- a/ui/src/lib/recent-agent-chats.test.ts
+++ b/ui/src/lib/recent-agent-chats.test.ts
@@ -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 = {
diff --git a/ui/src/pages/IssueDetail.test.tsx b/ui/src/pages/IssueDetail.test.tsx
index b09b99e02a..70a6939762 100644
--- a/ui/src/pages/IssueDetail.test.tsx
+++ b/ui/src/pages/IssueDetail.test.tsx
@@ -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());
+ await flushReact();
+ return mockIssueChatThreadRender.mock.calls.at(-1)?.[0] as { onWorkModeChange: (mode: string) => Promise; onAdd: (body: string) => Promise };
+ };
+ 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([
diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx
index 4242879a9b..5581ac76fd 100644
--- a/ui/src/pages/IssueDetail.tsx
+++ b/ui/src/pages/IssueDetail.tsx
@@ -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;
onReviewConversation: () => Promise;
onImageUpload: (file: File) => Promise;
@@ -2303,7 +2304,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
>
("standard");
const draftIssue = useMemo(() => conversation ? agentChatDraft(conversation.agent, draftWorkMode) : undefined, [conversation?.agent, draftWorkMode]);
- const messageRequestIds = useRef(new Map());
+ const pendingDraftWorkMode = useRef(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);