fix(runner): preserve final replies and isolate provider helper requests

This commit is contained in:
Dotta committed 2026-09-19 20:39:01 -05:00
1 parent b193077582
commit 76800c2b5a
12 files changed
+232 -14

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,
&params,
@@ -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,
&params,
@@ -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. 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 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" };
+22 -6
View File
@@ -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
);
}
@@ -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";