mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix: harden agent chat runner tools and recovery (#13678)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent Chat turns discussion into plans, tasks, reviews, and hires. > - These workflows need reliable tool results and task context on the native runner. > - Live Claude and Codex tests exposed lost retry requests, invalid project inputs, and a child startup crash. > - Recovery also exposed a misleading retry action and missing child task context. > - This pull request fixes those paths and adds regression coverage. > - Agents can continue the original request and operators can inspect a stopped run. ## Linked Issues or Issue Description **What happened?** A failed Agent Chat retry could lose the user's question. Project creation accepted unsupported icons in its tool schema. Codex could stop when a helper's MCP startup event arrived before its thread lineage. A stopped task offered Retry even when the server required execution reconciliation. Resumed agents could miss existing delegated tasks. Hiring and review instructions did not describe the native runner's available tools and source requirements. **Expected behavior** Retries retain the selected request. Tool schemas match the API. Child startup information does not gain authority over the parent or stop it. Recovery actions match the server's requirements. Task context exposes existing child work. Handoffs contain the material the assignee needs. **Steps to reproduce** 1. Enable experimental Agent Chat in an isolated development instance. 2. Configure native Codex and ACPX Claude agents on Paperclip Runner. 3. Ask for a plan, revise it, approve task creation, and request a hire and status report. 4. Retry a failed chat turn and check that it answers the original request. 5. Start a Codex helper before its thread lineage arrives. 6. Resume a delegated task and inspect its existing children and saved output. **Paperclip version or commit** The live failures were found at `f2c5e54dc`. This branch is rebased onto `86b7ee992`. **Deployment mode** Isolated local development instance with native Codex and ACPX Claude. No database migration or default permission change. Related work: Refs #13284 for Agent Chat. Refs #13438 for the server-side API receipt fix, which this branch preserves. The transport also accepts the earlier HTTP receipt format. Refs #13655 for the current Codex continuation and helper lineage handling, which this branch also preserves. ## What Changed - Preserve failed Agent Chat wake-comment IDs and session generation from the authorized source run. Reject pre-reset retries. - Wrap API receipts with the correct semantic call identity. Test current and earlier receipt formats through real HTTP and runnerd. - Classify early child MCP startup notifications as information. Keep foreign completion and result events rejected. - Constrain project icons on both tool surfaces and regenerate the protocol contracts. - Include bounded, company-scoped visible direct child tasks in task context. Filter hidden tasks before applying the limit. - Replace the rejected Retry action with Inspect run for native continuation reconciliation. - Update hiring, review handoff, status reporting, and development guidance. ## Verification - Live tests covered Claude and Codex questions, plan revisions, approval, task creation, hiring, status, chat reset, failures, and recovery. - The recovered task produced its saved checklist and example. A later follow-up read the existing child tasks and document without creating more work. - Full build, repository type checks, token gates, 142 focused tests, 188 runner TypeScript tests, and the Rust notification/descendant regressions passed after rebase. The separate local full-suite run was stopped after the complete CI suite passed. - Review fixes passed the updated route, tool-authority, and icon regression tests plus server type checking. - Required commands: `pnpm build`, `pnpm -r typecheck`, `PAPERCLIP_IN_WORKTREE=false pnpm test:run`, and `pnpm check:token-gates`. - At `4ce8047b0`, all 55 applicable GitHub checks pass (two Storybook checks are intentionally skipped), including the complete general/serialized test matrix, runner tests, browser tests, build, type checks, Docker checks, and canary dry run. - Fresh Greptile review is 5/5 on `4ce8047b0`; all three findings were fixed with regressions and there are no unresolved review threads. - Two initial CI service-startup timeouts passed unchanged in local reproductions and in the latest CI run. ## Risks - The new event classification is limited to MCP startup information. It does not authorize foreign task completion, results, or tool requests. - Task context returns at most 100 direct child tasks and reports truncation. It excludes hidden tasks and other companies. This improves delegation context but does not enforce semantic duplicate detection. - Native reconciliation still requires an operator to inspect and record prior outcomes. The new link does not replace the recovery API. - API tools remain opt-in. Claude permission choices remain explicit. No default permission, schema, or workflow changes. ## Model Used OpenAI GPT-6 in Codex, with reasoning, repository editing, code execution, API tools, and browser testing. The exact deployment identifier and context-window size are not exposed in this session. Live acceptance agents used OpenAI `gpt-5.6-sol` and Anthropic `claude-sonnet-4-6`. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
9335b7db10
commit
36dbb7ed1c
22 files changed
+414
-44
No files matched your search
@@ -1026,6 +1026,27 @@ OpenCode retains `allow`, `ask`, and `deny`; ACPX retains `approve-all`,
|
||||
otherwise stores the shared `gpt-5.6-sol` default. The native execution boundary
|
||||
applies the same default to older runner rows whose model is missing or blank.
|
||||
|
||||
For an Agent Chat test drive, enable **Agent Chat** in Experimental settings and
|
||||
configure two agents with Paperclip Runner: native Codex and ACPX Claude. Connect
|
||||
the Claude account through the agent's **AI connection** section (or supply an
|
||||
explicit supported provider credential); an ambient Claude CLI login alone is
|
||||
not a credential source for its isolated runner home. The conservative ACPX
|
||||
permission mode fails closed when no coordinator decision is available, including
|
||||
for MCP tools. Use `approve-all` only for agents authorized to perform the test's
|
||||
tool actions; the server still enforces company and action permissions.
|
||||
|
||||
Test questions, saved plan revisions, approval before task handoff, status
|
||||
lookups, and `/new` preserving chat history. Hiring additionally requires the
|
||||
operator-controlled [runner API tools](runner-api-tools.md) rollout; enabling
|
||||
Agent Chat does not enable that API surface. Failed-turn retries restore the
|
||||
selected run's user comments so the agent can answer the original request.
|
||||
|
||||
A native continuation that requires reconciliation shows **Recovery needed**
|
||||
with **Inspect run**; inspect the original outcome before resolving its recovery
|
||||
hold. A generic retry cannot resolve this incident. The runner's
|
||||
`get_task_context` includes up to 100 existing direct child tasks and a truncation
|
||||
flag so resumed agents can reuse delegated work and inspect completed results.
|
||||
|
||||
For native Codex runs, Paperclip passes the resolved execution workspace as
|
||||
`PAPERCLIP_WORKSPACE_CWD` and uses it as the provider containment boundary. A
|
||||
workspace below the host `HOME` is valid, including the default projectless
|
||||
|
||||
@@ -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
|
||||
@@ -1729,7 +1729,48 @@
|
||||
},
|
||||
"icon": {
|
||||
"description": "Project icon.",
|
||||
"maxLength": 20000,
|
||||
"enum": [
|
||||
"folder",
|
||||
"rocket",
|
||||
"code",
|
||||
"terminal",
|
||||
"database",
|
||||
"globe",
|
||||
"package",
|
||||
"boxes",
|
||||
"box",
|
||||
"layers",
|
||||
"briefcase",
|
||||
"compass",
|
||||
"target",
|
||||
"flame",
|
||||
"zap",
|
||||
"star",
|
||||
"bug",
|
||||
"wrench",
|
||||
"hammer",
|
||||
"lightbulb",
|
||||
"sparkles",
|
||||
"shield",
|
||||
"lock",
|
||||
"search",
|
||||
"cog",
|
||||
"brain",
|
||||
"cpu",
|
||||
"git-branch",
|
||||
"file-code",
|
||||
"puzzle",
|
||||
"gem",
|
||||
"atom",
|
||||
"heart",
|
||||
"mail",
|
||||
"message-square",
|
||||
"crown",
|
||||
"radar",
|
||||
"telescope",
|
||||
"hexagon",
|
||||
null
|
||||
],
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:2214d199c37fe39fd1d639da5442b024a923eec209d80dd33498e63a8249e459",
|
||||
"catalogSha256": "sha256:cf243ac6e38f931e556db65b7593f7ed58ffb4d0a2543c90219b0cc723840196",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "9af7a29befaf9a4d6b47cd329cfd2f48ff83d53b896a8b72dc13bece2fc3b6a5",
|
||||
"sha256": "8a86a398f09ed822e7fd9acbbcd08a007921562ad21df4306bb47c45e32d0b60",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -1448,6 +1448,11 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
"params": {"turn": {"id": provider_turn_id}}
|
||||
}))?;
|
||||
if descendant_notifications {
|
||||
// Real Codex announces a child's MCP startup before its
|
||||
// thread/started notification establishes parent lineage.
|
||||
send(json!({"method":"mcpServer/startupStatus/updated","params":{
|
||||
"threadId":"descendant-0", "name":"paperclip", "status":"starting", "error":null
|
||||
}}))?;
|
||||
// Codex can announce a helper through the root's spawn receipt
|
||||
// before emitting any thread/started notification for that helper.
|
||||
send(json!({"method": "item/completed", "params": {
|
||||
|
||||
@@ -3409,6 +3409,9 @@ fn classify_notification_thread(
|
||||
| "configWarning"
|
||||
| "guardianWarning"
|
||||
| "deprecationNotice"
|
||||
// Child MCP startup can precede thread/started and its lineage.
|
||||
// It is diagnostic information, not root execution authority.
|
||||
| "mcpServer/startupStatus/updated"
|
||||
) {
|
||||
return Ok(NotificationThread::UnrelatedInformation);
|
||||
}
|
||||
@@ -4944,6 +4947,40 @@ mod notification_identity_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_mcp_startup_before_lineage_has_no_execution_authority() {
|
||||
let params =
|
||||
json!({"threadId": "not-yet-known-child", "name": "paperclip", "status": "starting"});
|
||||
assert_eq!(
|
||||
classify_notification_thread(
|
||||
"mcpServer/startupStatus/updated",
|
||||
"root",
|
||||
&BTreeSet::new(),
|
||||
¶ms
|
||||
)
|
||||
.unwrap(),
|
||||
NotificationThread::UnrelatedInformation
|
||||
);
|
||||
assert!(
|
||||
classify_notification_thread("turn/completed", "root", &BTreeSet::new(), ¶ms)
|
||||
.is_err()
|
||||
);
|
||||
assert!(classify_notification_thread(
|
||||
"paperclip/runResult",
|
||||
"root",
|
||||
&BTreeSet::new(),
|
||||
¶ms
|
||||
)
|
||||
.is_err());
|
||||
assert!(classify_notification_thread(
|
||||
"mcpServer/startupStatus/updated",
|
||||
"root",
|
||||
&BTreeSet::new(),
|
||||
&json!({"threadId": "child", "thread": {"id": "different"}}),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_authority_and_malformed_turn_identities() {
|
||||
for method in [
|
||||
|
||||
@@ -20,6 +20,18 @@ const packageRoot = resolve(
|
||||
);
|
||||
|
||||
describe("semantic action catalog", () => {
|
||||
it("advertises only icons accepted by the project API on both tool surfaces", async () => {
|
||||
const { PROJECT_ICON_NAMES } = await import("../../../shared/src/constants.js");
|
||||
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: "Onboarding", idempotencyKey: "onboarding" };
|
||||
expect(schema).toMatchObject({ properties: { icon: { enum: [...PROJECT_ICON_NAMES, null] } } });
|
||||
for (const icon of [...PROJECT_ICON_NAMES, null]) expect(validate({ ...input, icon }), String(icon)).toBe(true);
|
||||
expect(validate({ ...input, icon: "users" })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
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]) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
import { createSkillAction } from "../protocol-actions/create-skill.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";
|
||||
import { projectIconSchema, 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;
|
||||
@@ -460,7 +460,7 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [
|
||||
},
|
||||
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."),
|
||||
targetDate: nullableText("Target date."), color: nullableText("Project color."), icon: projectIconSchema,
|
||||
env: openObject, executionWorkspacePolicy: openObject, archivedAt: nullableText("Archive timestamp."),
|
||||
}, ["idempotencyKey", "name"]),
|
||||
outputSchema: openObject,
|
||||
|
||||
@@ -42,6 +42,9 @@ describe("provider notification authority", () => {
|
||||
{ threadId: "unrelated" },
|
||||
"unrelated_information",
|
||||
],
|
||||
["mcpServer/startupStatus/updated", { threadId: "not-yet-known-child", status: "starting" }, "unrelated_information"],
|
||||
["mcpServer/startupStatus/updated", { threadId: "child", status: "ready" }, "descendant"],
|
||||
["mcpServer/startupStatus/updated", { threadId: "child", thread: { id: "different" } }, "invalid_authority"],
|
||||
["paperclip/runResult", { threadId: "child" }, "invalid_authority"],
|
||||
["turn/completed", { threadId: "unrelated" }, "invalid_authority"],
|
||||
["item/started", { threadId: 5 }, "invalid_authority"],
|
||||
|
||||
@@ -15,6 +15,8 @@ const informational = new Set([
|
||||
"configWarning",
|
||||
"guardianWarning",
|
||||
"deprecationNotice",
|
||||
// Child startup may arrive before thread/started establishes its lineage.
|
||||
"mcpServer/startupStatus/updated",
|
||||
]);
|
||||
const record = (v: unknown): Record<string, unknown> =>
|
||||
v !== null && typeof v === "object" && !Array.isArray(v)
|
||||
|
||||
@@ -5454,7 +5454,7 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
"PRP semantic tool call no longer belongs to an admitted turn",
|
||||
);
|
||||
}
|
||||
return unwrapToolResponse(
|
||||
const outcome = unwrapToolResponse(
|
||||
await this.#handler({
|
||||
id: call.callId,
|
||||
method: "item/tool/call",
|
||||
@@ -5475,6 +5475,29 @@ class DurablePrpCodexTransport implements CodexAppServerTransport {
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
if (call.operationId === "call_api") {
|
||||
const result = record(outcome.result);
|
||||
if (
|
||||
result.operationId !== call.operationId ||
|
||||
result.callId !== call.callId
|
||||
) {
|
||||
// HTTP receipts also contain `ok` and `operationId` (the HTTP route).
|
||||
// Bind that application value inside a real semantic envelope so the
|
||||
// runner cannot mistake the route for the provider tool's identity.
|
||||
return {
|
||||
...outcome,
|
||||
result: {
|
||||
ok: !outcome.isError,
|
||||
operationId: call.operationId,
|
||||
callId: call.callId,
|
||||
...(outcome.isError
|
||||
? { error: outcome.result }
|
||||
: { result: outcome.result }),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
async #startTurn(
|
||||
|
||||
@@ -1,3 +1,51 @@
|
||||
/** Project icon contract; kept aligned with the server project validator. */
|
||||
export const projectIconSchema = {
|
||||
type: ["string", "null"],
|
||||
description: "Project icon.",
|
||||
enum: [
|
||||
"folder",
|
||||
"rocket",
|
||||
"code",
|
||||
"terminal",
|
||||
"database",
|
||||
"globe",
|
||||
"package",
|
||||
"boxes",
|
||||
"box",
|
||||
"layers",
|
||||
"briefcase",
|
||||
"compass",
|
||||
"target",
|
||||
"flame",
|
||||
"zap",
|
||||
"star",
|
||||
"bug",
|
||||
"wrench",
|
||||
"hammer",
|
||||
"lightbulb",
|
||||
"sparkles",
|
||||
"shield",
|
||||
"lock",
|
||||
"search",
|
||||
"cog",
|
||||
"brain",
|
||||
"cpu",
|
||||
"git-branch",
|
||||
"file-code",
|
||||
"puzzle",
|
||||
"gem",
|
||||
"atom",
|
||||
"heart",
|
||||
"mail",
|
||||
"message-square",
|
||||
"crown",
|
||||
"radar",
|
||||
"telescope",
|
||||
"hexagon",
|
||||
null,
|
||||
],
|
||||
} as const;
|
||||
|
||||
/** Existing GitHub repository references, never arbitrary network/resource URIs. */
|
||||
export const projectRepositoryUrlSchema = {
|
||||
type: "string",
|
||||
@@ -154,14 +202,7 @@ export const createProjectAction = {
|
||||
"description": "Project color.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"icon": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"description": "Project icon.",
|
||||
"maxLength": 20000
|
||||
},
|
||||
"icon": projectIconSchema,
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
|
||||
@@ -1030,6 +1030,54 @@ describe("agent live run routes", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it.each([0, 3])("preserves an Agent Chat request and generation %s using only the selected run's context", async (generation) => {
|
||||
const fixture = createFailedChatRetryDb(false);
|
||||
mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, contextSnapshot: {
|
||||
issueId: failedChatIssueId,
|
||||
wakeCommentIds: ["first-comment", "original-comment"],
|
||||
wakeCommentId: "original-comment",
|
||||
...(generation > 0 ? { conversationSessionGeneration: generation } : {}),
|
||||
} });
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: failedChatIssueId, companyId: "company-1", assigneeAgentId: routeAgentId,
|
||||
conversationAgentId: routeAgentId, conversationUserId: "local-board",
|
||||
status: "in_review", conversationState: "waiting", conversationSessionGeneration: generation,
|
||||
});
|
||||
const res = await requestApp(await createApp(fixture.db), url =>
|
||||
request(url).post(`/api/agents/${routeAgentId}/wakeup`).send({
|
||||
...retryBody, payload: { commentId: "forged-comment", wakeCommentIds: ["forged-comment"], conversationSessionGeneration: 999 },
|
||||
}));
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(202);
|
||||
expect(mockHeartbeatService.wakeup).toHaveBeenCalledWith(routeAgentId, expect.objectContaining({
|
||||
payload: { issueId: failedChatIssueId },
|
||||
contextSnapshot: expect.objectContaining({
|
||||
wakeCommentIds: ["first-comment", "original-comment"],
|
||||
wakeCommentId: "original-comment",
|
||||
conversationSessionGeneration: generation,
|
||||
}),
|
||||
}));
|
||||
expect(mockChatRunRetries.prepareFailedChatRunRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([undefined, 0, 2])("rejects pre-reset Agent Chat retries from generation %s", async (generation) => {
|
||||
const fixture = createFailedChatRetryDb(false);
|
||||
mockHeartbeatService.getRun.mockResolvedValue({ ...selectedRun, contextSnapshot: {
|
||||
issueId: failedChatIssueId, wakeCommentId: "original-comment",
|
||||
...(generation === undefined ? {} : { conversationSessionGeneration: generation }),
|
||||
} });
|
||||
mockIssueService.getById.mockResolvedValue({
|
||||
id: failedChatIssueId, companyId: "company-1", assigneeAgentId: routeAgentId,
|
||||
conversationAgentId: routeAgentId, conversationUserId: "local-board",
|
||||
conversationSessionGeneration: 3,
|
||||
});
|
||||
const res = await requestApp(await createApp(fixture.db), url =>
|
||||
request(url).post(`/api/agents/${routeAgentId}/wakeup`).send(retryBody));
|
||||
expect(res.status, JSON.stringify(res.body)).toBe(409);
|
||||
expect(res.body.error).toContain("Conversation session changed");
|
||||
expect(mockHeartbeatService.wakeup).not.toHaveBeenCalled();
|
||||
expect(mockChatRunRetries.prepareFailedChatRunRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["viewer", "missing", "other-company", "reassigned", "other-chat-owner"])(
|
||||
"rejects a %s task retry without dispatching or requiring agent creation", async (fault) => {
|
||||
const fixture = createFailedChatRetryDb(false);
|
||||
|
||||
@@ -5753,6 +5753,7 @@ export function agentRoutes(
|
||||
}
|
||||
|
||||
let wakePayload = req.body.payload ?? null;
|
||||
let retryConversationContext: Record<string, unknown> = {};
|
||||
if (req.body.failedRunId) {
|
||||
assertBoard(req);
|
||||
if (
|
||||
@@ -5798,6 +5799,30 @@ export function agentRoutes(
|
||||
});
|
||||
if (!decision.allowed) throw forbidden(decision.explanation, authorizationDeniedDetails(decision));
|
||||
if (issue.assigneeAgentId !== agent.id) throw conflict("The task is no longer assigned to this agent.");
|
||||
if (issue.conversationAgentId) {
|
||||
// Agent Chat has no task description to replay. Recover the exact
|
||||
// request from the selected server-owned run, never caller markers.
|
||||
// Keep the generation pinned so a reset before dispatch cannot revive
|
||||
// the old request. Runs that failed before turn preparation belong to
|
||||
// the initial generation and cannot be retried after a reset.
|
||||
const generation = failedContext.conversationSessionGeneration ?? 0;
|
||||
if (!Number.isInteger(generation) || generation !== issue.conversationSessionGeneration) {
|
||||
throw conflict("Conversation session changed; this older turn cannot be retried.");
|
||||
}
|
||||
retryConversationContext = { conversationSessionGeneration: generation };
|
||||
const commentIds = [...new Set([
|
||||
...(Array.isArray(failedContext.wakeCommentIds) ? failedContext.wakeCommentIds : []),
|
||||
failedContext.wakeCommentId,
|
||||
failedContext.commentId,
|
||||
].filter((value): value is string => typeof value === "string" && value.trim().length > 0))];
|
||||
if (commentIds.length > 0) {
|
||||
retryConversationContext = {
|
||||
...retryConversationContext,
|
||||
wakeCommentIds: commentIds,
|
||||
wakeCommentId: commentIds[commentIds.length - 1],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
const chatBinding = issueId
|
||||
? await db
|
||||
@@ -5876,6 +5901,7 @@ export function agentRoutes(
|
||||
requestedByActorType: req.actor.type === "agent" ? "agent" : "user",
|
||||
requestedByActorId: req.actor.type === "agent" ? req.actor.agentId ?? null : req.actor.userId ?? null,
|
||||
contextSnapshot: {
|
||||
...retryConversationContext,
|
||||
triggeredBy: req.actor.type,
|
||||
originIdentityContextId: req.actor.identityContextId ?? null,
|
||||
responsibleUserId: req.actor.type === "agent" ? req.actor.onBehalfOfUserId ?? null : req.actor.userId ?? null,
|
||||
|
||||
@@ -87,6 +87,10 @@ Before handing off work, inspect available projects and repositories. Every task
|
||||
|
||||
Create ordinary assigned tasks, never subtasks of this conversation. Give each task a clear outcome, context, acceptance criteria, project, and appropriate assignee. Use create_task with initialPlan to copy the relevant plan into the new task before execution starts. If using the HTTP API directly, POST /api/companies/{companyId}/issues with projectId, assigneeAgentId, status: "todo", initialPlan containing the relevant plan Markdown, and an idempotencyKey; omit parentId. Putting a plan in description does not create the task's plan document. Verify the new task's plan document before claiming the handoff is complete. Preserve the original plan here. Carry forward only the remaining execution steps, not completed planning, approval, project creation, or task creation steps. Include the source conversation ID, approved plan revision ID, and accepted interaction ID so the worker can verify the recorded approval. State the approved scope and what is already done; do not claim the new plan document has its own approval. The worker should execute that authorized scope, and ask again only if scope changes or another applicable gate requires it. When splitting work, include the relevant part of the plan in each task. Create and link each task before claiming it exists.
|
||||
|
||||
For review or follow-up tasks, include the existing source materials the assignee needs, not just task identifiers or a rubric. Read the source task's saved documents and comments, then copy the relevant existing content into the handoff or attach resources the recipient can actually read. A new task's tools may only expose its own documents and history. Supplying existing source material is coordination, not creating the assigned deliverable.
|
||||
|
||||
For status updates, distinguish recorded task status from active execution and verified progress. Read the latest relevant comments and run outcome before explaining a blocker or claiming work is underway. Use the advertised API tools for another task's details when needed. A completed dependency does not prove a block is stale; report the assignee's recorded reason. Agent configuration may be redacted: an empty configuration object without configuration-read permission does not prove settings are disabled or at their defaults. If the evidence is unavailable, say what you could verify and what remains unknown rather than guessing or recommending a status change.
|
||||
|
||||
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. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
@@ -158,6 +158,44 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
).rejects.toThrow("paperclip_runner_tool_not_advertised");
|
||||
});
|
||||
|
||||
it("exposes existing child tasks on continuation without crossing company boundaries", async () => {
|
||||
const completedChildId = randomUUID();
|
||||
const activeChildId = randomUUID();
|
||||
const unrelatedId = randomUUID();
|
||||
const otherCompanyId = randomUUID();
|
||||
const foreignChildId = randomUUID();
|
||||
const hiddenChildIds = Array.from({ length: 101 }, () => randomUUID());
|
||||
await db.insert(companies).values({ id: otherCompanyId, name: "Other company", issuePrefix: "OTHER" });
|
||||
await db.insert(issues).values([
|
||||
{ id: completedChildId, companyId, parentId: issueId, title: "Existing completed draft", status: "done", assigneeAgentId: agentId },
|
||||
{ id: activeChildId, companyId, parentId: issueId, title: "Existing active review", status: "in_progress", assigneeAgentId: agentId },
|
||||
{ id: unrelatedId, companyId, title: "Unrelated task", status: "todo" },
|
||||
// Even inconsistent imported data cannot expose another company's task.
|
||||
{ id: foreignChildId, companyId: otherCompanyId, parentId: issueId, title: "Foreign child", status: "todo" },
|
||||
// Hidden rows must neither enter context nor consume the visible child limit.
|
||||
...hiddenChildIds.map((id) => ({ id, companyId, parentId: issueId, title: "Hidden child",
|
||||
hiddenAt: new Date(), createdAt: new Date(Date.now() + 1000) })),
|
||||
]);
|
||||
try {
|
||||
const authority = new PaperclipRunnerToolAuthority(db, { companyId, agentId, issueId, runId });
|
||||
const context = await authority.execute({ tool: "get_task_context", callId: "context-existing-children", arguments: {} });
|
||||
expect(context).toMatchObject({
|
||||
childTasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: completedChildId, status: "done", parentId: issueId }),
|
||||
expect.objectContaining({ id: activeChildId, status: "in_progress", assigneeAgentId: agentId }),
|
||||
]),
|
||||
childTasksTruncated: false,
|
||||
delegationGuidance: expect.stringContaining("Reuse existing child tasks"),
|
||||
});
|
||||
expect(JSON.stringify(context)).not.toContain(foreignChildId);
|
||||
expect(JSON.stringify(context)).not.toContain(unrelatedId);
|
||||
for (const id of hiddenChildIds) expect(JSON.stringify(context)).not.toContain(id);
|
||||
} finally {
|
||||
await db.delete(issues).where(inArray(issues.id, [completedChildId, activeChildId, unrelatedId, foreignChildId, ...hiddenChildIds]));
|
||||
await db.delete(companies).where(eq(companies.id, otherCompanyId));
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves direct-chat file tools across the guarded API rollout", () => {
|
||||
const previousEnabled = process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED;
|
||||
const previousCompanies =
|
||||
|
||||
@@ -368,26 +368,39 @@ export class PaperclipRunnerToolAuthority {
|
||||
const { operationId, ...response } = record(await this.#callApi(call.callId, call.arguments));
|
||||
return { ...response, apiOperationId: operationId };
|
||||
}
|
||||
case "get_task_context": return {
|
||||
company: { id: this.binding.companyId },
|
||||
actor: redactedActor(context.actor),
|
||||
activeTask: redactedTask(context.issue),
|
||||
run: {
|
||||
id: this.binding.runId,
|
||||
status: context.run.status,
|
||||
invocationSource: context.run.invocationSource,
|
||||
},
|
||||
connectionGuidance: CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
acceptedPlan: await this.#acceptedPlan(context.run.contextSnapshot),
|
||||
sourcePlanApproval: await handoffPlanContext(this.db, context.issue),
|
||||
childReviewOutcomes: await childReviewOutcomes(this.db, this.binding.companyId, this.binding.issueId),
|
||||
...(this.binding.nativeReview ? {
|
||||
assignedReview: (await getNativeReviewAssignment(this.db, {
|
||||
...this.binding, contextSnapshot: this.binding.nativeReview,
|
||||
allowResolvedByRunId: this.binding.runId,
|
||||
}))?.interaction,
|
||||
} : {}),
|
||||
};
|
||||
case "get_task_context": {
|
||||
const childTasks = await this.db.select().from(issues)
|
||||
.where(and(
|
||||
eq(issues.companyId, this.binding.companyId),
|
||||
eq(issues.parentId, this.binding.issueId),
|
||||
isNull(issues.hiddenAt),
|
||||
))
|
||||
.orderBy(desc(issues.createdAt), desc(issues.id))
|
||||
.limit(101);
|
||||
return {
|
||||
company: { id: this.binding.companyId },
|
||||
actor: redactedActor(context.actor),
|
||||
activeTask: redactedTask(context.issue),
|
||||
childTasks: childTasks.slice(0, 100).map(redactedTask),
|
||||
childTasksTruncated: childTasks.length > 100,
|
||||
delegationGuidance: "Reuse existing child tasks for delegated work. Inspect a completed child's comments and documents before creating another task for the same deliverable. Use search_tasks if the child task list is truncated.",
|
||||
run: {
|
||||
id: this.binding.runId,
|
||||
status: context.run.status,
|
||||
invocationSource: context.run.invocationSource,
|
||||
},
|
||||
connectionGuidance: CONNECTION_INTENT_AGENT_GUIDANCE,
|
||||
acceptedPlan: await this.#acceptedPlan(context.run.contextSnapshot),
|
||||
sourcePlanApproval: await handoffPlanContext(this.db, context.issue),
|
||||
childReviewOutcomes: await childReviewOutcomes(this.db, this.binding.companyId, this.binding.issueId),
|
||||
...(this.binding.nativeReview ? {
|
||||
assignedReview: (await getNativeReviewAssignment(this.db, {
|
||||
...this.binding, contextSnapshot: this.binding.nativeReview,
|
||||
allowResolvedByRunId: this.binding.runId,
|
||||
}))?.interaction,
|
||||
} : {}),
|
||||
};
|
||||
}
|
||||
case "get_task_history": {
|
||||
const limit = boundedLimit(input.limit);
|
||||
const comments = await this.db.select({
|
||||
|
||||
@@ -27,9 +27,9 @@ describe("runner API against real HTTP routes", () => {
|
||||
else process.env.PAPERCLIP_RUNNER_API_TOOLS_ENABLED = oldEnabled;
|
||||
});
|
||||
|
||||
it.skipIf(!process.env.PAPERCLIP_REQUIRE_RUNNER_API_INTEGRATION && !existsSync(defaultCapabilityRunnerdBinary()))("runs runnerd → PRP → authority → actual authenticated HTTP", async () => {
|
||||
it.skipIf(!process.env.PAPERCLIP_REQUIRE_RUNNER_API_INTEGRATION && !existsSync(defaultCapabilityRunnerdBinary())).each(["current", "legacy_http"])("runs runnerd → PRP → authority → actual authenticated HTTP (%s receipt)", async (receiptFormat) => {
|
||||
const fixture = await server.fixture();
|
||||
const provider = join(server.root, "scripted-api-provider.mjs");
|
||||
const provider = join(server.root, `scripted-api-provider-${receiptFormat}.mjs`);
|
||||
await writeFile(provider, `#!${process.execPath}
|
||||
import { createInterface } from 'node:readline';
|
||||
const send = value => process.stdout.write(JSON.stringify(value)+'\\n');
|
||||
@@ -38,7 +38,16 @@ const calls = [{tool:'search_api',arguments:{query:'list projects'}},{tool:'call
|
||||
const next = () => { const c=calls[step++]; if(c) send({id:'call-'+step,method:'item/tool/call',params:{threadId:'api-thread',turnId:'api-turn',itemId:'api-item-'+step,callId:'api-call-'+step,...c}}); else send({method:'turn/completed',params:{turn:{id:'api-turn',status:'completed'}}}); };
|
||||
for await (const line of createInterface({input:process.stdin})) {
|
||||
const m=JSON.parse(line);
|
||||
if(!m.method) {if(String(m.id).startsWith('call-')) next(); continue;}
|
||||
if(!m.method) {if(String(m.id).startsWith('call-')) {
|
||||
if(step > 1) {
|
||||
const envelope=JSON.parse(m.result.contentItems[0].text);
|
||||
if(envelope.operationId!=='call_api'||envelope.callId!=='api-call-'+step) throw new Error('API receipt lost semantic call identity');
|
||||
const receipt=envelope.result;
|
||||
if(step===2 && (receipt.status!==200||receipt.data[0].name!=='Aurora')) throw new Error('Provider did not receive successful HTTP result');
|
||||
if(step===3 && (receipt.status!==404||receipt.ok!==false)) throw new Error('Provider did not receive HTTP denial');
|
||||
}
|
||||
next();
|
||||
} continue;}
|
||||
if(m.method==='initialize') send({id:m.id,result:{userAgent:'scripted-api-provider'}});
|
||||
else if(m.method==='thread/start') send({id:m.id,result:{thread:{id:'api-thread',sessionId:'api-session'}}});
|
||||
else if(m.method==='turn/start') {send({id:m.id,result:{turn:{id:'api-turn',status:'inProgress'}}});send({method:'turn/started',params:{turn:{id:'api-turn'}}});next();}
|
||||
@@ -48,7 +57,7 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
|
||||
await chmod(provider, 0o700);
|
||||
const bundle = createRunnerdCodexTransport({
|
||||
runnerBinary: defaultCapabilityRunnerdBinary(), codexCommand: provider, codexArgs: [],
|
||||
stateDirectory: join(server.root, "scripted-runner"), lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
stateDirectory: join(server.root, `scripted-runner-${receiptFormat}`), lifecyclePolicy: { mode: "per_turn", idleTimeoutMs: null },
|
||||
prpIdentity: { runnerInstanceId: "api-test", environmentLeaseId: "api-test-lease", runId: fixture.runId, normalizedSessionId: "api-test-session", turnId: "api-test-turn", itemId: "api-test-item" },
|
||||
controlPlaneRegistration: prp => registerRunnerPrpAuthority({ companyId: fixture.companyId, runId: fixture.runId, authority: prp }),
|
||||
});
|
||||
@@ -59,7 +68,11 @@ else if(m.id!==undefined) send({id:m.id,result:{}});
|
||||
results.push(result);
|
||||
// Match production's dynamicToolResponse: an extra test-only envelope
|
||||
// hides collisions between API response fields and PRP tool identity.
|
||||
return { success: true, contentItems: [{ type: "inputText", text: JSON.stringify(result) }] };
|
||||
const { apiOperationId, ...receipt } = result as Record<string, unknown>;
|
||||
const wireResult = receiptFormat === "legacy_http" && params.tool === "call_api"
|
||||
? { ...receipt, operationId: apiOperationId }
|
||||
: result;
|
||||
return { success: true, contentItems: [{ type: "inputText", text: JSON.stringify(wireResult) }] };
|
||||
});
|
||||
try {
|
||||
await bundle.transport.request("initialize", {});
|
||||
|
||||
@@ -21,6 +21,22 @@ If you do not have this permission, escalate to your CEO or board.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Choose the API transport
|
||||
|
||||
On **Paperclip Runner**, use the advertised Paperclip tools. Use `get_task_context`
|
||||
and `list_agents` for identity and team context, then `search_api` to discover the
|
||||
configuration, instruction-template, icon, and `agent-hires` endpoints used below.
|
||||
Call the discovered operations with `call_api`; the server supplies company and
|
||||
authentication context. Read the returned schemas before drafting the hire.
|
||||
|
||||
The shell examples below apply to adapters that receive `PAPERCLIP_API_URL` and
|
||||
`PAPERCLIP_API_KEY`. Paperclip Runner does not supply those variables. Do not
|
||||
search workspace files for a server address or credentials to recreate that path.
|
||||
If `search_api` / `call_api` are unavailable, report that hiring requires the
|
||||
operator to enable runner API tools for this company. Preserve the proposed hire
|
||||
in the conversation; do not claim an agent was created or substitute a temporary
|
||||
subagent for the requested permanent hire.
|
||||
|
||||
### 1. Confirm identity and company context
|
||||
|
||||
```sh
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { act, type AnchorHTMLAttributes } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ExecutionBlockerNotice } from "./ExecutionBlockerNotice";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { activityApi } from "../api/activity";
|
||||
vi.mock("../lib/router", () => ({
|
||||
Link: ({ to, ...props }: AnchorHTMLAttributes<HTMLAnchorElement> & { to: string }) => <a href={to} {...props} />,
|
||||
}));
|
||||
vi.mock("../api/agents", () => ({ agentsApi: { retryFailedRun: vi.fn() } }));
|
||||
vi.mock("../api/activity", () => ({ activityApi: { runsForIssue: vi.fn() } }));
|
||||
|
||||
@@ -46,6 +49,21 @@ describe("stopped task recovery notice", () => {
|
||||
expect(container.textContent).toContain("Verify the external action outcome before continuing.");
|
||||
expect(container.textContent).not.toContain("Automatic recovery of this task stopped.");
|
||||
});
|
||||
it("links to the source run instead of offering a retry rejected by native reconciliation", async () => {
|
||||
await act(async () => root.render(<QueryClientProvider client={client}>
|
||||
<ExecutionBlockerNotice companyId="company" issueId="task" onRetried={onRetried} blocker={{
|
||||
recoveryActionId: "recovery", runId: "failed-run", agentId: "agent",
|
||||
cause: "native_continuation_requires_reconciliation",
|
||||
nextAction: "Inspect the original failure and reconcile the previous execution before continuing.",
|
||||
}} />
|
||||
</QueryClientProvider>));
|
||||
expect(container.textContent).toContain("Recovery needed.");
|
||||
expect(container.textContent).not.toContain("Retry");
|
||||
const link = container.querySelector("a")!;
|
||||
expect(link.textContent).toBe("Inspect run");
|
||||
expect(link.getAttribute("href")).toBe("/agents/agent/runs/failed-run");
|
||||
expect(agentsApi.retryFailedRun).not.toHaveBeenCalled();
|
||||
});
|
||||
it("retries the exact failed run and refreshes the task", async () => {
|
||||
vi.mocked(agentsApi.retryFailedRun).mockResolvedValue({} as never);
|
||||
await act(async () => container.querySelector<HTMLButtonElement>("button")!.click());
|
||||
|
||||
@@ -4,6 +4,7 @@ import { agentsApi } from "../api/agents";
|
||||
import { activityApi } from "../api/activity";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { Button } from "./ui/button";
|
||||
import { Link } from "../lib/router";
|
||||
|
||||
export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried }: {
|
||||
companyId: string;
|
||||
@@ -18,6 +19,7 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried
|
||||
});
|
||||
const failedRun = runs?.find(run => run.runId === blocker.runId &&
|
||||
["failed", "timed_out"].includes(run.status));
|
||||
const requiresInspection = blocker.cause === "native_continuation_requires_reconciliation";
|
||||
const retry = useMutation({
|
||||
mutationFn: () => agentsApi.retryFailedRun(failedRun!.agentId, failedRun!.runId, companyId),
|
||||
onSuccess: () => {
|
||||
@@ -30,8 +32,15 @@ export function ExecutionBlockerNotice({ companyId, issueId, blocker, onRetried
|
||||
});
|
||||
return (
|
||||
<div role="status" aria-label="Task recovery" className="mx-(--sz-execution-blocker-inline) my-(--sz-execution-blocker-block) flex flex-wrap items-center justify-between execution-blocker-notice border border-border bg-muted text-foreground">
|
||||
<span>{blocker.cause === "legacy_execution_requires_reconciliation" ? "Automatic recovery of this task stopped." : blocker.nextAction}</span>
|
||||
{failedRun && (
|
||||
<span>{blocker.cause === "legacy_execution_requires_reconciliation"
|
||||
? "Automatic recovery of this task stopped."
|
||||
: `${requiresInspection ? "Recovery needed. " : ""}${blocker.nextAction}`}</span>
|
||||
{requiresInspection && blocker.agentId && blocker.runId && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={`/agents/${blocker.agentId}/runs/${blocker.runId}`}>Inspect run</Link>
|
||||
</Button>
|
||||
)}
|
||||
{!requiresInspection && failedRun && (
|
||||
<Button variant="outline" size="sm" disabled={retry.isPending} onClick={() => retry.mutate()}>
|
||||
{retry.isPending ? "Retrying…" : "Retry"}
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user