session-tool-progress.test.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158
  1. import { describe, expect } from "bun:test"
  2. import { asc, eq } from "drizzle-orm"
  3. import { DateTime, Effect, Schema } from "effect"
  4. import { Database } from "@opencode-ai/core/database/database"
  5. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  6. import { EventV2 } from "@opencode-ai/core/event"
  7. import { EventTable } from "@opencode-ai/core/event/sql"
  8. import { ModelV2 } from "@opencode-ai/core/model"
  9. import { Project } from "@opencode-ai/core/project"
  10. import { ProjectTable } from "@opencode-ai/core/project/sql"
  11. import { ProviderV2 } from "@opencode-ai/core/provider"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { SessionV2 } from "@opencode-ai/core/session"
  14. import { SessionEvent } from "@opencode-ai/core/session/event"
  15. import { SessionMessage } from "@opencode-ai/core/session/message"
  16. import { SessionProjector } from "@opencode-ai/core/session/projector"
  17. import { SessionTable, SessionMessageTable } from "@opencode-ai/core/session/sql"
  18. import { testEffect } from "./lib/effect"
  19. const it = testEffect(LayerNode.compile(LayerNode.group([Database.node, EventV2.node, SessionProjector.node])))
  20. const timestamp = DateTime.makeUnsafe(1)
  21. const model = { id: ModelV2.ID.make("model"), providerID: ProviderV2.ID.make("provider") }
  22. const content = (text: string) => [{ type: "text" as const, text }]
  23. describe("Tool.Progress", () => {
  24. it.effect("projects durable progress and keeps final settlements durable", () =>
  25. Effect.gen(function* () {
  26. const { db } = yield* Database.Service
  27. const service = yield* EventV2.Service
  28. const sessionID = SessionV2.ID.make("ses_tool_progress_projector")
  29. yield* db
  30. .insert(ProjectTable)
  31. .values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
  32. .onConflictDoNothing()
  33. .run()
  34. .pipe(Effect.orDie)
  35. yield* db
  36. .insert(SessionTable)
  37. .values({
  38. id: sessionID,
  39. project_id: Project.ID.global,
  40. slug: "progress",
  41. directory: "/project",
  42. title: "progress",
  43. version: "test",
  44. })
  45. .run()
  46. .pipe(Effect.orDie)
  47. const assistantMessageID = SessionMessage.ID.create()
  48. yield* service.publish(SessionEvent.Step.Started, {
  49. sessionID,
  50. assistantMessageID,
  51. timestamp,
  52. agent: "build",
  53. model,
  54. })
  55. const readAssistant = Effect.gen(function* () {
  56. const row = yield* db
  57. .select()
  58. .from(SessionMessageTable)
  59. .where(eq(SessionMessageTable.id, assistantMessageID))
  60. .get()
  61. .pipe(Effect.orDie)
  62. if (!row) return yield* Effect.die("Missing projected assistant")
  63. return Schema.decodeUnknownSync(SessionMessage.Assistant)({ ...row.data, id: row.id, type: row.type })
  64. })
  65. const start = (callID: string) =>
  66. Effect.gen(function* () {
  67. yield* service.publish(SessionEvent.Tool.Input.Started, {
  68. sessionID,
  69. timestamp,
  70. assistantMessageID,
  71. callID,
  72. name: "bash",
  73. })
  74. yield* service.publish(SessionEvent.Tool.Called, {
  75. sessionID,
  76. timestamp,
  77. assistantMessageID,
  78. callID,
  79. tool: "bash",
  80. input: { command: "pwd" },
  81. provider: { executed: false },
  82. })
  83. })
  84. yield* start("call-success")
  85. expect((yield* readAssistant).content[0]).toMatchObject({
  86. state: { status: "running", structured: {}, content: [] },
  87. })
  88. yield* service.publish(SessionEvent.Tool.Progress, {
  89. sessionID,
  90. timestamp,
  91. assistantMessageID,
  92. callID: "call-success",
  93. structured: { phase: "checkpoint" },
  94. content: content("saved"),
  95. })
  96. expect((yield* readAssistant).content[0]).toMatchObject({
  97. state: { status: "running", structured: { phase: "checkpoint" }, content: content("saved") },
  98. })
  99. const success = yield* service.publish(SessionEvent.Tool.Success, {
  100. sessionID,
  101. timestamp,
  102. assistantMessageID,
  103. callID: "call-success",
  104. structured: { phase: "done" },
  105. content: content("complete"),
  106. provider: { executed: false },
  107. })
  108. expect((yield* readAssistant).content[0]).toMatchObject({
  109. state: { status: "completed", structured: { phase: "done" }, content: content("complete") },
  110. })
  111. yield* start("call-failed")
  112. yield* service.publish(SessionEvent.Tool.Progress, {
  113. sessionID,
  114. timestamp,
  115. assistantMessageID,
  116. callID: "call-failed",
  117. structured: { phase: "checkpoint" },
  118. content: content("before failure"),
  119. })
  120. const failed = yield* service.publish(SessionEvent.Tool.Failed, {
  121. sessionID,
  122. timestamp,
  123. assistantMessageID,
  124. callID: "call-failed",
  125. error: { type: "unknown", message: "boom" },
  126. provider: { executed: false },
  127. })
  128. expect((yield* readAssistant).content[1]).toMatchObject({
  129. state: {
  130. status: "error",
  131. structured: { phase: "checkpoint" },
  132. content: content("before failure"),
  133. error: { type: "unknown", message: "boom" },
  134. },
  135. })
  136. expect(Schema.is(SessionEvent.Durable)(success)).toBe(true)
  137. expect(Schema.is(SessionEvent.Durable)(failed)).toBe(true)
  138. const rows = yield* db
  139. .select({ type: EventTable.type })
  140. .from(EventTable)
  141. .where(eq(EventTable.aggregate_id, sessionID))
  142. .orderBy(asc(EventTable.seq))
  143. .all()
  144. .pipe(Effect.orDie)
  145. expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Progress.type, 1))
  146. expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Success.type, 1))
  147. expect(rows.map((row) => row.type)).toContain(EventV2.versionedType(SessionEvent.Tool.Failed.type, 1))
  148. }),
  149. )
  150. })