Add a "send now" button to queued AI-chat messages: it interrupts the running agent and immediately sends that message, while the agent's partial output at interruption is kept in history and the next turn is marked as a user interrupt. Client: - queue-helpers: pure `promoteToHead` to move a queued message to the head. - chat-thread: `sendNow` (promote head + abort + flush-on-abort), one-shot `flushOnAbortRef`/`interruptNextSendRef`, `interrupted` flag in the request body, and the "send now" ActionIcon in the queued list. Server: - `interrupted` on AiChatStreamBody; pure `isInterruptResume` confirms the client hint against persisted history (prev assistant turn aborted/ streaming) before honouring it. - prompt: INTERRUPT_NOTE injected in the context section only on a confirmed interrupt-resume turn so the model treats the partial answer above as incomplete. Tests: promoteToHead, chat-thread send-now (abort + resend + one-shot interrupt flag + non-streaming immediate send), isInterruptResume, and the prompt interrupt-note injection. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
155 lines
4.3 KiB
TypeScript
155 lines
4.3 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
enqueueMessage,
|
|
dequeue,
|
|
promoteToHead,
|
|
removeQueuedById,
|
|
type QueuedMessage,
|
|
} from "./queue-helpers";
|
|
|
|
describe("enqueueMessage", () => {
|
|
it("appends a message to the end of the queue", () => {
|
|
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
|
const next = enqueueMessage(queue, { id: "b", text: "second" });
|
|
expect(next).toEqual([
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
]);
|
|
});
|
|
|
|
it("does not mutate the input queue", () => {
|
|
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
|
enqueueMessage(queue, { id: "b", text: "second" });
|
|
expect(queue).toEqual([{ id: "a", text: "first" }]);
|
|
});
|
|
});
|
|
|
|
describe("dequeue", () => {
|
|
it("returns {head:null, rest:[]} for an empty queue", () => {
|
|
expect(dequeue([])).toEqual({ head: null, rest: [] });
|
|
});
|
|
|
|
it("returns the first item as head and the remainder as rest", () => {
|
|
const queue: QueuedMessage[] = [
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
{ id: "c", text: "third" },
|
|
];
|
|
const { head, rest } = dequeue(queue);
|
|
expect(head).toEqual({ id: "a", text: "first" });
|
|
expect(rest).toEqual([
|
|
{ id: "b", text: "second" },
|
|
{ id: "c", text: "third" },
|
|
]);
|
|
});
|
|
|
|
it("does not mutate the input queue", () => {
|
|
const queue: QueuedMessage[] = [
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
];
|
|
dequeue(queue);
|
|
expect(queue).toEqual([
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("removeQueuedById", () => {
|
|
it("removes the matching id and leaves the others", () => {
|
|
const queue: QueuedMessage[] = [
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
{ id: "c", text: "third" },
|
|
];
|
|
const next = removeQueuedById(queue, "b");
|
|
expect(next).toEqual([
|
|
{ id: "a", text: "first" },
|
|
{ id: "c", text: "third" },
|
|
]);
|
|
});
|
|
|
|
it("returns an equivalent list when the id is not present", () => {
|
|
const queue: QueuedMessage[] = [{ id: "a", text: "first" }];
|
|
expect(removeQueuedById(queue, "missing")).toEqual([
|
|
{ id: "a", text: "first" },
|
|
]);
|
|
});
|
|
|
|
it("does not mutate the input queue", () => {
|
|
const queue: QueuedMessage[] = [
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
];
|
|
removeQueuedById(queue, "a");
|
|
expect(queue).toEqual([
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("promoteToHead", () => {
|
|
it("moves the matching id to the front, preserving the rest's order", () => {
|
|
const queue: QueuedMessage[] = [
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
{ id: "c", text: "third" },
|
|
];
|
|
expect(promoteToHead(queue, "c")).toEqual([
|
|
{ id: "c", text: "third" },
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
]);
|
|
});
|
|
|
|
it("is a no-op order-wise when the id is already the head", () => {
|
|
const queue: QueuedMessage[] = [
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
];
|
|
expect(promoteToHead(queue, "a")).toEqual([
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
]);
|
|
});
|
|
|
|
it("returns an equivalent list when the id is not present", () => {
|
|
const queue: QueuedMessage[] = [
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
];
|
|
expect(promoteToHead(queue, "missing")).toEqual(queue);
|
|
});
|
|
|
|
it("does not mutate the input queue", () => {
|
|
const queue: QueuedMessage[] = [
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
];
|
|
promoteToHead(queue, "b");
|
|
expect(queue).toEqual([
|
|
{ id: "a", text: "first" },
|
|
{ id: "b", text: "second" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("FIFO order", () => {
|
|
it("preserves order across enqueue -> dequeue", () => {
|
|
let queue: QueuedMessage[] = [];
|
|
queue = enqueueMessage(queue, { id: "1", text: "one" });
|
|
queue = enqueueMessage(queue, { id: "2", text: "two" });
|
|
queue = enqueueMessage(queue, { id: "3", text: "three" });
|
|
|
|
const order: string[] = [];
|
|
while (queue.length > 0) {
|
|
const { head, rest } = dequeue(queue);
|
|
if (head) order.push(head.text);
|
|
queue = rest;
|
|
}
|
|
expect(order).toEqual(["one", "two", "three"]);
|
|
});
|
|
});
|