Kaynağa Gözat

fix(app): reconcile v2 session projections (#40376)

Brendan Allan 1 hafta önce
ebeveyn
işleme
4eb1b37d9b

+ 4 - 0
packages/app/e2e/performance/timeline-stability/fixture.test.ts

@@ -45,6 +45,10 @@ describe("timeline fixture validation", () => {
     expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
     expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
   })
+
+  test("uses the projected tool ID as its call ID", () => {
+    expect(toolPart("call_1", "read", "running", {})).toMatchObject({ id: "call_1", callID: "call_1" })
+  })
 })
 
 if (false) {

+ 32 - 18
packages/app/e2e/regression/session-timeline-history-root.spec.ts

@@ -10,7 +10,6 @@ import {
   status,
   textPart,
   title,
-  userID,
   userMessage,
 } from "../performance/timeline-stability/fixture"
 import { mockOpenCodeServer } from "../utils/mock-server"
@@ -19,18 +18,22 @@ import { expectSessionTitle } from "../utils/waits"
 
 const initialPageSize = 20
 const historyPageSize = 200
-const assistants = Array.from({ length: initialPageSize + 1 }, (_, index) =>
-  assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
-    id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
-    parentID: userID,
-    created: 1700000001000 + index * 1_000,
-    completed: index < initialPageSize,
-  }),
-)
-const messages = [userMessage(), ...assistants]
+const messages = Array.from({ length: initialPageSize + 1 }, (_, index) => {
+  const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
+  return [
+    userMessage(undefined, { id, created: 1700000000000 + index * 2_000 }),
+    assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
+      id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
+      parentID: id,
+      created: 1700000001000 + index * 2_000,
+      completed: index < initialPageSize,
+    }),
+  ]
+}).flat()
+const assistants = messages.filter((message) => message.info.role === "assistant")
 const lastAssistant = assistants.at(-1)!
-const lastPartID = assistants.at(-1)!.parts[0]!.id
-const userPartID = `prt_${userID}_text`
+const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
+const userPartID = `${messages.at(-2)!.info.id}:text:0`
 const completed = {
   ...lastAssistant.info,
   time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
@@ -59,6 +62,7 @@ for (const scenario of scenarios) {
       retry: 20,
     })
     await mockOpenCodeServer(page, {
+      protocol: "v2",
       directory,
       project: project(),
       provider: {
@@ -154,15 +158,23 @@ for (const scenario of scenarios) {
     await expectSessionTitle(page, title)
     await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
     await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
-    await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
+    const viewport = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
+    await viewport.hover()
+    const deadline = Date.now() + 10_000
+    while (requests.filter((request) => request.phase === "start").length < 2) {
+      if (Date.now() >= deadline) throw new Error("Timed out scrolling to the history boundary")
+      await page.mouse.wheel(0, -240)
+      await page.waitForTimeout(20)
+    }
     expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
-    expect(sequence.slice(0, 4)).toEqual([
+    expect(sequence.slice(0, 3)).toEqual([
       "messages:start:latest",
       "messages:end:latest",
-      `message:${userID}`,
       `messages:start:${messages.at(-initialPageSize)!.info.id}`,
     ])
-    await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(initialPageSize)
+    await expect(page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]')).toHaveCount(
+      initialPageSize / 2,
+    )
     await page.evaluate(() => {
       ;(
         window as Window & {
@@ -174,7 +186,9 @@ for (const scenario of scenarios) {
     expect(await visibleContentHidden(page)).toBe(false)
     const beforeHistory = await probeSamples(page)
     history.resolve()
-    await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(assistants.length)
+    await expect
+      .poll(() => page.locator('[data-timeline-part-id*="_history_root_assistant:text:0"]').count())
+      .toBeGreaterThan(initialPageSize / 2)
     await expect.poll(() => requests.filter((request) => request.phase === "end").length).toBe(2)
     await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
     await waitForProbeSamples(page, beforeHistory)
@@ -182,7 +196,7 @@ for (const scenario of scenarios) {
       { before: undefined, limit: initialPageSize },
       { before: messages.at(-initialPageSize)!.info.id, limit: historyPageSize },
     ])
-    expect(roots).toEqual([{ sessionID, messageID: userID }])
+    expect(roots).toEqual([])
 
     const message = messageUpdated(scenario.info)
     const idle = status("idle")

+ 28 - 0
packages/app/src/context/server-session.test.ts

@@ -265,6 +265,34 @@ describe("server session", () => {
 
     expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
     expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
+    expect(store.history.more("root")).toBe(false)
+  })
+
+  test("replaces stale current projections on complete refreshes", async () => {
+    const first = { id: "msg_1", type: "user", text: "first", time: { created: 1 } } as const
+    const second = { id: "msg_2", type: "user", text: "second", time: { created: 2 } } as const
+    const pages = [
+      { data: [first], cursor: { previous: null, next: null } },
+      { data: [second], cursor: { previous: null, next: null } },
+      { data: [], cursor: { previous: null, next: null } },
+    ]
+    const messageApi = {
+      list: async () => pages.shift()!,
+    } as unknown as MessageApi
+    const sessionApi = { get: async () => session("root") } as unknown as SessionApi
+    const store = createServerSession({} as OpencodeClient, sessionApi, messageApi)
+    store.remember(session("root"))
+
+    await store.sync("root")
+    expect(store.data.session_message.root.map((message) => message.id)).toEqual([first.id])
+
+    await store.sync("root", { force: true })
+    expect(store.data.session_message.root.map((message) => message.id)).toEqual([second.id])
+    expect(store.data.message.root.map((message) => message.id)).toEqual([second.id])
+
+    await store.sync("root", { force: true })
+    expect(store.data.session_message.root).toEqual([])
+    expect(store.data.message.root).toEqual([])
   })
 
   test("extends a current page to include the user for split assistant turns", async () => {

+ 9 - 2
packages/app/src/context/server-session.ts

@@ -563,7 +563,7 @@ export function createServerSession(
         sourceMode: before ? ("older" as const) : ("latest" as const),
         projectSource: true,
         cursor: response.cursor.next ?? undefined,
-        complete: response.data.length === 0,
+        complete: !response.cursor.next,
       }
     }
     const response = await (options?.retry ?? retry)(() => {
@@ -683,7 +683,14 @@ export function createServerSession(
       ? (() => {
           const incoming = new Map(page.source.map((message) => [message.id, message]))
           const existing = data.session_message[sessionID] ?? []
-          const current = existing.filter((message) => !incoming.has(message.id))
+          const boundary = Math.min(...page.source.map((message) => message.time.created))
+          const current = existing.filter(
+            (message) =>
+              !incoming.has(message.id) &&
+              (page.sourceMode === "older" ||
+                load?.touchedSource.has(message.id) ||
+                (!page.complete && message.time.created < boundary)),
+          )
           const live = new Map(existing.map((message) => [message.id, message]))
           return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map(
             (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message),

+ 25 - 0
packages/app/src/utils/session-message.test.ts

@@ -3,6 +3,31 @@ import type { SessionMessageInfo } from "@opencode-ai/client/promise"
 import { normalizeSessionMessages } from "./session-message"
 
 describe("normalizeSessionMessages", () => {
+  test("keeps attachments without inventing an empty text part", () => {
+    const source = [
+      {
+        id: "msg_1",
+        type: "user",
+        text: "",
+        files: [
+          {
+            data: "aGVsbG8=",
+            mime: "text/plain",
+            name: "note.txt",
+            source: { type: "inline" },
+          },
+        ],
+        agents: [{ name: "review" }],
+        time: { created: 1 },
+      },
+    ] satisfies SessionMessageInfo[]
+
+    const result = normalizeSessionMessages("ses_1", source)
+
+    expect(result.messages).toHaveLength(1)
+    expect(result.parts.get("msg_1")?.map((part) => part.type)).toEqual(["file", "agent"])
+  })
+
   test("projects current turns into stable legacy rendering records", () => {
     const source = [
       { id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } },

+ 1 - 1
packages/app/src/utils/session-message.ts

@@ -196,7 +196,7 @@ function userMessage(
 
 function userParts(sessionID: string, message: SessionMessageUser): Part[] {
   return [
-    textPart(sessionID, message.id, 0, message.text),
+    ...(message.text ? [textPart(sessionID, message.id, 0, message.text)] : []),
     ...(message.files ?? []).map(
       (file, index): FilePart => ({
         id: `${message.id}:file:${index}`,