session-compaction.test.ts 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { expect, test } from "bun:test"
  2. import { LLMClient, LLMEvent, Model, type LLMRequest } from "@opencode-ai/llm"
  3. import { OpenAIChat } from "@opencode-ai/llm/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/core/effect/layer-node"
  9. import { EventV2 } from "@opencode-ai/core/event"
  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 { SessionV2 } from "@opencode-ai/core/session"
  19. import { Project } from "@opencode-ai/core/project"
  20. import { ProjectTable } from "@opencode-ai/core/project/sql"
  21. import { AbsolutePath } from "@opencode-ai/core/schema"
  22. import { DateTime, Effect, Fiber, Layer, Stream } from "effect"
  23. import { asc, eq } from "drizzle-orm"
  24. import { testEffect } from "./lib/effect"
  25. let requests: LLMRequest[] = []
  26. const model = Model.make({
  27. id: "summary-model",
  28. provider: "test",
  29. route: OpenAIChat.route.with({ limits: { context: 10_000, output: 1_000 } }),
  30. })
  31. const client = Layer.mock(LLMClient.Service)({
  32. prepare: () => Effect.die("unused"),
  33. stream: (request: LLMRequest) => {
  34. requests.push(request)
  35. return Stream.make(LLMEvent.textDelta({ id: "summary", text: "manual summary" }))
  36. },
  37. generate: () => Effect.die("unused"),
  38. })
  39. const config = Layer.mock(Config.Service)({ entries: () => Effect.succeed([]) })
  40. const models = Layer.mock(SessionRunnerModel.Service)({
  41. resolve: () => Effect.succeed(SessionRunnerModel.resolved(model)),
  42. })
  43. const it = testEffect(
  44. AppNodeBuilder.build(
  45. LayerNode.group([Database.node, EventV2.node, SessionProjector.node, SessionStore.node, SessionCompaction.node]),
  46. [
  47. [llmClient, client],
  48. [Config.node, config],
  49. [SessionRunnerModel.node, models],
  50. ],
  51. ),
  52. )
  53. test("compaction prompt preserves detailed work state and relevant files", () => {
  54. const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
  55. expect(prompt).toContain("## Work State\n### Completed")
  56. expect(prompt).toContain("### Active")
  57. expect(prompt).toContain("### Blocked")
  58. expect(prompt).toContain("## Relevant Files")
  59. })
  60. test("compaction describes tool media without embedding base64", () => {
  61. const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  62. const serialized = SessionCompaction.serializeToolContent([
  63. { type: "text", text: "Image read successfully" },
  64. {
  65. type: "file",
  66. uri: `data:image/png;base64,${base64}`,
  67. mime: "image/png",
  68. name: "pixel.png",
  69. },
  70. ])
  71. expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
  72. expect(serialized).not.toContain(base64)
  73. })
  74. test("compaction prompt requires the checkpoint headings in order", () => {
  75. const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
  76. expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
  77. "## Objective",
  78. "## Important Details",
  79. "## Work State",
  80. "### Completed",
  81. "### Active",
  82. "### Blocked",
  83. "## Next Move",
  84. "## Relevant Files",
  85. ])
  86. expect(prompt).toContain("one or two brief sentences")
  87. expect(prompt).toContain("constraints/preferences, decisions and why")
  88. expect(prompt).toContain("immediate concrete action")
  89. expect(prompt).toContain("next action if known")
  90. expect(prompt).toContain("Keep every section, even when empty.")
  91. })
  92. it.effect("requires compaction before a request reaches the inference body limit", () =>
  93. Effect.gen(function* () {
  94. const compaction = yield* SessionCompaction.Service
  95. expect(
  96. compaction.required({
  97. sessionID: SessionV2.ID.make("ses_request_body_compaction"),
  98. messages: [],
  99. model,
  100. requestBytes: 8 * 1024 * 1024,
  101. }),
  102. ).toBe(true)
  103. }),
  104. )
  105. it.effect("manual compaction summarizes short context instead of no-op", () =>
  106. Effect.gen(function* () {
  107. requests = []
  108. const db = (yield* Database.Service).db
  109. const compaction = yield* SessionCompaction.Service
  110. const events = yield* EventV2.Service
  111. const store = yield* SessionStore.Service
  112. const sessionID = SessionV2.ID.make("ses_manual_compaction")
  113. const userMessage = {
  114. id: SessionMessage.ID.create(),
  115. type: "user" as const,
  116. text: "Manual compaction should include this short conversation.",
  117. time: { created: DateTime.makeUnsafe(0) },
  118. }
  119. yield* db
  120. .insert(ProjectTable)
  121. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  122. .onConflictDoNothing()
  123. .run()
  124. .pipe(Effect.orDie)
  125. yield* db
  126. .insert(SessionTable)
  127. .values({
  128. id: sessionID,
  129. project_id: Project.ID.global,
  130. slug: "manual-compaction",
  131. directory: "/project",
  132. title: "Manual compaction",
  133. version: "test",
  134. })
  135. .run()
  136. .pipe(Effect.orDie)
  137. const session = yield* store
  138. .get(sessionID)
  139. .pipe(
  140. Effect.flatMap((session) =>
  141. session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"),
  142. ),
  143. )
  144. const delta = yield* events
  145. .subscribe(SessionEvent.Compaction.Delta)
  146. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  147. yield* Effect.yieldNow
  148. expect(
  149. yield* compaction.compactManual({
  150. session,
  151. messages: [userMessage],
  152. inputID: SessionMessage.ID.make("msg_manual_compaction"),
  153. }),
  154. ).toEqual({ status: "completed" })
  155. expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
  156. expect(requests).toHaveLength(1)
  157. expect(requests[0]?.generation?.maxTokens).toBe(4_096)
  158. expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
  159. expect(yield* store.context(sessionID)).toMatchObject([
  160. { type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
  161. ])
  162. expect(
  163. yield* db
  164. .select({ type: EventTable.type })
  165. .from(EventTable)
  166. .where(eq(EventTable.aggregate_id, sessionID))
  167. .orderBy(asc(EventTable.seq))
  168. .all()
  169. .pipe(Effect.orDie),
  170. ).toEqual([
  171. { type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
  172. { type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
  173. ])
  174. }),
  175. )
  176. it.effect("manual compaction rolls large histories through bounded requests", () =>
  177. Effect.gen(function* () {
  178. requests = []
  179. const db = (yield* Database.Service).db
  180. const compaction = yield* SessionCompaction.Service
  181. const store = yield* SessionStore.Service
  182. const sessionID = SessionV2.ID.make("ses_bounded_compaction")
  183. yield* db
  184. .insert(ProjectTable)
  185. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  186. .onConflictDoNothing()
  187. .run()
  188. .pipe(Effect.orDie)
  189. yield* db
  190. .insert(SessionTable)
  191. .values({
  192. id: sessionID,
  193. project_id: Project.ID.global,
  194. slug: "bounded-compaction",
  195. directory: "/project",
  196. title: "Bounded compaction",
  197. version: "test",
  198. })
  199. .run()
  200. .pipe(Effect.orDie)
  201. const session = yield* store
  202. .get(sessionID)
  203. .pipe(Effect.flatMap((value) => (value ? Effect.succeed(value) : Effect.die("test session missing"))))
  204. expect(
  205. yield* compaction.compactManual({
  206. session,
  207. messages: [
  208. {
  209. id: SessionMessage.ID.create(),
  210. type: "user",
  211. text: "a".repeat(300_000),
  212. time: { created: DateTime.makeUnsafe(0) },
  213. },
  214. ],
  215. inputID: SessionMessage.ID.make("msg_bounded_compaction"),
  216. }),
  217. ).toEqual({ status: "completed" })
  218. expect(requests.length).toBeGreaterThan(1)
  219. expect(requests.every((request) => JSON.stringify(request.messages).length < 150_000)).toBe(true)
  220. expect(JSON.stringify(requests[1]?.messages)).toContain("manual summary")
  221. }),
  222. )