agent.ts 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181
  1. import {
  2. RequestError,
  3. type Agent as ACPAgent,
  4. type AgentSideConnection,
  5. type AuthenticateRequest,
  6. type AuthMethod,
  7. type CancelNotification,
  8. type InitializeRequest,
  9. type InitializeResponse,
  10. type LoadSessionRequest,
  11. type NewSessionRequest,
  12. type PermissionOption,
  13. type PlanEntry,
  14. type PromptRequest,
  15. type SetSessionModelRequest,
  16. type SetSessionModeRequest,
  17. type SetSessionModeResponse,
  18. type ToolCallContent,
  19. type ToolKind,
  20. } from "@agentclientprotocol/sdk"
  21. import { Log } from "../util/log"
  22. import { ACPSessionManager } from "./session"
  23. import type { ACPConfig } from "./types"
  24. import { Provider } from "../provider/provider"
  25. import { Agent as AgentModule } from "../agent/agent"
  26. import { Installation } from "@/installation"
  27. import { MessageV2 } from "@/session/message-v2"
  28. import { Config } from "@/config/config"
  29. import { Todo } from "@/session/todo"
  30. import { z } from "zod"
  31. import { LoadAPIKeyError } from "ai"
  32. import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2"
  33. import { applyPatch } from "diff"
  34. export namespace ACP {
  35. const log = Log.create({ service: "acp-agent" })
  36. export async function init({ sdk: _sdk }: { sdk: OpencodeClient }) {
  37. return {
  38. create: (connection: AgentSideConnection, fullConfig: ACPConfig) => {
  39. return new Agent(connection, fullConfig)
  40. },
  41. }
  42. }
  43. export class Agent implements ACPAgent {
  44. private connection: AgentSideConnection
  45. private config: ACPConfig
  46. private sdk: OpencodeClient
  47. private sessionManager: ACPSessionManager
  48. private eventAbort = new AbortController()
  49. private eventStarted = false
  50. private permissionQueues = new Map<string, Promise<void>>()
  51. private permissionOptions: PermissionOption[] = [
  52. { optionId: "once", kind: "allow_once", name: "Allow once" },
  53. { optionId: "always", kind: "allow_always", name: "Always allow" },
  54. { optionId: "reject", kind: "reject_once", name: "Reject" },
  55. ]
  56. constructor(connection: AgentSideConnection, config: ACPConfig) {
  57. this.connection = connection
  58. this.config = config
  59. this.sdk = config.sdk
  60. this.sessionManager = new ACPSessionManager(this.sdk)
  61. this.startEventSubscription()
  62. }
  63. private startEventSubscription() {
  64. if (this.eventStarted) return
  65. this.eventStarted = true
  66. this.runEventSubscription().catch((error) => {
  67. if (this.eventAbort.signal.aborted) return
  68. log.error("event subscription failed", { error })
  69. })
  70. }
  71. private async runEventSubscription() {
  72. while (true) {
  73. if (this.eventAbort.signal.aborted) return
  74. const events = await this.sdk.global.event({
  75. signal: this.eventAbort.signal,
  76. })
  77. for await (const event of events.stream) {
  78. if (this.eventAbort.signal.aborted) return
  79. const payload = (event as any)?.payload
  80. if (!payload) continue
  81. await this.handleEvent(payload as Event).catch((error) => {
  82. log.error("failed to handle event", { error, type: payload.type })
  83. })
  84. }
  85. }
  86. }
  87. private async handleEvent(event: Event) {
  88. switch (event.type) {
  89. case "permission.asked": {
  90. const permission = event.properties
  91. const session = this.sessionManager.tryGet(permission.sessionID)
  92. if (!session) return
  93. const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve()
  94. const next = prev
  95. .then(async () => {
  96. const directory = session.cwd
  97. const res = await this.connection
  98. .requestPermission({
  99. sessionId: permission.sessionID,
  100. toolCall: {
  101. toolCallId: permission.tool?.callID ?? permission.id,
  102. status: "pending",
  103. title: permission.permission,
  104. rawInput: permission.metadata,
  105. kind: toToolKind(permission.permission),
  106. locations: toLocations(permission.permission, permission.metadata),
  107. },
  108. options: this.permissionOptions,
  109. })
  110. .catch(async (error) => {
  111. log.error("failed to request permission from ACP", {
  112. error,
  113. permissionID: permission.id,
  114. sessionID: permission.sessionID,
  115. })
  116. await this.sdk.permission.reply({
  117. requestID: permission.id,
  118. reply: "reject",
  119. directory,
  120. })
  121. return undefined
  122. })
  123. if (!res) return
  124. if (res.outcome.outcome !== "selected") {
  125. await this.sdk.permission.reply({
  126. requestID: permission.id,
  127. reply: "reject",
  128. directory,
  129. })
  130. return
  131. }
  132. if (res.outcome.optionId !== "reject" && permission.permission == "edit") {
  133. const metadata = permission.metadata || {}
  134. const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : ""
  135. const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : ""
  136. const content = await Bun.file(filepath).text()
  137. const newContent = getNewContent(content, diff)
  138. if (newContent) {
  139. this.connection.writeTextFile({
  140. sessionId: session.id,
  141. path: filepath,
  142. content: newContent,
  143. })
  144. }
  145. }
  146. await this.sdk.permission.reply({
  147. requestID: permission.id,
  148. reply: res.outcome.optionId as "once" | "always" | "reject",
  149. directory,
  150. })
  151. })
  152. .catch((error) => {
  153. log.error("failed to handle permission", { error, permissionID: permission.id })
  154. })
  155. .finally(() => {
  156. if (this.permissionQueues.get(permission.sessionID) === next) {
  157. this.permissionQueues.delete(permission.sessionID)
  158. }
  159. })
  160. this.permissionQueues.set(permission.sessionID, next)
  161. return
  162. }
  163. case "message.part.updated": {
  164. log.info("message part updated", { event: event.properties })
  165. const props = event.properties
  166. const part = props.part
  167. const session = this.sessionManager.tryGet(part.sessionID)
  168. if (!session) return
  169. const sessionId = session.id
  170. const directory = session.cwd
  171. const message = await this.sdk.session
  172. .message(
  173. {
  174. sessionID: part.sessionID,
  175. messageID: part.messageID,
  176. directory,
  177. },
  178. { throwOnError: true },
  179. )
  180. .then((x) => x.data)
  181. .catch((error) => {
  182. log.error("unexpected error when fetching message", { error })
  183. return undefined
  184. })
  185. if (!message || message.info.role !== "assistant") return
  186. if (part.type === "tool") {
  187. switch (part.state.status) {
  188. case "pending":
  189. await this.connection
  190. .sessionUpdate({
  191. sessionId,
  192. update: {
  193. sessionUpdate: "tool_call",
  194. toolCallId: part.callID,
  195. title: part.tool,
  196. kind: toToolKind(part.tool),
  197. status: "pending",
  198. locations: [],
  199. rawInput: {},
  200. },
  201. })
  202. .catch((error) => {
  203. log.error("failed to send tool pending to ACP", { error })
  204. })
  205. return
  206. case "running":
  207. await this.connection
  208. .sessionUpdate({
  209. sessionId,
  210. update: {
  211. sessionUpdate: "tool_call_update",
  212. toolCallId: part.callID,
  213. status: "in_progress",
  214. kind: toToolKind(part.tool),
  215. title: part.tool,
  216. locations: toLocations(part.tool, part.state.input),
  217. rawInput: part.state.input,
  218. },
  219. })
  220. .catch((error) => {
  221. log.error("failed to send tool in_progress to ACP", { error })
  222. })
  223. return
  224. case "completed": {
  225. const kind = toToolKind(part.tool)
  226. const content: ToolCallContent[] = [
  227. {
  228. type: "content",
  229. content: {
  230. type: "text",
  231. text: part.state.output,
  232. },
  233. },
  234. ]
  235. if (kind === "edit") {
  236. const input = part.state.input
  237. const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
  238. const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
  239. const newText =
  240. typeof input["newString"] === "string"
  241. ? input["newString"]
  242. : typeof input["content"] === "string"
  243. ? input["content"]
  244. : ""
  245. content.push({
  246. type: "diff",
  247. path: filePath,
  248. oldText,
  249. newText,
  250. })
  251. }
  252. if (part.tool === "todowrite") {
  253. const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
  254. if (parsedTodos.success) {
  255. await this.connection
  256. .sessionUpdate({
  257. sessionId,
  258. update: {
  259. sessionUpdate: "plan",
  260. entries: parsedTodos.data.map((todo) => {
  261. const status: PlanEntry["status"] =
  262. todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
  263. return {
  264. priority: "medium",
  265. status,
  266. content: todo.content,
  267. }
  268. }),
  269. },
  270. })
  271. .catch((error) => {
  272. log.error("failed to send session update for todo", { error })
  273. })
  274. } else {
  275. log.error("failed to parse todo output", { error: parsedTodos.error })
  276. }
  277. }
  278. await this.connection
  279. .sessionUpdate({
  280. sessionId,
  281. update: {
  282. sessionUpdate: "tool_call_update",
  283. toolCallId: part.callID,
  284. status: "completed",
  285. kind,
  286. content,
  287. title: part.state.title,
  288. rawInput: part.state.input,
  289. rawOutput: {
  290. output: part.state.output,
  291. metadata: part.state.metadata,
  292. },
  293. },
  294. })
  295. .catch((error) => {
  296. log.error("failed to send tool completed to ACP", { error })
  297. })
  298. return
  299. }
  300. case "error":
  301. await this.connection
  302. .sessionUpdate({
  303. sessionId,
  304. update: {
  305. sessionUpdate: "tool_call_update",
  306. toolCallId: part.callID,
  307. status: "failed",
  308. kind: toToolKind(part.tool),
  309. title: part.tool,
  310. rawInput: part.state.input,
  311. content: [
  312. {
  313. type: "content",
  314. content: {
  315. type: "text",
  316. text: part.state.error,
  317. },
  318. },
  319. ],
  320. rawOutput: {
  321. error: part.state.error,
  322. },
  323. },
  324. })
  325. .catch((error) => {
  326. log.error("failed to send tool error to ACP", { error })
  327. })
  328. return
  329. }
  330. }
  331. if (part.type === "text") {
  332. const delta = props.delta
  333. if (delta && part.synthetic !== true) {
  334. await this.connection
  335. .sessionUpdate({
  336. sessionId,
  337. update: {
  338. sessionUpdate: "agent_message_chunk",
  339. content: {
  340. type: "text",
  341. text: delta,
  342. },
  343. },
  344. })
  345. .catch((error) => {
  346. log.error("failed to send text to ACP", { error })
  347. })
  348. }
  349. return
  350. }
  351. if (part.type === "reasoning") {
  352. const delta = props.delta
  353. if (delta) {
  354. await this.connection
  355. .sessionUpdate({
  356. sessionId,
  357. update: {
  358. sessionUpdate: "agent_thought_chunk",
  359. content: {
  360. type: "text",
  361. text: delta,
  362. },
  363. },
  364. })
  365. .catch((error) => {
  366. log.error("failed to send reasoning to ACP", { error })
  367. })
  368. }
  369. }
  370. return
  371. }
  372. }
  373. }
  374. async initialize(params: InitializeRequest): Promise<InitializeResponse> {
  375. log.info("initialize", { protocolVersion: params.protocolVersion })
  376. const authMethod: AuthMethod = {
  377. description: "Run `opencode auth login` in the terminal",
  378. name: "Login with opencode",
  379. id: "opencode-login",
  380. }
  381. // If client supports terminal-auth capability, use that instead.
  382. if (params.clientCapabilities?._meta?.["terminal-auth"] === true) {
  383. authMethod._meta = {
  384. "terminal-auth": {
  385. command: "opencode",
  386. args: ["auth", "login"],
  387. label: "OpenCode Login",
  388. },
  389. }
  390. }
  391. return {
  392. protocolVersion: 1,
  393. agentCapabilities: {
  394. loadSession: true,
  395. mcpCapabilities: {
  396. http: true,
  397. sse: true,
  398. },
  399. promptCapabilities: {
  400. embeddedContext: true,
  401. image: true,
  402. },
  403. },
  404. authMethods: [authMethod],
  405. agentInfo: {
  406. name: "OpenCode",
  407. version: Installation.VERSION,
  408. },
  409. }
  410. }
  411. async authenticate(_params: AuthenticateRequest) {
  412. throw new Error("Authentication not implemented")
  413. }
  414. async newSession(params: NewSessionRequest) {
  415. const directory = params.cwd
  416. try {
  417. const model = await defaultModel(this.config, directory)
  418. // Store ACP session state
  419. const state = await this.sessionManager.create(params.cwd, params.mcpServers, model)
  420. const sessionId = state.id
  421. log.info("creating_session", { sessionId, mcpServers: params.mcpServers.length })
  422. const load = await this.loadSessionMode({
  423. cwd: directory,
  424. mcpServers: params.mcpServers,
  425. sessionId,
  426. })
  427. return {
  428. sessionId,
  429. models: load.models,
  430. modes: load.modes,
  431. _meta: {},
  432. }
  433. } catch (e) {
  434. const error = MessageV2.fromError(e, {
  435. providerID: this.config.defaultModel?.providerID ?? "unknown",
  436. })
  437. if (LoadAPIKeyError.isInstance(error)) {
  438. throw RequestError.authRequired()
  439. }
  440. throw e
  441. }
  442. }
  443. async loadSession(params: LoadSessionRequest) {
  444. const directory = params.cwd
  445. const sessionId = params.sessionId
  446. try {
  447. const model = await defaultModel(this.config, directory)
  448. // Store ACP session state
  449. await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model)
  450. log.info("load_session", { sessionId, mcpServers: params.mcpServers.length })
  451. const result = await this.loadSessionMode({
  452. cwd: directory,
  453. mcpServers: params.mcpServers,
  454. sessionId,
  455. })
  456. // Replay session history
  457. const messages = await this.sdk.session
  458. .messages(
  459. {
  460. sessionID: sessionId,
  461. directory,
  462. },
  463. { throwOnError: true },
  464. )
  465. .then((x) => x.data)
  466. .catch((err) => {
  467. log.error("unexpected error when fetching message", { error: err })
  468. return undefined
  469. })
  470. const lastUser = messages?.findLast((m) => m.info.role === "user")?.info
  471. if (lastUser?.role === "user") {
  472. result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}`
  473. if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) {
  474. result.modes.currentModeId = lastUser.agent
  475. }
  476. }
  477. for (const msg of messages ?? []) {
  478. log.debug("replay message", msg)
  479. await this.processMessage(msg)
  480. }
  481. return result
  482. } catch (e) {
  483. const error = MessageV2.fromError(e, {
  484. providerID: this.config.defaultModel?.providerID ?? "unknown",
  485. })
  486. if (LoadAPIKeyError.isInstance(error)) {
  487. throw RequestError.authRequired()
  488. }
  489. throw e
  490. }
  491. }
  492. private async processMessage(message: SessionMessageResponse) {
  493. log.debug("process message", message)
  494. if (message.info.role !== "assistant" && message.info.role !== "user") return
  495. const sessionId = message.info.sessionID
  496. for (const part of message.parts) {
  497. if (part.type === "tool") {
  498. switch (part.state.status) {
  499. case "pending":
  500. await this.connection
  501. .sessionUpdate({
  502. sessionId,
  503. update: {
  504. sessionUpdate: "tool_call",
  505. toolCallId: part.callID,
  506. title: part.tool,
  507. kind: toToolKind(part.tool),
  508. status: "pending",
  509. locations: [],
  510. rawInput: {},
  511. },
  512. })
  513. .catch((err) => {
  514. log.error("failed to send tool pending to ACP", { error: err })
  515. })
  516. break
  517. case "running":
  518. await this.connection
  519. .sessionUpdate({
  520. sessionId,
  521. update: {
  522. sessionUpdate: "tool_call_update",
  523. toolCallId: part.callID,
  524. status: "in_progress",
  525. kind: toToolKind(part.tool),
  526. title: part.tool,
  527. locations: toLocations(part.tool, part.state.input),
  528. rawInput: part.state.input,
  529. },
  530. })
  531. .catch((err) => {
  532. log.error("failed to send tool in_progress to ACP", { error: err })
  533. })
  534. break
  535. case "completed":
  536. const kind = toToolKind(part.tool)
  537. const content: ToolCallContent[] = [
  538. {
  539. type: "content",
  540. content: {
  541. type: "text",
  542. text: part.state.output,
  543. },
  544. },
  545. ]
  546. if (kind === "edit") {
  547. const input = part.state.input
  548. const filePath = typeof input["filePath"] === "string" ? input["filePath"] : ""
  549. const oldText = typeof input["oldString"] === "string" ? input["oldString"] : ""
  550. const newText =
  551. typeof input["newString"] === "string"
  552. ? input["newString"]
  553. : typeof input["content"] === "string"
  554. ? input["content"]
  555. : ""
  556. content.push({
  557. type: "diff",
  558. path: filePath,
  559. oldText,
  560. newText,
  561. })
  562. }
  563. if (part.tool === "todowrite") {
  564. const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output))
  565. if (parsedTodos.success) {
  566. await this.connection
  567. .sessionUpdate({
  568. sessionId,
  569. update: {
  570. sessionUpdate: "plan",
  571. entries: parsedTodos.data.map((todo) => {
  572. const status: PlanEntry["status"] =
  573. todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"])
  574. return {
  575. priority: "medium",
  576. status,
  577. content: todo.content,
  578. }
  579. }),
  580. },
  581. })
  582. .catch((err) => {
  583. log.error("failed to send session update for todo", { error: err })
  584. })
  585. } else {
  586. log.error("failed to parse todo output", { error: parsedTodos.error })
  587. }
  588. }
  589. await this.connection
  590. .sessionUpdate({
  591. sessionId,
  592. update: {
  593. sessionUpdate: "tool_call_update",
  594. toolCallId: part.callID,
  595. status: "completed",
  596. kind,
  597. content,
  598. title: part.state.title,
  599. rawInput: part.state.input,
  600. rawOutput: {
  601. output: part.state.output,
  602. metadata: part.state.metadata,
  603. },
  604. },
  605. })
  606. .catch((err) => {
  607. log.error("failed to send tool completed to ACP", { error: err })
  608. })
  609. break
  610. case "error":
  611. await this.connection
  612. .sessionUpdate({
  613. sessionId,
  614. update: {
  615. sessionUpdate: "tool_call_update",
  616. toolCallId: part.callID,
  617. status: "failed",
  618. kind: toToolKind(part.tool),
  619. title: part.tool,
  620. rawInput: part.state.input,
  621. content: [
  622. {
  623. type: "content",
  624. content: {
  625. type: "text",
  626. text: part.state.error,
  627. },
  628. },
  629. ],
  630. rawOutput: {
  631. error: part.state.error,
  632. },
  633. },
  634. })
  635. .catch((err) => {
  636. log.error("failed to send tool error to ACP", { error: err })
  637. })
  638. break
  639. }
  640. } else if (part.type === "text") {
  641. if (part.text) {
  642. await this.connection
  643. .sessionUpdate({
  644. sessionId,
  645. update: {
  646. sessionUpdate: message.info.role === "user" ? "user_message_chunk" : "agent_message_chunk",
  647. content: {
  648. type: "text",
  649. text: part.text,
  650. },
  651. },
  652. })
  653. .catch((err) => {
  654. log.error("failed to send text to ACP", { error: err })
  655. })
  656. }
  657. } else if (part.type === "reasoning") {
  658. if (part.text) {
  659. await this.connection
  660. .sessionUpdate({
  661. sessionId,
  662. update: {
  663. sessionUpdate: "agent_thought_chunk",
  664. content: {
  665. type: "text",
  666. text: part.text,
  667. },
  668. },
  669. })
  670. .catch((err) => {
  671. log.error("failed to send reasoning to ACP", { error: err })
  672. })
  673. }
  674. }
  675. }
  676. }
  677. private async loadSessionMode(params: LoadSessionRequest) {
  678. const directory = params.cwd
  679. const model = await defaultModel(this.config, directory)
  680. const sessionId = params.sessionId
  681. const providers = await this.sdk.config.providers({ directory }).then((x) => x.data!.providers)
  682. const entries = providers.sort((a, b) => {
  683. const nameA = a.name.toLowerCase()
  684. const nameB = b.name.toLowerCase()
  685. if (nameA < nameB) return -1
  686. if (nameA > nameB) return 1
  687. return 0
  688. })
  689. const availableModels = entries.flatMap((provider) => {
  690. const models = Provider.sort(Object.values(provider.models))
  691. return models.map((model) => ({
  692. modelId: `${provider.id}/${model.id}`,
  693. name: `${provider.name}/${model.name}`,
  694. }))
  695. })
  696. const agents = await this.config.sdk.app
  697. .agents(
  698. {
  699. directory,
  700. },
  701. { throwOnError: true },
  702. )
  703. .then((resp) => resp.data!)
  704. const commands = await this.config.sdk.command
  705. .list(
  706. {
  707. directory,
  708. },
  709. { throwOnError: true },
  710. )
  711. .then((resp) => resp.data!)
  712. const availableCommands = commands.map((command) => ({
  713. name: command.name,
  714. description: command.description ?? "",
  715. }))
  716. const names = new Set(availableCommands.map((c) => c.name))
  717. if (!names.has("compact"))
  718. availableCommands.push({
  719. name: "compact",
  720. description: "compact the session",
  721. })
  722. const availableModes = agents
  723. .filter((agent) => agent.mode !== "subagent" && !agent.hidden)
  724. .map((agent) => ({
  725. id: agent.name,
  726. name: agent.name,
  727. description: agent.description,
  728. }))
  729. const defaultAgentName = await AgentModule.defaultAgent()
  730. const currentModeId = availableModes.find((m) => m.name === defaultAgentName)?.id ?? availableModes[0].id
  731. // Persist the default mode so prompt() uses it immediately
  732. this.sessionManager.setMode(sessionId, currentModeId)
  733. const mcpServers: Record<string, Config.Mcp> = {}
  734. for (const server of params.mcpServers) {
  735. if ("type" in server) {
  736. mcpServers[server.name] = {
  737. url: server.url,
  738. headers: server.headers.reduce<Record<string, string>>((acc, { name, value }) => {
  739. acc[name] = value
  740. return acc
  741. }, {}),
  742. type: "remote",
  743. }
  744. } else {
  745. mcpServers[server.name] = {
  746. type: "local",
  747. command: [server.command, ...server.args],
  748. environment: server.env.reduce<Record<string, string>>((acc, { name, value }) => {
  749. acc[name] = value
  750. return acc
  751. }, {}),
  752. }
  753. }
  754. }
  755. await Promise.all(
  756. Object.entries(mcpServers).map(async ([key, mcp]) => {
  757. await this.sdk.mcp
  758. .add(
  759. {
  760. directory,
  761. name: key,
  762. config: mcp,
  763. },
  764. { throwOnError: true },
  765. )
  766. .catch((error) => {
  767. log.error("failed to add mcp server", { name: key, error })
  768. })
  769. }),
  770. )
  771. setTimeout(() => {
  772. this.connection.sessionUpdate({
  773. sessionId,
  774. update: {
  775. sessionUpdate: "available_commands_update",
  776. availableCommands,
  777. },
  778. })
  779. }, 0)
  780. return {
  781. sessionId,
  782. models: {
  783. currentModelId: `${model.providerID}/${model.modelID}`,
  784. availableModels,
  785. },
  786. modes: {
  787. availableModes,
  788. currentModeId,
  789. },
  790. _meta: {},
  791. }
  792. }
  793. async setSessionModel(params: SetSessionModelRequest) {
  794. const session = this.sessionManager.get(params.sessionId)
  795. const model = Provider.parseModel(params.modelId)
  796. this.sessionManager.setModel(session.id, {
  797. providerID: model.providerID,
  798. modelID: model.modelID,
  799. })
  800. return {
  801. _meta: {},
  802. }
  803. }
  804. async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse | void> {
  805. this.sessionManager.get(params.sessionId)
  806. await this.config.sdk.app
  807. .agents({}, { throwOnError: true })
  808. .then((x) => x.data)
  809. .then((agent) => {
  810. if (!agent) throw new Error(`Agent not found: ${params.modeId}`)
  811. })
  812. this.sessionManager.setMode(params.sessionId, params.modeId)
  813. }
  814. async prompt(params: PromptRequest) {
  815. const sessionID = params.sessionId
  816. const session = this.sessionManager.get(sessionID)
  817. const directory = session.cwd
  818. const current = session.model
  819. const model = current ?? (await defaultModel(this.config, directory))
  820. if (!current) {
  821. this.sessionManager.setModel(session.id, model)
  822. }
  823. const agent = session.modeId ?? (await AgentModule.defaultAgent())
  824. const parts: Array<
  825. { type: "text"; text: string } | { type: "file"; url: string; filename: string; mime: string }
  826. > = []
  827. for (const part of params.prompt) {
  828. switch (part.type) {
  829. case "text":
  830. parts.push({
  831. type: "text" as const,
  832. text: part.text,
  833. })
  834. break
  835. case "image":
  836. if (part.data) {
  837. parts.push({
  838. type: "file",
  839. url: `data:${part.mimeType};base64,${part.data}`,
  840. filename: "image",
  841. mime: part.mimeType,
  842. })
  843. } else if (part.uri && part.uri.startsWith("http:")) {
  844. parts.push({
  845. type: "file",
  846. url: part.uri,
  847. filename: "image",
  848. mime: part.mimeType,
  849. })
  850. }
  851. break
  852. case "resource_link":
  853. const parsed = parseUri(part.uri)
  854. parts.push(parsed)
  855. break
  856. case "resource":
  857. const resource = part.resource
  858. if ("text" in resource) {
  859. parts.push({
  860. type: "text",
  861. text: resource.text,
  862. })
  863. }
  864. break
  865. default:
  866. break
  867. }
  868. }
  869. log.info("parts", { parts })
  870. const cmd = (() => {
  871. const text = parts
  872. .filter((p): p is { type: "text"; text: string } => p.type === "text")
  873. .map((p) => p.text)
  874. .join("")
  875. .trim()
  876. if (!text.startsWith("/")) return
  877. const [name, ...rest] = text.slice(1).split(/\s+/)
  878. return { name, args: rest.join(" ").trim() }
  879. })()
  880. const done = {
  881. stopReason: "end_turn" as const,
  882. _meta: {},
  883. }
  884. if (!cmd) {
  885. await this.sdk.session.prompt({
  886. sessionID,
  887. model: {
  888. providerID: model.providerID,
  889. modelID: model.modelID,
  890. },
  891. parts,
  892. agent,
  893. directory,
  894. })
  895. return done
  896. }
  897. const command = await this.config.sdk.command
  898. .list({ directory }, { throwOnError: true })
  899. .then((x) => x.data!.find((c) => c.name === cmd.name))
  900. if (command) {
  901. await this.sdk.session.command({
  902. sessionID,
  903. command: command.name,
  904. arguments: cmd.args,
  905. model: model.providerID + "/" + model.modelID,
  906. agent,
  907. directory,
  908. })
  909. return done
  910. }
  911. switch (cmd.name) {
  912. case "compact":
  913. await this.config.sdk.session.summarize(
  914. {
  915. sessionID,
  916. directory,
  917. providerID: model.providerID,
  918. modelID: model.modelID,
  919. },
  920. { throwOnError: true },
  921. )
  922. break
  923. }
  924. return done
  925. }
  926. async cancel(params: CancelNotification) {
  927. const session = this.sessionManager.get(params.sessionId)
  928. await this.config.sdk.session.abort(
  929. {
  930. sessionID: params.sessionId,
  931. directory: session.cwd,
  932. },
  933. { throwOnError: true },
  934. )
  935. }
  936. }
  937. function toToolKind(toolName: string): ToolKind {
  938. const tool = toolName.toLocaleLowerCase()
  939. switch (tool) {
  940. case "bash":
  941. return "execute"
  942. case "webfetch":
  943. return "fetch"
  944. case "edit":
  945. case "patch":
  946. case "write":
  947. return "edit"
  948. case "grep":
  949. case "glob":
  950. case "context7_resolve_library_id":
  951. case "context7_get_library_docs":
  952. return "search"
  953. case "list":
  954. case "read":
  955. return "read"
  956. default:
  957. return "other"
  958. }
  959. }
  960. function toLocations(toolName: string, input: Record<string, any>): { path: string }[] {
  961. const tool = toolName.toLocaleLowerCase()
  962. switch (tool) {
  963. case "read":
  964. case "edit":
  965. case "write":
  966. return input["filePath"] ? [{ path: input["filePath"] }] : []
  967. case "glob":
  968. case "grep":
  969. return input["path"] ? [{ path: input["path"] }] : []
  970. case "bash":
  971. return []
  972. case "list":
  973. return input["path"] ? [{ path: input["path"] }] : []
  974. default:
  975. return []
  976. }
  977. }
  978. async function defaultModel(config: ACPConfig, cwd?: string) {
  979. const sdk = config.sdk
  980. const configured = config.defaultModel
  981. if (configured) return configured
  982. const directory = cwd ?? process.cwd()
  983. const specified = await sdk.config
  984. .get({ directory }, { throwOnError: true })
  985. .then((resp) => {
  986. const cfg = resp.data
  987. if (!cfg || !cfg.model) return undefined
  988. const parsed = Provider.parseModel(cfg.model)
  989. return {
  990. providerID: parsed.providerID,
  991. modelID: parsed.modelID,
  992. }
  993. })
  994. .catch((error) => {
  995. log.error("failed to load user config for default model", { error })
  996. return undefined
  997. })
  998. const providers = await sdk.config
  999. .providers({ directory }, { throwOnError: true })
  1000. .then((x) => x.data?.providers ?? [])
  1001. .catch((error) => {
  1002. log.error("failed to list providers for default model", { error })
  1003. return []
  1004. })
  1005. if (specified && providers.length) {
  1006. const provider = providers.find((p) => p.id === specified.providerID)
  1007. if (provider && provider.models[specified.modelID]) return specified
  1008. }
  1009. if (specified && !providers.length) return specified
  1010. const opencodeProvider = providers.find((p) => p.id === "opencode")
  1011. if (opencodeProvider) {
  1012. if (opencodeProvider.models["big-pickle"]) {
  1013. return { providerID: "opencode", modelID: "big-pickle" }
  1014. }
  1015. const [best] = Provider.sort(Object.values(opencodeProvider.models))
  1016. if (best) {
  1017. return {
  1018. providerID: best.providerID,
  1019. modelID: best.id,
  1020. }
  1021. }
  1022. }
  1023. const models = providers.flatMap((p) => Object.values(p.models))
  1024. const [best] = Provider.sort(models)
  1025. if (best) {
  1026. return {
  1027. providerID: best.providerID,
  1028. modelID: best.id,
  1029. }
  1030. }
  1031. if (specified) return specified
  1032. return { providerID: "opencode", modelID: "big-pickle" }
  1033. }
  1034. function parseUri(
  1035. uri: string,
  1036. ): { type: "file"; url: string; filename: string; mime: string } | { type: "text"; text: string } {
  1037. try {
  1038. if (uri.startsWith("file://")) {
  1039. const path = uri.slice(7)
  1040. const name = path.split("/").pop() || path
  1041. return {
  1042. type: "file",
  1043. url: uri,
  1044. filename: name,
  1045. mime: "text/plain",
  1046. }
  1047. }
  1048. if (uri.startsWith("zed://")) {
  1049. const url = new URL(uri)
  1050. const path = url.searchParams.get("path")
  1051. if (path) {
  1052. const name = path.split("/").pop() || path
  1053. return {
  1054. type: "file",
  1055. url: `file://${path}`,
  1056. filename: name,
  1057. mime: "text/plain",
  1058. }
  1059. }
  1060. }
  1061. return {
  1062. type: "text",
  1063. text: uri,
  1064. }
  1065. } catch {
  1066. return {
  1067. type: "text",
  1068. text: uri,
  1069. }
  1070. }
  1071. }
  1072. function getNewContent(fileOriginal: string, unifiedDiff: string): string | undefined {
  1073. const result = applyPatch(fileOriginal, unifiedDiff)
  1074. if (result === false) {
  1075. log.error("Failed to apply unified diff (context mismatch)")
  1076. return undefined
  1077. }
  1078. return result
  1079. }
  1080. }