Files
PaperClipAI/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx
T
Devin FoleyandPaperclip c9213d1be4 fix: send saved queued messages after Stop (#17959)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - People can save follow-up messages while an agent runs.
> - Stop preserves those messages and stops the active execution.
> - The queue removed its delivery controls when that execution ended.
> - Native admission also refused an explicit resend of older queued
input.
> - This change adds Send for a stopped queue and records fresh board
intent.
> - The saved message can continue through normal cleanup and admission
checks.

## Linked Issues or Issue Description

**What happened?**

A person queued a message with a screenshot while an agent was starting.
Steering was not yet available. They stopped the run. The message
remained visible, but no control could send it. Native queue delivery
also rejected this transition.

**Expected behavior**

A stopped queue offers Send. The click preserves message IDs, content,
attachments and original authors. It starts at most one eligible
continuation. Stop remains effective until the person gives new
direction.

**Steps to reproduce**

1. Start a native task and queue a second message.
2. Stop the active run before it consumes that message.
3. Open the task after cleanup. Observe the saved message with no send
control.

Refs #16965 for the queue-control change and #15015 for stopped
conversation recovery. No open repair for this exact transition was
found.

## What Changed

- Show Send for a saved deferred queue with no active run.
- Accept the existing board queue action for a stopped native target.
Keep live native interruption restricted.
- Use the server-recorded click time to authorize older native messages.
Reject stale, malformed and wrong-actor intents.
- Reuse normal queue dispatch, revision checks, cleanup proofs and
execution admission, including a failed native predecessor after a
verified Send.
- Ignore a fully committed, successful recovered export when admitting a
fresh message, while retaining the wait for results still committing.
- Honor an applied `/new` conversation boundary while retaining native
execution holds until the existing stop and cleanup checks verify them.
Workspace holds also survive reset. Check the newest failed or cancelled
native execution even when older recovery actions exist.
- Keep native automatic delivery separate from the explicit Send action.
Update execution and access documentation.

## Verification

- Meaningful regressions failed before the fixes: stopped queues lacked
Send, native admission refused fresh intent, cleanup and dispatch
receipts were missing, deferred rows disappeared, live plan Interrupt
rejected its own receipt, and Agent Chat `/new` inherited an old
non-workspace execution hold.
- Six assembled-wakeup cases fail before the reset cleanup fix. All 11
reset/history cases pass after it: live processes, missing identity,
quarantined cleanup, remote leases and live controllers remain held;
verified cleanup proceeds and retires the hold. Two more cases fail
before the fallback fix and pass after it: Send verifies a stopped or
live native predecessor despite a later reset receipt and no recovery
record.
- **525 server tests pass** across seven queue admission, route,
dispatch, envelope, provenance, blocker and conversation files. **343
queue/thread UI tests** also pass. Server/UI TypeScript, token and map
guards pass. The final scoped server typecheck also builds native Runner
successfully.
- The latest regressions prove that completed recovered exports permit
fresh input, and clearing conversation history still requires old native
stop and cleanup verification; an unsafe workspace still blocks after
`/new`; failed native Send reaches actual continuation dispatch with the
original queued comment. A result still being committed remains
deferred. History plus a newer Stop cannot hide a missing
process/controller/lease receipt.
- The real Chromium Stop/Send journey passes on current head
`75fa5e7222c59f43ff7ac326e81093bc9da28330`: queue a follow-up, Stop,
reload, explicitly Send, then verify exactly one useful response and no
live run. Earlier browser journeys cover legacy Interrupt, interruption
during a write and Stop followed by a new direction. These use an
isolated app/database and controlled ACP fixture, not an external AI
account or a staging deployment.
- Negative cases retain actor, company, issue, assignee, queue and
cancellation-receipt bindings; cleanup/process/workspace/controller
holds; approvals; pause and budget gates. Typed responses keep their
original resolver and immutable result.
- Full local recursive typecheck stopped in unchanged Rust Runner
compilation with ENOSPC, so the chained full tests/build did not run.
All hosted CI gates pass on final commit
`75fa5e7222c59f43ff7ac326e81093bc9da28330`, including typecheck, tests,
build and E2E. Apex review on that commit is 5/5, with no unresolved
findings. Earlier unrelated CI failures involved PostgreSQL
fixture-cleanup deadlock and an unchanged decision-expiry deadline
assertion; the latter's 20 cases pass locally without source changes. No
assertions or limits were weakened. Local resource cleanup was limited
to this task's verified completed test processes and rebuildable npm
caches.

## Risks

- Send can authorize a new turn for a saved message after Stop. A Send
click must be newer than the stopped execution; a live plan Interrupt is
bound to the cancellation receipt produced by that same click. It does
not imply that cleanup finished or that an external action is safe to
replay.
- The existing endpoint and intent format are reused. No schema or
credential change is required. Live native steering and legacy
interruption keep their existing paths.

## Model Used

OpenAI Codex, GPT-6 family, with reasoning, repository inspection, tool
use and code execution. The session does not expose the exact serving
model ID or context window size.

## 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-10-27 20:34:14 -07:00

430 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { act, type ComponentProps } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IssueQueuedCommentQueue } from "@paperclipai/shared";
import {
reorderQueuedMessageEntries,
TaskChatQueuedMessages,
} from "./TaskChatQueuedMessages";
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((innerResolve, innerReject) => {
resolve = innerResolve;
reject = innerReject;
});
return { promise, resolve, reject };
}
const queue: IssueQueuedCommentQueue = {
issueId: "issue-1",
queueId: "wake-1",
state: "deferred",
targetRunId: "run-1",
revision: "rev-1",
protocol: "paperclip_runner_v1",
steeringDisposition: "available",
entries: ["First queued message", "Second queued message"].map(
(body, position) => ({
comment: {
id: `comment-${position + 1}`,
companyId: "company-1",
issueId: "issue-1",
authorType: "user",
authorAgentId: null,
authorUserId: "user-1",
body,
presentation: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
},
position,
canEdit: true,
canDiscard: true,
}),
),
};
describe("TaskChatQueuedMessages", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root.unmount());
container.remove();
});
function render(
overrides: Partial<ComponentProps<typeof TaskChatQueuedMessages>> = {},
) {
const props = {
queue,
onEdit: vi.fn(),
onReorder: vi.fn().mockResolvedValue(undefined),
onSteer: vi.fn().mockResolvedValue(undefined),
onDiscard: vi.fn().mockResolvedValue(undefined),
...overrides,
};
flushSync(() => root.render(<TaskChatQueuedMessages {...props} />));
return props;
}
it("keeps saved input visible without duplicating the task status", () => {
const message = "Waiting for the previous environment to stop. Your message will start automatically.";
render({ queue: { ...queue, executionWait: { reason: "remote_cleanup", message } } });
expect(container.textContent).not.toContain(message);
expect(container.textContent).toContain("First queued message");
render();
expect(container.textContent).not.toContain(message);
});
it("renders each queued message once as a compact one-line row", () => {
render();
const pane = container.querySelector(
'[data-testid="task-chat-queued-messages"]',
);
expect(pane?.classList).toContain("mx-3");
expect(pane?.classList).toContain("rounded-b-none");
expect(pane?.classList).toContain("border-b-0");
expect(pane?.classList).toContain("-mb-px");
expect(
container.querySelectorAll('[data-testid^="task-chat-queued-message-"]'),
).toHaveLength(2);
expect(container.textContent).toContain("First queued message");
expect(container.textContent).toContain("Second queued message");
});
it.each(["legacy", "native", "native-plan"] as const)("shows a read-only response and sends it only on click for %s", async (runtime) => {
const entry = { ...queue.entries[0], canEdit: false, canDiscard: false,
comment: { ...queue.entries[0].comment, body: 'Accepted: Build the app\n\n{"revision": "v1"}' },
source: { kind: "interaction" as const, interactionId: "confirmation-1", interactionKind: "request_confirmation",
requiresFreshSession: runtime === "native-plan" } };
const props = render({ queue: { ...queue, entries: [entry],
protocol: runtime === "legacy" ? "legacy" : "paperclip_runner_v1" },
onInterrupt: vi.fn().mockResolvedValue(undefined) });
expect(props.onSteer).not.toHaveBeenCalled();
expect(props.onInterrupt).not.toHaveBeenCalled();
expect(container.textContent).toContain("Accepted: Build the app");
expect(container.textContent).not.toContain('"revision"');
expect(container.querySelector<HTMLButtonElement>('[aria-label^="Reorder"]')?.disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>('[data-testid^="task-chat-queued-discard-"]')?.disabled).toBe(true);
expect(reorderQueuedMessageEntries([entry, queue.entries[1]], "comment-1", "comment-2")).toBeNull();
const action = runtime === "native" ? "steer" : "interrupt";
await act(async () => { container.querySelector<HTMLButtonElement>(`[data-testid="task-chat-queued-${action}-comment-1"]`)!.click(); });
if (action === "steer") expect(props.onSteer).toHaveBeenCalledWith("comment-1", "rev-1");
else expect(props.onInterrupt).toHaveBeenCalledOnce();
});
it("reorders the complete queue and rewrites contiguous positions", () => {
const next = reorderQueuedMessageEntries(
queue.entries,
"comment-2",
"comment-1",
);
expect(next?.map((entry) => entry.comment.id)).toEqual([
"comment-2",
"comment-1",
]);
expect(next?.map((entry) => entry.position)).toEqual([0, 1]);
});
it("removes the selected steering row before the provider acknowledges it", async () => {
const acknowledgement = deferred<void>();
const props = render({
onSteer: vi.fn().mockReturnValue(acknowledgement.promise),
});
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-steer-comment-1"]',
)
?.click();
await Promise.resolve();
});
expect(props.onSteer).toHaveBeenCalledWith("comment-1", "rev-1");
expect(
container.querySelector(
'[data-testid="task-chat-queued-message-comment-1"]',
),
).toBeNull();
expect(container.textContent).toContain("Steering queued message.");
expect(
container.querySelector(
'[data-testid="task-chat-queued-message-comment-2"]',
),
).not.toBeNull();
await act(async () => {
render({ ...props, queue: { ...queue, entries: [...queue.entries] } });
});
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).toBeNull();
await act(async () => {
acknowledgement.resolve();
await acknowledgement.promise;
});
});
it("clears the entire interrupt queue immediately and restores it inline on failure", async () => {
let rejectInterrupt!: (error: Error) => void;
const interrupted = new Promise<void>((_, reject) => { rejectInterrupt = reject; });
const props = render({ queue: { ...queue, protocol: "legacy", steeringDisposition: "unsupported" },
onInterrupt: vi.fn().mockReturnValue(interrupted) });
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-interrupt-comment-1"]')!.click();
});
expect(props.onInterrupt).toHaveBeenCalledOnce();
expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).toBeNull();
await act(async () => { rejectInterrupt(new Error("Connection lost")); await interrupted.catch(() => undefined); });
expect(container.querySelectorAll('[data-testid^="task-chat-queued-message-"]')).toHaveLength(2);
expect(container.textContent).toContain("Couldn’t interrupt. Message is still queued.");
});
it.each(["steer", "interrupt"] as const)("shows a delivery error even when the queue becomes empty while %s is pending", async action => {
const delivery = deferred<void>();
const props = render({ queue: { ...queue, protocol: action === "interrupt" ? "legacy" : "paperclip_runner_v1" },
onSteer: vi.fn().mockReturnValue(delivery.promise), onInterrupt: vi.fn().mockReturnValue(delivery.promise) });
await act(async () => { container.querySelector<HTMLButtonElement>(`[data-testid="task-chat-queued-${action}-comment-1"]`)!.click(); });
await act(async () => { render({ ...props, queue: { ...props.queue, revision: "empty-poll", entries: [] } }); });
await act(async () => { delivery.reject(new Error("Connection lost")); await delivery.promise.catch(() => undefined); });
const error = action === "steer" ? "Couldn’t steer." : "Couldn’t interrupt.";
expect([...container.querySelectorAll('[role="status"]')].some(status => status.textContent?.includes(error))).toBe(true);
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).toBeNull();
});
it("does not restore a message discarded elsewhere while steering was pending", async () => {
let rejectSteer!: (error: Error) => void;
const delivery = new Promise<void>((_, reject) => { rejectSteer = reject; });
const props = render({ onSteer: vi.fn().mockReturnValue(delivery) });
await act(async () => { container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-steer-comment-1"]')!.click(); });
await act(async () => { render({ ...props, queue: { ...queue, revision: "rev-2", entries: [queue.entries[1]] } }); });
await act(async () => { rejectSteer(new Error("Queue changed")); await delivery.catch(() => undefined); });
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-1"]')).toBeNull();
expect(container.querySelector('[data-testid="task-chat-queued-message-comment-2"]')).not.toBeNull();
expect(container.textContent).toContain("Couldn’t steer.");
});
it("keeps a row queued when steering fails and announces the retryable state", async () => {
render({
onSteer: vi.fn().mockRejectedValue(new Error("steering_timeout")),
});
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-steer-comment-1"]',
)
?.click();
});
expect(
container.querySelector(
'[data-testid="task-chat-queued-message-comment-1"]',
),
).not.toBeNull();
expect(container.textContent).toContain(
"Couldn’t steer. Message is still queued.",
);
});
it("disables steering when the provider does not advertise it", () => {
render({ queue: { ...queue, steeringDisposition: "unsupported" } });
expect(
container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-steer-comment-1"]',
)?.disabled,
).toBe(true);
});
it("waits for authoritative discard acknowledgement before removing the row", async () => {
const acknowledgement = deferred<void>();
render({ onDiscard: vi.fn().mockReturnValue(acknowledgement.promise) });
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-discard-comment-1"]',
)
?.click();
await Promise.resolve();
});
expect(
container.querySelector(
'[data-testid="task-chat-queued-message-comment-1"]',
),
).not.toBeNull();
expect(container.textContent).not.toContain("Queued message discarded.");
await act(async () => {
acknowledgement.resolve();
await acknowledgement.promise;
});
expect(
container.querySelector(
'[data-testid="task-chat-queued-message-comment-1"]',
),
).toBeNull();
expect(container.textContent).toContain("Queued message discarded.");
});
it("keeps a too-late discard visible with an explicit error", async () => {
render({
onDiscard: vi.fn().mockRejectedValue({
body: { details: { code: "queued_comment_already_dispatching" } },
}),
});
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-discard-comment-1"]',
)
?.click();
});
expect(
container.querySelector(
'[data-testid="task-chat-queued-message-comment-1"]',
),
).not.toBeNull();
expect(container.textContent).toContain(
"Too late to discard: this message is already being sent.",
);
});
it("shows stale revisions without announcing a discard", async () => {
render({
onDiscard: vi.fn().mockRejectedValue({
body: { details: { code: "queued_comment_revision_conflict" } },
}),
});
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-discard-comment-1"]',
)
?.click();
});
expect(container.textContent).toContain(
"The queue changed in another session. Review it and try again.",
);
expect(container.textContent).not.toContain("Queued message discarded.");
});
it("does not expose queue controls before a real queue id is acknowledged", () => {
const props = render({ queue: { ...queue, queueId: null, state: null } });
const discard = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-discard-comment-1"]',
);
expect(discard?.disabled).toBe(true);
discard?.click();
expect(props.onDiscard).not.toHaveBeenCalled();
});
it("can discard a local optimistic row before the queue id is acknowledged", async () => {
const optimisticQueue = {
...queue,
queueId: null,
state: "deferred" as const,
entries: [
{
...queue.entries[0],
comment: {
...queue.entries[0].comment,
id: "optimistic-local-1",
},
},
],
};
const props = render({ queue: optimisticQueue });
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-discard-optimistic-local-1"]',
)
?.click();
});
expect(props.onDiscard).toHaveBeenCalledWith("optimistic-local-1", "rev-1");
expect(
container.querySelector(
'[data-testid="task-chat-queued-message-optimistic-local-1"]',
),
).toBeNull();
});
it("delivers legacy queued messages to a live target", async () => {
const targetRunId = "run-1";
const onInterrupt = vi.fn().mockResolvedValue(undefined);
render({
queue: {
...queue,
targetRunId,
protocol: "legacy",
steeringDisposition: "unsupported",
},
onInterrupt,
});
await act(async () => {
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-interrupt-comment-1"]',
)
?.click();
});
expect(onInterrupt).toHaveBeenCalledOnce();
expect(
container.querySelector(
'[data-testid="task-chat-queued-steer-comment-1"]',
),
).toBeNull();
expect(
container.querySelector(
'[data-testid="task-chat-queued-message-comment-1"]',
),
).toBeNull();
});
it("keeps deferred sends visible and explains their current wait", async () => {
const saved = { ...queue, targetRunId: null, executionWait: { reason: "decision_pending", message: "Resolve the pending approval before sending." } };
const onInterrupt = vi.fn().mockResolvedValue(saved);
render({ queue: saved, onInterrupt });
await act(async () => { container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-interrupt-comment-1"]')!.click(); });
expect(container.textContent).toContain("First queued message");
expect(container.textContent).toContain(saved.executionWait.message);
expect(container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-interrupt-comment-1"]')?.disabled).toBe(false);
});
it("clears a deferred wait message when a refreshed queue finishes sending", async () => {
const saved = { ...queue, targetRunId: null, executionWait: { reason: "decision_pending", message: "Resolve the pending approval before sending." } };
render({ queue: saved, onInterrupt: vi.fn().mockResolvedValue(saved) });
await act(async () => { container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-interrupt-comment-1"]')!.click(); });
expect(container.textContent).toContain(saved.executionWait.message);
render({ queue: { ...saved, queueId: null, entries: [], state: null, executionWait: null } });
await act(async () => {});
expect(container.textContent).not.toContain(saved.executionWait.message);
expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).toBeNull();
});
it.each(["legacy", "paperclip_runner_v1"] as const)("sends a saved %s queue explicitly after Stop", async (protocol) => {
const onInterrupt = vi.fn().mockResolvedValue(undefined);
render({ queue: { ...queue, targetRunId: null, protocol, steeringDisposition: "temporarily_unavailable" }, onInterrupt });
expect(onInterrupt).not.toHaveBeenCalled();
const send = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-interrupt-comment-1"]');
expect(send?.textContent).toBe("Send");
expect(send?.getAttribute("aria-label")).toBe("Send queued messages");
expect(send?.disabled).toBe(false);
await act(async () => { send!.click(); });
expect(onInterrupt).toHaveBeenCalledOnce();
expect(container.querySelector('[data-testid="task-chat-queued-steer-comment-1"]')).toBeNull();
});
});