session-compaction.test.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  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("manual compaction summarizes short context instead of no-op", () =>
  93. Effect.gen(function* () {
  94. requests = []
  95. const db = (yield* Database.Service).db
  96. const compaction = yield* SessionCompaction.Service
  97. const events = yield* EventV2.Service
  98. const store = yield* SessionStore.Service
  99. const sessionID = SessionV2.ID.make("ses_manual_compaction")
  100. const userMessage = {
  101. id: SessionMessage.ID.create(),
  102. type: "user" as const,
  103. text: "Manual compaction should include this short conversation.",
  104. time: { created: DateTime.makeUnsafe(0) },
  105. }
  106. yield* db
  107. .insert(ProjectTable)
  108. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  109. .onConflictDoNothing()
  110. .run()
  111. .pipe(Effect.orDie)
  112. yield* db
  113. .insert(SessionTable)
  114. .values({
  115. id: sessionID,
  116. project_id: Project.ID.global,
  117. slug: "manual-compaction",
  118. directory: "/project",
  119. title: "Manual compaction",
  120. version: "test",
  121. })
  122. .run()
  123. .pipe(Effect.orDie)
  124. const session = yield* store
  125. .get(sessionID)
  126. .pipe(
  127. Effect.flatMap((session) =>
  128. session ? Effect.succeed(session) : Effect.die("manual compaction test session missing"),
  129. ),
  130. )
  131. const delta = yield* events
  132. .subscribe(SessionEvent.Compaction.Delta)
  133. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  134. yield* Effect.yieldNow
  135. expect(
  136. yield* compaction.compactManual({
  137. session,
  138. messages: [userMessage],
  139. inputID: SessionMessage.ID.make("msg_manual_compaction"),
  140. }),
  141. ).toEqual({ status: "completed" })
  142. expect(Array.from(yield* Fiber.join(delta)).map((event) => event.data.text)).toEqual(["manual summary"])
  143. expect(requests).toHaveLength(1)
  144. expect(requests[0]?.generation).toBeUndefined()
  145. expect(JSON.stringify(requests[0]?.messages)).toContain("Manual compaction should include this short conversation.")
  146. expect(yield* store.context(sessionID)).toMatchObject([
  147. { type: "compaction", reason: "manual", summary: "manual summary", recent: "" },
  148. ])
  149. expect(
  150. yield* db
  151. .select({ type: EventTable.type })
  152. .from(EventTable)
  153. .where(eq(EventTable.aggregate_id, sessionID))
  154. .orderBy(asc(EventTable.seq))
  155. .all()
  156. .pipe(Effect.orDie),
  157. ).toEqual([
  158. { type: EventV2.versionedType(SessionEvent.Compaction.Started.type, 1) },
  159. { type: EventV2.versionedType(SessionEvent.Compaction.Ended.type, 1) },
  160. ])
  161. }),
  162. )