fix(app): preserve paginated timeline order (#38641)
This commit is contained in:
@@ -56,6 +56,7 @@ test("animates todo lifecycle without replaying it across session tabs", async (
|
|||||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||||
},
|
},
|
||||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||||
|
sessionStatus: { [sourceID]: { type: "busy" } },
|
||||||
pageMessages: () => ({ items: [] }),
|
pageMessages: () => ({ items: [] }),
|
||||||
events: () => events.splice(0, 1),
|
events: () => events.splice(0, 1),
|
||||||
eventRetry: 16,
|
eventRetry: 16,
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ describe("server session", () => {
|
|||||||
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("reprojects current assistants when an older page supplies their user", async () => {
|
test("extends a current page to include the user for split assistant turns", async () => {
|
||||||
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
|
const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
|
||||||
const assistant = (id: string, created: number) => ({
|
const assistant = (id: string, created: number) => ({
|
||||||
id,
|
id,
|
||||||
@@ -282,17 +282,22 @@ describe("server session", () => {
|
|||||||
{ data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
|
{ data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
|
||||||
{ data: [assistants[0], user], cursor: { previous: null, next: null } },
|
{ data: [assistants[0], user], cursor: { previous: null, next: null } },
|
||||||
]
|
]
|
||||||
|
const requests: unknown[] = []
|
||||||
const messageApi = {
|
const messageApi = {
|
||||||
list: async () => pages.shift()!,
|
list: async (input: unknown) => {
|
||||||
|
requests.push(input)
|
||||||
|
return pages.shift()!
|
||||||
|
},
|
||||||
} as unknown as MessageApi
|
} as unknown as MessageApi
|
||||||
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
|
const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi)
|
||||||
store.remember(session("root"))
|
store.remember(session("root"))
|
||||||
|
|
||||||
await store.sync("root")
|
await store.sync("root")
|
||||||
expect(store.data.message.root).toEqual([])
|
|
||||||
|
|
||||||
await store.history.loadMore("root")
|
|
||||||
|
|
||||||
|
expect(requests).toEqual([
|
||||||
|
{ sessionID: "root", limit: 20, order: "desc" },
|
||||||
|
{ sessionID: "root", limit: 20, cursor: "older" },
|
||||||
|
])
|
||||||
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
expect(store.data.message.root.map((message) => message.id)).toEqual([
|
||||||
user.id,
|
user.id,
|
||||||
...assistants.map((item) => item.id),
|
...assistants.map((item) => item.id),
|
||||||
|
|||||||
@@ -30,6 +30,17 @@ const historyMessagePageSize = 200
|
|||||||
const sessionInfoLimit = 2_048
|
const sessionInfoLimit = 2_048
|
||||||
const emptyIDs: ReadonlySet<string> = new Set()
|
const emptyIDs: ReadonlySet<string> = new Set()
|
||||||
|
|
||||||
|
function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) {
|
||||||
|
const boundary = source.find(
|
||||||
|
(message) =>
|
||||||
|
message.type === "user" ||
|
||||||
|
message.type === "shell" ||
|
||||||
|
message.type === "assistant" ||
|
||||||
|
(message.type === "synthetic" && message.description?.trim()),
|
||||||
|
)
|
||||||
|
return boundary?.type === "assistant"
|
||||||
|
}
|
||||||
|
|
||||||
type OptimisticItem = {
|
type OptimisticItem = {
|
||||||
message: Message
|
message: Message
|
||||||
parts: Part[]
|
parts: Part[]
|
||||||
@@ -525,11 +536,20 @@ export function createServerSession(
|
|||||||
|
|
||||||
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => {
|
||||||
if (messageApi && (await options?.protocol) !== "v1") {
|
if (messageApi && (await options?.protocol) !== "v1") {
|
||||||
const response = await (options?.retry ?? retry)(() => {
|
const request = (cursor?: string) =>
|
||||||
onAttempt?.()
|
(options?.retry ?? retry)(() => {
|
||||||
return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" })
|
onAttempt?.()
|
||||||
})
|
return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" })
|
||||||
const source = [...response.data].reverse()
|
})
|
||||||
|
const first = await request(before)
|
||||||
|
const pages = [first]
|
||||||
|
while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) {
|
||||||
|
const response = await request(pages.at(-1)!.cursor.next ?? undefined)
|
||||||
|
pages.push(response)
|
||||||
|
if (!response.data.length) break
|
||||||
|
}
|
||||||
|
const response = pages.at(-1)!
|
||||||
|
const source = pages.flatMap((page) => page.data).toReversed()
|
||||||
const normalized = normalizeSessionMessages(sessionID, source)
|
const normalized = normalizeSessionMessages(sessionID, source)
|
||||||
return {
|
return {
|
||||||
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
session: normalized.messages.sort((a, b) => cmp(a.id, b.id)),
|
||||||
|
|||||||
@@ -90,23 +90,32 @@ describe("current session timeline rows", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("associates assistants with a projected parent missing from the source page", () => {
|
test("keeps a projected parent missing from the source page before newer turns", () => {
|
||||||
const source = [
|
const source = [
|
||||||
{ id: "msg_user", type: "user", text: "question", time: { created: 1 } },
|
{ id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } },
|
||||||
{
|
{
|
||||||
id: "msg_assistant",
|
id: "msg_assistant_1",
|
||||||
type: "assistant",
|
type: "assistant",
|
||||||
agent: "build",
|
agent: "build",
|
||||||
model: { id: "model", providerID: "provider" },
|
model: { id: "model", providerID: "provider" },
|
||||||
content: [{ type: "text", text: "answer" }],
|
content: [{ type: "text", text: "first answer" }],
|
||||||
time: { created: 2, completed: 3 },
|
time: { created: 2, completed: 3 },
|
||||||
},
|
},
|
||||||
|
{ id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } },
|
||||||
|
{
|
||||||
|
id: "msg_assistant_2",
|
||||||
|
type: "assistant",
|
||||||
|
agent: "build",
|
||||||
|
model: { id: "model", providerID: "provider" },
|
||||||
|
content: [{ type: "text", text: "second answer" }],
|
||||||
|
time: { created: 5, completed: 6 },
|
||||||
|
},
|
||||||
] satisfies SessionMessageInfo[]
|
] satisfies SessionMessageInfo[]
|
||||||
const normalized = normalizeSessionMessages("ses_1", source)
|
const normalized = normalizeSessionMessages("ses_1", source)
|
||||||
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
const messages = new Map(normalized.messages.map((message) => [message.id, message]))
|
||||||
|
|
||||||
const result = Timeline.constructSessionMessageRows(
|
const result = Timeline.constructSessionMessageRows(
|
||||||
[source[1]!],
|
source.slice(1),
|
||||||
(messageID) => messages.get(messageID),
|
(messageID) => messages.get(messageID),
|
||||||
(messageID) => normalized.parts.get(messageID) ?? [],
|
(messageID) => normalized.parts.get(messageID) ?? [],
|
||||||
true,
|
true,
|
||||||
@@ -115,8 +124,11 @@ describe("current session timeline rows", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(result.rows.map(TimelineRow.key)).toEqual([
|
expect(result.rows.map(TimelineRow.key)).toEqual([
|
||||||
"user-message:msg_user",
|
"user-message:msg_user_1",
|
||||||
"assistant-part:msg_user:msg_assistant:text:0",
|
"assistant-part:msg_user_1:msg_assistant_1:text:0",
|
||||||
|
"turn-gap:msg_user_2",
|
||||||
|
"user-message:msg_user_2",
|
||||||
|
"assistant-part:msg_user_2:msg_assistant_2:text:0",
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,17 +40,24 @@ export namespace Timeline {
|
|||||||
status: SessionStatus["type"],
|
status: SessionStatus["type"],
|
||||||
inlineComments: boolean,
|
inlineComments: boolean,
|
||||||
) {
|
) {
|
||||||
const turns = messages.flatMap<{ user: UserMessage; assistants: AssistantMessage[] }>((message) => {
|
const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = []
|
||||||
|
const turnByUserID = new Map<string, (typeof turns)[number]>()
|
||||||
|
messages.forEach((message) => {
|
||||||
const projected = getMessage(message.id)
|
const projected = getMessage(message.id)
|
||||||
if (message.type === "shell" && projected?.role === "user") {
|
if (message.type === "shell" && projected?.role === "user") {
|
||||||
const assistant = getMessage(`${message.id}:assistant`)
|
const assistant = getMessage(`${message.id}:assistant`)
|
||||||
return [{ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }]
|
const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }
|
||||||
|
turns.push(turn)
|
||||||
|
turnByUserID.set(projected.id, turn)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (projected?.role === "user") {
|
||||||
|
if (turnByUserID.has(projected.id)) return
|
||||||
|
const turn = { user: projected, assistants: [] }
|
||||||
|
turns.push(turn)
|
||||||
|
turnByUserID.set(projected.id, turn)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return projected?.role === "user" ? [{ user: projected, assistants: [] }] : []
|
|
||||||
})
|
|
||||||
const turnByUserID = new Map(turns.map((turn) => [turn.user.id, turn]))
|
|
||||||
messages.forEach((message) => {
|
|
||||||
const projected = getMessage(message.id)
|
|
||||||
if (projected?.role !== "assistant") return
|
if (projected?.role !== "assistant") return
|
||||||
const existing = turnByUserID.get(projected.parentID)
|
const existing = turnByUserID.get(projected.parentID)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
|||||||
Reference in New Issue
Block a user