runner.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import { Flag } from "@kirincode-ai/core/flag/flag"
  2. import { ConfigV1 } from "@kirincode-ai/core/v1/config/config"
  3. import { SessionV1 } from "@kirincode-ai/core/v1/session"
  4. import { Cause, Duration, Effect, Layer, Scope } from "effect"
  5. import { TestLLMServer } from "../../lib/llm-server"
  6. import type { Config } from "../../../src/config/config"
  7. import type { MessageV2 } from "../../../src/session/message-v2"
  8. import { MessageID, PartID } from "../../../src/session/schema"
  9. import { call, callAuthProbe, disposeApps } from "./backend"
  10. import { original } from "./environment"
  11. import { runtime } from "./runtime"
  12. import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
  13. import { ProviderV2 } from "@kirincode-ai/core/provider"
  14. import { ModelV2 } from "@kirincode-ai/core/model"
  15. export function runScenario(options: Options) {
  16. return (scenario: Scenario) => {
  17. if (scenario.kind === "todo") return Effect.succeed({ status: "skip", scenario } as Result)
  18. return runActive(options, scenario).pipe(
  19. Effect.timeoutOrElse({
  20. duration: options.scenarioTimeout,
  21. orElse: () => Effect.die(new Error(`scenario timed out after ${Duration.format(options.scenarioTimeout)}`)),
  22. }),
  23. Effect.as({ status: "pass", scenario } as Result),
  24. Effect.catchCause((cause) => Effect.succeed({ status: "fail" as const, scenario, message: Cause.pretty(cause) })),
  25. Effect.scoped,
  26. )
  27. }
  28. }
  29. function runActive(options: Options, scenario: ActiveScenario) {
  30. if (options.mode === "auth") return runAuth(scenario)
  31. return withContext(options, scenario, "shared", (ctx) =>
  32. Effect.gen(function* () {
  33. yield* trace(options, scenario, "request start")
  34. const result = yield* call(scenario, ctx)
  35. yield* trace(options, scenario, `response ${result.status}`)
  36. yield* trace(options, scenario, "expect start")
  37. yield* scenario.expect(ctx, ctx.state, result)
  38. yield* trace(options, scenario, "expect done")
  39. }),
  40. )
  41. }
  42. function runAuth(scenario: ActiveScenario) {
  43. return Effect.gen(function* () {
  44. const result = yield* callAuthProbe(scenario, "missing")
  45. if (scenario.auth === "protected") {
  46. if (result.status !== 401) throw new Error(`auth expected 401, got ${result.status}`)
  47. const authed = yield* callAuthProbe(scenario, "valid")
  48. if (authed.status === 401) throw new Error("auth rejected valid credentials")
  49. return
  50. }
  51. if (result.status === 401) throw new Error("auth expected public access, got 401")
  52. if (result.timedOut) throw new Error("auth expected public access, probe timed out")
  53. })
  54. }
  55. function withContext<A, E>(
  56. options: Options,
  57. scenario: ActiveScenario,
  58. label: string,
  59. use: (ctx: SeededContext<unknown>) => Effect.Effect<A, E>,
  60. ) {
  61. return Effect.acquireRelease(
  62. Effect.gen(function* () {
  63. yield* trace(options, scenario, `${label} context acquire start`)
  64. const llm = scenario.project?.llm ? yield* TestLLMServer : undefined
  65. const project = scenario.project
  66. const dir = project
  67. ? yield* Effect.promise(async () => (await runtime()).tmpdir(projectOptions(project, llm?.url)))
  68. : undefined
  69. yield* trace(options, scenario, `${label} context acquire done`)
  70. return { dir, llm }
  71. }),
  72. (ctx) =>
  73. Effect.gen(function* () {
  74. yield* trace(options, scenario, `${label} tmpdir cleanup start`)
  75. yield* Effect.promise(async () => {
  76. await ctx.dir?.[Symbol.asyncDispose]()
  77. }).pipe(Effect.ignore)
  78. yield* trace(options, scenario, `${label} tmpdir cleanup done`)
  79. }),
  80. ).pipe(
  81. Effect.flatMap((context) =>
  82. Effect.gen(function* () {
  83. yield* trace(options, scenario, `${label} runtime start`)
  84. const modules = yield* Effect.promise(() => runtime())
  85. const scope = yield* Scope.Scope
  86. const app = yield* Layer.buildWithMemoMap(modules.AppLayer, modules.memoMap, scope)
  87. yield* trace(options, scenario, `${label} runtime done`)
  88. const path = context.dir?.path
  89. const instance = path
  90. ? yield* trace(options, scenario, `${label} instance load start`).pipe(
  91. Effect.andThen(
  92. modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
  93. Effect.provide(app),
  94. Effect.catchCause((cause) =>
  95. Effect.sleep("100 millis").pipe(
  96. Effect.andThen(
  97. modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
  98. Effect.provide(app),
  99. ),
  100. ),
  101. Effect.catchCause(() => Effect.failCause(cause)),
  102. ),
  103. ),
  104. ),
  105. ),
  106. Effect.tap(() => trace(options, scenario, `${label} instance load done`)),
  107. )
  108. : undefined
  109. const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
  110. effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(app))
  111. const directory = () => {
  112. if (!context.dir?.path) throw new Error("scenario needs a project directory")
  113. return context.dir.path
  114. }
  115. const llm = () => {
  116. if (!context.llm) throw new Error("scenario needs fake LLM")
  117. return context.llm
  118. }
  119. const base: ScenarioContext = {
  120. directory: context.dir?.path,
  121. headers: (extra) => ({
  122. ...(context.dir?.path ? { "x-opencode-directory": context.dir.path } : {}),
  123. ...extra,
  124. }),
  125. file: (name, content) =>
  126. Effect.promise(() => {
  127. return Bun.write(`${directory()}/${name}`, content)
  128. }).pipe(Effect.asVoid),
  129. session: (input) =>
  130. run(modules.Session.Service.use((svc) => svc.create({ title: input?.title, parentID: input?.parentID }))),
  131. sessionGet: (sessionID) =>
  132. run(modules.Session.Service.use((svc) => svc.get(sessionID))).pipe(
  133. Effect.catchCause(() => Effect.succeed(undefined)),
  134. ),
  135. project: () =>
  136. Effect.sync(() => {
  137. if (!instance) throw new Error("scenario needs a project directory")
  138. return instance.project
  139. }),
  140. message: (sessionID, input) =>
  141. Effect.gen(function* () {
  142. const info: SessionV1.User = {
  143. id: MessageID.ascending(),
  144. sessionID,
  145. role: "user",
  146. time: { created: Date.now() },
  147. agent: "build",
  148. model: {
  149. providerID: ProviderV2.ID.kirincode,
  150. modelID: ModelV2.ID.make("test"),
  151. },
  152. }
  153. const part: SessionV1.TextPart = {
  154. id: PartID.ascending(),
  155. sessionID,
  156. messageID: info.id,
  157. type: "text",
  158. text: input?.text ?? "hello",
  159. }
  160. yield* run(
  161. modules.Session.Service.use((svc) =>
  162. Effect.gen(function* () {
  163. yield* svc.updateMessage(info)
  164. yield* svc.updatePart(part)
  165. }),
  166. ),
  167. )
  168. return { info, part }
  169. }),
  170. messages: (sessionID) =>
  171. run(modules.Session.Service.use((svc) => svc.messages({ sessionID }).pipe(Effect.orDie))),
  172. todos: (sessionID, todos) => run(modules.Todo.Service.use((svc) => svc.update({ sessionID, todos }))),
  173. worktree: (input) => run(modules.Worktree.Service.use((svc) => svc.create(input).pipe(Effect.orDie))),
  174. worktreeRemove: (directory) =>
  175. run(modules.Worktree.Service.use((svc) => svc.remove({ directory })).pipe(Effect.ignore)),
  176. llmText: (value) => Effect.suspend(() => llm().text(value)),
  177. llmWait: (count) => Effect.suspend(() => llm().wait(count)),
  178. tuiRequest: (request) => Effect.sync(() => modules.Tui.submitTuiRequest(request)),
  179. }
  180. yield* trace(options, scenario, `${label} seed start`)
  181. const state = yield* scenario.seed(base)
  182. yield* trace(options, scenario, `${label} seed done`)
  183. yield* trace(options, scenario, `${label} use start`)
  184. const result = yield* use({ ...base, state })
  185. yield* trace(options, scenario, `${label} use done`)
  186. return result
  187. }).pipe(Effect.ensuring(context.llm ? context.llm.reset : Effect.void)),
  188. ),
  189. Effect.ensuring(scenario.reset ? resetState : Effect.void),
  190. )
  191. }
  192. function trace(options: Options, scenario: ActiveScenario, phase: string) {
  193. return Effect.sync(() => {
  194. if (!options.trace) return
  195. console.log(`[trace] ${scenario.name}: ${phase}`)
  196. })
  197. }
  198. function projectOptions(
  199. project: ProjectOptions,
  200. llmUrl: string | undefined,
  201. ): { git?: boolean; config?: Partial<ConfigV1.Info> } {
  202. if (!project.llm || !llmUrl) return { git: project.git, config: project.config }
  203. const fake = fakeLlmConfig(llmUrl)
  204. return {
  205. git: project.git,
  206. config: {
  207. ...fake,
  208. ...project.config,
  209. provider: {
  210. ...fake.provider,
  211. ...project.config?.provider,
  212. },
  213. },
  214. }
  215. }
  216. function fakeLlmConfig(url: string): Partial<ConfigV1.Info> {
  217. return {
  218. model: "test/test-model",
  219. small_model: "test/test-model",
  220. provider: {
  221. test: {
  222. name: "Test",
  223. id: "test",
  224. env: [],
  225. npm: "@ai-sdk/openai-compatible",
  226. models: {
  227. "test-model": {
  228. id: "test-model",
  229. name: "Test Model",
  230. attachment: false,
  231. reasoning: false,
  232. temperature: false,
  233. tool_call: true,
  234. release_date: "2025-01-01",
  235. limit: { context: 100000, output: 10000 },
  236. cost: { input: 0, output: 0 },
  237. options: {},
  238. },
  239. },
  240. options: {
  241. apiKey: "test-key",
  242. baseURL: url,
  243. },
  244. },
  245. },
  246. }
  247. }
  248. const resetState = Effect.promise(async () => {
  249. const modules = await runtime()
  250. Flag.KIRINCODE_SERVER_PASSWORD = original.KIRINCODE_SERVER_PASSWORD
  251. Flag.KIRINCODE_SERVER_USERNAME = original.KIRINCODE_SERVER_USERNAME
  252. await disposeApps()
  253. await modules.disposeAllInstances()
  254. await modules.resetDatabase()
  255. await Bun.sleep(25)
  256. })