session-compaction.test.ts 7.9 KB

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