service-session.test.ts 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248
  1. import { describe, expect, it } from "bun:test"
  2. import type {
  3. AgentSideConnection,
  4. ForkSessionResponse,
  5. LoadSessionResponse,
  6. NewSessionResponse,
  7. SessionNotification,
  8. ResumeSessionResponse,
  9. SessionConfigOption,
  10. SessionConfigSelectOption,
  11. SetSessionConfigOptionResponse,
  12. } from "@agentclientprotocol/sdk"
  13. import type { AssistantMessage, OpencodeClient } from "@kirincode-ai/sdk/v2"
  14. import { ProviderV2 } from "@kirincode-ai/core/provider"
  15. import { ModelV2 } from "@kirincode-ai/core/model"
  16. import { Effect } from "effect"
  17. import * as ACPService from "@/acp/service"
  18. import * as ACPError from "@/acp/error"
  19. import { UsageService } from "@/acp/usage"
  20. import type { Provider } from "@/provider/provider"
  21. const providerID = ProviderV2.ID.make("test")
  22. const modelID = ModelV2.ID.make("test-model")
  23. const configuredModelID = ModelV2.ID.make("configured-model")
  24. const secondModelID = ModelV2.ID.make("second-model")
  25. const provider: Provider.Info = {
  26. id: providerID,
  27. name: "Test",
  28. source: "config",
  29. env: [],
  30. options: {},
  31. models: {
  32. [modelID]: {
  33. id: modelID,
  34. providerID,
  35. api: {
  36. id: modelID,
  37. url: "https://example.com",
  38. npm: "@ai-sdk/openai-compatible",
  39. },
  40. name: "Test Model",
  41. family: "test",
  42. capabilities: {
  43. temperature: true,
  44. reasoning: true,
  45. attachment: false,
  46. toolcall: true,
  47. input: { text: true, audio: false, image: false, video: false, pdf: false },
  48. output: { text: true, audio: false, image: false, video: false, pdf: false },
  49. interleaved: false,
  50. },
  51. cost: {
  52. input: 0,
  53. output: 0,
  54. cache: { read: 0, write: 0 },
  55. },
  56. limit: {
  57. context: 128000,
  58. output: 4096,
  59. },
  60. status: "active",
  61. options: {},
  62. headers: {},
  63. release_date: "2026-01-01",
  64. variants: {
  65. default: {},
  66. high: { reasoningEffort: "high" },
  67. },
  68. },
  69. [configuredModelID]: {
  70. id: configuredModelID,
  71. providerID,
  72. api: {
  73. id: configuredModelID,
  74. url: "https://example.com",
  75. npm: "@ai-sdk/openai-compatible",
  76. },
  77. name: "Configured Model",
  78. family: "test",
  79. capabilities: {
  80. temperature: true,
  81. reasoning: false,
  82. attachment: false,
  83. toolcall: true,
  84. input: { text: true, audio: false, image: false, video: false, pdf: false },
  85. output: { text: true, audio: false, image: false, video: false, pdf: false },
  86. interleaved: false,
  87. },
  88. cost: {
  89. input: 0,
  90. output: 0,
  91. cache: { read: 0, write: 0 },
  92. },
  93. limit: {
  94. context: 128000,
  95. output: 4096,
  96. },
  97. status: "active",
  98. options: {},
  99. headers: {},
  100. release_date: "2026-01-01",
  101. },
  102. [secondModelID]: {
  103. id: secondModelID,
  104. providerID,
  105. api: {
  106. id: secondModelID,
  107. url: "https://example.com",
  108. npm: "@ai-sdk/openai-compatible",
  109. },
  110. name: "Second Model",
  111. family: "test",
  112. capabilities: {
  113. temperature: true,
  114. reasoning: true,
  115. attachment: false,
  116. toolcall: true,
  117. input: { text: true, audio: false, image: false, video: false, pdf: false },
  118. output: { text: true, audio: false, image: false, video: false, pdf: false },
  119. interleaved: false,
  120. },
  121. cost: {
  122. input: 0,
  123. output: 0,
  124. cache: { read: 0, write: 0 },
  125. },
  126. limit: {
  127. context: 128000,
  128. output: 4096,
  129. },
  130. status: "active",
  131. options: {},
  132. headers: {},
  133. release_date: "2026-01-01",
  134. variants: {
  135. low: { reasoningEffort: "low" },
  136. medium: { reasoningEffort: "medium" },
  137. },
  138. },
  139. },
  140. }
  141. describe("ACP service sessions", () => {
  142. const makeService = (
  143. messages: readonly { info: unknown; parts: readonly unknown[] }[] = [],
  144. options?: {
  145. abort?: (input: { sessionID: string }) => Promise<{ data: boolean }>
  146. prompt?: (input: unknown) => Promise<{ data: { info: ReturnType<typeof assistantInfo> } }>
  147. },
  148. ) => {
  149. const updates: SessionNotification[] = []
  150. const mcpAdds: string[] = []
  151. const aborts: string[] = []
  152. const forks: string[] = []
  153. const prompts: unknown[] = []
  154. const commands: unknown[] = []
  155. const summarizes: unknown[] = []
  156. const usageUpdates: string[] = []
  157. const sessions = Array.from({ length: 102 }, (_, index) => ({
  158. id: `ses_${index + 1}`,
  159. directory: index % 2 === 0 ? "/workspace" : "/other",
  160. title: `Session ${index + 1}`,
  161. time: { created: index + 1, updated: index + 1 },
  162. }))
  163. const sdk = {
  164. config: {
  165. providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
  166. get: () => Promise.resolve({ data: {} }),
  167. },
  168. app: {
  169. agents: () =>
  170. Promise.resolve({
  171. data: [
  172. { name: "build", mode: "primary", permission: [], options: {} },
  173. { name: "plan", mode: "primary", description: "Plan first", permission: [], options: {} },
  174. { name: "hidden", mode: "primary", hidden: true, permission: [], options: {} },
  175. ],
  176. }),
  177. skills: () =>
  178. Promise.resolve({
  179. data: [{ name: "review-skill", description: "Review", location: "/skills/review", content: "review" }],
  180. }),
  181. },
  182. command: {
  183. list: () =>
  184. Promise.resolve({
  185. data: [{ name: "init", description: "Initialize", source: "command", template: "init", hints: [] }],
  186. }),
  187. },
  188. session: {
  189. create: () => Promise.resolve({ data: { id: "ses_new" } }),
  190. get: () => Promise.resolve({ data: { id: "ses_loaded" } }),
  191. list: (input: { directory?: string }) =>
  192. Promise.resolve({
  193. data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions,
  194. }),
  195. messages: () => Promise.resolve({ data: messages }),
  196. prompt:
  197. options?.prompt ??
  198. ((input: unknown) => {
  199. prompts.push(input)
  200. return Promise.resolve({
  201. data: {
  202. info: assistantInfo({
  203. input: 100,
  204. output: 40,
  205. reasoning: 7,
  206. cache: { read: 11, write: 13 },
  207. }),
  208. },
  209. })
  210. }),
  211. command: (input: unknown) => {
  212. commands.push(input)
  213. return Promise.resolve({
  214. data: {
  215. info: assistantInfo({
  216. input: 3,
  217. output: 4,
  218. reasoning: 0,
  219. cache: { read: 0, write: 0 },
  220. }),
  221. },
  222. })
  223. },
  224. summarize: (input: unknown) => {
  225. summarizes.push(input)
  226. return Promise.resolve({ data: true })
  227. },
  228. abort:
  229. options?.abort ??
  230. ((input: { sessionID: string }) => {
  231. aborts.push(input.sessionID)
  232. return Promise.resolve({ data: true })
  233. }),
  234. fork: (input: { sessionID: string }) => {
  235. forks.push(input.sessionID)
  236. return Promise.resolve({ data: { id: `fork_${input.sessionID}` } })
  237. },
  238. },
  239. mcp: {
  240. add: (input: { name?: string }) => {
  241. if (input.name) mcpAdds.push(input.name)
  242. return Promise.resolve({ data: {} })
  243. },
  244. },
  245. } as unknown as OpencodeClient
  246. const connection = {
  247. sessionUpdate: (update: SessionNotification) => {
  248. updates.push(update)
  249. return Promise.resolve()
  250. },
  251. } as Pick<AgentSideConnection, "sessionUpdate">
  252. const usage = UsageService.Service.of({
  253. buildUsage: UsageService.buildUsage,
  254. latestAssistantMessage: UsageService.latestAssistantMessage,
  255. totalSessionCost: UsageService.totalSessionCost,
  256. contextLimit: () => Effect.succeed(128000),
  257. sendUpdate: (input) =>
  258. Effect.sync(() => {
  259. usageUpdates.push(input.sessionID)
  260. }),
  261. })
  262. return {
  263. service: ACPService.make({ sdk, connection, usage }),
  264. updates,
  265. mcpAdds,
  266. aborts,
  267. forks,
  268. prompts,
  269. commands,
  270. summarizes,
  271. usageUpdates,
  272. }
  273. }
  274. it("creates a backed session with config options and command update", async () => {
  275. const { service, updates, mcpAdds } = makeService()
  276. const result = await Effect.runPromise(
  277. service.newSession({
  278. cwd: "/workspace",
  279. mcpServers: [
  280. { name: "tools", command: "node", args: ["server.js"], env: [] },
  281. { name: "tools", command: "node", args: ["server.js"], env: [] },
  282. ],
  283. }),
  284. )
  285. await new Promise((resolve) => setTimeout(resolve, 5))
  286. expect(result.sessionId).toBe("ses_new")
  287. expect(categories(result)).toContain("model")
  288. expect(categories(result)).toContain("thought_level")
  289. expect(categories(result)).toContain("mode")
  290. expect(updates).toHaveLength(1)
  291. expect(JSON.stringify(updates[0])).toContain("available_commands_update")
  292. expect(JSON.stringify(updates[0])).toContain("review-skill")
  293. expect(mcpAdds).toEqual(["tools"])
  294. })
  295. it("loads a session and restores model variant and mode from messages", async () => {
  296. const { service } = makeService([
  297. {
  298. info: {
  299. role: "assistant",
  300. providerID: "test",
  301. modelID: "test-model",
  302. variant: "high",
  303. mode: "plan",
  304. },
  305. parts: [],
  306. },
  307. ])
  308. const result = await Effect.runPromise(
  309. service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
  310. )
  311. expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
  312. expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
  313. })
  314. it("replays loaded session transcript chunks", async () => {
  315. const { service, updates } = makeService([
  316. {
  317. info: { id: "msg_user", sessionID: "ses_loaded", role: "user" },
  318. parts: [{ id: "part_user", sessionID: "ses_loaded", messageID: "msg_user", type: "text", text: "hello" }],
  319. },
  320. {
  321. info: { id: "msg_assistant", sessionID: "ses_loaded", role: "assistant" },
  322. parts: [
  323. {
  324. id: "part_assistant",
  325. sessionID: "ses_loaded",
  326. messageID: "msg_assistant",
  327. type: "text",
  328. text: "hi there",
  329. },
  330. ],
  331. },
  332. ])
  333. await Effect.runPromise(service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }))
  334. expect(
  335. updates
  336. .map((item) => item.update)
  337. .filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
  338. ).toEqual([
  339. {
  340. sessionUpdate: "user_message_chunk",
  341. messageId: "msg_user",
  342. content: { type: "text", text: "hello" },
  343. },
  344. {
  345. sessionUpdate: "agent_message_chunk",
  346. messageId: "msg_assistant",
  347. content: { type: "text", text: "hi there" },
  348. },
  349. ])
  350. })
  351. it("lists sessions sorted by updated time with cursor support", async () => {
  352. const { service } = makeService()
  353. const first = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
  354. const second = await Effect.runPromise(service.listSessions({ cwd: "/workspace", cursor: first.nextCursor }))
  355. expect(first.sessions).toHaveLength(51)
  356. expect(first.sessions[0]?.sessionId).toBe("ses_101")
  357. expect(first.sessions.at(-1)?.sessionId).toBe("ses_1")
  358. expect(first.nextCursor).toBeUndefined()
  359. expect(second.sessions).toEqual(first.sessions)
  360. })
  361. it("includes live ACP sessions before they appear in server-backed session list", async () => {
  362. const { service } = makeService()
  363. const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  364. const listed = await Effect.runPromise(service.listSessions({ cwd: "/workspace" }))
  365. expect(listed.sessions[0]?.sessionId).toBe(created.sessionId)
  366. expect(listed.sessions[0]?.cwd).toBe("/workspace")
  367. })
  368. it("lists all sessions with next cursor when the first page is full", async () => {
  369. const { service } = makeService()
  370. const first = await Effect.runPromise(service.listSessions({}))
  371. const second = await Effect.runPromise(service.listSessions({ cursor: first.nextCursor }))
  372. expect(first.sessions).toHaveLength(100)
  373. expect(first.sessions[0]?.sessionId).toBe("ses_102")
  374. expect(first.sessions.at(-1)?.sessionId).toBe("ses_3")
  375. expect(first.nextCursor).toBe("3")
  376. expect(second.sessions.map((session) => session.sessionId)).toEqual(["ses_2", "ses_1"])
  377. })
  378. it("resumes a session and stores restored state without replaying transcript chunks", async () => {
  379. const { service, updates } = makeService([
  380. {
  381. info: {
  382. id: "msg_user",
  383. sessionID: "ses_resume",
  384. role: "user",
  385. model: { providerID: "test", modelID: "test-model", variant: "high" },
  386. agent: "plan",
  387. },
  388. parts: [{ id: "part_user", sessionID: "ses_resume", messageID: "msg_user", type: "text", text: "hello" }],
  389. },
  390. {
  391. info: { id: "msg_assistant", sessionID: "ses_resume", role: "assistant" },
  392. parts: [
  393. {
  394. id: "part_assistant",
  395. sessionID: "ses_resume",
  396. messageID: "msg_assistant",
  397. type: "text",
  398. text: "hi there",
  399. },
  400. ],
  401. },
  402. ])
  403. const resumed = await Effect.runPromise(
  404. service.resumeSession({ cwd: "/workspace", sessionId: "ses_resume", mcpServers: [] }),
  405. )
  406. const updated = await Effect.runPromise(
  407. service.setSessionConfigOption({ sessionId: "ses_resume", configId: "effort", value: "default" }),
  408. )
  409. expect(select(resumed, "effort")?.currentValue).toBe("high")
  410. expect(select(updated, "effort")?.currentValue).toBe("default")
  411. expect(
  412. updates
  413. .map((item) => item.update)
  414. .filter((item) => item.sessionUpdate === "user_message_chunk" || item.sessionUpdate === "agent_message_chunk"),
  415. ).toEqual([])
  416. })
  417. it("closes local ACP state and aborts the backing session best-effort", async () => {
  418. const { service, aborts } = makeService()
  419. const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  420. expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({})
  421. const missing = await Effect.runPromise(
  422. service
  423. .setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" })
  424. .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
  425. )
  426. expect(missing.code).toBe(-32602)
  427. expect(aborts).toEqual([created.sessionId])
  428. expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
  429. })
  430. it("cancel aborts the backing session and keeps the ACP session", async () => {
  431. const { service, aborts } = makeService()
  432. const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  433. await Effect.runPromise(service.cancel({ sessionId: created.sessionId }))
  434. // The running turn was aborted via the core session API.
  435. expect(aborts).toEqual([created.sessionId])
  436. // Unlike closeSession, the ACP session is still present afterwards so
  437. // the client can keep prompting.
  438. const stillUsable = await Effect.runPromise(
  439. service.setSessionConfigOption({ sessionId: created.sessionId, configId: "effort", value: "high" }),
  440. )
  441. expect(stillUsable).toBeDefined()
  442. })
  443. it("does not fail cancel or close when the backing abort fails", async () => {
  444. const { service } = makeService([], { abort: () => Promise.reject(new Error("nope")) })
  445. const created = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  446. await Effect.runPromise(service.cancel({ sessionId: created.sessionId }))
  447. expect(await Effect.runPromise(service.closeSession({ sessionId: created.sessionId }))).toEqual({})
  448. expect(await Effect.runPromise(service.closeSession({ sessionId: "missing" }))).toEqual({})
  449. })
  450. it("forks a session, loads fork state, and returns config options", async () => {
  451. const { service, forks } = makeService([
  452. {
  453. info: {
  454. role: "assistant",
  455. providerID: "test",
  456. modelID: "second-model",
  457. variant: "medium",
  458. mode: "plan",
  459. },
  460. parts: [],
  461. },
  462. ])
  463. const forked = await Effect.runPromise(
  464. service.forkSession({ cwd: "/workspace", sessionId: "ses_parent", mcpServers: [] }),
  465. )
  466. const updated = await Effect.runPromise(
  467. service.setSessionConfigOption({ sessionId: forked.sessionId, configId: "effort", value: "low" }),
  468. )
  469. expect(forked.sessionId).toBe("fork_ses_parent")
  470. expect(select(forked, "model")?.currentValue).toBe("test/second-model")
  471. expect(select(forked, "effort")?.currentValue).toBe("medium")
  472. expect(select(updated, "effort")?.currentValue).toBe("low")
  473. expect(forks).toEqual(["ses_parent"])
  474. })
  475. it("restores model variant and mode from the latest user message", async () => {
  476. const { service } = makeService([
  477. {
  478. info: {
  479. role: "user",
  480. model: { providerID: "test", modelID: "test-model", variant: "default" },
  481. agent: "build",
  482. },
  483. parts: [],
  484. },
  485. {
  486. info: {
  487. role: "user",
  488. model: { providerID: "test", modelID: "test-model", variant: "high" },
  489. agent: "plan",
  490. },
  491. parts: [],
  492. },
  493. ])
  494. const result = await Effect.runPromise(
  495. service.loadSession({ cwd: "/workspace", sessionId: "ses_loaded", mcpServers: [] }),
  496. )
  497. expect(result.configOptions?.find((option) => option.id === "effort")?.currentValue).toBe("high")
  498. expect(result.configOptions?.find((option) => option.id === "mode")?.currentValue).toBe("plan")
  499. })
  500. it("maps provider auth failures to auth-required request errors", async () => {
  501. const service = ACPService.make({
  502. sdk: {
  503. config: {
  504. providers: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
  505. get: () => Promise.resolve({ data: {} }),
  506. },
  507. app: {
  508. agents: () => Promise.resolve({ data: [] }),
  509. skills: () => Promise.resolve({ data: [] }),
  510. },
  511. command: {
  512. list: () => Promise.resolve({ data: [] }),
  513. },
  514. } as unknown as OpencodeClient,
  515. })
  516. const error = await Effect.runPromise(
  517. service
  518. .newSession({ cwd: "/workspace", mcpServers: [] })
  519. .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
  520. )
  521. expect(error.code).toBe(-32000)
  522. })
  523. it("does not cache failed directory snapshots", async () => {
  524. let providersCalls = 0
  525. const sdk = {
  526. config: {
  527. providers: () => {
  528. providersCalls++
  529. if (providersCalls === 1) {
  530. return Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } })
  531. }
  532. return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
  533. },
  534. get: () => Promise.resolve({ data: {} }),
  535. },
  536. app: {
  537. agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
  538. skills: () => Promise.resolve({ data: [] }),
  539. },
  540. command: {
  541. list: () => Promise.resolve({ data: [] }),
  542. },
  543. session: {
  544. create: () => Promise.resolve({ data: { id: "ses_retry" } }),
  545. list: () => Promise.resolve({ data: [] }),
  546. },
  547. mcp: {
  548. add: () => Promise.resolve({ data: {} }),
  549. },
  550. } as unknown as OpencodeClient
  551. const service = ACPService.make({ sdk })
  552. const first = await Effect.runPromise(
  553. service
  554. .newSession({ cwd: "/workspace", mcpServers: [] })
  555. .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
  556. )
  557. const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  558. expect(first.code).toBe(-32000)
  559. expect(second.sessionId).toBe("ses_retry")
  560. expect(providersCalls).toBe(2)
  561. })
  562. it("registers same-name MCP servers again for different sessions or configs", async () => {
  563. const adds: unknown[] = []
  564. let nextSession = 0
  565. const sdk = {
  566. config: {
  567. providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
  568. get: () => Promise.resolve({ data: {} }),
  569. },
  570. app: {
  571. agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
  572. skills: () => Promise.resolve({ data: [] }),
  573. },
  574. command: {
  575. list: () => Promise.resolve({ data: [] }),
  576. },
  577. session: {
  578. create: () => {
  579. nextSession++
  580. return Promise.resolve({ data: { id: `ses_${nextSession}` } })
  581. },
  582. list: () => Promise.resolve({ data: [] }),
  583. },
  584. mcp: {
  585. add: (input: unknown) => {
  586. adds.push(input)
  587. return Promise.resolve({ data: {} })
  588. },
  589. },
  590. } as unknown as OpencodeClient
  591. const service = ACPService.make({ sdk })
  592. await Effect.runPromise(
  593. service.newSession({
  594. cwd: "/workspace",
  595. mcpServers: [{ name: "tools", command: "node", args: ["one.js"], env: [] }],
  596. }),
  597. )
  598. await Effect.runPromise(
  599. service.newSession({
  600. cwd: "/workspace",
  601. mcpServers: [{ name: "tools", command: "node", args: ["two.js"], env: [] }],
  602. }),
  603. )
  604. expect(adds).toHaveLength(2)
  605. expect(JSON.stringify(adds[0])).toContain("one.js")
  606. expect(JSON.stringify(adds[1])).toContain("two.js")
  607. })
  608. it("uses the configured model as the new session default", async () => {
  609. const sdk = {
  610. config: {
  611. providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
  612. get: () => Promise.resolve({ data: { model: "test/configured-model" } }),
  613. },
  614. app: {
  615. agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
  616. skills: () => Promise.resolve({ data: [] }),
  617. },
  618. command: {
  619. list: () => Promise.resolve({ data: [] }),
  620. },
  621. session: {
  622. create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
  623. list: () => Promise.resolve({ data: [] }),
  624. },
  625. mcp: {
  626. add: () => Promise.resolve({ data: {} }),
  627. },
  628. } as unknown as OpencodeClient
  629. const service = ACPService.make({ sdk })
  630. const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  631. expect(result.sessionId).toBe("configured-model")
  632. expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/configured-model")
  633. })
  634. it("does not scan last-used sessions when resolving the new session default", async () => {
  635. const historyCalls: string[] = []
  636. const sdk = {
  637. config: {
  638. providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
  639. get: () => Promise.resolve({ data: {} }),
  640. },
  641. app: {
  642. agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
  643. skills: () => Promise.resolve({ data: [] }),
  644. },
  645. command: {
  646. list: () => Promise.resolve({ data: [] }),
  647. },
  648. session: {
  649. create: (input: { model?: { id?: string } }) => Promise.resolve({ data: { id: input.model?.id } }),
  650. list: () => {
  651. historyCalls.push("list")
  652. return Promise.resolve({ data: [{ id: "ses_recent" }] })
  653. },
  654. messages: () => {
  655. historyCalls.push("messages")
  656. return Promise.resolve({
  657. data: [{ info: { role: "user", model: { providerID: "test", modelID: "second-model" } } }],
  658. })
  659. },
  660. },
  661. mcp: {
  662. add: () => Promise.resolve({ data: {} }),
  663. },
  664. } as unknown as OpencodeClient
  665. const service = ACPService.make({ sdk })
  666. const result = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  667. expect(result.sessionId).toBe("test-model")
  668. expect(result.configOptions?.find((option) => option.id === "model")?.currentValue).toBe("test/test-model")
  669. expect(historyCalls).toEqual([])
  670. })
  671. it("switches model and returns updated model and effort options", async () => {
  672. const { service } = makeService()
  673. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  674. const updated = await Effect.runPromise(
  675. service.setSessionConfigOption({
  676. sessionId: session.sessionId,
  677. configId: "model",
  678. value: "test/second-model",
  679. }),
  680. )
  681. expect(select(updated, "model")?.currentValue).toBe("test/second-model")
  682. expect(select(updated, "effort")?.currentValue).toBe("low")
  683. expect(flattenSelectOptions(select(updated, "effort")).map((option) => option.value)).toEqual(["low", "medium"])
  684. })
  685. it("switches effort and returns the updated effort current value", async () => {
  686. const { service } = makeService()
  687. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  688. const updated = await Effect.runPromise(
  689. service.setSessionConfigOption({
  690. sessionId: session.sessionId,
  691. configId: "effort",
  692. value: "high",
  693. }),
  694. )
  695. expect(select(updated, "effort")?.currentValue).toBe("high")
  696. })
  697. it("switches mode and returns the updated mode current value", async () => {
  698. const { service } = makeService()
  699. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  700. const updated = await Effect.runPromise(
  701. service.setSessionConfigOption({
  702. sessionId: session.sessionId,
  703. configId: "mode",
  704. value: "plan",
  705. }),
  706. )
  707. expect(select(updated, "mode")?.currentValue).toBe("plan")
  708. })
  709. it("maps invalid model effort mode and config id to invalid params", async () => {
  710. const { service } = makeService()
  711. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  712. const results = await Promise.all(
  713. [
  714. { configId: "model", value: "test/missing-model" },
  715. { configId: "effort", value: "max" },
  716. { configId: "mode", value: "missing-mode" },
  717. { configId: "missing", value: "value" },
  718. ].map((input) =>
  719. Effect.runPromise(
  720. service
  721. .setSessionConfigOption({ sessionId: session.sessionId, ...input })
  722. .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
  723. ),
  724. ),
  725. )
  726. expect(results.map((error) => error.code)).toEqual([-32602, -32602, -32602, -32602])
  727. })
  728. it("does not refetch providers modes or commands when switching effort from session snapshot", async () => {
  729. const calls = {
  730. providers: 0,
  731. agents: 0,
  732. commands: 0,
  733. skills: 0,
  734. mcpAdds: 0,
  735. }
  736. const sdk = {
  737. config: {
  738. providers: () => {
  739. calls.providers++
  740. return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
  741. },
  742. get: () => Promise.resolve({ data: {} }),
  743. },
  744. app: {
  745. agents: () => {
  746. calls.agents++
  747. return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
  748. },
  749. skills: () => {
  750. calls.skills++
  751. return Promise.resolve({ data: [] })
  752. },
  753. },
  754. command: {
  755. list: () => {
  756. calls.commands++
  757. return Promise.resolve({ data: [] })
  758. },
  759. },
  760. session: {
  761. create: () => Promise.resolve({ data: { id: "ses_fast" } }),
  762. list: () => Promise.resolve({ data: [] }),
  763. },
  764. mcp: {
  765. add: () => {
  766. calls.mcpAdds++
  767. return Promise.resolve({ data: {} })
  768. },
  769. },
  770. } as unknown as OpencodeClient
  771. const service = ACPService.make({ sdk })
  772. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  773. expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
  774. await Effect.runPromise(
  775. service.setSessionConfigOption({
  776. sessionId: session.sessionId,
  777. configId: "effort",
  778. value: "high",
  779. }),
  780. )
  781. expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1, mcpAdds: 0 })
  782. })
  783. it("switches model against the warm provider snapshot without refetching", async () => {
  784. const calls = {
  785. providers: 0,
  786. agents: 0,
  787. commands: 0,
  788. skills: 0,
  789. }
  790. const sdk = {
  791. config: {
  792. providers: () => {
  793. calls.providers++
  794. return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
  795. },
  796. get: () => Promise.resolve({ data: {} }),
  797. },
  798. app: {
  799. agents: () => {
  800. calls.agents++
  801. return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
  802. },
  803. skills: () => {
  804. calls.skills++
  805. return Promise.resolve({ data: [] })
  806. },
  807. },
  808. command: {
  809. list: () => {
  810. calls.commands++
  811. return Promise.resolve({ data: [] })
  812. },
  813. },
  814. session: {
  815. create: () => Promise.resolve({ data: { id: "ses_model_fast" } }),
  816. list: () => Promise.resolve({ data: [] }),
  817. },
  818. mcp: {
  819. add: () => Promise.resolve({ data: {} }),
  820. },
  821. } as unknown as OpencodeClient
  822. const service = ACPService.make({ sdk })
  823. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  824. const updated = await Effect.runPromise(
  825. service.setSessionConfigOption({
  826. sessionId: session.sessionId,
  827. configId: "model",
  828. value: "test/second-model",
  829. }),
  830. )
  831. expect(select(updated, "model")?.currentValue).toBe("test/second-model")
  832. expect(calls).toEqual({ providers: 1, agents: 1, commands: 1, skills: 1 })
  833. })
  834. it("reuses the warm directory snapshot for a second new session in the same cwd", async () => {
  835. const calls = {
  836. providers: 0,
  837. config: 0,
  838. agents: 0,
  839. commands: 0,
  840. skills: 0,
  841. sessionList: 0,
  842. messages: 0,
  843. creates: 0,
  844. }
  845. const sdk = {
  846. config: {
  847. providers: () => {
  848. calls.providers++
  849. return Promise.resolve({ data: { providers: [provider], default: { test: modelID } } })
  850. },
  851. get: () => {
  852. calls.config++
  853. return Promise.resolve({ data: {} })
  854. },
  855. },
  856. app: {
  857. agents: () => {
  858. calls.agents++
  859. return Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] })
  860. },
  861. skills: () => {
  862. calls.skills++
  863. return Promise.resolve({ data: [] })
  864. },
  865. },
  866. command: {
  867. list: () => {
  868. calls.commands++
  869. return Promise.resolve({ data: [] })
  870. },
  871. },
  872. session: {
  873. create: () => {
  874. calls.creates++
  875. return Promise.resolve({ data: { id: `ses_warm_${calls.creates}` } })
  876. },
  877. list: () => {
  878. calls.sessionList++
  879. return Promise.resolve({ data: [] })
  880. },
  881. messages: () => {
  882. calls.messages++
  883. return Promise.resolve({ data: [] })
  884. },
  885. },
  886. mcp: {
  887. add: () => Promise.resolve({ data: {} }),
  888. },
  889. } as unknown as OpencodeClient
  890. const service = ACPService.make({ sdk })
  891. const first = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  892. const second = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  893. expect(first.sessionId).toBe("ses_warm_1")
  894. expect(second.sessionId).toBe("ses_warm_2")
  895. expect(calls).toEqual({
  896. providers: 1,
  897. config: 1,
  898. agents: 1,
  899. commands: 1,
  900. skills: 1,
  901. sessionList: 0,
  902. messages: 0,
  903. creates: 2,
  904. })
  905. })
  906. it("normal text prompt sends model variant mode and converted parts", async () => {
  907. const { service, prompts, usageUpdates } = makeService()
  908. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  909. await Effect.runPromise(
  910. service.setSessionConfigOption({
  911. sessionId: session.sessionId,
  912. configId: "effort",
  913. value: "high",
  914. }),
  915. )
  916. await Effect.runPromise(
  917. service.setSessionConfigOption({
  918. sessionId: session.sessionId,
  919. configId: "mode",
  920. value: "plan",
  921. }),
  922. )
  923. const result = await Effect.runPromise(
  924. service.prompt({
  925. sessionId: session.sessionId,
  926. messageId: "00000000-0000-4000-8000-000000000001",
  927. prompt: [{ type: "text", text: "hello" }],
  928. }),
  929. )
  930. expect(prompts).toEqual([
  931. {
  932. sessionID: session.sessionId,
  933. model: { providerID, modelID },
  934. variant: "high",
  935. parts: [{ type: "text", text: "hello" }],
  936. agent: "plan",
  937. directory: "/workspace",
  938. },
  939. ])
  940. expect(result).toEqual({
  941. stopReason: "end_turn",
  942. usage: {
  943. inputTokens: 100,
  944. outputTokens: 40,
  945. thoughtTokens: 7,
  946. cachedReadTokens: 11,
  947. cachedWriteTokens: 13,
  948. totalTokens: 171,
  949. },
  950. userMessageId: "00000000-0000-4000-8000-000000000001",
  951. _meta: {},
  952. })
  953. expect(usageUpdates).toEqual([session.sessionId])
  954. })
  955. it("maps assistant prompt errors to request errors instead of end turn", async () => {
  956. const { service } = makeService([], {
  957. prompt: () =>
  958. Promise.resolve({
  959. data: {
  960. info: assistantInfo(
  961. { input: 8, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
  962. { name: "APIError", data: { message: "Provider request failed", isRetryable: false } },
  963. ),
  964. },
  965. }),
  966. })
  967. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  968. const error = await Effect.runPromise(
  969. service
  970. .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
  971. .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
  972. )
  973. expect(error.code).toBe(-32603)
  974. expect(error.message).toBe("Internal error: Provider request failed")
  975. expect(error.data).toEqual({ service: "session", errorName: "APIError" })
  976. })
  977. it("maps aborted assistant prompt errors to cancelled", async () => {
  978. const { service } = makeService([], {
  979. prompt: () =>
  980. Promise.resolve({
  981. data: {
  982. info: assistantInfo(
  983. { input: 8, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
  984. { name: "MessageAbortedError", data: { message: "Aborted" } },
  985. ),
  986. },
  987. }),
  988. })
  989. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  990. const result = await Effect.runPromise(
  991. service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }),
  992. )
  993. expect(result.stopReason).toBe("cancelled")
  994. })
  995. it("prompt maps assistant and user audience annotations", async () => {
  996. const { service, prompts } = makeService()
  997. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  998. await Effect.runPromise(
  999. service.prompt({
  1000. sessionId: session.sessionId,
  1001. prompt: [
  1002. { type: "text", text: "assistant context", annotations: { audience: ["assistant"] } },
  1003. { type: "text", text: "user context", annotations: { audience: ["user"] } },
  1004. ],
  1005. }),
  1006. )
  1007. expect(prompts).toContainEqual({
  1008. sessionID: session.sessionId,
  1009. model: { providerID, modelID },
  1010. variant: "default",
  1011. parts: [
  1012. { type: "text", text: "assistant context", synthetic: true },
  1013. { type: "text", text: "user context", ignored: true },
  1014. ],
  1015. agent: "build",
  1016. directory: "/workspace",
  1017. })
  1018. })
  1019. it("prompt sends image and resource parts", async () => {
  1020. const { service, prompts } = makeService()
  1021. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  1022. await Effect.runPromise(
  1023. service.prompt({
  1024. sessionId: session.sessionId,
  1025. prompt: [
  1026. { type: "image", data: "AAAA", mimeType: "image/png", uri: "file:///tmp/screenshot.png" },
  1027. {
  1028. type: "resource",
  1029. resource: {
  1030. uri: "file:///tmp/report.pdf",
  1031. mimeType: "application/pdf",
  1032. blob: "JVBERg==",
  1033. },
  1034. },
  1035. ],
  1036. }),
  1037. )
  1038. expect((prompts[0] as { parts?: unknown }).parts).toEqual([
  1039. {
  1040. type: "file",
  1041. url: "data:image/png;base64,AAAA",
  1042. filename: "screenshot.png",
  1043. mime: "image/png",
  1044. },
  1045. {
  1046. type: "file",
  1047. url: "data:application/pdf;base64,JVBERg==",
  1048. filename: "report.pdf",
  1049. mime: "application/pdf",
  1050. },
  1051. ])
  1052. })
  1053. it("slash command prompt calls session command", async () => {
  1054. const { service, prompts, commands } = makeService()
  1055. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  1056. const result = await Effect.runPromise(
  1057. service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/init now" }] }),
  1058. )
  1059. expect(prompts).toEqual([])
  1060. expect(commands).toEqual([
  1061. {
  1062. sessionID: session.sessionId,
  1063. command: "init",
  1064. arguments: "now",
  1065. model: "test/test-model",
  1066. variant: "default",
  1067. agent: "build",
  1068. directory: "/workspace",
  1069. },
  1070. ])
  1071. expect(result.usage).toEqual({ inputTokens: 3, outputTokens: 4, totalTokens: 7 })
  1072. })
  1073. it("compact slash command calls summarize path", async () => {
  1074. const { service, prompts, commands, summarizes } = makeService()
  1075. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  1076. await Effect.runPromise(
  1077. service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "/compact" }] }),
  1078. )
  1079. expect(prompts).toEqual([])
  1080. expect(commands).toEqual([])
  1081. expect(summarizes).toEqual([
  1082. {
  1083. sessionID: session.sessionId,
  1084. directory: "/workspace",
  1085. providerID,
  1086. modelID,
  1087. },
  1088. ])
  1089. })
  1090. it("maps prompt auth failures to auth-required request errors", async () => {
  1091. const { service } = makeService()
  1092. const session = await Effect.runPromise(service.newSession({ cwd: "/workspace", mcpServers: [] }))
  1093. const failing = ACPService.make({
  1094. sdk: {
  1095. config: {
  1096. providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }),
  1097. get: () => Promise.resolve({ data: {} }),
  1098. },
  1099. app: {
  1100. agents: () => Promise.resolve({ data: [{ name: "build", mode: "primary", permission: [], options: {} }] }),
  1101. skills: () => Promise.resolve({ data: [] }),
  1102. },
  1103. command: {
  1104. list: () => Promise.resolve({ data: [] }),
  1105. },
  1106. session: {
  1107. create: () => Promise.resolve({ data: { id: session.sessionId } }),
  1108. list: () => Promise.resolve({ data: [] }),
  1109. prompt: () => Promise.reject({ name: "ProviderAuthError", data: { providerID: "test" } }),
  1110. },
  1111. mcp: {
  1112. add: () => Promise.resolve({ data: {} }),
  1113. },
  1114. } as unknown as OpencodeClient,
  1115. usage: UsageService.Service.of({
  1116. buildUsage: UsageService.buildUsage,
  1117. latestAssistantMessage: UsageService.latestAssistantMessage,
  1118. totalSessionCost: UsageService.totalSessionCost,
  1119. contextLimit: () => Effect.succeed(128000),
  1120. sendUpdate: () => Effect.void,
  1121. }),
  1122. })
  1123. await Effect.runPromise(failing.newSession({ cwd: "/workspace", mcpServers: [] }))
  1124. const error = await Effect.runPromise(
  1125. failing
  1126. .prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] })
  1127. .pipe(Effect.mapError(ACPError.toRequestError), Effect.flip),
  1128. )
  1129. expect(error.code).toBe(-32000)
  1130. })
  1131. })
  1132. function assistantInfo(
  1133. tokens: UsageService.AssistantTokenCost["tokens"],
  1134. error?: AssistantMessage["error"],
  1135. ): UsageService.AssistantMessage & Pick<AssistantMessage, "error"> {
  1136. return {
  1137. role: "assistant",
  1138. providerID: "test",
  1139. modelID: "test-model",
  1140. cost: 0,
  1141. tokens,
  1142. ...(error ? { error } : {}),
  1143. }
  1144. }
  1145. function categories(result: NewSessionResponse | LoadSessionResponse) {
  1146. return result.configOptions?.map((option) => option.category) ?? []
  1147. }
  1148. function select(
  1149. result: SetSessionConfigOptionResponse | ResumeSessionResponse | NewSessionResponse | ForkSessionResponse,
  1150. id: string,
  1151. ) {
  1152. return result.configOptions?.find(
  1153. (option): option is Extract<SessionConfigOption, { type: "select" }> =>
  1154. option.id === id && option.type === "select",
  1155. )
  1156. }
  1157. function flattenSelectOptions(option: Extract<SessionConfigOption, { type: "select" }> | undefined) {
  1158. return option?.options.flatMap((item): SessionConfigSelectOption[] => ("value" in item ? [item] : item.options)) ?? []
  1159. }