fix(webui): derive temporary chats from session policy

This commit is contained in:
chengyongru
2026-08-08 23:20:59 +08:00
committed by Xubin Ren
parent a5bc3bfbb9
commit 75e333a3c5
13 changed files with 647 additions and 185 deletions
+15 -9
View File
@@ -20,6 +20,7 @@ const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const setSidebarStateSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
const sendMessageSpy = vi.fn();
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
@@ -238,6 +239,7 @@ vi.mock("@/lib/nanobot-client", async (importOriginal) => {
getGoalState = () => undefined;
sendMessage = sendMessageSpy;
newChat = vi.fn();
newTemporaryChat = newTemporaryChatSpy;
attach = attachSpy;
setSidebarState = setSidebarStateSpy;
discardTemporaryChat = discardTemporaryChatSpy;
@@ -270,6 +272,10 @@ describe("App layout", () => {
attachSpy.mockReset();
setSidebarStateSpy.mockReset();
discardTemporaryChatSpy.mockReset();
let temporaryChatCounter = 0;
newTemporaryChatSpy.mockImplementation(async () => (
`00000000-0000-4000-8000-${String(++temporaryChatCounter).padStart(12, "0")}`
));
sendMessageSpy.mockReset();
statusHandlers.clear();
runStatusHandlers.clear();
@@ -425,9 +431,9 @@ describe("App layout", () => {
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
const firstHash = window.location.hash;
expect(firstHash).toMatch(/^#\/temporary\/temporary-/);
expect(firstHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(createChatSpy).not.toHaveBeenCalled();
@@ -441,9 +447,9 @@ describe("App layout", () => {
target: { value: "second private message" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
const secondHash = window.location.hash;
expect(secondHash).toMatch(/^#\/temporary\/temporary-/);
expect(secondHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
expect(secondHash).not.toBe(firstHash);
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
@@ -481,8 +487,8 @@ describe("App layout", () => {
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
expect(new Set(discardedChatIds).size).toBe(2);
expect(discardedChatIds).toEqual([
expect.stringMatching(/^temporary-/),
expect.stringMatching(/^temporary-/),
"00000000-0000-4000-8000-000000000001",
"00000000-0000-4000-8000-000000000002",
]);
});
@@ -544,7 +550,7 @@ describe("App layout", () => {
target: { value: "start temporary chat" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Clear temporary chat" })).not.toBeInTheDocument();
@@ -560,7 +566,7 @@ describe("App layout", () => {
target: { value: "do not lose this" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
const beforeUnload = new Event("beforeunload", { cancelable: true });
act(() => window.dispatchEvent(beforeUnload));
@@ -579,7 +585,7 @@ describe("App layout", () => {
target: { value: "connection-sensitive message" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/temporary-/));
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
act(() => {
statusHandlers.forEach((handler) => handler("reconnecting"));
+55 -8
View File
@@ -71,19 +71,25 @@ afterEach(() => {
});
describe("NanobotClient", () => {
it("keeps temporary chats out of attachment and reconnect state", () => {
it("keeps temporary chats out of attachment and reconnect state", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const chatId = "temporary-test";
const chatId = "temp-server-id";
client.connect();
lastSocket().fakeOpen();
const creation = client.newTemporaryChat();
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
type: "new_temporary_chat",
});
lastSocket().fakeMessage({ event: "attached", chat_id: chatId, temporary: true });
await expect(creation).resolves.toBe(chatId);
lastSocket().sent = [];
client.onChat(chatId, vi.fn());
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
lastSocket().fakeOpen();
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
{
type: "message",
@@ -101,6 +107,30 @@ describe("NanobotClient", () => {
});
});
it("waits for the temporary attachment when creating a temporary chat", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const creation = client.newTemporaryChat();
let resolved = false;
void creation.then(() => { resolved = true; });
lastSocket().fakeMessage({ event: "attached", chat_id: "ordinary-chat" });
await Promise.resolve();
expect(resolved).toBe(false);
lastSocket().fakeMessage({
event: "attached",
chat_id: "server-temporary-chat",
temporary: true,
});
await expect(creation).resolves.toBe("server-temporary-chat");
});
it("forgets every temporary chat when the socket drops", async () => {
const client = new NanobotClient({
url: "ws://test",
@@ -112,20 +142,37 @@ describe("NanobotClient", () => {
const secondHandler = vi.fn();
client.connect();
lastSocket().fakeOpen();
client.onChat("temporary-drop-a", firstHandler);
client.onChat("temporary-drop-b", secondHandler);
const firstCreation = client.newTemporaryChat();
lastSocket().fakeMessage({
event: "attached",
chat_id: "temp-drop-a",
temporary: true,
});
await firstCreation;
const secondCreation = client.newTemporaryChat();
lastSocket().fakeMessage({
event: "attached",
chat_id: "temp-drop-b",
temporary: true,
});
await secondCreation;
lastSocket().sent = [];
client.onChat("temp-drop-a", firstHandler);
client.onChat("temp-drop-b", secondHandler);
firstHandler.mockClear();
secondHandler.mockClear();
lastSocket().close();
await vi.advanceTimersByTimeAsync(1);
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "message",
chat_id: "temporary-drop-a",
chat_id: "temp-drop-a",
text: "stale first chat",
});
lastSocket().fakeMessage({
event: "message",
chat_id: "temporary-drop-b",
chat_id: "temp-drop-b",
text: "stale second chat",
});
+2 -2
View File
@@ -2944,7 +2944,7 @@ describe("ThreadComposer", () => {
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="temporary-private"
pendingQueueKey={null}
placeholder="Type your message..."
/>,
);
@@ -2966,7 +2966,7 @@ describe("ThreadComposer", () => {
onSend={onSend}
onStop={vi.fn()}
isStreaming
pendingQueueKey="temporary-private"
pendingQueueKey={null}
placeholder="Type your message..."
/>,
);