Files
opencode/packages/tui/test/context/session-retention.test.ts
2026-10-26 08:34:06 +10:00

235 lines
7.4 KiB
TypeScript

import { expect, test } from "bun:test"
import type { OpenCodeEvent } from "@opencode/client"
import { createData, createSessionRetention } from "@opencode/client/solid"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createRoot, createSignal } from "solid-js"
import { createApi, createFetch, directory, json } from "../fixture/tui-client"
function setup(options: { keep?: string[]; current?: string; limit?: number } = {}) {
return createRoot((dispose) => {
const events = createGlobalEmitter<{
[Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }>
}>()
const api = createApi(
createFetch((url) => {
if (url.pathname.endsWith("/message"))
return json({
data: [{ id: "msg_test", type: "user", text: "Transcript", time: { created: 1 } }],
cursor: {},
})
if (url.pathname.endsWith("/inbox")) return json({ data: [] })
return undefined
}).fetch,
)
const data = createData({ api: () => api, event: events, directory })
const [current, setCurrent] = createSignal(options.current)
const [keep, setKeep] = createSignal(options.keep ?? [])
const evictions: string[] = []
createSessionRetention({
session: {
...data.session,
evict(id) {
evictions.push(id)
data.session.evict(id)
},
},
current,
keep,
limit: options.limit ?? 3,
})
return {
dispose,
data,
events,
evictions,
setCurrent,
setKeep,
remember(id: string, parentID?: string, updated = 0) {
data.session.remember({
id,
parentID,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated },
})
},
async view(id: string) {
setCurrent(id)
await data.session.message.sync(id)
},
cached: (id: string) => data.session.message.list(id).length > 0,
}
})
}
test("retains three recently viewed families and child views touch their root", async () => {
const scope = setup()
try {
for (const id of ["a", "b", "c", "d", "e"]) scope.remember(id)
scope.remember("child", "a")
scope.remember("grandchild", "child")
await scope.view("a")
await scope.view("grandchild")
await scope.view("b")
await scope.view("c")
await scope.view("child")
await scope.view("d")
expect(["a", "child", "grandchild", "c", "d"].every(scope.cached)).toBe(true)
expect(scope.cached("b")).toBe(false)
scope.setCurrent(undefined)
expect(scope.cached("a")).toBe(true)
await scope.view("e")
expect(scope.cached("c")).toBe(false)
await scope.view("c")
expect(["a", "child", "grandchild"].some(scope.cached)).toBe(false)
expect(["c", "d", "e"].every(scope.cached)).toBe(true)
expect(scope.data.session.family("a").toSorted()).toEqual(["a", "child", "grandchild"])
} finally {
scope.dispose()
}
})
test("explicitly kept families are exempt from the recent-family budget", async () => {
const scope = setup({ keep: ["a", "b", "c", "d"] })
try {
for (const id of ["a", "b", "c", "d", "e", "f", "g", "h"]) {
scope.remember(id)
await scope.view(id)
}
expect(["a", "b", "c", "d", "f", "g", "h"].every(scope.cached)).toBe(true)
expect(scope.cached("e")).toBe(false)
scope.setKeep(["b", "c", "d"])
expect(scope.cached("a")).toBe(false)
await scope.view("b")
scope.setKeep(["c", "d"])
expect(scope.cached("b")).toBe(true)
expect(scope.cached("f")).toBe(false)
scope.setKeep([])
expect(["c", "d"].some(scope.cached)).toBe(false)
expect(["b", "g", "h"].every(scope.cached)).toBe(true)
} finally {
scope.dispose()
}
})
test("background metadata changes do not refresh view recency or repeat eviction", async () => {
const scope = setup()
try {
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
scope.remember("a", undefined, 100)
scope.remember("d")
await scope.view("d")
expect(scope.cached("a")).toBe(false)
expect(["b", "c", "d"].every(scope.cached)).toBe(true)
const count = scope.evictions.length
scope.remember("a", undefined, 200)
scope.remember("d", undefined, 300)
expect(scope.evictions.length).toBe(count)
} finally {
scope.dispose()
}
})
test("unviewed families and newly discovered descendants are evicted", async () => {
const scope = setup({ current: "current" })
try {
await scope.data.session.message.sync("background")
expect(scope.cached("background")).toBe(true)
scope.remember("background")
expect(scope.cached("background")).toBe(false)
await scope.data.session.message.sync("child")
scope.remember("child", "background")
expect(scope.cached("child")).toBe(false)
expect(scope.evictions.filter((id) => id === "background")).toHaveLength(2)
scope.remember("current")
scope.remember("kept-child", "current")
await scope.data.session.message.sync("kept-child")
scope.remember("kept-child", "current", 10)
expect(scope.cached("kept-child")).toBe(true)
expect(
scope.data.session
.list()
.map((session) => session.id)
.toSorted(),
).toEqual(["background", "child", "current", "kept-child"])
} finally {
scope.dispose()
}
})
test("current child protects late-arriving ancestry and reopening restores an evicted transcript", async () => {
const scope = setup({ current: "child" })
try {
scope.remember("root")
await scope.data.session.message.sync("child")
scope.remember("child", "parent")
scope.remember("parent", "root")
scope.remember("root")
expect(scope.cached("child")).toBe(true)
expect(scope.evictions).toEqual(["root"])
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
expect(scope.cached("child")).toBe(false)
await scope.view("child")
expect(scope.cached("child")).toBe(true)
expect(scope.cached("a")).toBe(false)
} finally {
scope.dispose()
}
})
test("uses the caller's recent-family limit", async () => {
const scope = setup({ keep: ["a"], limit: 1 })
try {
for (const id of ["a", "b", "c"]) {
scope.remember(id)
await scope.view(id)
}
expect(scope.cached("a")).toBe(true)
expect(scope.cached("b")).toBe(false)
expect(scope.cached("c")).toBe(true)
} finally {
scope.dispose()
}
})
test("re-evicts an already excluded family when background turn events repopulate its transcript", async () => {
const scope = setup({ current: "current" })
try {
scope.remember("current")
scope.remember("background")
expect(scope.evictions).toEqual(["background"])
scope.events.emit("session.step.started", {
id: "evt_step_1",
created: 1,
type: "session.step.started",
durable: { aggregateID: "background", seq: 1, version: 1 },
data: {
sessionID: "background",
assistantMessageID: "msg_bg_assistant",
agent: "build",
model: { providerID: "openai", id: "gpt-5" },
started: 1,
},
})
expect(scope.cached("background")).toBe(true)
scope.remember("background", undefined, 10)
expect(scope.cached("background")).toBe(false)
expect(scope.evictions).toEqual(["background", "background"])
} finally {
scope.dispose()
}
})