Files
PaperClipAI/tests/runner-e2e/first-task-cases.ts
T
DottaandPaperclip d0b67bfe71 feat: queue approvals and answers during active runs (#13539)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Users guide running agents through messages, questions, and approval
cards.
> - Messages already wait in a queue when an agent is running.
> - Card responses did not appear in that queue. Some question answers
also steered a later run without a user click.
> - A fast approval could invalidate the agent's review handoff and
cause it to stop its own run.
> - This pull request gives card responses the same queue controls and
preserves the exact response during delivery.
> - Users can wait for completion or explicitly send the response with
Interrupt or Steer.

## Linked Issues or Issue Description

Refs #13517, which is merged. This PR targets master and adds queued
interaction responses on top of the onboarding changes. Related
continuation work: #10519 and #12866.

**What happened?**

Accepting a proposal while its source run was active left a saved
response outside the message queue. The agent could then lose its review
path, reassign the task, and cancel itself. Answers to older questions
could also steer another active turn without a click.

**Expected behavior**

Save the response immediately. Queue its continuation behind the active
run. Deliver it after completion, or when the user explicitly chooses
Interrupt or Steer. Preserve approval revisions and answer choices.

**Steps to reproduce**

1. Let an agent publish a confirmation card while its run is still
active.
2. Accept the card before the agent finishes its review handoff.
3. Inspect the message queue and the task's next run.

**Paperclip version or commit**

Reproduced on da8a3876c with the onboarding changes from #13517.

**Deployment mode**

Local development from source. The fix covers legacy adapters and native
Runner turns.

## What Changed

- Project resolved cards into the existing queue as immutable responses.
Keep answers and exact approval revisions.
- Require an explicit click to steer a response into a compatible native
turn. Use Interrupt when a fresh session is required.
- Preserve typed response context through interruption, cleanup waits,
and normal queue promotion. Keep the direct answer channel for a
provider blocked on its original question request.
- Accept the source run's review handoff after its card resolves. Reject
stale agent reassignment that would orphan a queued response.
- Add deterministic regression tests and an `accept-while-running` case
to the first-task suite. Require recorded timestamp overlap before that
case can pass.
- Keep the first-task skill name out of user-facing messages.

## Verification

- Red-green: the original route failed the queue regression; the changed
route passes it.
- Focused server/UI tests: 139 passed, including 64 queue-route tests.
- Runner harness unit tests: 314 passed.
- Server, UI, and Runner E2E typechecks passed. UI token gates passed.
- Full repository typecheck and build passed. Server typecheck passed
again after review fixes.
- Review regressions: 165 queue/reopen route tests, 53 wake admission
tests, and 18 run identity tests passed. Approval acknowledgement
recovery and both message/approval arrival orders are covered.
- Full local test run: 12,401 passed; three new admission regressions
ran against a cached pre-fix module. A fresh run of that entire suite
passed (53 tests). The complete CI suite passed on the final commit.
- Previous-head CI at `c28e2ef12`: 32 checks passed and 2 optional
Storybook checks skipped. Every server/workspace/browser shard, Runner
verification, build, typecheck/release registry, canary, policy, and
security check passed. Greptile: 5/5, no unresolved threads. Earlier
interrupted CI workers were replaced by this fresh complete run.
- After integrating the updated parent: 314 harness tests, 119
queue/admission tests, 44 onboarding/question-delivery tests, and 13
native recovery tests passed locally. Full repository typecheck and
build passed.
- Clarified the skill wording preference: routine replies describe the
action without announcing the internal skill; direct questions and
permission/security/execution disclosures remain truthful.
- The paid `accept-while-running` scenario is registered for all four
local first-task profiles. It has not been run against a model in this
change.

- Rebased onto the merged parent at `11921075a`; the resulting tree
exactly matches the locally verified integration tree. Final-head CI on
`b53054807` passed: 54 successful checks, 2 optional Storybook checks
skipped, no failed checks. Every new server/browser shard, aggregate
verify/e2e gate, Runner, typecheck, build, canary, and security check
passed on the first attempt. Greptile reviewed this exact head at 5/5
with no unresolved threads.

## Risks

- Responses now wait instead of implicitly steering another active turn.
A provider blocked on the original question still receives its answer
directly.
- Approval receipts cannot be edited, discarded, or reordered as
comments. This preserves the recorded decision.
- Interruption must still prove that the prior execution stopped. The
tests cover cleanup waits and duplicate delivery.
- The new paid overlap case can be unexercised if the model finishes
before the click lands. It cannot pass without evidence of overlap.
- No database migration is required. This repairs the existing approvals
and execution controls; it does not implement the roadmap's work-stream
queues.

## Model Used

OpenAI GPT-6 through Codex. The exact deployed model ID and
context-window size were not exposed in this session. Capabilities used:
agentic reasoning, repository inspection, code editing, terminal
commands, and test execution.

## 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>
2026-09-16 14:44:36 -05:00

86 lines
3.8 KiB
TypeScript

import type { RunnerTaskFixture } from "./types.js";
export const FIRST_TASK_CASES = [
["interview-first-response", "Interview: first response", "interview", 1],
["clear-task-first-response", "Clear task: first response", "task", 1],
[
"ambiguous-task-first-response",
"Ambiguous task: first response",
"ambiguous",
1,
],
[
"plain-message-first-response",
"Opening card replaced by a message",
"message",
1,
],
["plan-first-response", "Explicit plan: first response", "plan", 1],
[
"ordinary-task-control",
"Other tasks do not inherit onboarding policy",
"ordinary",
1,
],
["interview-plan-accept", "Interview, plan, and acceptance", "interview", 3],
["task-card-accept", "Subtask accepted through a card", "task", 3],
["accept-while-running", "Accept a proposal while its agent is still running", "task", 3],
["task-reply-accept", "Subtask accepted in conversation", "task", 3],
[
"clarify-propose-accept",
"Clarification, proposal, and acceptance",
"ambiguous",
4,
],
["revise-accept", "Revise scope before accepting", "task", 4],
["reject-no-execution", "Decline proposed work", "task", 2],
] as const;
export type FirstTaskCaseId = (typeof FIRST_TASK_CASES)[number][0];
export type FirstTaskOpening = (typeof FIRST_TASK_CASES)[number][2];
export const firstTaskMarker = (nonce: string) =>
`GARDEN${nonce.replace(/[^a-z0-9]/gi, "")}`;
export function firstTaskScenario(id: string, nonce: string) {
const entry = FIRST_TASK_CASES.find((row) => row[0] === id);
if (!entry) throw new Error(`Unknown first-task case ${id}`);
const marker = firstTaskMarker(nonce);
const originalMarker = id === "revise-accept" ? `DRAFT${marker}` : marker;
const task = `I need a two-sentence welcome note for our neighborhood garden club. It should invite beginners to our free Saturday meetup and include the phrase ${originalMarker}. Save the finished note as a document attached to the task.`;
const facts = `We run a neighborhood garden club for beginners. Our first goal is a two-sentence welcome note inviting people to our free Saturday meetup. We have no budget and need no outside tools. Done means the note is saved as a task document and includes ${marker}.`;
return {
id: entry[0],
opening: entry[2],
marker,
originalMarker,
facts,
firstResponseOnly:
id.endsWith("first-response") || id === "ordinary-task-control",
prompt:
entry[2] === "ambiguous"
? "Help me get my organization going."
: entry[2] === "plan"
? `Write a short plan for welcoming new members to our garden club. ${facts} Let me review the plan before any work starts.`
: entry[2] === "ordinary"
? `Write the welcome note now on this task; no interview or subtask is needed. ${task}`
: task,
revision: `Change the note: invite people to our Sunday meetup instead, and replace ${originalMarker} with ${marker}. Please revise your proposal; I have not accepted it yet.`,
acceptance: "Yes, I accept that proposal. Please do it.",
rejection:
"No, do not proceed. I do not want this work done. Please acknowledge that and stop.",
};
}
export const firstTaskTasks: readonly RunnerTaskFixture[] =
FIRST_TASK_CASES.map(([id, label, , expectedRunCount]) => ({
id,
label,
groups: ["onboarding"],
flow: "first_task",
workMode: "standard",
expectedRunCount,
attemptTimeoutMs: { local: 15 * 60_000, daytona: 15 * 60_000 },
expectedTerminalState: { issue: "in_review", run: "succeeded" },
buildTitle: (nonce) => `First task ${id} ${nonce}`,
buildPrompt: (nonce) => firstTaskScenario(id, nonce).prompt,
buildVisibleMarker: firstTaskMarker,
buildMatchers: () => [], // Checkpoints and state effects are graded by first-task-scoring.
}));