mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix(studio): send the selected queue item next
This commit is contained in:
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user