mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(tui): load history until the oldest group is complete (#50930)
This commit is contained in:
@@ -90,6 +90,7 @@ import { Slot } from "../../plugin/render"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import {
|
||||
cacheReuseDrop,
|
||||
completeGroupBoundary,
|
||||
createSessionRows,
|
||||
legacyTurns,
|
||||
messageBoundaryIDs,
|
||||
@@ -395,7 +396,16 @@ export function Session(props: {
|
||||
const prependHistory = createHistoryPrepend({
|
||||
sessionID: () => route.sessionID,
|
||||
more: (id) => data.session.message.more(id),
|
||||
loadMore: (id) => data.session.message.loadMore(id),
|
||||
loadMore: async (id) => {
|
||||
await data.session.message.loadMore(id)
|
||||
await completeGroupBoundary({
|
||||
rows,
|
||||
messages: () => data.session.message.list(id).length,
|
||||
more: () => data.session.message.more(id),
|
||||
loadMore: () => data.session.message.loadMore(id),
|
||||
active: () => route.sessionID === id,
|
||||
})
|
||||
},
|
||||
height: () => scroll.scrollHeight,
|
||||
afterLayout,
|
||||
active: (id) => route.sessionID === id && Boolean(scroll && !scroll.isDestroyed),
|
||||
|
||||
@@ -19,6 +19,26 @@ import {
|
||||
} from "./grouping/session"
|
||||
export type { CacheUsage, PartRef, SessionRow } from "./grouping/session"
|
||||
|
||||
/**
|
||||
* A page boundary can cut a group in half, which would show a partial summary and
|
||||
* give the group a provisional ID (derived from its first part). While the oldest
|
||||
* row is a group, keep loading older pages until something precedes it.
|
||||
*/
|
||||
export async function completeGroupBoundary(input: {
|
||||
rows: readonly SessionRow[]
|
||||
messages: () => number
|
||||
more: () => boolean
|
||||
loadMore: () => Promise<void>
|
||||
active: () => boolean
|
||||
}) {
|
||||
while (input.active() && input.rows[0]?.type === "group" && input.more()) {
|
||||
const before = input.messages()
|
||||
await input.loadMore()
|
||||
// A page that adds nothing would otherwise loop forever.
|
||||
if (input.messages() === before) return
|
||||
}
|
||||
}
|
||||
|
||||
export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessionID: string) => void) {
|
||||
const data = useData()
|
||||
const client = useClient()
|
||||
@@ -75,14 +95,22 @@ export function createSessionRows(sessionID: Accessor<string>, onSynced?: (sessi
|
||||
if (status !== "connected") return
|
||||
setRows(reconcile(reduce()))
|
||||
void data.session.pending.sync(id).catch(() => undefined)
|
||||
void data.session.message.sync(id).then(
|
||||
() => {
|
||||
void data.session.message
|
||||
.sync(id)
|
||||
.then(async () => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduce()))
|
||||
onSynced?.(id)
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
// Restoration waits for complete boundary groups so saved group IDs resolve.
|
||||
await completeGroupBoundary({
|
||||
rows,
|
||||
messages: () => data.session.message.list(id).length,
|
||||
more: () => data.session.message.more(id),
|
||||
loadMore: () => data.session.message.loadMore(id),
|
||||
active: () => sessionID() === id,
|
||||
}).catch(() => undefined)
|
||||
if (sessionID() === id) onSynced?.(id)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { Composer } from "../../../src/routes/session/composer"
|
||||
import { DialogProvider } from "../../../src/ui/dialog"
|
||||
import { ToastProvider } from "../../../src/ui/toast"
|
||||
import { createSessionRows, type SessionRow } from "../../../src/routes/session/rows"
|
||||
import { groupRefs } from "../../../src/routes/session/grouping/session"
|
||||
import { unwrap } from "solid-js/store"
|
||||
import { createApi, createEventStream, createFetch, directory, json, worktree } from "../../fixture/tui-client"
|
||||
import { emptyThemeSource } from "../../fixture/fixture"
|
||||
import { TestTuiContexts } from "../../fixture/tui-environment"
|
||||
@@ -1085,6 +1087,79 @@ test("classifies live tool rows independently of their call ID", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("loads older pages until the oldest exploration group is complete before reporting sync", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-boundary"
|
||||
const model = { id: "model", providerID: "provider" }
|
||||
// One prompt, then 50 single-read steps: the 20-message first page cuts the group.
|
||||
const history = [
|
||||
{ type: "user", id: "msg_000", text: "Explore", time: { created: 0 } },
|
||||
...Array.from({ length: 50 }, (_, index) => ({
|
||||
type: "assistant",
|
||||
id: `msg_${String(index + 1).padStart(3, "0")}`,
|
||||
agent: "build",
|
||||
model,
|
||||
time: { created: index + 1, completed: index + 1 },
|
||||
finish: "tool-calls",
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: `read-${index}`,
|
||||
name: "read",
|
||||
time: { created: index + 1, completed: index + 1 },
|
||||
state: { status: "completed", input: { path: `${index}.ts` }, content: [], metadata: {} },
|
||||
},
|
||||
],
|
||||
})),
|
||||
]
|
||||
const pages: string[] = []
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${sessionID}/message`) return
|
||||
const end = Number(url.searchParams.get("cursor") ?? history.length)
|
||||
const start = Math.max(0, end - Number(url.searchParams.get("limit") ?? 20))
|
||||
pages.push(`${start}-${end}`)
|
||||
return json({ data: history.slice(start, end).toReversed(), cursor: start > 0 ? { next: String(start) } : {} })
|
||||
}, events)
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let client!: ReturnType<typeof useClient>
|
||||
const synced: SessionRow[] = []
|
||||
|
||||
function Probe() {
|
||||
client = useClient()
|
||||
rows = createSessionRows(
|
||||
() => sessionID,
|
||||
() => synced.push(structuredClone(unwrap(rows[0]))),
|
||||
)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<ClientProvider api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => client.connection.status() === "connected")
|
||||
await wait(() => synced.length > 0, 4000)
|
||||
expect(pages).toEqual(["31-51", "11-31", "0-11"])
|
||||
// Sync is reported only once the group's true first read is loaded.
|
||||
expect(synced[0]).toEqual({ type: "message", messageID: "msg_000" })
|
||||
const group = rows[1]
|
||||
if (group?.type !== "group") throw new Error("Expected exploration group")
|
||||
expect(group.size).toBe(50)
|
||||
expect(groupRefs(group)[0]).toEqual({ messageID: "msg_001", partID: "read-0" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("removes committed revert messages from local state", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-revert"
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { completeGroupBoundary, type SessionRow } from "../../../src/routes/session/rows"
|
||||
|
||||
const group: SessionRow = { type: "group", kind: "reasoning", children: [], size: 0, completed: true }
|
||||
const prompt: SessionRow = { type: "message", messageID: "msg_user" }
|
||||
|
||||
// Each page is the number of older messages it adds and whether it reaches the prompt
|
||||
// preceding the group. Pages inside the group merge into the existing first row.
|
||||
function history(pages: { messages: number; prompt?: boolean }[], first: SessionRow = group) {
|
||||
const rows: SessionRow[] = [first]
|
||||
let count = 1
|
||||
let loads = 0
|
||||
let active = true
|
||||
return {
|
||||
rows,
|
||||
loads: () => loads,
|
||||
deactivate: () => (active = false),
|
||||
input: (after?: () => void) => ({
|
||||
rows,
|
||||
messages: () => count,
|
||||
more: () => loads < pages.length,
|
||||
loadMore: async () => {
|
||||
const page = pages[loads++]
|
||||
count += page.messages
|
||||
if (page.prompt) rows.unshift(prompt)
|
||||
after?.()
|
||||
},
|
||||
active: () => active,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
test("keeps loading while the oldest row is a group", async () => {
|
||||
const state = history([{ messages: 20 }, { messages: 20 }, { messages: 5, prompt: true }, { messages: 20 }])
|
||||
await completeGroupBoundary(state.input())
|
||||
expect(state.loads()).toBe(3)
|
||||
expect(state.rows[0]).toBe(prompt)
|
||||
})
|
||||
|
||||
test("does not load when the oldest row cannot continue into older history", async () => {
|
||||
const state = history([{ messages: 20 }], prompt)
|
||||
await completeGroupBoundary(state.input())
|
||||
expect(state.loads()).toBe(0)
|
||||
})
|
||||
|
||||
test("stops at the beginning of history", async () => {
|
||||
const state = history([{ messages: 20 }, { messages: 3 }])
|
||||
await completeGroupBoundary(state.input())
|
||||
expect(state.loads()).toBe(2)
|
||||
expect(state.rows[0]).toBe(group)
|
||||
})
|
||||
|
||||
test("stops when a page adds no messages", async () => {
|
||||
const state = history([{ messages: 0 }, { messages: 20 }])
|
||||
await completeGroupBoundary(state.input())
|
||||
expect(state.loads()).toBe(1)
|
||||
})
|
||||
|
||||
test("stops when the session is no longer active", async () => {
|
||||
const state = history([{ messages: 20 }, { messages: 20 }, { messages: 1, prompt: true }])
|
||||
await completeGroupBoundary(state.input(() => state.deactivate()))
|
||||
expect(state.loads()).toBe(1)
|
||||
})
|
||||
Reference in New Issue
Block a user