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);