fix(webui): reconcile threads after browser resume
This commit is contained in:
@@ -77,6 +77,7 @@ describe("webui API helpers", () => {
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -217,6 +217,7 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
attach = attachSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
updateMaxFrameBytes = vi.fn();
|
||||
}
|
||||
|
||||
return { NanobotClient: MockClient };
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user