mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
Merge origin/master for agent chat hardening validation
Co-Authored-By: Paperclip <noreply@paperclip.ing> * origin/master: fix(runner): preserve task replies and warm process continuity (#13738)
This commit is contained in:
commit
360eb734af
23 files changed
+502
-42
No files matched your search
@@ -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 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 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 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 command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"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 command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"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","requiredClaimLine truncated
|
||||
[{"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 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 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 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 command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"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 command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"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","requiredClaimLine truncated
|
||||
@@ -66,7 +66,7 @@
|
||||
"planning",
|
||||
"skill_test"
|
||||
],
|
||||
"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.",
|
||||
"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. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.",
|
||||
"effect": "read",
|
||||
"inputSchema": {
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:3e8ece5670302fc3dac8782793f59f98f4992d78542565caae1ccb2a37cc0988",
|
||||
"catalogSha256": "sha256:73321806bf90df701a9677a7a9527c5b3d39485078e4d3fa4bb3a244a74a0c3b",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "09e442ca5f4adb9237d765601d8341f53b43607532390704cacd7c4167eb4e02",
|
||||
"sha256": "ab9247489ae2427fec5a86c54b55698ac3e211bd65d93cf4e973e363050455fa",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -840,9 +840,33 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut delayed_interrupt_terminal_scheduled = false;
|
||||
let mut answered_questions = 0u8;
|
||||
let mut replayed_completed_tool_calls = 0_u64;
|
||||
let mut rejected_helper_requests = 0_u8;
|
||||
|
||||
for line in io::stdin().lock().lines() {
|
||||
let message: Value = serde_json::from_str(&line?)?;
|
||||
if message.get("method").is_none()
|
||||
&& message
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| id.starts_with("helper-request-"))
|
||||
{
|
||||
if message.pointer("/result/success") != Some(&json!(false))
|
||||
&& message.get("error").is_none()
|
||||
{
|
||||
return Err("helper request was granted root authority".into());
|
||||
}
|
||||
rejected_helper_requests += 1;
|
||||
log_call(call_log.as_deref(), "helper-request:rejected")?;
|
||||
if rejected_helper_requests == 3 {
|
||||
send(
|
||||
json!({"id":"tool-request-1", "method":"item/tool/call", "params":{
|
||||
"threadId":state.thread_id, "turnId":state.active_turn_id,
|
||||
"callId":"root-after-helper", "tool":"get_task_context", "arguments":{}
|
||||
}}),
|
||||
)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if message.get("method").is_none()
|
||||
&& message
|
||||
.get("id")
|
||||
@@ -1447,6 +1471,32 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"method": "turn/started",
|
||||
"params": {"turn": {"id": provider_turn_id}}
|
||||
}))?;
|
||||
if args.iter().any(|value| value == "--helper-tool-requests") {
|
||||
if !args.iter().any(|value| value == "--foreign-helper-tool") {
|
||||
send(json!({"method":"item/completed", "params":{
|
||||
"threadId":state.thread_id, "turnId":provider_turn_id,
|
||||
"item":{"id":"spawn-helper", "type":"collabAgentToolCall",
|
||||
"tool":"spawnAgent", "status":"completed",
|
||||
"senderThreadId":state.thread_id, "receiverThreadIds":["helper-thread"]}
|
||||
}}))?;
|
||||
}
|
||||
for (index, tool) in ["get_task_context", "paperclip_finish"].iter().enumerate()
|
||||
{
|
||||
send(
|
||||
json!({"id":format!("helper-request-{index}"), "method":"item/tool/call", "params":{
|
||||
"threadId":"helper-thread", "turnId":"helper-turn",
|
||||
"callId":format!("helper-call-{index}"), "tool":tool, "arguments":{}
|
||||
}}),
|
||||
)?;
|
||||
}
|
||||
send(
|
||||
json!({"id":"helper-request-question", "method":"item/tool/requestUserInput", "params":{
|
||||
"threadId":"helper-thread", "turnId":"helper-turn", "itemId":"helper-question",
|
||||
"questions":[]
|
||||
}}),
|
||||
)?;
|
||||
continue;
|
||||
}
|
||||
if descendant_notifications {
|
||||
// Real Codex announces a child's MCP startup before its
|
||||
// thread/started notification establishes parent lineage.
|
||||
|
||||
@@ -1981,6 +1981,31 @@ impl CodexProvider {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reject_descendant_request(
|
||||
&mut self,
|
||||
rpc_id: Value,
|
||||
method: &str,
|
||||
) -> Result<Option<CodexProviderEvent>, LocalRunnerError> {
|
||||
// A recognized helper is part of the provider conversation, but has no
|
||||
// Paperclip task binding. Reject its RPC without borrowing root authority
|
||||
// or quarantining the root run. Unknown foreign threads still fail closed.
|
||||
let message = "Paperclip tools are authorized only for the parent task. Return your findings to the parent agent; it must perform Paperclip coordination and ask the user questions.";
|
||||
let response = if method == "item/tool/call" {
|
||||
json!({"id": rpc_id, "result": codex_tool_failure(message)})
|
||||
} else {
|
||||
json!({"id": rpc_id, "error": {"code": -32000, "message": message}})
|
||||
};
|
||||
self.send_frame(&response)?;
|
||||
if self.notification_identity_diagnostics >= 32 {
|
||||
return Ok(None);
|
||||
}
|
||||
self.notification_identity_diagnostics += 1;
|
||||
Ok(Some(CodexProviderEvent::Notification {
|
||||
method: "warning".to_owned(),
|
||||
params: json!({"message": message, "providerMethod": bounded_method(method)}),
|
||||
}))
|
||||
}
|
||||
|
||||
fn reject_post_terminal_request(
|
||||
&mut self,
|
||||
rpc_id: Value,
|
||||
@@ -2278,6 +2303,13 @@ impl CodexProvider {
|
||||
if method == "item/tool/call" {
|
||||
let params = message.get("params").cloned().unwrap_or(Value::Null);
|
||||
if params.get("threadId").and_then(Value::as_str) != Some(self.thread_id.as_str()) {
|
||||
if params
|
||||
.get("threadId")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| self.descendant_thread_ids.contains(id))
|
||||
{
|
||||
return self.reject_descendant_request(rpc_id, method);
|
||||
}
|
||||
return Ok(Some(self.identity_failure(
|
||||
method,
|
||||
¶ms,
|
||||
@@ -2402,6 +2434,13 @@ impl CodexProvider {
|
||||
&& params.get("threadId").and_then(Value::as_str)
|
||||
!= Some(self.thread_id.as_str())
|
||||
{
|
||||
if params
|
||||
.get("threadId")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|id| self.descendant_thread_ids.contains(id))
|
||||
{
|
||||
return self.reject_descendant_request(rpc_id, method);
|
||||
}
|
||||
return Ok(Some(self.identity_failure(
|
||||
method,
|
||||
¶ms,
|
||||
|
||||
@@ -654,6 +654,75 @@ fn rejected_codex_goal_activation_restores_turn_reconciliation() {
|
||||
fs::remove_dir_all(directory).expect("remove Codex integration-test directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helper_tool_requests_do_not_terminate_or_borrow_root_authority() {
|
||||
for foreign in [false, true] {
|
||||
let directory = temporary_directory("helper-tool-requests");
|
||||
let mut flags = vec!["--helper-tool-requests"];
|
||||
if foreign {
|
||||
flags.push("--foreign-helper-tool");
|
||||
}
|
||||
let config = provider_config(&directory, &flags);
|
||||
let mut provider =
|
||||
CodexProvider::start_with_tools(&config, [task_context_tool()], None).unwrap();
|
||||
provider
|
||||
.start_turn("Hire a persistent teammate.", &config.cwd)
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let mut completed = false;
|
||||
let mut root_tool_delivered = false;
|
||||
while std::time::Instant::now() < deadline {
|
||||
match provider.poll().unwrap() {
|
||||
Some(CodexProviderEvent::ProtocolFailure { diagnostic }) => {
|
||||
assert!(foreign, "recognized helper crashed the root: {diagnostic}");
|
||||
assert_eq!(diagnostic["code"], "thread_binding_mismatch");
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
Some(CodexProviderEvent::RuntimeRequest { .. }) => {
|
||||
panic!("helper borrowed root question authority")
|
||||
}
|
||||
Some(CodexProviderEvent::ToolCall {
|
||||
call_id,
|
||||
operation_id,
|
||||
..
|
||||
}) => {
|
||||
assert!(!foreign);
|
||||
assert_eq!(
|
||||
call_id, "root-after-helper",
|
||||
"helper borrowed root tool authority"
|
||||
);
|
||||
provider
|
||||
.deliver_tool_result(&ToolResult {
|
||||
call_id,
|
||||
operation_id,
|
||||
result: json!({"ok":true,"task":{"id":"task-1"}}),
|
||||
is_error: false,
|
||||
})
|
||||
.unwrap();
|
||||
root_tool_delivered = true;
|
||||
}
|
||||
Some(CodexProviderEvent::Notification { method, .. })
|
||||
if method == "turn/completed" =>
|
||||
{
|
||||
assert!(!foreign);
|
||||
assert!(root_tool_delivered);
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
_ => std::thread::sleep(std::time::Duration::from_millis(1)),
|
||||
}
|
||||
}
|
||||
assert!(completed, "helper requests did not settle");
|
||||
assert_eq!(
|
||||
call_count(&directory, "helper-request:rejected"),
|
||||
if foreign { 0 } else { 3 }
|
||||
);
|
||||
provider.shutdown().unwrap();
|
||||
fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_dynamic_tool_round_trips_through_the_provider_boundary() {
|
||||
let directory = temporary_directory("dynamic-tool");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const NATIVE_RUNTIME_ASSET_SCHEMA = "paperclip.runtime-asset.v1" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v3" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. Hire persistent teammates through Paperclip hiring; provider helper threads do not create Paperclip agents. Delegate with create_task. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When a task needs an external service, use installed tools if available; otherwise use connections_search to discover catalog services or authorized configured connections, then connection_request with the returned service identifier. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT_REVISION = "paperclip-execution.v4" as const;
|
||||
export const PAPERCLIP_EXECUTION_PROMPT = "You are running as a Paperclip agent. Complete the assigned task in the provided execution environment. Follow the attached agent instructions and use assigned skills and tools when relevant. Use Paperclip tools for coordination. To hire or reuse a persistent teammate, use list_agents, then search_api for agent-hires and call_api if a hire is needed. Provider helper threads do not create Paperclip agents. When the user assigns work or a revision to a teammate, use create_task with that agent's ID; review their result rather than doing their assigned work yourself. When remaining work depends on a child task, use set_dependencies to add its ID while preserving existing blocker IDs. Complete independent work, then call paperclip_block with the child agent as owner and child completion as the unblock action. End the turn so the child can use the workspace. Do not sleep or poll for child results while holding the workspace. Paperclip resumes the parent when the dependency completes. When a task needs an external service, use installed tools if available; otherwise use connections_search to discover catalog services or authorized configured connections, then connection_request with the returned service identifier. The request appears as a card in the task. Finish independent work before yielding for access; do not poll or request the same connection repeatedly. Paperclip will continue automatically with updated tools after resolution. After a decline, pursue alternatives unless the user explicitly asks to retry. Finish exactly once with `paperclip_finish` or `paperclip_block`." as const;
|
||||
|
||||
export interface NativeRuntimeAssetReference {
|
||||
schema: typeof NATIVE_RUNTIME_ASSET_SCHEMA;
|
||||
|
||||
@@ -28,7 +28,7 @@ export const searchApiAction = {
|
||||
},
|
||||
"documentation": {
|
||||
"title": "Search the Paperclip API",
|
||||
"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.",
|
||||
"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. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.",
|
||||
"note": "Production HTTP fallback; does not grant privileges or replace dedicated tools."
|
||||
},
|
||||
"examples": {
|
||||
@@ -51,7 +51,7 @@ export const searchApiAction = {
|
||||
"operationId": "search_api",
|
||||
"version": 1,
|
||||
"title": "Search the Paperclip API",
|
||||
"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.",
|
||||
"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. For a persistent hire, first list_agents to reuse a suitable teammate. Search agent-hires for the hiring schema and agent-configurations for compatible adapter/runtime settings, then use call_api with the returned operationId. Supply role-specific instructionsBundle.files as a filename-to-content record. Use the returned agent.id for delegation and obey any pending approval. Provider helper threads are temporary workers, not Paperclip hires. If a hire response is uncertain, reconcile with list_agents before retrying.",
|
||||
"exposure": "optional",
|
||||
"requiredClaims": [
|
||||
"api:discover"
|
||||
|
||||
@@ -902,6 +902,35 @@ describe("projectHistoricalHeartbeatRunComment", () => {
|
||||
});
|
||||
|
||||
describe("findHeartbeatRunCompletionComment", () => {
|
||||
it.each(["applied", "duplicate"])("does not let a %s file-preparation receipt hide the final reply", (disposition) => {
|
||||
const prepared = { id: "prepared-comment", body: "Prepared Continuity file for this response." };
|
||||
const explicit = { id: "explicit-comment", body: "An intentional agent reply." };
|
||||
const resultJson = {
|
||||
semanticToolReceipts: {
|
||||
file: {
|
||||
operationId: "register_deliverable",
|
||||
result: {
|
||||
commandId: "deliverable-prepared:attachment-1",
|
||||
disposition,
|
||||
attachmentId: "attachment-1",
|
||||
entityRefs: ["attachment-1", "work-product-1", prepared.id],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const existingComment = findHeartbeatRunCompletionComment([prepared], resultJson);
|
||||
expect(existingComment).toBeNull();
|
||||
expect(resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
existingComment,
|
||||
finalAgentMessage: { text: "PAPERCLIP_E2E_WARM_T3", sourceEventId: "final-event", channel: "final" },
|
||||
})).toMatchObject({ text: "PAPERCLIP_E2E_WARM_T3", decision: { commentAction: "create" } });
|
||||
// A final reply already materialized on a retry keeps precedence.
|
||||
expect(findHeartbeatRunCompletionComment([prepared, explicit], resultJson)).toEqual(explicit);
|
||||
// The body alone does not mark an ordinary agent comment as generated.
|
||||
expect(findHeartbeatRunCompletionComment([prepared], {})).toEqual(prepared);
|
||||
});
|
||||
|
||||
it("does not let semantic progress satisfy the final comment", () => {
|
||||
const progress = { id: "progress-comment" };
|
||||
const final = { id: "final-comment" };
|
||||
|
||||
@@ -285,6 +285,45 @@ describe("issue update comment wakeups", () => {
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["done", "cancelled"])(
|
||||
"keeps %s assignment-only updates from waking completed work",
|
||||
async (status) => {
|
||||
// A parent may restore the assignee after releasing a completed child.
|
||||
// That is recordkeeping, not a request to execute the child again.
|
||||
const existing = makeIssue({ status, assigneeAgentId: null, assigneeUserId: null });
|
||||
const updated = makeIssue({ status, assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null });
|
||||
mockIssueService.getById.mockResolvedValue(existing);
|
||||
mockIssueService.update.mockResolvedValue(updated);
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({ assigneeAgentId: ASSIGNEE_AGENT_ID });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toMatchObject({ status, assigneeAgentId: ASSIGNEE_AGENT_ID });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([false, true])("still wakes explicitly reopened work (reassigned: %s)", async (reassigned) => {
|
||||
const existing = makeIssue({ status: "done", assigneeAgentId: PREVIOUS_AGENT_ID, assigneeUserId: null });
|
||||
const agentId = reassigned ? ASSIGNEE_AGENT_ID : PREVIOUS_AGENT_ID;
|
||||
mockIssueService.getById.mockResolvedValue(existing);
|
||||
mockIssueService.update.mockResolvedValue(makeIssue({ status: "todo", assigneeAgentId: agentId, assigneeUserId: null }));
|
||||
|
||||
const res = await request(await createApp())
|
||||
.patch(`/api/issues/${existing.id}`)
|
||||
.send({ status: "todo", ...(reassigned ? { assigneeAgentId: agentId } : {}) });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
await vi.waitFor(() => expect(mockHeartbeatService.wakeup).toHaveBeenCalledTimes(1));
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(agentId, expect.objectContaining({
|
||||
reason: reassigned ? "issue_assigned" : "issue_status_changed",
|
||||
payload: expect.objectContaining({ issueId: existing.id }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("includes the new comment in assignment wakes from issue updates", async () => {
|
||||
const existing = makeIssue();
|
||||
const updated = makeIssue({
|
||||
|
||||
@@ -14551,6 +14551,9 @@ export function issueRoutes(
|
||||
assigneeChanged &&
|
||||
issue.assigneeAgentId &&
|
||||
issue.status !== "backlog" &&
|
||||
// Restoring an assignee on completed work is not a reopen request.
|
||||
// Explicit reopen/resume transitions are already reflected in issue.status.
|
||||
!isClosedIssueStatus(issue.status) &&
|
||||
deferWakeForGoal !== true
|
||||
) {
|
||||
addWakeup(issue.assigneeAgentId, {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { cleanupGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target";
|
||||
import type { CommandManagedRuntimeRunner } from "@paperclipai/adapter-utils/command-managed-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js";
|
||||
|
||||
const target = { kind: "remote" as const, transport: "sandbox" as const, providerKey: "daytona", remoteCwd: "/workspace" };
|
||||
|
||||
describe("heartbeat GitHub launcher lifetime", () => {
|
||||
it("keeps anonymous native sandbox launchers stable without issuing a run capability", async () => {
|
||||
const createBrokerToken = vi.fn(() => "run-secret");
|
||||
const prepareLaunchers = vi.fn(async (input) => input.env);
|
||||
const inputs = { native: true, githubConfigured: false, agentId: "agent-a", target,
|
||||
cwd: "/workspace", env: { GH_TOKEN: "ambient-secret" }, brokerUrl: "https://paperclip.test", createBrokerToken };
|
||||
const first = await prepareHeartbeatGitHubLaunchers({ ...inputs, runId: "run-one" }, prepareLaunchers);
|
||||
const second = await prepareHeartbeatGitHubLaunchers({ ...inputs, runId: "run-two" }, prepareLaunchers);
|
||||
expect(createBrokerToken).not.toHaveBeenCalled();
|
||||
expect(prepareLaunchers.mock.calls.map(([input]) => input.runId)).toEqual([expect.stringMatching(/^anonymous-agent-a-/), prepareLaunchers.mock.calls[0]?.[0].runId]);
|
||||
expect(first.cleanupLocation).toBeNull();
|
||||
expect(second.cleanupLocation).toBeNull();
|
||||
expect(first.env).toMatchObject({ GH_TOKEN: "", PAPERCLIP_GITHUB_BROKER_TOKEN: "", PAPERCLIP_GITHUB_BROKER_URL: "" });
|
||||
});
|
||||
it.each([false, true])("cleans partial run-scoped staging and preserves its error (cleanup fails: %s)", async (cleanupFails) => {
|
||||
const stagingError = new Error("remote launcher staging failed");
|
||||
const prepareLaunchers = vi.fn(async () => { throw stagingError; });
|
||||
const cleanupLaunchers = vi.fn(async () => {
|
||||
if (cleanupFails) throw new Error("cleanup unavailable");
|
||||
});
|
||||
await expect(prepareHeartbeatGitHubLaunchers({
|
||||
native: true, githubConfigured: true, agentId: "agent-a", target,
|
||||
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
|
||||
createBrokerToken: () => "current-run-secret",
|
||||
}, prepareLaunchers, cleanupLaunchers)).rejects.toBe(stagingError);
|
||||
expect(cleanupLaunchers).toHaveBeenCalledExactlyOnceWith({ runId: "failed-run", target });
|
||||
});
|
||||
|
||||
it("does not remove shared anonymous wrappers when staging a later run fails", async () => {
|
||||
const stagingError = new Error("remote launcher staging failed");
|
||||
const cleanupLaunchers = vi.fn(async () => undefined);
|
||||
await expect(prepareHeartbeatGitHubLaunchers({
|
||||
native: true, githubConfigured: false, agentId: "agent-a", target,
|
||||
runId: "failed-run", cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test",
|
||||
createBrokerToken: () => { throw new Error("must not mint a capability"); },
|
||||
}, async () => { throw stagingError; }, cleanupLaunchers)).rejects.toBe(stagingError);
|
||||
expect(cleanupLaunchers).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ native: true, githubConfigured: true, target },
|
||||
{ native: false, githubConfigured: false, target },
|
||||
{ native: true, githubConfigured: false, target: null },
|
||||
])("preserves run-scoped managed capabilities outside anonymous native sandboxes: %j", async (mode) => {
|
||||
const createBrokerToken = vi.fn(() => "current-run-secret");
|
||||
const prepareLaunchers = vi.fn(async (input) => input.env);
|
||||
const result = await prepareHeartbeatGitHubLaunchers({ ...mode, agentId: "agent-a", runId: "run-one",
|
||||
cwd: "/workspace", env: {}, brokerUrl: "https://paperclip.test", createBrokerToken }, prepareLaunchers);
|
||||
expect(createBrokerToken).toHaveBeenCalledOnce();
|
||||
expect(prepareLaunchers.mock.calls[0]?.[0].runId).toBe("run-one");
|
||||
expect(result.cleanupLocation).toEqual({ runId: "run-one", target: mode.target });
|
||||
expect(result.env.PAPERCLIP_GITHUB_BROKER_TOKEN).toBe("current-run-secret");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
it("keeps anonymous wrappers usable after run cleanup and excludes sandbox image credentials", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "native-anonymous-github-"));
|
||||
try {
|
||||
const bin = path.join(root, "bin");
|
||||
await mkdir(bin);
|
||||
await mkdir(path.join(root, ".config", "gh"), { recursive: true });
|
||||
await writeFile(path.join(root, ".config", "gh", "hosts.yml"), "ambient-image-secret");
|
||||
await writeFile(path.join(bin, "gh"), `#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
process.stdout.write(JSON.stringify({token:process.env.GH_TOKEN || '', githubToken:process.env.GITHUB_TOKEN || '', ssh:process.env.SSH_AUTH_SOCK, global:process.env.GIT_CONFIG_GLOBAL, imageConfig:fs.existsSync(process.env.GH_CONFIG_DIR + '/hosts.yml')}));`, { mode: 0o700 });
|
||||
const imageEnv = { HOME: root, PATH: `${bin}:${path.dirname(process.execPath)}:/usr/bin:/bin`, GH_TOKEN: "ambient-token", GITHUB_TOKEN: "ambient-other-token", SSH_AUTH_SOCK: "/ambient/socket" };
|
||||
const execute: CommandManagedRuntimeRunner["execute"] = async (input) => {
|
||||
const startedAt = new Date().toISOString();
|
||||
const child = promisify(execFile)(input.command, input.args ?? [], { cwd: input.cwd ?? root, env: { ...imageEnv, ...input.env }, timeout: 15_000 });
|
||||
child.child.stdin?.on("error", () => undefined);
|
||||
child.child.stdin?.end(input.stdin ?? "");
|
||||
const result = await child;
|
||||
return { ...result, exitCode: 0, signal: null, timedOut: false, pid: null, startedAt };
|
||||
};
|
||||
const sandboxTarget = { ...target, remoteCwd: root, runner: { execute } };
|
||||
const base = { native: true, githubConfigured: false, agentId: "agent-a", target: sandboxTarget,
|
||||
cwd: root, env: imageEnv, brokerUrl: "https://unused.invalid", createBrokerToken: () => { throw new Error("must not mint a capability"); } };
|
||||
const first = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-one" });
|
||||
await cleanupGitHubOperationLaunchers({ runId: "run-one", target: sandboxTarget });
|
||||
const second = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-two" });
|
||||
expect(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).toBe(second.env.PAPERCLIP_GITHUB_LAUNCHER_DIR);
|
||||
expect(await readFile(path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), "utf8")).not.toContain("ambient-token");
|
||||
// A still-live provider uses its first-turn environment, not the new one.
|
||||
const command = await execute({ command: path.join(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR, "gh"), env: first.env });
|
||||
expect(JSON.parse(command.stdout)).toEqual({ token: "", githubToken: "", ssh: "", global: "/dev/null", imageConfig: false });
|
||||
const changedPath = await prepareHeartbeatGitHubLaunchers({ ...base, runId: "run-three", env: { ...imageEnv, PATH: `${imageEnv.PATH}:/extra` } });
|
||||
expect(changedPath.env.PAPERCLIP_GITHUB_LAUNCHER_DIR).not.toBe(first.env.PAPERCLIP_GITHUB_LAUNCHER_DIR);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 20_000);
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
|
||||
import { cleanupGitHubOperationLaunchers, prepareGitHubOperationLaunchers } from "@paperclipai/adapter-utils/execution-target";
|
||||
|
||||
type LauncherInput = Parameters<typeof prepareGitHubOperationLaunchers>[0];
|
||||
|
||||
export async function prepareHeartbeatGitHubLaunchers(
|
||||
input: LauncherInput & {
|
||||
native: boolean;
|
||||
githubConfigured: boolean;
|
||||
agentId: string;
|
||||
brokerUrl: string;
|
||||
createBrokerToken: () => string;
|
||||
},
|
||||
prepareLaunchers = prepareGitHubOperationLaunchers,
|
||||
cleanupLaunchers = cleanupGitHubOperationLaunchers,
|
||||
) {
|
||||
// An unconfigured sandbox has no managed GitHub identity to broker. Its
|
||||
// token-free wrappers still isolate image credentials, but may live as long
|
||||
// as the workspace so a warm provider never inherits a deleted run path.
|
||||
// Configured identities keep their run-scoped capability/retirement rules.
|
||||
const anonymous = input.native && !input.githubConfigured &&
|
||||
input.target?.kind === "remote" && input.target.transport === "sandbox";
|
||||
const location = {
|
||||
runId: anonymous
|
||||
? `anonymous-${input.agentId}-${createHash("sha256").update(input.env.PATH ?? "").digest("hex").slice(0, 16)}`
|
||||
: input.runId,
|
||||
target: input.target,
|
||||
};
|
||||
const env = githubBrokerEnvironment(input.env, {
|
||||
url: anonymous ? "" : input.brokerUrl,
|
||||
token: anonymous ? "" : input.createBrokerToken(),
|
||||
});
|
||||
try {
|
||||
return {
|
||||
env: await prepareLaunchers({ ...location, cwd: input.cwd, env }),
|
||||
cleanupLocation: anonymous ? null : location,
|
||||
};
|
||||
} catch (error) {
|
||||
// The caller cannot retain a cleanup location until staging returns. Clean
|
||||
// partial run-specific files here, preserving the original staging failure.
|
||||
// Anonymous files may still belong to a live warm provider; never delete them.
|
||||
if (!anonymous) await cleanupLaunchers(location).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -565,22 +565,38 @@ export function findHeartbeatRunCompletionComment<T extends { id: string }>(
|
||||
return comments[0] ?? null;
|
||||
}
|
||||
|
||||
const progressCommentIds = new Set<string>();
|
||||
const generatedCommentIds = new Set<string>();
|
||||
for (const receipt of Object.values(receipts)) {
|
||||
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt))
|
||||
continue;
|
||||
const receiptRecord = receipt as Record<string, unknown>;
|
||||
if (receiptRecord.operationId !== "report_progress") continue;
|
||||
const result = receiptRecord.result;
|
||||
if (!result || typeof result !== "object" || Array.isArray(result))
|
||||
continue;
|
||||
const commentId = (result as Record<string, unknown>).commentId;
|
||||
if (typeof commentId === "string" && commentId.length > 0) {
|
||||
progressCommentIds.add(commentId);
|
||||
const receiptResult = result as Record<string, unknown>;
|
||||
if (receiptRecord.operationId === "report_progress") {
|
||||
const commentId = receiptResult.commentId;
|
||||
if (typeof commentId === "string" && commentId.length > 0) {
|
||||
generatedCommentIds.add(commentId);
|
||||
}
|
||||
} else if (
|
||||
receiptRecord.operationId === "register_deliverable" &&
|
||||
typeof receiptResult.attachmentId === "string" &&
|
||||
receiptResult.commandId === `deliverable-prepared:${receiptResult.attachmentId}` &&
|
||||
["applied", "duplicate"].includes(String(receiptResult.disposition)) &&
|
||||
Array.isArray(receiptResult.entityRefs)
|
||||
) {
|
||||
// File preparation binds an attachment to a generated comment. That
|
||||
// receipt is not the agent's final answer. Use the server-issued entity
|
||||
// references, never the comment's wording, to exclude its comment from
|
||||
// final-response deduplication. The attachment and binding stay intact.
|
||||
for (const ref of receiptResult.entityRefs) {
|
||||
if (typeof ref === "string") generatedCommentIds.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
comments.find((comment) => !progressCommentIds.has(comment.id)) ?? null
|
||||
comments.find((comment) => !generatedCommentIds.has(comment.id)) ?? null
|
||||
);
|
||||
}
|
||||
@@ -45,10 +45,9 @@ import {
|
||||
unadmittedChatWakeupCondition,
|
||||
type DurableChatWakeupRequest,
|
||||
} from "./durable-chat-wakeup.js";
|
||||
import { githubBrokerEnvironment } from "@paperclipai/adapter-utils/github-launcher";
|
||||
import { prepareHeartbeatGitHubLaunchers } from "./heartbeat-github-launchers.js";
|
||||
import {
|
||||
cleanupGitHubOperationLaunchers,
|
||||
prepareGitHubOperationLaunchers,
|
||||
prepareGitHubExecutionEnvironment,
|
||||
startAdapterExecutionTargetPaperclipBridge,
|
||||
} from "@paperclipai/adapter-utils/execution-target";
|
||||
@@ -22270,27 +22269,25 @@ export function heartbeatService(
|
||||
for (const key of MANAGED_GITHUB_TOKEN_KEYS) secretKeys.add(key);
|
||||
context.githubAuthenticationMode = useHostGitHub ? "host" : "managed";
|
||||
if (!useHostGitHub) {
|
||||
const githubBrokerToken = createRuntimeToolsToken({
|
||||
const githubLaunchers = await prepareHeartbeatGitHubLaunchers({
|
||||
native: agent.adapterType === "paperclip_runner",
|
||||
githubConfigured: githubSelection.configured,
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
responsibleUserId: responsibleUserId ?? "",
|
||||
scope: "github_credentials",
|
||||
});
|
||||
const githubBrokerEnv = githubBrokerEnvironment(gitExecutionEnv, {
|
||||
url: configuredPaperclipApiBaseUrl() ?? "",
|
||||
token: githubBrokerToken?.token ?? "",
|
||||
});
|
||||
githubLauncherLocation = { runId: run.id, target: executionTarget };
|
||||
runtimeConfig = {
|
||||
...runtimeConfig,
|
||||
env: await prepareGitHubOperationLaunchers({
|
||||
target: executionTarget,
|
||||
cwd: executionWorkspace.cwd,
|
||||
env: gitExecutionEnv,
|
||||
brokerUrl: configuredPaperclipApiBaseUrl() ?? "",
|
||||
createBrokerToken: () => createRuntimeToolsToken({
|
||||
agentId: agent.id,
|
||||
companyId: agent.companyId,
|
||||
runId: run.id,
|
||||
target: executionTarget,
|
||||
cwd: executionWorkspace.cwd,
|
||||
env: githubBrokerEnv,
|
||||
}),
|
||||
};
|
||||
responsibleUserId: responsibleUserId ?? "",
|
||||
scope: "github_credentials",
|
||||
})?.token ?? "",
|
||||
});
|
||||
githubLauncherLocation = githubLaunchers.cleanupLocation;
|
||||
runtimeConfig = { ...runtimeConfig, env: githubLaunchers.env };
|
||||
secretKeys.add("PAPERCLIP_GITHUB_BROKER_TOKEN");
|
||||
}
|
||||
context.paperclipEnvironment = {
|
||||
|
||||
@@ -36,6 +36,7 @@ import { createLocalDiskStorageProvider } from "../../storage/local-disk-provide
|
||||
import { createStorageService } from "../../storage/service.js";
|
||||
import type { StorageService } from "../../storage/types.js";
|
||||
import { issueService } from "../issues.js";
|
||||
import { findHeartbeatRunCompletionComment, resolveHeartbeatRunResponse } from "../heartbeat-run-summary.js";
|
||||
import {
|
||||
renderNativeRunnerStagedAttachmentPrompt,
|
||||
stageNativeRunnerWakeAttachments,
|
||||
@@ -298,6 +299,20 @@ describe("native runner file handoff", () => {
|
||||
downloadPath: first.downloadPath,
|
||||
});
|
||||
|
||||
// Exercise real persisted comments with the actual applied/duplicate receipts.
|
||||
// Preparing the attachment must not consume the agent's final reply slot.
|
||||
const preparedComments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId));
|
||||
for (const receipt of [first, replayWithNewKey]) {
|
||||
const resultJson = { semanticToolReceipts: { file: { operationId: "register_deliverable", result: receipt } } };
|
||||
const existingComment = findHeartbeatRunCompletionComment(preparedComments, resultJson);
|
||||
expect(existingComment).toBeNull();
|
||||
expect(resolveHeartbeatRunResponse({
|
||||
resultJson,
|
||||
existingComment,
|
||||
finalAgentMessage: { text: "Here is the requested answer.", channel: "final", sourceEventId: "final-1" },
|
||||
})).toMatchObject({ text: "Here is the requested answer.", decision: { commentAction: "create" } });
|
||||
}
|
||||
|
||||
await expect(nativeCompletionFeedback(db, runId, doneReport([`deliverable:${first.entityRefs[0]}`])))
|
||||
.resolves.toContain("Completion report accepted");
|
||||
const otherIssueId = "00000000-0000-4000-8000-000000009111";
|
||||
|
||||
@@ -100,7 +100,7 @@ canonical Plan revision, capture its pending UI, approve in the browser, and
|
||||
prove exactly two successful runs. `warm_three_turn` provides exactly two
|
||||
browser follow-up messages, preserves one project/execution-workspace scope,
|
||||
verifies host file contents after every turn, and finishes within three
|
||||
ten-minute turn deadlines.
|
||||
ten-minute turn deadlines. Native turns 1 and 2 include an actionable human review in the completion report's `attentionRequests`. Paperclip creates the review gate from that report. An explicit question-tool wait yields the turn and suppresses its final prose, so it is not interchangeable with this completion-review fixture. Turn 3 reports Done without another review.
|
||||
|
||||
Every selected case runs in its own isolated Paperclip process, and independent
|
||||
cases may run concurrently. Follow-up turns inside one case retain their shared
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizePrpResultSignals } from "../../packages/paperclip-runner/src/protocol/result-normalization.js";
|
||||
import {
|
||||
connectionReviewSuite,
|
||||
runnerEnvironments,
|
||||
@@ -25,6 +26,19 @@ import {
|
||||
} from "./selectors.js";
|
||||
|
||||
describe("runner E2E catalog", () => {
|
||||
it("supplies an actionable human review in native warm completion examples", () => {
|
||||
const prompts = [daytonaWarmContinuityTask.buildPrompt("nonce"), ...daytonaWarmContinuityTask.buildFollowupMessages!("nonce")];
|
||||
for (const [index, prompt] of prompts.entries()) {
|
||||
const match = prompt.match(/attentionRequests:(\[.*?\]),evidence:/);
|
||||
expect(match).not.toBeNull();
|
||||
const signals = normalizePrpResultSignals({ attentionRequests: JSON.parse(match![1]) });
|
||||
expect(signals.ignoredAttentionRequests).toEqual([]);
|
||||
expect(signals.actionableAttentionRequests).toHaveLength(index === 2 ? 0 : 1);
|
||||
if (index < 2) expect(signals.actionableAttentionRequests[0]).toMatchObject({kind:"review", ownerClass:"human"});
|
||||
expect(prompt).not.toContain("call request_human_input");
|
||||
}
|
||||
});
|
||||
|
||||
it("defines sixteen local connection-review journeys without expanding the default matrix", () => {
|
||||
expect(connectionReviewSuite.expectedMatrixSize).toBe(16);
|
||||
expect(new Set(connectionReviewSuite.profiles.map(profile => profile.id))).toEqual(new Set(["runner-codex", "runner-acpx-claude", "legacy-codex", "legacy-claude"]));
|
||||
|
||||
@@ -811,6 +811,10 @@ function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) {
|
||||
);
|
||||
const marker = warmTurnMarker(turn, nonce);
|
||||
const finalTurn = turn === 3;
|
||||
const reviewRequests = finalTurn ? [] : [{
|
||||
kind: "review", ownerClass: "human",
|
||||
summary: `Review the verified turn ${turn} workspace file and decide whether this task is ready to complete or needs another revision.`,
|
||||
}];
|
||||
const legacyCompletion = finalTurn
|
||||
? `In a legacy runner, make exactly one public-API completion write after verification: PATCH /api/issues/$PAPERCLIP_TASK_ID with {"status":"done","comment":"${marker}"}. Include Authorization and X-Paperclip-Run-Id. Do not POST a separate comment.`
|
||||
: `In a legacy runner, after verification POST exactly one request_confirmation to /api/issues/$PAPERCLIP_TASK_ID/interactions with {"kind":"request_confirmation","idempotencyKey":"daytona-warm-review-T${turn}-${nonce}","resolverPolicy":"human_only","title":"Warm continuity turn ${turn}","summary":"Review completed warm continuity turn ${turn}.","continuationPolicy":"wake_assignee","payload":{"version":1,"prompt":"Is this warm continuity task ready to complete after turn ${turn}?","acceptLabel":"Approve completion","rejectLabel":"Continue work","rejectRequiresReason":true,"allowDeclineReason":true,"supersedeOnUserComment":false,"target":{"type":"custom","key":"daytona_warm_turn_${turn}","revisionId":"${nonce}-T${turn}","label":"Warm continuity turn ${turn}"}}}. Capture the returned interaction id. Then make exactly one issue PATCH with {"status":"in_review","comment":"${marker}","reviewInteractionId":"<returned interaction id>"}. Include Authorization and X-Paperclip-Run-Id on both writes. If the issue PATCH fails, retry only that PATCH and never create another interaction. Do not POST a separate comment. After both writes succeed, end the response and heartbeat immediately; do not wait or poll because the reviewer action will start the next turn.`;
|
||||
@@ -820,7 +824,7 @@ function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) {
|
||||
? `Create ${file} with exactly this one line followed by a newline: ${lines[0]}`
|
||||
: `Before changing anything, read ${file} and verify its content is exactly ${lines.slice(0, -1).join("\\n")} followed by a newline. Then append exactly ${lines.at(-1)} followed by a newline.`,
|
||||
`After the write, verify ${file} contains exactly these lines, once each and in order: ${lines.join(" | ")}.`,
|
||||
`In a native runner, use the current turn completion revision and criterion IDs (CURRENT_TURN_REVISION below is a placeholder, not a literal), then call paperclip_finish exactly once with {reportedWorkDisposition:"${finalTurn ? "done" : "needs_review"}",summary:"${marker}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},evidence:[],verification:[{commandOrCheck:"read ${file}",status:"passed"}]}. Wait for that tool call to succeed, then emit exactly ${marker} once as the complete user-facing final response.`,
|
||||
`In a native runner, use the current turn completion revision and criterion IDs (CURRENT_TURN_REVISION below is a placeholder, not a literal), then call paperclip_finish exactly once with {reportedWorkDisposition:"${finalTurn ? "done" : "needs_review"}",summary:"${marker}",completionClaim:{contractRevision:CURRENT_TURN_REVISION,objectiveSatisfied:true,criteria:[{criterionId:"objective",status:"satisfied",evidenceRefs:[]}],remainingWork:[]},attentionRequests:${JSON.stringify(reviewRequests)},evidence:[],verification:[{commandOrCheck:"read ${file}",status:"passed"}]}. Wait for that tool call to succeed, then emit exactly ${marker} once as the complete user-facing final response.`,
|
||||
legacyCompletion,
|
||||
`In a legacy runner, the PATCH comment is the complete visible response. After its 2xx response, finish silently: do not print, echo, or emit ${marker} again as assistant text.`,
|
||||
`Do not include ${marker} in any other visible response or write. Do not recreate, truncate, reorder, or duplicate prior lines.`,
|
||||
|
||||
@@ -166,3 +166,20 @@ export async function evaluateMatchers(
|
||||
matchers.map((matcher) => evaluateMatcher(matcher, observation)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function persistedFinalRunMessage(
|
||||
comments: Array<{ id: string; createdByRunId?: string | null; body?: string | null }>,
|
||||
run: { id: string; resultJson?: Record<string, unknown> | null },
|
||||
): string {
|
||||
const runComments = comments.filter(comment => comment.createdByRunId === run.id);
|
||||
const decision = run.resultJson?.presentationDecision;
|
||||
const selectedId = decision && typeof decision === "object" && !Array.isArray(decision)
|
||||
? (decision as Record<string, unknown>).commentId : null;
|
||||
// Read the persisted comment selected for the user, not a finish summary or
|
||||
// attachment preparation message. Missing selected evidence must still fail.
|
||||
if (typeof selectedId === "string") {
|
||||
return runComments.find(comment => comment.id === selectedId)?.body ?? "";
|
||||
}
|
||||
return runComments.map(comment => comment.body ?? "").join("\n");
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { classifyFailure } from "./failure-classifier.js";
|
||||
import { runnerE2EServerControlPaths } from "./harness-env.js";
|
||||
import { setupConnectionReview } from "./connection-reviews.js";
|
||||
import { setupLiveFixtures, type LiveFixtureValues } from "./live-fixtures.js";
|
||||
import { evaluateMatcher, type MatcherResult } from "./matchers.js";
|
||||
import { evaluateMatcher, persistedFinalRunMessage, type MatcherResult } from "./matchers.js";
|
||||
import {
|
||||
acceptedPlanSessionResetFailures,
|
||||
hasTerminalMalformedPlanConfirmation,
|
||||
@@ -1707,10 +1707,7 @@ for (const execution of executions) {
|
||||
const message = agentComments
|
||||
.map((comment) => comment.body ?? "")
|
||||
.join("\n");
|
||||
const finalRunMessage = agentComments
|
||||
.filter((comment) => comment.createdByRunId === finalRun.id)
|
||||
.map((comment) => comment.body ?? "")
|
||||
.join("\n");
|
||||
const finalRunMessage = persistedFinalRunMessage(agentComments, finalRun);
|
||||
const pendingInteractions = terminal.interactions.filter(
|
||||
(interaction) => interaction.status === "pending",
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "./harness-env.js";
|
||||
import { runnerExecutionById, runnerMatrix } from "./catalog.js";
|
||||
import { assertEmbeddedDatabaseIsolation } from "./instance-isolation.js";
|
||||
import { evaluateMatchers } from "./matchers.js";
|
||||
import { evaluateMatchers, persistedFinalRunMessage } from "./matchers.js";
|
||||
import {
|
||||
assertSecretFree,
|
||||
findSecretLeak,
|
||||
@@ -1310,3 +1310,24 @@ describe("runner E2E macOS shared-memory cleanup", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("persisted final response selection", () => {
|
||||
const comments = [
|
||||
{ id: "attachment-comment", body: "Prepared file for this response.", createdByRunId: "run-1" },
|
||||
{ id: "reply", body: "FINAL", createdByRunId: "run-1" },
|
||||
{ id: "other-run", body: "unrelated", createdByRunId: "run-2" },
|
||||
];
|
||||
const run = { id: "run-1", resultJson: { presentationDecision: { commentId: "reply" } } };
|
||||
it("grades the real final comment independently from an attachment's preparation comment", () => {
|
||||
expect(persistedFinalRunMessage(comments, run)).toBe("FINAL");
|
||||
});
|
||||
it("fails closed when the selected final comment is missing or belongs to another run", () => {
|
||||
expect(persistedFinalRunMessage(comments.slice(0, 1), run)).toBe("");
|
||||
expect(persistedFinalRunMessage(comments, { ...run, resultJson: { presentationDecision: { commentId: "other-run" } } })).toBe("");
|
||||
});
|
||||
it("keeps legacy fallback and does not replace absent visible text with a summary", () => {
|
||||
expect(persistedFinalRunMessage(comments, { id: "run-1" })).toBe("Prepared file for this response.\nFINAL");
|
||||
expect(persistedFinalRunMessage([], { id: "run-1", resultJson: { summary: "FINAL" } })).toBe("");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user