mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## 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>
430 lines
17 KiB
TypeScript
430 lines
17 KiB
TypeScript
// @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();
|
||
});
|
||
|
||
});
|