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
+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",