fix(webui): reconcile threads after browser resume
This commit is contained in:
@@ -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