mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Task conversations keep follow-up messages pending while an agent works. > - The screen combines optimistic sends, queue snapshots, and run input records. > - A late send response can restore a local queue flag after a run takes the message. > - A stale queue snapshot can then keep the same message in pending. > - This pull request matches the input record to its source queue and clears stale pending flags. > - The benefit is that consumed messages leave pending while unsent messages stay visible. ## Linked Issues or Issue Description **What happened?** A follow-up can remain in the task composer's pending row after a run takes it as input. Local queue state can outlive the send request. The old projection cleared that state for steering, but not for ordinary run-start input. A stale queue read can also retain the consumed entry. **Expected behavior** A message that a started run takes as input appears once in history and leaves pending. Other pending messages remain available. A message assigned to a run that has not started remains pending. **Steps to reproduce** 1. Send a follow-up while a task has a live run. 2. Let a started run record that message as input before or after the send response arrives. 3. Let a queue read still include the consumed message. 4. Observe the message remain pending on the base revision. **Paperclip version or commit** Reproduced against master at `33d24e48ad` with controlled request ordering. **Deployment mode** Source checkout. Browser verification used the production task screen with a simulated native runner and delayed API responses. It did not run a live model or change a deployed instance. Related: [PR #15030](https://github.com/paperclipai/paperclip/pull/15030) addresses acknowledgement matching in the runner console. This change addresses the task-detail composer and does not modify that console. ## What Changed - Clear local pending flags when a started run records the comment as input. - Use live-run input IDs while the run-history query catches up. - Match stale queue entries by their primary or coalesced source wake ID, even when the snapshot targets the previous run or has no target. - Retain coalesced source queue IDs in run context and include receipt IDs in compact task run reads. - Keep unstarted run inputs and authoritative retries pending. Cover send ordering, same-run and successor delivery, queued snapshots, coalesced inputs, and retry consumption. - Add the delivery-race checks to the steering feature map. ## Verification - Before the fix, the first four request-order regressions failed with the consumed message still in pending. Coverage now includes 16 combinations of receipt source, send-response order, and stale snapshot kind. - Focused UI checks: 5 files and 233 tests passed. These cover task detail, queue projection, optimistic comments, queued-message controls, and the task-chat adapter. - `pnpm -r typecheck` passed. - Server receipt checks: 5 files and 139 tests passed with `PAPERCLIP_TEST_POSTGRES_TEMPLATE=0`. These exercise real database reads, coalesced heartbeat input, and polling routes. The default cached-template path timed out during setup on this host; the fresh-cluster path passed without longer timeouts. - Token gates and `node scripts/check-maps.mjs` passed. - Browser: sent a follow-up with a delayed response in the production task screen. The consumed message moved into history. A second unsent follow-up stayed queued. Reload preserved both states. The API and runner were simulated. - `pnpm build` passed. - The full local repository test run was started, then stopped to isolate database setup timeouts. All 55 GitHub CI checks passed on `4ec6820cf1f89880b801fd5f938d72f54591c5e2`, including the full test matrix. One serialized server shard passed on its single rerun after a 15-second route-test timeout; the same 97-test route suite also passed locally. - Greptile reviewed the final head at 5/5. All review threads are resolved. ## Risks - Queue display depends on matching comment IDs, source queue IDs, and a recorded start time. Rows without start evidence stay pending. Newer queues remain visible until their own input receipt arrives. - Run context gains additive receipt metadata. No schema migration is required. Dispatch order, stored queue state, company access, and steering authority stay unchanged. - Browser evidence covers controlled response ordering. It does not establish deployment or live-provider behavior. ## Model Used - OpenAI Codex, GPT-6, with reasoning, tool use, and code execution. The exact serving model ID and context-window limit are not exposed in this session. ## 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 #` 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>
167 lines
6.9 KiB
TypeScript
167 lines
6.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { buildQueuedCommentQueueSnapshot, decideQueuedCommentQueueSteering, withQueuedCommentIdsInRunContext } from "./issue-queued-comment-queue.js";
|
|
|
|
describe("decideQueuedCommentQueueSteering", () => {
|
|
it("answers unsupported on the legacy protocol", () => {
|
|
const decision = decideQueuedCommentQueueSteering({
|
|
state: "deferred",
|
|
queueRunRuntimeMode: null,
|
|
activeRun: { id: "run-1", runtimeMode: "legacy" },
|
|
assignedAgentAdapterType: "codex_local",
|
|
queuedCommentCount: 1,
|
|
});
|
|
|
|
expect(decision).toEqual({ protocol: "legacy", kind: "unsupported" });
|
|
});
|
|
|
|
it("answers temporarily_unavailable for a promoted native queue with no deferred run", () => {
|
|
const decision = decideQueuedCommentQueueSteering({
|
|
state: "queued",
|
|
queueRunRuntimeMode: "native",
|
|
activeRun: null,
|
|
assignedAgentAdapterType: "paperclip_runner",
|
|
queuedCommentCount: 1,
|
|
});
|
|
|
|
expect(decision).toEqual({ protocol: "paperclip_runner_v1", kind: "temporarily_unavailable" });
|
|
});
|
|
|
|
it("answers temporarily_unavailable when the queue holds no live comments", () => {
|
|
const decision = decideQueuedCommentQueueSteering({
|
|
state: "deferred",
|
|
queueRunRuntimeMode: null,
|
|
activeRun: { id: "run-1", runtimeMode: "native" },
|
|
assignedAgentAdapterType: "paperclip_runner",
|
|
queuedCommentCount: 0,
|
|
});
|
|
|
|
expect(decision).toEqual({ protocol: "paperclip_runner_v1", kind: "temporarily_unavailable" });
|
|
});
|
|
|
|
it("tells the caller it may probe a running deferred turn on the native protocol", () => {
|
|
const decision = decideQueuedCommentQueueSteering({
|
|
state: "deferred",
|
|
queueRunRuntimeMode: null,
|
|
activeRun: { id: "run-1", runtimeMode: "native" },
|
|
assignedAgentAdapterType: "paperclip_runner",
|
|
queuedCommentCount: 1,
|
|
});
|
|
|
|
expect(decision).toEqual({ protocol: "paperclip_runner_v1", kind: "probe", steeringRunId: "run-1" });
|
|
});
|
|
|
|
// Acceptance-criterion fact pattern: a deferred queue whose active run
|
|
// has not resolved a runtime mode yet, for an agent on the
|
|
// `paperclip_runner` adapter. The protocol resolves to
|
|
// `paperclip_runner_v1` through the adapter-type fallback, and the
|
|
// decision hands the run to the caller to probe live — it never answers
|
|
// the flat "unsupported" value a duplicated, unshared rule can drift to.
|
|
it("resolves the protocol through the adapter-type fallback and asks the caller to probe", () => {
|
|
const decision = decideQueuedCommentQueueSteering({
|
|
state: "deferred",
|
|
queueRunRuntimeMode: null,
|
|
activeRun: { id: "run-1", runtimeMode: null },
|
|
assignedAgentAdapterType: "paperclip_runner",
|
|
queuedCommentCount: 1,
|
|
});
|
|
|
|
expect(decision).toEqual({ protocol: "paperclip_runner_v1", kind: "probe", steeringRunId: "run-1" });
|
|
});
|
|
|
|
it("keeps a preparing Paperclip Runner on the steering protocol until runtime selection finishes", () => {
|
|
expect(decideQueuedCommentQueueSteering({ state: "deferred", queueRunRuntimeMode: null,
|
|
activeRun: { id: "preparing-run", runtimeMode: "legacy", runtimeModeResolvedAt: null,
|
|
runnerProfileJson: { adapterDispatch: { adapterType: "paperclip_runner" } } },
|
|
assignedAgentAdapterType: "paperclip_runner", queuedCommentCount: 1,
|
|
})).toEqual({ protocol: "paperclip_runner_v1", kind: "temporarily_unavailable" });
|
|
});
|
|
|
|
it("preserves the historical adapter when mutable agent settings change", () => {
|
|
expect(decideQueuedCommentQueueSteering({ state: "deferred", queueRunRuntimeMode: null,
|
|
activeRun: { id: "legacy-run", runtimeMode: "legacy", runtimeModeResolvedAt: null,
|
|
runnerProfileJson: { adapterDispatch: { adapterType: "codex_local" } } },
|
|
assignedAgentAdapterType: "paperclip_runner", queuedCommentCount: 1,
|
|
})).toEqual({ protocol: "legacy", kind: "unsupported" });
|
|
});
|
|
});
|
|
|
|
describe("buildQueuedCommentQueueSnapshot entry permissions", () => {
|
|
const baseFacts = {
|
|
issueId: "issue-1",
|
|
queueId: "queue-1",
|
|
state: "queued" as const,
|
|
activeRunId: null,
|
|
protocol: "legacy" as const,
|
|
steeringDisposition: "unsupported" as const,
|
|
};
|
|
|
|
it("grants edit and discard to the user who authored the queued comment", () => {
|
|
const queue = buildQueuedCommentQueueSnapshot({
|
|
...baseFacts,
|
|
actorType: "user",
|
|
actorId: "user-1",
|
|
comments: [{ id: "comment-1", updatedAt: new Date(), authorUserId: "user-1" }],
|
|
});
|
|
|
|
expect(queue.entries[0]?.canEdit).toBe(true);
|
|
expect(queue.entries[0]?.canDiscard).toBe(true);
|
|
});
|
|
|
|
it("denies edit and discard to a user who did not author the queued comment", () => {
|
|
const queue = buildQueuedCommentQueueSnapshot({
|
|
...baseFacts,
|
|
actorType: "user",
|
|
actorId: "user-1",
|
|
comments: [{ id: "comment-1", updatedAt: new Date(), authorUserId: "user-2" }],
|
|
});
|
|
|
|
expect(queue.entries[0]?.canEdit).toBe(false);
|
|
expect(queue.entries[0]?.canDiscard).toBe(false);
|
|
});
|
|
|
|
it("denies edit and discard to an agent actor even when the comment carries a matching author id", () => {
|
|
const queue = buildQueuedCommentQueueSnapshot({
|
|
...baseFacts,
|
|
actorType: "agent",
|
|
actorId: "user-1",
|
|
comments: [{ id: "comment-1", updatedAt: new Date(), authorUserId: "user-1" }],
|
|
});
|
|
|
|
expect(queue.entries[0]?.canEdit).toBe(false);
|
|
expect(queue.entries[0]?.canDiscard).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("withQueuedCommentIdsInRunContext", () => {
|
|
it("invalidates every generated task projection and ownership metadata", () => {
|
|
const result = withQueuedCommentIdsInRunContext({
|
|
issueId: "issue-1",
|
|
preserved: "keep",
|
|
paperclipWake: { comments: [{ id: "comment-1" }] },
|
|
paperclipWakeComment: { id: "comment-1" },
|
|
paperclipTaskMarkdown: "historical",
|
|
paperclipTaskMarkdownCompact: "historical compact",
|
|
paperclipTaskMarkdownAssignment: "assignment",
|
|
paperclipTaskMarkdownAssignmentCompact: "assignment compact",
|
|
paperclipTurnContext: { version: 1, events: { owner: "wake_prompt" } },
|
|
}, ["comment-2"], ["source-wake"]);
|
|
|
|
expect(result).toMatchObject({
|
|
issueId: "issue-1",
|
|
preserved: "keep",
|
|
queuedCommentSourceWakeIds: ["source-wake"],
|
|
wakeCommentIds: ["comment-2"],
|
|
wakeCommentId: "comment-2",
|
|
commentId: "comment-2",
|
|
});
|
|
expect(result).not.toHaveProperty("paperclipWake");
|
|
expect(result).not.toHaveProperty("paperclipWakeComment");
|
|
expect(result).not.toHaveProperty("paperclipTaskMarkdown");
|
|
expect(result).not.toHaveProperty("paperclipTaskMarkdownCompact");
|
|
expect(result).not.toHaveProperty("paperclipTaskMarkdownAssignment");
|
|
expect(result).not.toHaveProperty("paperclipTaskMarkdownAssignmentCompact");
|
|
expect(result).not.toHaveProperty("paperclipTurnContext");
|
|
expect(withQueuedCommentIdsInRunContext(result, ["comment-3"]).queuedCommentSourceWakeIds).toEqual(["source-wake"]);
|
|
});
|
|
});
|