fix(webui): reconcile threads after browser resume

This commit is contained in:
chengyongru
2026-07-28 16:25:08 +08:00
committed by chengyongru
parent 78cf68c291
commit ae089aa3ae
39 changed files with 6151 additions and 243 deletions
+1
View File
@@ -77,6 +77,7 @@ describe("webui API helpers", () => {
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
credentials: "same-origin",
cache: "no-store",
}),
);
});
+1
View File
@@ -217,6 +217,7 @@ vi.mock("@/lib/nanobot-client", () => {
attach = attachSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
updateMaxFrameBytes = vi.fn();
}
return { NanobotClient: MockClient };
+885
View File
@@ -238,6 +238,891 @@ describe("NanobotClient", () => {
expect(handler).toHaveBeenLastCalledWith("chat-strip", null);
});
it("rejects a completed snapshot when a newer run is not represented", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const requestGeneration = client.getRunGeneration("chat-race");
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-race",
status: "running",
started_at: 12_345,
turn_id: "turn-new",
});
expect(
client.reconcileCanonicalCompletion("chat-race", requestGeneration, ["turn-old"]),
).toBe(false);
expect(client.getRunStartedAt("chat-race")).toBe(12_345);
});
it("rejects a user-only snapshot for a submitted turn that has not completed", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-submitted", "question", undefined, { turnId: "turn-submitted" });
const requestGeneration = client.getRunGeneration("chat-submitted");
expect(
client.reconcileCanonicalCompletion("chat-submitted", requestGeneration, []),
).toBe(false);
});
it("does not register injected guidance as an independently unsettled run", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-guidance",
status: "running",
started_at: 12_345,
turn_id: "turn-active",
});
const requestGeneration = client.getRunGeneration("chat-guidance");
client.sendMessage("chat-guidance", "focus on sources", undefined, {
turnId: "turn-guidance",
startsNewRun: false,
});
expect(client.getRunGeneration("chat-guidance")).toBe(requestGeneration);
expect(
client.reconcileCanonicalCompletion(
"chat-guidance",
requestGeneration,
["turn-active"],
),
).toBe(true);
});
it("accepts an explicitly completed turn with no assistant row", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-empty-answer", "question", undefined, {
turnId: "turn-empty-answer",
});
const requestGeneration = client.getRunGeneration("chat-empty-answer");
expect(
client.reconcileCanonicalCompletion(
"chat-empty-answer",
requestGeneration,
["turn-empty-answer"],
),
).toBe(true);
});
it.each([
"message_rejected",
"attachment_rejected",
"workspace_scope_rejected",
])("settles a specifically rejected outbound turn (%s)", (detail) => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-rejected", "question", undefined, {
turnId: "turn-rejected",
});
const requestGeneration = client.getRunGeneration("chat-rejected");
expect(
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
).toBe(false);
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-rejected",
turn_id: "turn-rejected",
detail,
reason: "policy",
});
expect(
client.reconcileCanonicalCompletion("chat-rejected", requestGeneration, []),
).toBe(true);
});
it("does not let an older rejection settle or stop a newer run", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-rejection-race", "first", undefined, {
turnId: "turn-old",
});
client.sendMessage("chat-rejection-race", "second", undefined, {
turnId: "turn-new",
});
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-rejection-race",
status: "running",
started_at: 2_000,
turn_id: "turn-new",
});
const requestGeneration = client.getRunGeneration("chat-rejection-race");
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-rejection-race",
turn_id: "turn-old",
detail: "message_rejected",
reason: "text_too_large",
});
expect(client.getRunStartedAt("chat-rejection-race")).toBe(2_000);
expect(
client.reconcileCanonicalCompletion("chat-rejection-race", requestGeneration, []),
).toBe(false);
});
it("restores the previous turn clock when the newer running turn is rejected", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-reject-newer-clock", "first", undefined, {
turnId: "turn-clock-first",
});
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-reject-newer-clock",
status: "running",
started_at: 1_000,
turn_id: "turn-clock-first",
});
client.sendMessage("chat-reject-newer-clock", "second", undefined, {
turnId: "turn-clock-second",
});
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-reject-newer-clock",
status: "running",
started_at: 2_000,
turn_id: "turn-clock-second",
});
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-reject-newer-clock",
turn_id: "turn-clock-second",
detail: "message_rejected",
});
expect(client.getRunStartedAt("chat-reject-newer-clock")).toBe(1_000);
expect(client.hasUnsettledRun("chat-reject-newer-clock")).toBe(true);
});
it("rolls back lifecycle sends that close 1009 before server acceptance", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
client.onError((error) => errors.push(error));
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-too-big", "oversized", undefined, {
turnId: "turn-too-big",
});
const requestGeneration = client.getRunGeneration("chat-too-big");
lastSocket().fakeCloseWithCode(1009);
expect(errors).toEqual([{
kind: "message_too_big",
chatId: "chat-too-big",
turnId: "turn-too-big",
}]);
expect(
client.reconcileCanonicalCompletion("chat-too-big", requestGeneration, []),
).toBe(true);
});
it("preserves an accepted older run when a newer send closes 1009", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
client.onError((error) => errors.push(error));
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-too-big-race", "first", undefined, {
turnId: "turn-accepted",
});
lastSocket().fakeMessage({
event: "message_accepted",
chat_id: "chat-too-big-race",
turn_id: "turn-accepted",
});
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-too-big-race",
status: "running",
started_at: 1_000,
turn_id: "turn-accepted",
});
client.sendMessage("chat-too-big-race", "oversized", undefined, {
turnId: "turn-rejected",
});
const requestGeneration = client.getRunGeneration("chat-too-big-race");
lastSocket().fakeCloseWithCode(1009);
expect(errors).toEqual([{
kind: "message_too_big",
chatId: "chat-too-big-race",
turnId: "turn-rejected",
}]);
expect(client.getRunStartedAt("chat-too-big-race")).toBe(1_000);
expect(
client.reconcileCanonicalCompletion(
"chat-too-big-race",
requestGeneration,
[],
),
).toBe(false);
});
it("does not roll back a lifecycle send after its acceptance ACK", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-accepted", "question", undefined, {
turnId: "turn-accepted",
});
const requestGeneration = client.getRunGeneration("chat-accepted");
lastSocket().fakeMessage({
event: "message_accepted",
chat_id: "chat-accepted",
turn_id: "turn-accepted",
});
lastSocket().fakeCloseWithCode(1009);
expect(
client.reconcileCanonicalCompletion("chat-accepted", requestGeneration, []),
).toBe(false);
});
it("preflights exact websocket frame bytes and rejects only the oversized turn", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
maxFrameBytes: 180,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
client.onError((error) => errors.push(error));
client.connect();
lastSocket().fakeOpen();
const sentBefore = lastSocket().sent.length;
client.sendMessage("chat-preflight-size", "x".repeat(500), undefined, {
turnId: "turn-preflight-size",
});
expect(lastSocket().sent).toHaveLength(sentBefore);
expect(client.hasUnsettledRun("chat-preflight-size")).toBe(false);
expect(errors).toEqual([{
kind: "message_too_big",
chatId: "chat-preflight-size",
turnId: "turn-preflight-size",
}]);
});
it("does not attribute a fallback 1009 close across multiple unacknowledged chats", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
client.onError((error) => errors.push(error));
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-size-a", "first", undefined, { turnId: "turn-size-a" });
client.sendMessage("chat-size-b", "second", undefined, { turnId: "turn-size-b" });
lastSocket().fakeCloseWithCode(1009);
expect(errors).toEqual([{ kind: "message_too_big" }]);
expect(client.hasUnsettledRun("chat-size-a")).toBe(true);
expect(client.hasUnsettledRun("chat-size-b")).toBe(true);
});
it("does not attribute 1009 to an unacknowledged message when another frame followed it", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
client.onError((error) => errors.push(error));
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-before-audio", "question", undefined, {
turnId: "turn-before-audio",
});
const transcription = client.transcribeAudio("data:audio/webm;base64,AAAA");
lastSocket().fakeCloseWithCode(1009);
await expect(transcription).rejects.toThrow("socket closed");
expect(errors).toEqual([{ kind: "message_too_big" }]);
expect(client.hasUnsettledRun("chat-before-audio")).toBe(true);
});
it("settles an unknown send absent from an idle canonical snapshot after disconnect", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-never-arrived", "question", undefined, {
turnId: "turn-never-arrived",
});
const requestGeneration = client.getRunGeneration("chat-never-arrived");
lastSocket().close();
const snapshot = {
observedTurnIds: [],
hasPendingToolCalls: false,
activeTurnId: null,
};
expect(
client.canReconcileCanonicalCompletion(
"chat-never-arrived",
requestGeneration,
[],
snapshot,
),
).toBe(true);
expect(
client.reconcileCanonicalCompletion(
"chat-never-arrived",
requestGeneration,
[],
snapshot,
),
).toBe(true);
expect(client.hasUnsettledRun("chat-never-arrived")).toBe(false);
});
it("keeps an ACK-lost observed turn active, then settles it from an idle snapshot", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-ack-lost", "question", undefined, {
turnId: "turn-ack-lost",
});
const requestGeneration = client.getRunGeneration("chat-ack-lost");
lastSocket().close();
expect(
client.canReconcileCanonicalCompletion(
"chat-ack-lost",
requestGeneration,
[],
{
observedTurnIds: ["turn-ack-lost"],
hasPendingToolCalls: true,
activeTurnId: "turn-ack-lost",
},
),
).toBe(false);
expect(
client.reconcileCanonicalCompletion(
"chat-ack-lost",
requestGeneration,
[],
{
observedTurnIds: ["turn-ack-lost"],
hasPendingToolCalls: false,
activeTurnId: null,
},
),
).toBe(true);
expect(client.hasUnsettledRun("chat-ack-lost")).toBe(false);
});
it("settles an accepted turn that never reached running from canonical idle", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-accepted-idle", "question", undefined, {
turnId: "turn-accepted-idle",
});
const requestGeneration = client.getRunGeneration("chat-accepted-idle");
lastSocket().fakeMessage({
event: "message_accepted",
chat_id: "chat-accepted-idle",
turn_id: "turn-accepted-idle",
});
expect(
client.reconcileCanonicalCompletion(
"chat-accepted-idle",
requestGeneration,
[],
{
observedTurnIds: ["turn-accepted-idle"],
hasPendingToolCalls: false,
activeTurnId: null,
},
),
).toBe(true);
expect(client.hasUnsettledRun("chat-accepted-idle")).toBe(false);
});
it("does not let a pre-send idle response erase a newly accepted turn", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const requestGeneration = client.getRunGeneration("chat-stale-idle");
client.sendMessage("chat-stale-idle", "question", undefined, {
turnId: "turn-after-request",
});
lastSocket().fakeMessage({
event: "message_accepted",
chat_id: "chat-stale-idle",
turn_id: "turn-after-request",
});
expect(
client.reconcileCanonicalCompletion(
"chat-stale-idle",
requestGeneration,
[],
{
observedTurnIds: [],
hasPendingToolCalls: false,
activeTurnId: null,
},
),
).toBe(false);
expect(client.hasUnsettledRun("chat-stale-idle")).toBe(true);
});
it("correlates a legacy rejection only to one currently sent turn", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
client.onError((error) => errors.push(error));
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-legacy-reject", "question", undefined, {
turnId: "turn-legacy-reject",
});
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-legacy-reject",
detail: "message_rejected",
reason: "text_too_large",
});
expect(client.hasUnsettledRun("chat-legacy-reject")).toBe(false);
expect(errors).toEqual([expect.objectContaining({
kind: "turn_rejected",
chatId: "chat-legacy-reject",
turnId: "turn-legacy-reject",
})]);
});
it("correlates legacy lifecycle completion when exactly one turn is unsettled", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onChat("chat-legacy-idle", handler);
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-legacy-idle", "question", undefined, {
turnId: "turn-legacy-idle",
});
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-legacy-idle",
status: "running",
started_at: 4321,
});
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-legacy-idle",
status: "idle",
});
expect(client.hasUnsettledRun("chat-legacy-idle")).toBe(false);
expect(client.getRunStartedAt("chat-legacy-idle")).toBeNull();
expect(handler).toHaveBeenLastCalledWith(expect.objectContaining({
event: "goal_status",
status: "idle",
turn_id: "turn-legacy-idle",
}));
});
it("does not apply an uncorrelated legacy idle to multiple unsettled turns", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onChat("chat-legacy-ambiguous", handler);
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-legacy-ambiguous", "first", undefined, {
turnId: "turn-legacy-first",
});
client.sendMessage("chat-legacy-ambiguous", "second", undefined, {
turnId: "turn-legacy-second",
});
handler.mockClear();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-legacy-ambiguous",
status: "idle",
});
expect(client.hasUnsettledRun("chat-legacy-ambiguous")).toBe(true);
expect(handler).not.toHaveBeenCalled();
});
it("does not correlate a legacy scope error to an already accepted turn", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const errors: Array<{ kind: string; chatId?: string; turnId?: string }> = [];
client.onError((error) => errors.push(error));
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-legacy-scope", "question", undefined, {
turnId: "turn-already-accepted",
});
lastSocket().fakeMessage({
event: "message_accepted",
chat_id: "chat-legacy-scope",
turn_id: "turn-already-accepted",
});
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-legacy-scope",
detail: "workspace_scope_rejected",
reason: "chat_running",
});
expect(client.hasUnsettledRun("chat-legacy-scope")).toBe(true);
expect(errors).toEqual([{
kind: "workspace_scope_rejected",
reason: "chat_running",
chatId: "chat-legacy-scope",
turnId: undefined,
}]);
});
it("does not correlate a scope-control rejection to a preceding unacknowledged message", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-scope-control", "question", undefined, {
turnId: "turn-before-scope-control",
});
client.setWorkspaceScope("chat-scope-control", {
project_path: "/tmp/project",
project_name: "project",
access_mode: "restricted",
});
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-scope-control",
detail: "workspace_scope_rejected",
reason: "chat_running",
});
expect(client.hasUnsettledRun("chat-scope-control")).toBe(true);
});
it("does not correlate a new-chat scope rejection to an unrelated sent turn", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-unrelated-scope", "question", undefined, {
turnId: "turn-unrelated-scope",
});
const pendingChat = client.newChat(5_000, {
project_path: "/missing",
project_name: "missing",
access_mode: "restricted",
});
lastSocket().fakeMessage({
event: "error",
detail: "workspace_scope_rejected",
reason: "project_path must be an existing directory",
});
await expect(pendingChat).rejects.toThrow("workspace_scope_rejected");
expect(client.hasUnsettledRun("chat-unrelated-scope")).toBe(true);
});
it("rejects a correlated system command instead of leaving it pending", async () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const pending = client.sendSystemCommand("chat-system-reject", "/model invalid");
const sent = JSON.parse(lastSocket().sent.at(-1) ?? "{}") as { turn_id?: string };
expect(sent.turn_id).toMatch(/^webui-system:/);
lastSocket().fakeMessage({
event: "error",
chat_id: "chat-system-reject",
turn_id: sent.turn_id,
detail: "message_rejected",
reason: "invalid_command",
});
await expect(pending).rejects.toThrow("message_rejected:invalid_command");
});
it("ignores a delayed idle event from an older turn after a new run starts", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const chatHandler = vi.fn();
const runHandler = vi.fn();
client.onChat("chat-delayed-idle", chatHandler);
client.onRunStatus(runHandler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-delayed-idle",
status: "running",
started_at: 1_000,
turn_id: "turn-old",
});
client.sendMessage("chat-delayed-idle", "next question", undefined, {
turnId: "turn-new",
});
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-delayed-idle",
status: "running",
started_at: 2_000,
turn_id: "turn-new",
});
chatHandler.mockClear();
runHandler.mockClear();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-delayed-idle",
status: "idle",
turn_id: "turn-old",
});
expect(client.getRunStartedAt("chat-delayed-idle")).toBe(2_000);
expect(runHandler).not.toHaveBeenCalled();
expect(chatHandler).not.toHaveBeenCalled();
});
it("accepts a completed snapshot that represents a delayed running frame", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
const requestGeneration = client.getRunGeneration("chat-delayed-run");
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-delayed-run",
status: "running",
started_at: 12_345,
turn_id: "turn-complete",
});
expect(
client.reconcileCanonicalCompletion(
"chat-delayed-run",
requestGeneration,
["turn-complete"],
),
).toBe(true);
expect(client.getRunStartedAt("chat-delayed-run")).toBeNull();
});
it("preflights canonical completion without fencing or settling the turn", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const chatHandler = vi.fn();
client.onChat("chat-preflight", chatHandler);
client.connect();
lastSocket().fakeOpen();
client.sendMessage("chat-preflight", "question", undefined, {
turnId: "turn-preflight",
});
const requestGeneration = client.getRunGeneration("chat-preflight");
expect(
client.canReconcileCanonicalCompletion(
"chat-preflight",
requestGeneration,
["turn-preflight"],
),
).toBe(true);
expect(
client.canReconcileCanonicalCompletion("chat-preflight", requestGeneration, []),
).toBe(false);
lastSocket().fakeMessage({
event: "delta",
chat_id: "chat-preflight",
turn_id: "turn-preflight",
text: "still live",
});
expect(chatHandler).toHaveBeenCalledWith(
expect.objectContaining({ event: "delta", text: "still live" }),
);
});
it("clears the run cache and fences delayed frames after canonical completion", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const chatHandler = vi.fn();
const runHandler = vi.fn();
client.onChat("chat-canonical", chatHandler);
client.onRunStatus(runHandler);
client.connect();
lastSocket().fakeOpen();
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-canonical",
status: "running",
started_at: 12_345,
turn_id: "turn-canonical",
});
const requestGeneration = client.getRunGeneration("chat-canonical");
expect(
client.reconcileCanonicalCompletion(
"chat-canonical",
requestGeneration,
["turn-canonical"],
),
).toBe(true);
expect(client.getRunStartedAt("chat-canonical")).toBeNull();
expect(runHandler).toHaveBeenLastCalledWith("chat-canonical", null);
const deliveredBeforeLateFrames = chatHandler.mock.calls.length;
lastSocket().fakeMessage({
event: "delta",
chat_id: "chat-canonical",
text: " delayed",
turn_id: "turn-canonical",
});
lastSocket().fakeMessage({
event: "turn_end",
chat_id: "chat-canonical",
turn_id: "turn-canonical",
});
lastSocket().fakeMessage({
event: "goal_status",
chat_id: "chat-canonical",
status: "idle",
turn_id: "turn-canonical",
});
expect(chatHandler).toHaveBeenCalledTimes(deliveredBeforeLateFrames);
expect(client.getRunStartedAt("chat-canonical")).toBeNull();
});
it("notifies run status subscribers and replays running chats", () => {
const client = new NanobotClient({
url: "ws://test",
File diff suppressed because it is too large Load Diff
+309 -4
View File
@@ -3,15 +3,20 @@ import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
import { useNanobotStream } from "@/hooks/useNanobotStream";
import type { InboundEvent, GoalStateWsPayload } from "@/lib/types";
import type { StreamError } from "@/lib/nanobot-client";
import type { ConnectionStatus, InboundEvent, GoalStateWsPayload } from "@/lib/types";
import { ClientProvider } from "@/providers/ClientProvider";
const EMPTY_MESSAGES: import("@/lib/types").UIMessage[] = [];
function fakeClient() {
const handlers = new Map<string, Set<(ev: InboundEvent) => void>>();
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
const errorHandlers = new Set<(error: StreamError) => void>();
const runStartedAtByChatId = new Map<string, number>();
const unsettledRunByChatId = new Map<string, boolean>();
const goalStateByChatId = new Map<string, GoalStateWsPayload>();
let status: ConnectionStatus = "open";
function recordGoalStatusForRunStrip(chatId: string, ev: InboundEvent) {
if (ev.event === "turn_end") {
@@ -38,10 +43,19 @@ function fakeClient() {
return {
client: {
status: "open" as const,
get status() {
return status;
},
defaultChatId: null as string | null,
onStatus: () => () => {},
onError: () => () => {},
onStatus(handler: (nextStatus: ConnectionStatus) => void) {
statusHandlers.add(handler);
handler(status);
return () => statusHandlers.delete(handler);
},
onError(handler: (error: StreamError) => void) {
errorHandlers.add(handler);
return () => errorHandlers.delete(handler);
},
getRunStartedAt(chatId: string) {
const v = runStartedAtByChatId.get(chatId);
return v === undefined ? null : v;
@@ -49,6 +63,9 @@ function fakeClient() {
getGoalState(chatId: string) {
return goalStateByChatId.get(chatId);
},
hasUnsettledRun(chatId: string) {
return unsettledRunByChatId.get(chatId) === true;
},
onChat(chatId: string, h: (ev: InboundEvent) => void) {
let set = handlers.get(chatId);
if (!set) {
@@ -72,6 +89,16 @@ function fakeClient() {
const set = handlers.get(chatId);
set?.forEach((h) => h(ev));
},
emitStatus(nextStatus: ConnectionStatus) {
status = nextStatus;
statusHandlers.forEach((handler) => handler(status));
},
emitError(error: StreamError) {
errorHandlers.forEach((handler) => handler(error));
},
setUnsettled(chatId: string, unsettled: boolean) {
unsettledRunByChatId.set(chatId, unsettled);
},
};
}
@@ -180,6 +207,64 @@ describe("useNanobotStream", () => {
}
});
it("keeps the turn pending on disconnect without breaking a resumed stream", async () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-reconnect", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
act(() => {
fake.emit("chat-reconnect", {
event: "goal_status",
chat_id: "chat-reconnect",
status: "running",
started_at: 1_700,
});
fake.emit("chat-reconnect", {
event: "delta",
chat_id: "chat-reconnect",
text: "partial",
});
});
await flushStreamFrame();
const assistantId = result.current.messages[0].id;
expect(result.current.isStreaming).toBe(true);
act(() => fake.emitStatus("reconnecting"));
expect(result.current.runStartedAt).toBe(1_700);
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0]).toMatchObject({
id: assistantId,
content: "partial",
isStreaming: true,
});
act(() => {
fake.emitStatus("open");
fake.emit("chat-reconnect", {
event: "goal_status",
chat_id: "chat-reconnect",
status: "running",
started_at: 1_800,
});
fake.emit("chat-reconnect", {
event: "delta",
chat_id: "chat-reconnect",
text: " resumed",
});
});
await flushStreamFrame();
expect(result.current.runStartedAt).toBe(1_800);
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages[0]).toMatchObject({
id: assistantId,
content: "partial resumed",
isStreaming: true,
});
});
it("flushes pending delta text before turn_end finalizes the turn", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-flush", EMPTY_MESSAGES), {
@@ -1596,6 +1681,224 @@ describe("useNanobotStream", () => {
);
});
it("removes only the optimistic turn named by a correlated rejection", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-reject-one", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
let first: ReturnType<typeof result.current.send> = null;
let second: ReturnType<typeof result.current.send> = null;
act(() => {
first = result.current.send("first");
second = result.current.send("second");
});
fake.setUnsettled("chat-reject-one", true);
act(() => {
fake.emitError({
kind: "turn_rejected",
detail: "message_rejected",
chatId: "chat-reject-one",
turnId: first!.turnId,
});
});
expect(result.current.messages).toEqual([
expect.objectContaining({
id: second!.userMessageId,
turnId: second!.turnId,
content: "second",
}),
]);
expect(result.current.isStreaming).toBe(true);
expect(result.current.streamError).toMatchObject({
kind: "turn_rejected",
turnId: first!.turnId,
});
});
it("falls back to the previous running turn when the newer turn is rejected", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-reject-new", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
let first: ReturnType<typeof result.current.send> = null;
let second: ReturnType<typeof result.current.send> = null;
act(() => {
first = result.current.send("first");
fake.emit("chat-reject-new", {
event: "goal_status",
chat_id: "chat-reject-new",
status: "running",
started_at: 1234,
turn_id: first!.turnId,
});
second = result.current.send("second");
});
act(() => {
fake.emitError({
kind: "turn_rejected",
detail: "attachment_rejected",
chatId: "chat-reject-new",
turnId: second!.turnId,
});
});
expect(result.current.messages).toEqual([
expect.objectContaining({
id: first!.userMessageId,
turnId: first!.turnId,
}),
]);
expect(result.current.runStartedAt).toBe(1234);
expect(result.current.isStreaming).toBe(true);
});
it("ends the spinner and drops pending stream work when the only turn is rejected", async () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-reject-only", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
let submitted: ReturnType<typeof result.current.send> = null;
act(() => {
submitted = result.current.send("only");
fake.emit("chat-reject-only", {
event: "delta",
chat_id: "chat-reject-only",
turn_id: submitted!.turnId,
text: "must not survive",
});
});
act(() => {
fake.emitError({
kind: "turn_rejected",
detail: "access_denied",
chatId: "chat-reject-only",
turnId: submitted!.turnId,
});
});
await flushStreamFrame();
expect(result.current.messages).toEqual([]);
expect(result.current.runStartedAt).toBeNull();
expect(result.current.isStreaming).toBe(false);
});
it("applies a correlated rejection replayed through the chat event queue", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-replayed-reject", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
let submitted: ReturnType<typeof result.current.send> = null;
act(() => {
submitted = result.current.send("queued optimistic row");
});
act(() => {
fake.emit("chat-replayed-reject", {
event: "error",
detail: "message_rejected",
reason: "policy",
chat_id: "chat-replayed-reject",
turn_id: submitted!.turnId,
});
});
expect(result.current.messages).toEqual([]);
expect(result.current.streamError).toMatchObject({
kind: "turn_rejected",
chatId: "chat-replayed-reject",
turnId: submitted!.turnId,
});
});
it("does not show or apply an error correlated to another chat", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-visible", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
let submitted: ReturnType<typeof result.current.send> = null;
act(() => {
submitted = result.current.send("stay");
});
act(() => {
fake.emitError({
kind: "turn_rejected",
detail: "message_rejected",
chatId: "chat-background",
turnId: submitted!.turnId,
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].content).toBe("stay");
expect(result.current.streamError).toBeNull();
});
it("shows an uncorrelated 1009 fault without rolling back the current turn", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-generic-1009", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
act(() => {
result.current.send("stay visible");
fake.emitError({ kind: "message_too_big" });
});
expect(result.current.messages).toEqual([
expect.objectContaining({ role: "user", content: "stay visible" }),
]);
expect(result.current.streamError).toEqual({ kind: "message_too_big" });
});
it("removes rejected side-channel guidance without stopping the main run", () => {
const fake = fakeClient();
const { result } = renderHook(
() => useNanobotStream("chat-side-reject", EMPTY_MESSAGES),
{ wrapper: wrap(fake.client) },
);
let main: ReturnType<typeof result.current.send> = null;
let side: ReturnType<typeof result.current.send> = null;
act(() => {
main = result.current.send("main");
fake.emit("chat-side-reject", {
event: "goal_status",
chat_id: "chat-side-reject",
status: "running",
started_at: 9876,
turn_id: main!.turnId,
});
side = result.current.send("guidance", undefined, { sideChannel: true });
});
act(() => {
fake.emitError({
kind: "turn_rejected",
detail: "message_rejected",
chatId: "chat-side-reject",
turnId: side!.turnId,
});
});
expect(result.current.messages).toEqual([
expect.objectContaining({
id: main!.userMessageId,
turnId: main!.turnId,
}),
]);
expect(result.current.runStartedAt).toBe(9876);
expect(result.current.isStreaming).toBe(true);
});
it("adds optimistic user file attachments as media", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-send", EMPTY_MESSAGES), {
@@ -1801,6 +2104,7 @@ describe("useNanobotStream", () => {
const call = fake.client.sendMessage.mock.calls.at(-1)!;
const turnId = call[3]?.turnId;
expect(call[3]).not.toHaveProperty("sideChannel");
expect(call[3]).toMatchObject({ startsNewRun: false });
expect(result.current.isStreaming).toBe(false);
act(() => {
@@ -1956,6 +2260,7 @@ describe("useNanobotStream", () => {
const guideCall = fake.client.sendMessage.mock.calls.at(-1)!;
expect(guideCall[3]).not.toHaveProperty("continueActiveTurn");
expect(guideCall[3]).toMatchObject({ startsNewRun: false });
expect(result.current.messages.map((message) => message.content)).toEqual([
"research this",
"Initial findings",
+395
View File
@@ -450,6 +450,32 @@ describe("useSessions", () => {
expect(result.current.hasPendingToolCalls).toBe(false);
});
it("exposes turn ids backed by persisted completion events", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
has_pending_tool_calls: false,
completed_turn_ids: ["turn-empty", "", "turn-empty"],
messages: [
{
id: "u1",
role: "user",
content: "stop",
turnId: "turn-empty",
createdAt: 1,
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-empty"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.completedTurnIds).toEqual(["turn-empty"]);
expect(result.current.hasPendingToolCalls).toBe(false);
});
it("does not flag transcript as pending when last row is not a trace", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
@@ -520,6 +546,9 @@ describe("useSessions", () => {
});
expect(result.current.hasMoreBefore).toBe(true);
expect(result.current.userMessageOffset).toBe(1);
const latestVersion = result.current.version;
const latestLineage = result.current.lineage;
expect(result.current.continuity).toBe("initial");
await act(async () => {
await result.current.loadOlder();
@@ -537,6 +566,372 @@ describe("useSessions", () => {
]);
expect(result.current.hasMoreBefore).toBe(false);
expect(result.current.userMessageOffset).toBe(0);
expect(result.current.version).toBe(latestVersion);
expect(result.current.lineage).toBe(latestLineage);
expect(result.current.continuity).toBe("initial");
});
it("preserves a loaded prefix when a canonical latest window overlaps its tail", async () => {
vi.mocked(api.fetchWebuiThread)
.mockResolvedValueOnce({
schemaVersion: 3,
has_pending_tool_calls: true,
messages: [
{ id: "u2", role: "user", content: "middle question", createdAt: 2 },
{ id: "a2", role: "assistant", content: "middle answer", createdAt: 3 },
],
page: {
before_cursor: "cursor-middle",
has_more_before: true,
loaded_message_count: 2,
user_message_offset: 1,
},
})
.mockResolvedValueOnce({
schemaVersion: 3,
messages: [
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
],
page: {
before_cursor: null,
has_more_before: false,
loaded_message_count: 2,
user_message_offset: 0,
},
})
.mockResolvedValueOnce({
schemaVersion: 3,
has_pending_tool_calls: false,
completed_turn_ids: ["turn-3"],
messages: [
{ id: "a2-replayed", role: "assistant", content: "middle answer", createdAt: 3 },
{
id: "u3",
role: "user",
content: "latest question",
turnId: "turn-3",
createdAt: 4,
},
{
id: "a3",
role: "assistant",
content: "latest answer",
turnId: "turn-3",
createdAt: 5,
},
],
page: {
before_cursor: "cursor-shifted",
has_more_before: true,
loaded_message_count: 3,
user_message_offset: 1,
},
});
const { result } = renderHook(() => useSessionHistory("websocket:paged-refresh"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.loadOlder();
});
const loadedVersion = result.current.version;
const loadedLineage = result.current.lineage;
act(() => result.current.refresh());
await waitFor(() => expect(result.current.version).toBeGreaterThan(loadedVersion));
expect(result.current.messages.map((message) => message.id)).toEqual([
"u1",
"a1",
"u2",
"a2-replayed",
"u3",
"a3",
]);
expect(result.current.hasMoreBefore).toBe(false);
expect(result.current.userMessageOffset).toBe(0);
expect(result.current.hasPendingToolCalls).toBe(false);
expect(result.current.completedTurnIds).toEqual(["turn-3"]);
expect(result.current.continuity).toBe("overlap");
expect(result.current.lineage).toBe(loadedLineage);
});
it("starts a new lineage when more than 160 new rows remove all latest-page overlap", async () => {
const oldWindow = Array.from({ length: 160 }, (_, index) => ({
id: `old-${index}`,
role: index % 2 === 0 ? "user" as const : "assistant" as const,
content: `old window row ${index}`,
turnId: `old-turn-${Math.floor(index / 2)}`,
createdAt: index,
}));
const newWindow = Array.from({ length: 160 }, (_, index) => ({
id: `new-${index}`,
role: index % 2 === 0 ? "user" as const : "assistant" as const,
content: `new window row ${index}`,
turnId: `new-turn-${Math.floor(index / 2)}`,
createdAt: 1_000 + index,
}));
vi.mocked(api.fetchWebuiThread)
.mockResolvedValueOnce({
schemaVersion: 3,
messages: oldWindow,
page: {
before_cursor: "old-window-cursor",
has_more_before: true,
},
})
.mockResolvedValueOnce({
schemaVersion: 3,
messages: newWindow,
page: {
before_cursor: "new-window-cursor",
has_more_before: true,
},
});
const { result } = renderHook(() => useSessionHistory("websocket:window-reset"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
const initialLineage = result.current.lineage;
expect(result.current.messages[0]?.id).toBe("old-0");
act(() => result.current.refresh());
await waitFor(() => expect(result.current.messages[0]?.id).toBe("new-0"));
expect(result.current.messages).toHaveLength(160);
expect(result.current.messages.at(-1)?.id).toBe("new-159");
expect(result.current.continuity).toBe("reset");
expect(result.current.lineage).toBeGreaterThan(initialLineage);
expect(result.current.hasMoreBefore).toBe(true);
});
it("uses the longest consecutive semantic overlap for legacy unstable replay metadata", async () => {
vi.mocked(api.fetchWebuiThread)
.mockResolvedValueOnce({
schemaVersion: 3,
messages: [
{ id: "repeat-1-old", role: "user", content: "repeat", createdAt: 10 },
{ id: "answer-1-old", role: "assistant", content: "first answer", createdAt: 11 },
{ id: "repeat-2-old", role: "user", content: "repeat", createdAt: 12 },
{ id: "answer-2-old", role: "assistant", content: "second answer", createdAt: 13 },
],
page: {
before_cursor: "legacy-cursor",
has_more_before: true,
},
})
.mockResolvedValueOnce({
schemaVersion: 3,
messages: [
{ id: "old-prefix", role: "user", content: "old prefix", createdAt: 1 },
],
page: {
before_cursor: null,
has_more_before: false,
},
})
.mockResolvedValueOnce({
schemaVersion: 3,
messages: [
{ id: "repeat-2-new", role: "user", content: "repeat", createdAt: 9_012 },
{ id: "answer-2-new", role: "assistant", content: "second answer", createdAt: 9_013 },
{ id: "new-tail", role: "assistant", content: "new tail", createdAt: 9_014 },
],
page: {
before_cursor: "shifted-legacy-cursor",
has_more_before: true,
},
});
const { result } = renderHook(() => useSessionHistory("websocket:legacy-overlap"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.loadOlder();
});
const lineage = result.current.lineage;
act(() => result.current.refresh());
await waitFor(() => expect(result.current.messages.at(-1)?.id).toBe("new-tail"));
expect(result.current.messages.map((message) => message.id)).toEqual([
"old-prefix",
"repeat-1-old",
"answer-1-old",
"repeat-2-new",
"answer-2-new",
"new-tail",
]);
expect(result.current.continuity).toBe("overlap");
expect(result.current.lineage).toBe(lineage);
});
it("ignores an older-page response after a latest refresh resets its lineage", async () => {
let resolveOlder:
| ((value: Awaited<ReturnType<typeof api.fetchWebuiThread>>) => void)
| null = null;
vi.mocked(api.fetchWebuiThread)
.mockResolvedValueOnce({
schemaVersion: 3,
messages: [
{ id: "old-latest", role: "assistant", content: "old latest", createdAt: 10 },
],
page: {
before_cursor: "cursor-old-lineage",
has_more_before: true,
},
})
.mockImplementationOnce(() => new Promise((resolve) => {
resolveOlder = resolve;
}))
.mockResolvedValueOnce({
schemaVersion: 3,
messages: [
{ id: "new-latest", role: "assistant", content: "new latest", createdAt: 20 },
],
page: {
before_cursor: "cursor-new-lineage",
has_more_before: true,
},
});
const { result } = renderHook(() => useSessionHistory("websocket:paged-race"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
const oldLineage = result.current.lineage;
let olderRequest: Promise<void> | undefined;
act(() => {
olderRequest = result.current.loadOlder();
});
await waitFor(() => expect(api.fetchWebuiThread).toHaveBeenCalledTimes(2));
act(() => result.current.refresh());
await waitFor(() => expect(result.current.messages[0]?.id).toBe("new-latest"));
expect(result.current.continuity).toBe("reset");
expect(result.current.lineage).toBeGreaterThan(oldLineage);
await act(async () => {
resolveOlder?.({
schemaVersion: 3,
messages: [
{ id: "stale-prefix", role: "user", content: "stale prefix", createdAt: 1 },
],
page: {
before_cursor: null,
has_more_before: false,
},
});
await olderRequest;
});
expect(result.current.messages.map((message) => message.id)).toEqual(["new-latest"]);
expect(result.current.hasMoreBefore).toBe(true);
});
it("preserves authoritative active state while prepending older history", async () => {
vi.mocked(api.fetchWebuiThread)
.mockResolvedValueOnce({
schemaVersion: 3,
has_pending_tool_calls: true,
messages: [
{ id: "u2", role: "user", content: "current question", createdAt: 2 },
{ id: "a2", role: "assistant", content: "partial answer", createdAt: 3 },
],
page: {
before_cursor: "cursor-active",
has_more_before: true,
loaded_message_count: 2,
user_message_offset: 1,
},
})
.mockResolvedValueOnce({
schemaVersion: 3,
has_pending_tool_calls: false,
messages: [
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
],
page: {
before_cursor: null,
has_more_before: false,
loaded_message_count: 2,
user_message_offset: 0,
},
});
const { result } = renderHook(() => useSessionHistory("websocket:paged-active"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(true);
const latestVersion = result.current.version;
await act(async () => {
await result.current.loadOlder();
});
expect(result.current.hasPendingToolCalls).toBe(true);
expect(result.current.version).toBe(latestVersion);
});
it("preserves authoritative completed state while prepending trace history", async () => {
vi.mocked(api.fetchWebuiThread)
.mockResolvedValueOnce({
schemaVersion: 3,
has_pending_tool_calls: false,
messages: [
{
id: "t2",
role: "tool",
kind: "trace",
content: "completed trace",
traces: ["completed trace"],
createdAt: 2,
},
],
page: {
before_cursor: "cursor-complete",
has_more_before: true,
loaded_message_count: 1,
user_message_offset: 1,
},
})
.mockResolvedValueOnce({
schemaVersion: 3,
messages: [
{ id: "u1", role: "user", content: "old question", createdAt: 0 },
{ id: "a1", role: "assistant", content: "old answer", createdAt: 1 },
],
page: {
before_cursor: null,
has_more_before: false,
loaded_message_count: 2,
user_message_offset: 0,
},
});
const { result } = renderHook(() => useSessionHistory("websocket:paged-complete"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.hasPendingToolCalls).toBe(false);
await act(async () => {
await result.current.loadOlder();
});
expect(result.current.hasPendingToolCalls).toBe(false);
});
it("keeps the session in the list when delete fails", async () => {