session-tool-progress.test.ts 5.8 KB

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