Files
PaperClipAI/ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx
T
DottaandPaperclip 70d23612dd fix: offer Interrupt when queued-message steering is unavailable (#19450)
## Thinking Path

> - Paperclip lets people manage work performed by AI agents.
> - Task chat saves follow-up messages while an agent runs.
> - A native runner can accept Steer only after an active provider turn
exists.
> - During preparation, or with an unsupported provider, the queue
showed a disabled Steer button and no delivery action.
> - The server also rejected Interrupt for ordinary native queues.
> - This change offers an explicit Interrupt fallback and uses the
existing cancellation and admission path.
> - People can send their saved messages while Stop confirmation,
cleanup, and execution gates remain enforced.

## Linked Issues or Issue Description

Related: #15865 covers mixed legacy queues. #15030 covers steering
acknowledgements. This change covers native queues without available
steering.

**What happened?**

A queued message had a disabled Steer button while the native runner was
still preparing. Clicking it did nothing. The server returned HTTP 409
for an explicit Interrupt request against the same native queue.

**Expected behavior**

Offer Steer when the provider can accept same-turn input. Otherwise
offer Interrupt, labeled with its actual effect. Preserve saved messages
until the previous execution stops and normal admission accepts the
successor.

**Steps to reproduce**

1. Start a task with the Paperclip Runner adapter.
2. Submit a follow-up while the run is preparing, before its provider
turn opens.
3. Observe the disabled Steer button above the composer.
4. With this change, click Interrupt. The existing Stop path cancels the
previous execution and submits the saved queue through normal admission.

**Paperclip version or commit**

Reproduced locally on master at 523818ea9b. The branch is rebased on
2f9d3d43c3.

## What Changed

- Offer Interrupt for unavailable or unsupported native steering when
the parent supplies an interrupt callback.
- Keep Steer for an available provider turn.
- Accept explicit Interrupt for a deferred native queue and record its
run target.
- Bind native continuation to the exact Interrupt queue, run, actor, and
cancellation receipt. This lets one click send the messages after Stop
completes.
- Add component, task-thread, real PostgreSQL route, and continuation
authorization regressions.
- Close the route fixture database pool before disposing its PostgreSQL
instance.
- Document the fallback and its Stop and admission requirements.

## Verification

- Red: two component cases failed because Interrupt was absent. Two
route cases returned HTTP 409, `This queue does not support
interruption`, before the repair.
- Red: stronger delivery assertions also caught `send_predates_stop`
after the first repair. The admission fix covers both the freshness
exception and later eligibility check.
- Green: both route regressions now wait for preparation settlement,
then deliver both saved messages exactly once without a second click.
The complete route suite passes 119 tests. The complete continuation
suite passes 376 tests, including wrong actor, queue, target, and
missing-receipt cases.
- UI: task-thread and queued-message suites pass 394 tests, with one
existing skip. The issue-page suite also passes (the earlier three-suite
run passed 644 tests before the final three UI cases).
- Token gates and `git diff --check` pass.
- Full workspace build and low-memory typecheck pass. The initial
typecheck caught a missing `membershipRole` in an unrelated fixture; the
rebase picked up the correction already on master, and the complete
typecheck then passed.
- The full local `pnpm test:run` passed all six chat shards (1,931
tests). It was stopped during general-server validation after the final
commit passed all PR CI gates. The complete local suite is not claimed
as a pass.
- Earlier route-suite teardown timed out with an open database pool.
Closing that pool fixed teardown; the complete route suite then passed.
- All final-head CI gates pass: 52 successful checks and two intentional
skips. Greptile is 5/5 on the same head with no unresolved review
threads.

## Risks

Interrupt stops the previous execution and starts a successor; it is a
different action from same-turn steering. The UI labels that action
explicitly. Cleanup or execution gates can keep the messages queued.
Existing company access, board permissions, queue revision, run target,
cancellation proof, and admission checks remain in place. No schema
migration is needed.

## Model Used

OpenAI Codex (GPT-6), with reasoning, tool use, and code execution. The
exact model ID and context window are not exposed by this client.

## 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-11-06 07:11:38 -06:00

481 lines
20 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("shows a declined connection request as readable text, never its raw JSON receipt (BUG-08)", () => {
const receipt = JSON.stringify({ interactionId: "slack-1", kind: "connection_intent", status: "rejected",
payload: { serviceSlug: "slack", serviceName: "Slack" }, result: { outcome: "declined" } }, null, 2);
const entry = { ...queue.entries[0], canEdit: false, canDiscard: false,
comment: { ...queue.entries[0].comment, id: "slack-1", body: `Rejected: Connect Slack\n\n${receipt}` },
source: { kind: "interaction" as const, interactionId: "slack-1", interactionKind: "connection_intent",
summary: "Declined: Slack connection" } };
render({ queue: { ...queue, entries: [entry], steeringDisposition: "temporarily_unavailable" } });
const row = container.querySelector('[data-testid="task-chat-queued-message-slack-1"]')!;
const hoverAndLabels = [...row.querySelectorAll("[title], [aria-label]")]
.flatMap((element) => [element.getAttribute("title"), element.getAttribute("aria-label")])
.filter((text): text is string => Boolean(text));
expect(hoverAndLabels.length).toBeGreaterThan(0);
for (const text of hoverAndLabels) {
expect(text).not.toContain("{");
expect(text).not.toContain("interactionId");
}
expect(row.textContent).toContain("Declined: Slack connection");
expect(row.querySelector('[title="Declined: Slack connection"]')).not.toBeNull();
});
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.each(["temporarily_unavailable", "unsupported"] as const)(
"offers an explicit interrupt fallback when native steering is %s",
async (steeringDisposition) => {
const props = render({
queue: { ...queue, steeringDisposition },
onInterrupt: vi.fn().mockResolvedValue(undefined),
});
const interrupt = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-queued-interrupt-comment-1"]',
);
expect(interrupt).not.toBeNull();
expect(interrupt!.disabled).toBe(false);
expect(interrupt!.textContent).toContain("Interrupt");
await act(async () => { interrupt!.click(); });
expect(props.onInterrupt).toHaveBeenCalledOnce();
expect(props.onSteer).not.toHaveBeenCalled();
expect(container.querySelector('[data-testid="task-chat-queued-messages"]')).toBeNull();
},
);
it("keeps same-turn steering when the provider is available, even with an interrupt callback", async () => {
const props = render({ onInterrupt: vi.fn().mockResolvedValue(undefined) });
expect(container.querySelector('[data-testid="task-chat-queued-interrupt-comment-1"]')).toBeNull();
await act(async () => {
container.querySelector<HTMLButtonElement>('[data-testid="task-chat-queued-steer-comment-1"]')!.click();
});
expect(props.onSteer).toHaveBeenCalledWith("comment-1", "rev-1");
expect(props.onInterrupt).not.toHaveBeenCalled();
});
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();
});
});