session-compaction.test.ts 7.9 KB

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