session-compaction.test.ts 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  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. [llmClient, client],
  84. [Config.node, config],
  85. [SessionRunnerModel.node, models],
  86. ],
  87. ),
  88. )
  89. test("compaction prompt preserves detailed work state and relevant files", () => {
  90. const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
  91. expect(prompt).toContain("## Work State\n### Completed")
  92. expect(prompt).toContain("### Active")
  93. expect(prompt).toContain("### Blocked")
  94. expect(prompt).toContain("## Relevant Files")
  95. })
  96. test("compaction describes tool media without embedding base64", () => {
  97. const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
  98. const serialized = SessionCompaction.serializeToolContent([
  99. { type: "text", text: "Image read successfully" },
  100. {
  101. type: "file",
  102. uri: `data:image/png;base64,${base64}`,
  103. mime: "image/png",
  104. name: "pixel.png",
  105. },
  106. ])
  107. expect(serialized).toBe("Image read successfully\n[Attached image/png: pixel.png]")
  108. expect(serialized).not.toContain(base64)
  109. })
  110. test("compaction prompt requires the checkpoint headings in order", () => {
  111. const prompt = SessionCompaction.buildPrompt({ context: ["Conversation history"] })
  112. expect(prompt.match(/^#{2,3} .+$/gm)).toEqual([
  113. "## Objective",
  114. "## Important Details",
  115. "## Work State",
  116. "### Completed",
  117. "### Active",
  118. "### Blocked",
  119. "## Next Move",
  120. "## Relevant Files",
  121. ])
  122. expect(prompt).toContain("one or two brief sentences")
  123. expect(prompt).toContain("constraints/preferences, decisions and why")
  124. expect(prompt).toContain("immediate concrete action")
  125. expect(prompt).toContain("next action if known")
  126. expect(prompt).toContain("Keep every section, even when empty.")
  127. })
  128. it.effect("auto compaction reserves a buffer below the prompt ceiling", () =>
  129. Effect.gen(function* () {
  130. const compaction = yield* SessionCompaction.Service
  131. const session = Session.Info.make({
  132. id: Session.ID.make("ses_input_limit"),
  133. projectID: Project.ID.global,
  134. cost: Money.USD.zero,
  135. tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  136. time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
  137. location: Location.Ref.make({ directory: AbsolutePath.make("/tmp") }),
  138. })
  139. const input = (tokens: number, limits: { context: number; input?: number; output: number }) => ({
  140. session,
  141. model: LanguageModel.make({
  142. id: "test-model",
  143. provider: "test-provider",
  144. route: OpenAIChat.route.with({ limits }),
  145. }),
  146. cost: [],
  147. messages: [
  148. Schema.decodeUnknownSync(SessionMessage.Assistant)({
  149. id: SessionMessage.ID.make("msg_assistant"),
  150. type: "assistant",
  151. agent: Agent.defaultID,
  152. model: { id: "test-model", providerID: "test-provider" },
  153. content: [],
  154. tokens: { input: tokens, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  155. time: { created: 0, completed: 0 },
  156. }),
  157. ],
  158. })
  159. const inputLimited = { context: 400_000, input: 272_000, output: 128_000 }
  160. expect(compaction.required(input(251_999, inputLimited))).toBe(false)
  161. expect(compaction.required(input(252_000, inputLimited))).toBe(true)
  162. const contextLimited = { context: 100_000, output: 10_000 }
  163. expect(compaction.required(input(79_999, contextLimited))).toBe(false)
  164. expect(compaction.required(input(80_000, contextLimited))).toBe(true)
  165. const outputLimited = { context: 100_000, output: 30_000 }
  166. expect(compaction.required(input(69_999, outputLimited))).toBe(false)
  167. expect(compaction.required(input(70_000, outputLimited))).toBe(true)
  168. }),
  169. )
  170. it.effect("manual compaction summarizes short context instead of no-op", () =>
  171. Effect.gen(function* () {
  172. requests = []
  173. const db = (yield* Database.Service).db
  174. const compaction = yield* SessionCompaction.Service
  175. const bus = yield* Bus.Service
  176. const store = yield* SessionStore.Service
  177. const sessionID = Session.ID.make("ses_manual_compaction")
  178. const parentID = Session.ID.make("ses_manual_compaction_parent")
  179. const userMessage = {
  180. id: SessionMessage.ID.create(),
  181. type: "user" as const,
  182. text: "Manual compaction should include this short conversation.",
  183. time: { created: DateTime.makeUnsafe(0) },
  184. }
  185. yield* db
  186. .insert(ProjectTable)
  187. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  188. .onConflictDoNothing()
  189. .run()
  190. .pipe(Effect.orDie)
  191. yield* db
  192. .insert(SessionTable)
  193. .values({
  194. id: sessionID,
  195. project_id: Project.ID.global,
  196. parent_id: parentID,
  197. slug: "manual-compaction",
  198. directory: "/project",
  199. title: "Manual compaction",
  200. version: "test",
  201. })
  202. .run()
  203. .pipe(Effect.orDie)
  204. const session = yield* store
  205. .get(sessionID)
  206. .pipe(
  207. Effect.flatMap((session) =>
  208. session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"),
  209. ),
  210. )
  211. const delta = yield* bus
  212. .subscribe(SessionEvent.Compaction.Delta)
  213. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  214. yield* Effect.yieldNow
  215. expect(
  216. yield* compaction.compactManual({
  217. session,
  218. messages: [userMessage],
  219. inputID: SessionMessage.ID.make("msg_manual_compaction"),
  220. }),
  221. ).toEqual({ status: "completed" })
  222. expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
  223. expect(requests).toHaveLength(1)
  224. expect(requests[0]?.http?.headers).toEqual({
  225. "x-session-affinity": sessionID,
  226. "X-Session-Id": sessionID,
  227. "x-parent-session-id": parentID,
  228. "User-Agent": App.useragent(App.make()),
  229. "x-opencode-project": Project.ID.global,
  230. "x-opencode-session": sessionID,
  231. "x-opencode-client": "opencode",
  232. })
  233. expect(requests[0]?.generation).toBeUndefined()
  234. expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
  235. expect(yield* store.context(sessionID)).toMatchObject([
  236. { type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
  237. ])
  238. expect(yield* store.get(sessionID)).toMatchObject({
  239. cost: 0.0000233,
  240. tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 2 } },
  241. })
  242. expect(
  243. yield* db
  244. .select({ type: EventTable.type })
  245. .from(EventTable)
  246. .where(eq(EventTable.aggregate_id, sessionID))
  247. .orderBy(asc(EventTable.seq))
  248. .all()
  249. .pipe(Effect.orDie),
  250. ).toEqual([
  251. { type: Bus.versionedType(SessionEvent.Compaction.Started.type, 1) },
  252. { type: Bus.versionedType(SessionEvent.UsageRecorded.type, 1) },
  253. { type: Bus.versionedType(SessionEvent.Compaction.Ended.type, 1) },
  254. ])
  255. }),
  256. )