fix: keep conversation order stable during streaming
Sort pinned and unpinned conversations by their latest message creation time so parallel replies no longer repeatedly swap sidebar positions. Include the same timestamp in lightweight summaries while preserving updatedAt for persistence and snapshot merging. Validation: 13 focused tests, typecheck and ESLint passed. The full test run timed out after ten minutes and reported configuration-summary and Agent offline-install failures. Release note: Conversation order stays stable while replies stream or tools report progress; new messages still move conversations forward, with pinned conversations first.
This commit is contained in:
@@ -545,6 +545,10 @@ describe('AssistantDatabase', () => {
|
||||
expect(summaries).toHaveLength(100)
|
||||
expect(summaries.every(item => item.messages.length === 0 &&
|
||||
item.messageSummary?.count === 16 && item.messageSummary.firstRole === 'assistant')).toBe(true)
|
||||
for (const snapshot of snapshots) {
|
||||
expect(summaries.find(item => item.id === snapshot.id)?.messageSummary?.latestMessageAt)
|
||||
.toBe(Math.max(...snapshot.messages.map(message => message.createdAt)))
|
||||
}
|
||||
expect(JSON.stringify(summaries).length).toBeLessThan(JSON.stringify(snapshots).length * 0.02)
|
||||
const id = snapshots[0]!.id
|
||||
const detail = database.listConversationSummaries([id]).find(item => item.id === id)!
|
||||
@@ -557,12 +561,14 @@ describe('AssistantDatabase', () => {
|
||||
expect(database.searchConversations(' ')).toEqual([])
|
||||
const added = { id: randomUUID(), title: 'Newer', updatedAt: 1000 }
|
||||
database.saveLocalConversations([{ header: added, messages: [] }])
|
||||
expect(database.listConversationSummaries().find(item => item.id === added.id)?.messageSummary?.latestMessageAt).toBeUndefined()
|
||||
expect(database.listConversationSummaries().some(item => item.id === id)).toBe(true)
|
||||
expect(database.searchConversations('äpfel 中文 0 %_')).toEqual([id])
|
||||
expect(database.listConversationSummaries([id]).find(item => item.id === id)?.messages).toHaveLength(16)
|
||||
// A header-only save of a summary cannot replace or erase messages.
|
||||
database.saveLocalConversations([{ header: { id, title: 'Renamed', updatedAt: 101 }, messages: [] }])
|
||||
expect(database.getConversation(id).messages).toEqual(snapshots[0]!.messages)
|
||||
expect(database.listConversationSummaries().find(item => item.id === id)?.messageSummary?.latestMessageAt).toBe(15)
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
|
||||
@@ -2889,6 +2889,7 @@ export class AssistantDatabase {
|
||||
const summaryStatement = database.prepare(
|
||||
`SELECT count(*) AS count,
|
||||
max(state = 'streaming') AS streaming,
|
||||
max(created_at) AS latestMessageAt,
|
||||
(SELECT role FROM messages WHERE conversation_id = ? ORDER BY sequence LIMIT 1) AS firstRole
|
||||
FROM messages WHERE conversation_id = ?`
|
||||
)
|
||||
@@ -2899,12 +2900,16 @@ export class AssistantDatabase {
|
||||
return conversations.map((conversation) => {
|
||||
if (detailIds && !detailIds.has(conversation.id) && !activeIds.has(conversation.id)) {
|
||||
const summary = summaryStatement.get(conversation.id, conversation.id) as {
|
||||
count: number; streaming: number | null; firstRole: ConversationMessage['role'] | null
|
||||
count: number; streaming: number | null; firstRole: ConversationMessage['role'] | null; latestMessageAt: string | null
|
||||
}
|
||||
if (!summary.streaming) {
|
||||
return {
|
||||
...toConversationSnapshot(conversation, []),
|
||||
messageSummary: { count: summary.count, firstRole: summary.firstRole ?? undefined }
|
||||
messageSummary: {
|
||||
count: summary.count,
|
||||
firstRole: summary.firstRole ?? undefined,
|
||||
latestMessageAt: summary.latestMessageAt === null ? undefined : Date.parse(summary.latestMessageAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3644,6 +3644,69 @@ describe("App", () => {
|
||||
expect(screen.queryByRole("img", { name: "已置顶" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps parallel streaming conversations in message order while persisting fresh deltas", async () => {
|
||||
const snapshots: ConversationSnapshot[] = ["First", "Second"].map((title, index) => ({
|
||||
id: `parallel-${index}`, projectId, title, updatedAt: 100 - index, messages: [],
|
||||
}));
|
||||
vi.mocked(api.conversations.list).mockResolvedValue(snapshots);
|
||||
const { container } = render(<App />);
|
||||
await screen.findByLabelText("更多会话操作 First");
|
||||
const titles = (): string[] => Array.from(container.querySelectorAll(".conversation-item__title")).map(item => item.textContent!);
|
||||
for (const [index, title] of ["First", "Second"].entries()) {
|
||||
fireEvent.click(screen.getByRole("button", { name: new RegExp(`^${title}`) }));
|
||||
fireEvent.change(screen.getByLabelText("向 GoodBuddy 提问"), { target: { value: `Request ${title}` } });
|
||||
fireEvent.click(screen.getByLabelText("发送"));
|
||||
await waitFor(() => expect(run).toHaveBeenCalledTimes(index + 1));
|
||||
}
|
||||
expect(titles()).toEqual(["Second", "First"]);
|
||||
const first = run.mock.calls[0]![0];
|
||||
const second = run.mock.calls[1]![0];
|
||||
const savedBefore = vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch)
|
||||
.filter(entry => entry.header.id === first.conversationId).at(-1)!;
|
||||
for (const [request, delta] of [[first, "First output"], [second, "Second output"], [first, " continued"]] as const) {
|
||||
act(() => agentListener?.({ requestId: request.requestId, type: "text", delta }));
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch)
|
||||
.some(entry => entry.header.id === request.conversationId && entry.messages.some(message => message.content.includes(delta)))).toBe(true);
|
||||
expect(titles()).toEqual(["Second", "First"]);
|
||||
});
|
||||
}
|
||||
const savedAfter = vi.mocked(api.conversations.saveLocal).mock.calls.flatMap(([batch]) => batch)
|
||||
.filter(entry => entry.header.id === first.conversationId).at(-1)!;
|
||||
expect(savedAfter.header.updatedAt).toBeGreaterThan(savedBefore.header.updatedAt);
|
||||
act(() => agentListener?.({ requestId: first.requestId, type: "done" }));
|
||||
expect(titles()).toEqual(["Second", "First"]);
|
||||
act(() => agentListener?.({ requestId: second.requestId, type: "done" }));
|
||||
await waitFor(() => expect(screen.queryByLabelText("停止生成")).not.toBeInTheDocument());
|
||||
expect(titles()).toEqual(["Second", "First"]);
|
||||
});
|
||||
|
||||
it.each([false, true])("keeps summary and detail order stable across refreshes and moves a newly messaged conversation forward (channel: %s)", async (channel) => {
|
||||
const snapshots: ConversationSnapshot[] = ["Earlier", "Later"].map((title, index) => ({
|
||||
id: `stable-${index}`, projectId, title, updatedAt: 900 - index,
|
||||
...(channel ? { remote: { channel: "weixin" as const, accountDisplay: "Account", conversationType: "direct" as const } } : {}),
|
||||
messages: [{ id: `message-${index}`, role: "assistant", state: "complete", content: `Body ${title}`, createdAt: 100 + index }],
|
||||
}));
|
||||
vi.mocked(api.conversations.get).mockImplementation(async id => snapshots.find(item => item.id === id)!);
|
||||
vi.mocked(api.conversations.listSummaries).mockImplementation(async (input) => snapshots.map(item =>
|
||||
input?.includes(item.id) ? item : { ...item, messages: [], messageSummary: { count: item.messages.length, firstRole: "assistant", latestMessageAt: Math.max(...item.messages.map(message => message.createdAt)) } }));
|
||||
const { container } = render(<App />);
|
||||
const titles = (): string[] => Array.from(container.querySelectorAll(".conversation-item__title")).map(item => item.textContent!);
|
||||
await waitFor(() => expect(titles()).toEqual(["Later", "Earlier"]));
|
||||
fireEvent.click(container.querySelectorAll(".conversation-item")[0]!);
|
||||
await screen.findByText("Body Later");
|
||||
snapshots[0] = { ...snapshots[0]!, updatedAt: 1000, title: "Refreshed Earlier" };
|
||||
fireEvent(window, new Event("focus"));
|
||||
await waitFor(() => expect(titles()).toEqual(["Later", "Refreshed Earlier"]));
|
||||
fireEvent.click(container.querySelectorAll(".conversation-item")[1]!);
|
||||
await screen.findByText("Body Earlier");
|
||||
expect(titles()).toEqual(["Later", "Refreshed Earlier"]);
|
||||
snapshots[0] = { ...snapshots[0]!, updatedAt: 1100, messages: [...snapshots[0]!.messages,
|
||||
{ id: "new-message", role: "user", state: "complete", content: "New turn", createdAt: 200 }] };
|
||||
fireEvent(window, new Event("focus"));
|
||||
await waitFor(() => expect(titles()).toEqual(["Refreshed Earlier", "Later"]));
|
||||
});
|
||||
|
||||
it("uses the dedicated pin update for channel conversations", async () => {
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([{
|
||||
id: "pin-channel", projectId, title: "Channel discussion", updatedAt: 100, messages: [],
|
||||
|
||||
@@ -178,7 +178,7 @@ import {
|
||||
messageRenderBatchSize,
|
||||
type ChatScrollSnapshot,
|
||||
} from "./ChatHistoryPane";
|
||||
import { isUnusedConversation, type Conversation } from "./chat-conversation";
|
||||
import { isUnusedConversation, sortConversationsForDisplay, type Conversation } from "./chat-conversation";
|
||||
import {
|
||||
clearLegacyActivityHistory,
|
||||
loadLegacyActivityHistory,
|
||||
@@ -1234,7 +1234,7 @@ function mergePersistedConversations(
|
||||
merged.push(conversation);
|
||||
}
|
||||
}
|
||||
return merged.sort((left, right) => Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)) || right.updatedAt - left.updatedAt);
|
||||
return sortConversationsForDisplay(merged);
|
||||
}
|
||||
|
||||
function getProjectDefaultRuntimeSelection(
|
||||
@@ -3694,7 +3694,7 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
const filteredConversations = useMemo(() => {
|
||||
const query = deferredSearchQuery.trim().toLocaleLowerCase();
|
||||
return conversations.filter(
|
||||
return sortConversationsForDisplay(conversations.filter(
|
||||
(conversation) =>
|
||||
(!activeProjectId || conversation.projectId === activeProjectId) &&
|
||||
(activeProject?.kind !== "channel" ||
|
||||
@@ -3703,7 +3703,7 @@ function App(): React.JSX.Element {
|
||||
(persistedSearchMatches.query === query && persistedSearchMatches.ids.has(conversation.id)) ||
|
||||
conversation.title.toLocaleLowerCase().includes(query) ||
|
||||
(localSearchMatches.query === query && localSearchMatches.ids.has(conversation.id))),
|
||||
).sort((left, right) => Number(Boolean(right.pinned)) - Number(Boolean(left.pinned)) || right.updatedAt - left.updatedAt);
|
||||
));
|
||||
}, [
|
||||
activeProject,
|
||||
activeProjectId,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sortConversationsForDisplay, type Conversation } from "./chat-conversation";
|
||||
|
||||
describe("conversation display order", () => {
|
||||
it("uses stable message times for details, summaries and live messages attached to summaries", () => {
|
||||
const conversations: Conversation[] = [
|
||||
{ id: "detail", title: "Detail", updatedAt: 900, messages: [
|
||||
{ id: "a", role: "user", content: "", state: "complete", createdAt: 200 },
|
||||
{ id: "b", role: "assistant", content: "", state: "streaming", createdAt: 100 },
|
||||
] },
|
||||
{ id: "summary", title: "Summary", updatedAt: 800, messages: [], messageSummary: { count: 2, latestMessageAt: 300 } },
|
||||
{ id: "live", title: "Live", updatedAt: 700, messageSummary: { count: 2, latestMessageAt: 100 }, messages: [
|
||||
{ id: "c", role: "assistant", content: "", state: "streaming", createdAt: 400 },
|
||||
] },
|
||||
{ id: "empty", title: "Empty", updatedAt: 500, messages: [] },
|
||||
{ id: "pinned", title: "Pinned", updatedAt: 1, messages: [], pinned: true },
|
||||
];
|
||||
const sorted = sortConversationsForDisplay(conversations);
|
||||
expect(sorted.map(item => item.id)).toEqual(["pinned", "empty", "live", "summary", "detail"]);
|
||||
expect(conversations[0]?.id).toBe("detail");
|
||||
expect(sorted.at(-1)).toBe(conversations[0]);
|
||||
});
|
||||
|
||||
it("breaks equal message times by ID even when refresh input order and updatedAt change", () => {
|
||||
const a: Conversation = { id: "a", title: "A", updatedAt: 20, messages: [], messageSummary: { count: 1, latestMessageAt: 0 } };
|
||||
const b: Conversation = { ...a, id: "b", updatedAt: 30 };
|
||||
expect(sortConversationsForDisplay([b, a]).map(item => item.id)).toEqual(["a", "b"]);
|
||||
expect(sortConversationsForDisplay([{ ...a, updatedAt: 40 }, b]).map(item => item.id)).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,21 @@ export type Conversation = Omit<ConversationListSnapshot, "messages"> & {
|
||||
messages: Message[];
|
||||
};
|
||||
|
||||
export function sortConversationsForDisplay(conversations: readonly Conversation[]): Conversation[] {
|
||||
// updatedAt tracks snapshot freshness, including every streaming delta.
|
||||
// Compute stable message times once per conversation, not per comparison.
|
||||
return conversations.map(conversation => ({
|
||||
conversation,
|
||||
time: conversation.messages.reduce<number | undefined>(
|
||||
(latest, message) => Math.max(latest ?? message.createdAt, message.createdAt),
|
||||
conversation.messageSummary?.latestMessageAt,
|
||||
) ?? conversation.updatedAt,
|
||||
})).sort((left, right) =>
|
||||
Number(Boolean(right.conversation.pinned)) - Number(Boolean(left.conversation.pinned)) ||
|
||||
right.time - left.time || left.conversation.id.localeCompare(right.conversation.id),
|
||||
).map(item => item.conversation);
|
||||
}
|
||||
|
||||
export function isUnusedConversation(conversation: Conversation): boolean {
|
||||
return (
|
||||
conversation.title === "新对话" &&
|
||||
|
||||
@@ -617,6 +617,7 @@ export type ConversationListSnapshot = ConversationSnapshot & {
|
||||
messageSummary?: {
|
||||
count: number
|
||||
firstRole?: ConversationMessage['role']
|
||||
latestMessageAt?: number
|
||||
}
|
||||
}
|
||||
export const conversationListRequestSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user