1
0

session-compaction.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import { expect, test } from "bun:test"
  2. import { LLMClient, LLMEvent, LanguageModel, type LLMRequest } from "@opencode-ai/ai"
  3. import { OpenAIChat } from "@opencode-ai/ai/protocols"
  4. import { Config } from "@opencode-ai/core/config"
  5. import { Database } from "@opencode-ai/core/database/database"
  6. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  7. import { llmClient } from "@opencode-ai/core/effect/app-node-platform"
  8. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  9. import { Bus } from "@opencode-ai/core/bus"
  10. import { EventTable } from "@opencode-ai/core/event/sql"
  11. import { SessionCompaction } from "@opencode-ai/core/session/compaction"
  12. import { SessionEvent } from "@opencode-ai/core/session/event"
  13. import { SessionMessage } from "@opencode-ai/core/session/message"
  14. import { SessionProjector } from "@opencode-ai/core/session/projector"
  15. import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
  16. import { SessionTable } from "@opencode-ai/core/session/sql"
  17. import { SessionStore } from "@opencode-ai/core/session/store"
  18. import { Session } from "@opencode-ai/core/session"
  19. import { Project } from "@opencode-ai/core/project"
  20. import { ProjectTable } from "@opencode-ai/core/project/sql"
  21. import { App } from "@opencode-ai/core/app"
  22. import { Agent } from "@opencode-ai/core/agent"
  23. import { Location } from "@opencode-ai/core/location"
  24. import { AbsolutePath } from "@opencode-ai/core/schema"
  25. import { Money } from "@opencode-ai/schema/money"
  26. import { DateTime, Effect, Fiber, Layer, Schema, Stream } from "effect"
  27. import { asc, eq } from "drizzle-orm"
  28. import { testEffect } from "./lib/effect"
  29. let requests: LLMRequest[] = []
  30. const model = LanguageModel.make({
  31. id: "summary-model",
  32. provider: "test",
  33. route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
  34. })
  35. const cost = [
  36. {
  37. input: Money.USDPerMillionTokens.make(1),
  38. output: Money.USDPerMillionTokens.make(2),
  39. cache: {
  40. read: Money.USDPerMillionTokens.make(0.1),
  41. write: Money.USDPerMillionTokens.make(0.5),
  42. },
  43. },
  44. ]
  45. const client = Layer.mock(LLMClient.Service)({
  46. stream: (request: LLMRequest) => {
  47. requests.push(request)
  48. return Stream.make(
  49. LLMEvent.textDelta({ id: "summary", text: "manual summary" }),
  50. LLMEvent.stepFinish({
  51. index: 0,
  52. reason: { normalized: "stop" },
  53. usage: {
  54. inputTokens: 15,
  55. outputTokens: 6,
  56. nonCachedInputTokens: 10,
  57. cacheReadInputTokens: 3,
  58. cacheWriteInputTokens: 2,
  59. reasoningTokens: 2,
  60. },
  61. }),
  62. LLMEvent.finish({
  63. reason: { normalized: "stop" },
  64. }),
  65. )
  66. },
  67. generate: () => Effect.die("unused"),
  68. })
  69. const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
  70. const models = Layer.mock(SessionRunnerModel.Service)({
  71. resolve: () =>
  72. Effect.succeed(
  73. SessionRunnerModel.resolved(model, {
  74. capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
  75. cost,
  76. }),
  77. ),
  78. })
  79. const it = testEffect(
  80. AppNodeBuilder.build(
  81. LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
  82. [
  83. [Bus.node, Bus.configured({ persist: true })],
  84. [llmClient, client],
  85. [Config.node, config],
  86. [SessionRunnerModel.node, models],
  87. ],
  88. ),
  89. )
  90. test("compaction prompt preserves detailed work state and relevant files", () => {
  91. const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
  92. expect(prompt).toContain("## Work State\n### Completed")
  93. expect(prompt).toContain("### Active")
  94. expect(prompt).toContain("### Blocked")
  95. expect(prompt).toContain("## Relevant Files")
  96. })
  97. test("compaction describes tool media without embedding base64", () => {
  98. const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  99. const serialized = SessionCompaction.serializeToolContent([
  100. { type: "text", text: "Image read successfully" },
  101. {
  102. type: "file",
  103. uri: `data:image/png;base64,${base64}`,
  104. mime: "image/png",
  105. name: "pixel.png",
  106. },
  107. ])
  108. expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
  109. expect(serialized).not.toContain(base64)
  110. })
  111. test("compaction prompt requires the checkpoint headings in order", () => {
  112. const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
  113. expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
  114. "## Objective",
  115. "## Important Details",
  116. "## Work State",
  117. "### Completed",
  118. "### Active",
  119. "### Blocked",
  120. "## Next Move",
  121. "## Relevant Files",
  122. ])
  123. expect(prompt).toContain("one or two brief sentences")
  124. expect(prompt).toContain("constraints/preferences, decisions and why")
  125. expect(prompt).toContain("immediate concrete action")
  126. expect(prompt).toContain("next action if known")
  127. expect(prompt).toContain("Keep every section, even when empty.")
  128. })
  129. it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
  130. Effect.gen(function* () {
  131. const compaction = yield* SessionCompaction.Service
  132. const session = Session.Info.make({
  133. id: Session.ID.make("ses_input_limit"),
  134. projectID: Project.ID.global,
  135. cost: Money.USD.zero,
  136. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  137. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  138. location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
  139. })
  140. const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
  141. session,
  142. model: LanguageModel.make({
  143. id: "test-model",
  144. provider: "test-provider",
  145. route: OpenAIChat.route.with({ limits }),
  146. }),
  147. cost: [],
  148. messages: [
  149. Schema.decodeUnknownSync(SessionMessage.Assistant)({
  150. id: SessionMessage.ID.make("msg_assistant"),
  151. type: "assistant",
  152. agent: Agent.defaultID,
  153. model: { id: "test-model", providerID: "test-provider" },
  154. content: [],
  155. tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  156. time: { created: 0, completed: 0 },
  157. }),
  158. ],
  159. })
  160. const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
  161. expect(compaction.required(input(251_999, inputLimited))).toBe(false)
  162. expect(compaction.required(input(252_000, inputLimited))).toBe(true)
  163. const contextLimited = { context: 100_000, output: 10_000 }
  164. expect(compaction.required(input(79_999, contextLimited))).toBe(false)
  165. expect(compaction.required(input(80_000, contextLimited))).toBe(true)
  166. const outputLimited = { context: 100_000, output: 30_000 }
  167. expect(compaction.required(input(69_999, outputLimited))).toBe(false)
  168. expect(compaction.required(input(70_000, outputLimited))).toBe(true)
  169. }),
  170. )
  171. it.effect("manual compaction summarizes short context instead of no-op", () =>
  172. Effect.gen(function* () {
  173. requests = []
  174. const db = (yield* Database.Service).db
  175. const compaction = yield* SessionCompaction.Service
  176. const bus = yield* Bus.Service
  177. const store = yield* SessionStore.Service
  178. const sessionID = Session.ID.make("ses_manual_compaction")
  179. const parentID = Session.ID.make("ses_manual_compaction_parent")
  180. const userMessage = {
  181. id: SessionMessage.ID.create(),
  182. type: "user" as const,
  183. text: "Manual compaction should include this short conversation.",
  184. time: { created: DateTime.makeUnsafe(0) },
  185. }
  186. yield* db
  187. .insert(ProjectTable)
  188. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  189. .onConflictDoNothing()
  190. .run()
  191. .pipe(Effect.orDie)
  192. yield* db
  193. .insert(SessionTable)
  194. .values({
  195. id: sessionID,
  196. project_id: Project.ID.global,
  197. parent_id: parentID,
  198. slug: "manual-compaction",
  199. directory: "/project",
  200. title: "Manual compaction",
  201. version: "test",
  202. })
  203. .run()
  204. .pipe(Effect.orDie)
  205. const session = yield* store
  206. .get(sessionID)
  207. .pipe(
  208. Effect.flatMap((session) =>
  209. session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"),
  210. ),
  211. )
  212. const delta = yield* bus
  213. .subscribe(SessionEvent.Compaction.Delta)
  214. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  215. yield* Effect.yieldNow
  216. expect(
  217. yield* compaction.compactManual({
  218. session,
  219. messages: [userMessage],
  220. inputID: SessionMessage.ID.make("msg_manual_compaction"),
  221. }),
  222. ).toEqual({ status: "completed" })
  223. expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
  224. expect(requests).toHaveLength(1)
  225. expect(requests[0]?.promptCacheKey).toBe(sessionID)
  226. expect(requests[0]?.http?.headers).toEqual({
  227. "x-session-affinity": sessionID,
  228. "X-Session-Id": sessionID,
  229. "x-parent-session-id": parentID,
  230. "User-Agent": App.useragent(App.make()),
  231. "x-opencode-project": Project.ID.global,
  232. "x-opencode-session": sessionID,
  233. "x-opencode-client": "opencode",
  234. })
  235. expect(requests[0]?.generation).toBeUndefined()
  236. expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
  237. expect(yield* store.context(sessionID)).toMatchObject([
  238. { type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
  239. ])
  240. expect(yield* store.get(sessionID)).toMatchObject({
  241. cost: 0.0000233,
  242. tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
  243. })
  244. expect(
  245. yield* db
  246. .select({ type: EventTable.type })
  247. .from(EventTable)
  248. .where(eq(EventTable.aggregate_id, sessionID))
  249. .orderBy(asc(EventTable.seq))
  250. .all()
  251. .pipe(Effect.orDie),
  252. ).toEqual([
  253. { type: Bus.versionedType(SessionEvent.Compaction.Started.type, 1) },
  254. { type: Bus.versionedType(SessionEvent.UsageRecorded.type, 1) },
  255. { type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1) },
  256. ])
  257. }),
  258. )