fix(studio): send the selected queue item next

This commit is contained in:
xiexian committed 2026-10-08 14:09:03 +08:00
1 parent 3a3a720cda
commit 49094ac587
2 files changed
+138 -7

No files matched your search

@@ -0,0 +1,131 @@
// @vitest-environment jsdom
import { useEffect, useState } from "react";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import "./testkit";
import { MockPort } from "../port/mock";
import type { AgentPort } from "../port/port";
import { Queue } from "./Queue";
import { useQueueActions } from "./queueactions";
const ports: MockPort[] = [];
afterEach(async () => {
cleanup();
for (const port of ports.splice(0)) await port.setQueuePaused(true);
localStorage.clear();
vi.restoreAllMocks();
});
const BODIES = ["First queued instruction", "Second queued instruction", "Third queued instruction"];
const STEER = "Selected guidance: " + "keep the whole instruction, including its final sentence. ".repeat(4);
async function queued() {
const port = new MockPort();
ports.push(port);
let now = Date.now();
vi.spyOn(Date, "now").mockImplementation(() => ++now);
await port.setQueuePaused(true);
for (const text of BODIES) await port.queueFollowup(text);
await port.setQueuePaused(false);
return port;
}
function draw(port: MockPort) {
const dispatch = vi.fn();
const fail = vi.fn();
const beforeCancel: string[][] = [];
const cancel = port.cancel.bind(port);
vi.spyOn(port, "cancel").mockImplementation(async () => {
const snapshot = await port.queue();
beforeCancel.push(await Promise.all(snapshot.items.map((item) => port.readQueued(item.id))));
await cancel();
});
function Panel() {
const [moved, setMoved] = useState(0);
useEffect(() => {
const detach = port.subscribe((event) => {
if (event.kind === "inbox_changed") setMoved((n) => n + 1);
});
return () => { detach(); };
}, [port]);
const actions = useQueueActions({ port: port as unknown as AgentPort, dispatch, fail, moved });
return <Queue queue={actions.queue} running
onRead={actions.onQueueRead} onEdit={actions.onQueueEdit} onMove={actions.onQueueMove}
onCancel={actions.onQueueCancel} onSendNow={actions.onQueueSendNow}
onRetry={actions.onQueueRetry} onRefresh={actions.onQueueRefresh} onPause={actions.onQueuePause} />;
}
render(<Panel />);
return { beforeCancel, dispatch, fail };
}
async function send(index: number) {
const buttons = await screen.findAllByRole("button", { name: "立即发送" });
await userEvent.click(buttons[index]);
}
describe("sending the selected queue entry now", () => {
it.each([1, 2])("puts row %i first before stopping the current reply", async (index) => {
const port = await queued();
const { beforeCancel, fail } = draw(port);
await send(index);
await waitFor(() => expect(beforeCancel).toEqual([[BODIES[index], ...BODIES.filter((_, i) => i !== index)]]));
expect(fail).not.toHaveBeenCalled();
const snapshot = await port.queue();
expect(snapshot.items).toHaveLength(3);
});
it("keeps the order when the selected entry is already first", async () => {
const port = await queued();
const { beforeCancel, fail } = draw(port);
await send(0);
await waitFor(() => expect(beforeCancel).toEqual([BODIES]));
expect(fail).not.toHaveBeenCalled();
});
it("promotes the replacement follow-up for accepted guidance, preserving its full body", async () => {
const port = await queued();
const accepted = await port.steer(STEER);
const { beforeCancel, dispatch, fail } = draw(port);
await send(3);
await waitFor(() => expect(beforeCancel).toEqual([[STEER, ...BODIES]]));
const snapshot = await port.queue();
expect(snapshot.items.map((item) => item.id)).not.toContain(accepted.itemId);
expect(snapshot.items[0].intent).toBe("followup");
expect(dispatch).toHaveBeenCalledWith({ kind: "__unsent", id: accepted.itemId });
expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ kind: "__queued", itemId: snapshot.items[0].id }));
expect(fail).not.toHaveBeenCalled();
});
it("keeps the current reply running when reordering fails", async () => {
const port = await queued();
const error = new Error("queue write failed");
vi.spyOn(port, "moveQueued").mockRejectedValue(error);
const { beforeCancel, fail } = draw(port);
await send(1);
await waitFor(() => expect(fail).toHaveBeenCalledWith(error));
expect(beforeCancel).toEqual([]);
const snapshot = await port.queue();
expect(await Promise.all(snapshot.items.map((item) => port.readQueued(item.id)))).toEqual(BODIES);
});
it.each(["readQueued", "cancelQueued", "queueFollowup"] as const)("does not stop the reply if guidance conversion fails at %s", async (method) => {
const port = await queued();
await port.steer(STEER);
const error = new Error("conversion failed");
vi.spyOn(port, method).mockRejectedValue(error);
const { beforeCancel, fail } = draw(port);
await send(3);
await waitFor(() => expect(fail).toHaveBeenCalledWith(error));
expect(beforeCancel).toEqual([]);
});
it("offers no send-now action while the queue is paused", async () => {
const port = await queued();
await port.setQueuePaused(true);
const { beforeCancel } = draw(port);
await screen.findByText(BODIES[0]);
expect(screen.queryByRole("button", { name: "立即发送" })).toBeNull();
expect(beforeCancel).toEqual([]);
});
});
+7 -7
View File
@@ -37,14 +37,12 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionPath }: In
const onQueueRefresh = useCallback((id: string) => void port.refreshQueued(id).catch(fail), [port, fail]);
const onQueuePause = useCallback((on: boolean) => void port.setQueuePaused(on).catch(fail), [port, fail]);
const onQueueRead = useCallback((id: string) => port.readQueued(id), [port]);
// "Send now" means the only thing it can while a turn holds the session: end
// that turn, and the queue dispatches this line as the next one. Guidance the
// running turn already accepted has to leave it first — a turn that ends
// without reading an accepted steer parks it as uncertain and pauses the
// whole queue, which is the opposite of sending it.
// Cancellation lets the dispatcher take the queue head. Accepted guidance
// must leave the active turn and become a follow-up before that turn ends.
const onQueueSendNow = useCallback(
async (item: QueueItem) => {
try {
let itemId = item.id;
if (item.state === "steer_accepted") {
const text = await port.readQueued(item.id);
await port.cancelQueued(item.id);
@@ -52,14 +50,16 @@ export function useQueueActions({ port, dispatch, fail, moved, sessionPath }: In
const id = localId();
dispatch({ kind: "__user", text, pending: false, id } as never);
const again = await port.queueFollowup(text);
if (again?.itemId) dispatch({ kind: "__queued", id, itemId: again.itemId, queued: "followup" } as never);
itemId = again.itemId;
dispatch({ kind: "__queued", id, itemId, queued: "followup" } as never);
}
await port.moveQueued(itemId, 0);
await port.cancel();
} catch (e) {
fail(e);
}
},
[port, fail],
[port, fail, dispatch],
);
// The panel knows the entry, never the row the composer minted for it, so
// taking one back here has to name it the way the kernel does. The body is