agent.test.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638
  1. import { test, expect } from "bun:test"
  2. import { tmpdir } from "../fixture/fixture"
  3. import { Instance } from "../../src/project/instance"
  4. import { Agent } from "../../src/agent/agent"
  5. import { PermissionNext } from "../../src/permission/next"
  6. // Helper to evaluate permission for a tool with wildcard pattern
  7. function evalPerm(agent: Agent.Info | undefined, permission: string): PermissionNext.Action | undefined {
  8. if (!agent) return undefined
  9. return PermissionNext.evaluate(permission, "*", agent.permission).action
  10. }
  11. test("returns default native agents when no config", async () => {
  12. await using tmp = await tmpdir()
  13. await Instance.provide({
  14. directory: tmp.path,
  15. fn: async () => {
  16. const agents = await Agent.list()
  17. const names = agents.map((a) => a.name)
  18. expect(names).toContain("build")
  19. expect(names).toContain("plan")
  20. expect(names).toContain("general")
  21. expect(names).toContain("explore")
  22. expect(names).toContain("compaction")
  23. expect(names).toContain("title")
  24. expect(names).toContain("summary")
  25. },
  26. })
  27. })
  28. test("build agent has correct default properties", async () => {
  29. await using tmp = await tmpdir()
  30. await Instance.provide({
  31. directory: tmp.path,
  32. fn: async () => {
  33. const build = await Agent.get("build")
  34. expect(build).toBeDefined()
  35. expect(build?.mode).toBe("primary")
  36. expect(build?.native).toBe(true)
  37. expect(evalPerm(build, "edit")).toBe("allow")
  38. expect(evalPerm(build, "bash")).toBe("allow")
  39. },
  40. })
  41. })
  42. test("plan agent denies edits except .opencode/plans/*", async () => {
  43. await using tmp = await tmpdir()
  44. await Instance.provide({
  45. directory: tmp.path,
  46. fn: async () => {
  47. const plan = await Agent.get("plan")
  48. expect(plan).toBeDefined()
  49. // Wildcard is denied
  50. expect(evalPerm(plan, "edit")).toBe("deny")
  51. // But specific path is allowed
  52. expect(PermissionNext.evaluate("edit", ".opencode/plans/foo.md", plan!.permission).action).toBe("allow")
  53. },
  54. })
  55. })
  56. test("explore agent denies edit and write", async () => {
  57. await using tmp = await tmpdir()
  58. await Instance.provide({
  59. directory: tmp.path,
  60. fn: async () => {
  61. const explore = await Agent.get("explore")
  62. expect(explore).toBeDefined()
  63. expect(explore?.mode).toBe("subagent")
  64. expect(evalPerm(explore, "edit")).toBe("deny")
  65. expect(evalPerm(explore, "write")).toBe("deny")
  66. expect(evalPerm(explore, "todoread")).toBe("deny")
  67. expect(evalPerm(explore, "todowrite")).toBe("deny")
  68. },
  69. })
  70. })
  71. test("general agent denies todo tools", async () => {
  72. await using tmp = await tmpdir()
  73. await Instance.provide({
  74. directory: tmp.path,
  75. fn: async () => {
  76. const general = await Agent.get("general")
  77. expect(general).toBeDefined()
  78. expect(general?.mode).toBe("subagent")
  79. expect(general?.hidden).toBeUndefined()
  80. expect(evalPerm(general, "todoread")).toBe("deny")
  81. expect(evalPerm(general, "todowrite")).toBe("deny")
  82. },
  83. })
  84. })
  85. test("compaction agent denies all permissions", async () => {
  86. await using tmp = await tmpdir()
  87. await Instance.provide({
  88. directory: tmp.path,
  89. fn: async () => {
  90. const compaction = await Agent.get("compaction")
  91. expect(compaction).toBeDefined()
  92. expect(compaction?.hidden).toBe(true)
  93. expect(evalPerm(compaction, "bash")).toBe("deny")
  94. expect(evalPerm(compaction, "edit")).toBe("deny")
  95. expect(evalPerm(compaction, "read")).toBe("deny")
  96. },
  97. })
  98. })
  99. test("custom agent from config creates new agent", async () => {
  100. await using tmp = await tmpdir({
  101. config: {
  102. agent: {
  103. my_custom_agent: {
  104. model: "openai/gpt-4",
  105. description: "My custom agent",
  106. temperature: 0.5,
  107. top_p: 0.9,
  108. },
  109. },
  110. },
  111. })
  112. await Instance.provide({
  113. directory: tmp.path,
  114. fn: async () => {
  115. const custom = await Agent.get("my_custom_agent")
  116. expect(custom).toBeDefined()
  117. expect(custom?.model?.providerID).toBe("openai")
  118. expect(custom?.model?.modelID).toBe("gpt-4")
  119. expect(custom?.description).toBe("My custom agent")
  120. expect(custom?.temperature).toBe(0.5)
  121. expect(custom?.topP).toBe(0.9)
  122. expect(custom?.native).toBe(false)
  123. expect(custom?.mode).toBe("all")
  124. },
  125. })
  126. })
  127. test("custom agent config overrides native agent properties", async () => {
  128. await using tmp = await tmpdir({
  129. config: {
  130. agent: {
  131. build: {
  132. model: "anthropic/claude-3",
  133. description: "Custom build agent",
  134. temperature: 0.7,
  135. color: "#FF0000",
  136. },
  137. },
  138. },
  139. })
  140. await Instance.provide({
  141. directory: tmp.path,
  142. fn: async () => {
  143. const build = await Agent.get("build")
  144. expect(build).toBeDefined()
  145. expect(build?.model?.providerID).toBe("anthropic")
  146. expect(build?.model?.modelID).toBe("claude-3")
  147. expect(build?.description).toBe("Custom build agent")
  148. expect(build?.temperature).toBe(0.7)
  149. expect(build?.color).toBe("#FF0000")
  150. expect(build?.native).toBe(true)
  151. },
  152. })
  153. })
  154. test("agent disable removes agent from list", async () => {
  155. await using tmp = await tmpdir({
  156. config: {
  157. agent: {
  158. explore: { disable: true },
  159. },
  160. },
  161. })
  162. await Instance.provide({
  163. directory: tmp.path,
  164. fn: async () => {
  165. const explore = await Agent.get("explore")
  166. expect(explore).toBeUndefined()
  167. const agents = await Agent.list()
  168. const names = agents.map((a) => a.name)
  169. expect(names).not.toContain("explore")
  170. },
  171. })
  172. })
  173. test("agent permission config merges with defaults", async () => {
  174. await using tmp = await tmpdir({
  175. config: {
  176. agent: {
  177. build: {
  178. permission: {
  179. bash: {
  180. "rm -rf *": "deny",
  181. },
  182. },
  183. },
  184. },
  185. },
  186. })
  187. await Instance.provide({
  188. directory: tmp.path,
  189. fn: async () => {
  190. const build = await Agent.get("build")
  191. expect(build).toBeDefined()
  192. // Specific pattern is denied
  193. expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
  194. // Edit still allowed
  195. expect(evalPerm(build, "edit")).toBe("allow")
  196. },
  197. })
  198. })
  199. test("global permission config applies to all agents", async () => {
  200. await using tmp = await tmpdir({
  201. config: {
  202. permission: {
  203. bash: "deny",
  204. },
  205. },
  206. })
  207. await Instance.provide({
  208. directory: tmp.path,
  209. fn: async () => {
  210. const build = await Agent.get("build")
  211. expect(build).toBeDefined()
  212. expect(evalPerm(build, "bash")).toBe("deny")
  213. },
  214. })
  215. })
  216. test("agent steps/maxSteps config sets steps property", async () => {
  217. await using tmp = await tmpdir({
  218. config: {
  219. agent: {
  220. build: { steps: 50 },
  221. plan: { maxSteps: 100 },
  222. },
  223. },
  224. })
  225. await Instance.provide({
  226. directory: tmp.path,
  227. fn: async () => {
  228. const build = await Agent.get("build")
  229. const plan = await Agent.get("plan")
  230. expect(build?.steps).toBe(50)
  231. expect(plan?.steps).toBe(100)
  232. },
  233. })
  234. })
  235. test("agent mode can be overridden", async () => {
  236. await using tmp = await tmpdir({
  237. config: {
  238. agent: {
  239. explore: { mode: "primary" },
  240. },
  241. },
  242. })
  243. await Instance.provide({
  244. directory: tmp.path,
  245. fn: async () => {
  246. const explore = await Agent.get("explore")
  247. expect(explore?.mode).toBe("primary")
  248. },
  249. })
  250. })
  251. test("agent name can be overridden", async () => {
  252. await using tmp = await tmpdir({
  253. config: {
  254. agent: {
  255. build: { name: "Builder" },
  256. },
  257. },
  258. })
  259. await Instance.provide({
  260. directory: tmp.path,
  261. fn: async () => {
  262. const build = await Agent.get("build")
  263. expect(build?.name).toBe("Builder")
  264. },
  265. })
  266. })
  267. test("agent prompt can be set from config", async () => {
  268. await using tmp = await tmpdir({
  269. config: {
  270. agent: {
  271. build: { prompt: "Custom system prompt" },
  272. },
  273. },
  274. })
  275. await Instance.provide({
  276. directory: tmp.path,
  277. fn: async () => {
  278. const build = await Agent.get("build")
  279. expect(build?.prompt).toBe("Custom system prompt")
  280. },
  281. })
  282. })
  283. test("unknown agent properties are placed into options", async () => {
  284. await using tmp = await tmpdir({
  285. config: {
  286. agent: {
  287. build: {
  288. random_property: "hello",
  289. another_random: 123,
  290. },
  291. },
  292. },
  293. })
  294. await Instance.provide({
  295. directory: tmp.path,
  296. fn: async () => {
  297. const build = await Agent.get("build")
  298. expect(build?.options.random_property).toBe("hello")
  299. expect(build?.options.another_random).toBe(123)
  300. },
  301. })
  302. })
  303. test("agent options merge correctly", async () => {
  304. await using tmp = await tmpdir({
  305. config: {
  306. agent: {
  307. build: {
  308. options: {
  309. custom_option: true,
  310. another_option: "value",
  311. },
  312. },
  313. },
  314. },
  315. })
  316. await Instance.provide({
  317. directory: tmp.path,
  318. fn: async () => {
  319. const build = await Agent.get("build")
  320. expect(build?.options.custom_option).toBe(true)
  321. expect(build?.options.another_option).toBe("value")
  322. },
  323. })
  324. })
  325. test("multiple custom agents can be defined", async () => {
  326. await using tmp = await tmpdir({
  327. config: {
  328. agent: {
  329. agent_a: {
  330. description: "Agent A",
  331. mode: "subagent",
  332. },
  333. agent_b: {
  334. description: "Agent B",
  335. mode: "primary",
  336. },
  337. },
  338. },
  339. })
  340. await Instance.provide({
  341. directory: tmp.path,
  342. fn: async () => {
  343. const agentA = await Agent.get("agent_a")
  344. const agentB = await Agent.get("agent_b")
  345. expect(agentA?.description).toBe("Agent A")
  346. expect(agentA?.mode).toBe("subagent")
  347. expect(agentB?.description).toBe("Agent B")
  348. expect(agentB?.mode).toBe("primary")
  349. },
  350. })
  351. })
  352. test("Agent.get returns undefined for non-existent agent", async () => {
  353. await using tmp = await tmpdir()
  354. await Instance.provide({
  355. directory: tmp.path,
  356. fn: async () => {
  357. const nonExistent = await Agent.get("does_not_exist")
  358. expect(nonExistent).toBeUndefined()
  359. },
  360. })
  361. })
  362. test("default permission includes doom_loop and external_directory as ask", async () => {
  363. await using tmp = await tmpdir()
  364. await Instance.provide({
  365. directory: tmp.path,
  366. fn: async () => {
  367. const build = await Agent.get("build")
  368. expect(evalPerm(build, "doom_loop")).toBe("ask")
  369. expect(evalPerm(build, "external_directory")).toBe("ask")
  370. },
  371. })
  372. })
  373. test("webfetch is allowed by default", async () => {
  374. await using tmp = await tmpdir()
  375. await Instance.provide({
  376. directory: tmp.path,
  377. fn: async () => {
  378. const build = await Agent.get("build")
  379. expect(evalPerm(build, "webfetch")).toBe("allow")
  380. },
  381. })
  382. })
  383. test("legacy tools config converts to permissions", async () => {
  384. await using tmp = await tmpdir({
  385. config: {
  386. agent: {
  387. build: {
  388. tools: {
  389. bash: false,
  390. read: false,
  391. },
  392. },
  393. },
  394. },
  395. })
  396. await Instance.provide({
  397. directory: tmp.path,
  398. fn: async () => {
  399. const build = await Agent.get("build")
  400. expect(evalPerm(build, "bash")).toBe("deny")
  401. expect(evalPerm(build, "read")).toBe("deny")
  402. },
  403. })
  404. })
  405. test("legacy tools config maps write/edit/patch/multiedit to edit permission", async () => {
  406. await using tmp = await tmpdir({
  407. config: {
  408. agent: {
  409. build: {
  410. tools: {
  411. write: false,
  412. },
  413. },
  414. },
  415. },
  416. })
  417. await Instance.provide({
  418. directory: tmp.path,
  419. fn: async () => {
  420. const build = await Agent.get("build")
  421. expect(evalPerm(build, "edit")).toBe("deny")
  422. },
  423. })
  424. })
  425. test("Truncate.DIR is allowed even when user denies external_directory globally", async () => {
  426. const { Truncate } = await import("../../src/tool/truncation")
  427. await using tmp = await tmpdir({
  428. config: {
  429. permission: {
  430. external_directory: "deny",
  431. },
  432. },
  433. })
  434. await Instance.provide({
  435. directory: tmp.path,
  436. fn: async () => {
  437. const build = await Agent.get("build")
  438. expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
  439. expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
  440. expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
  441. },
  442. })
  443. })
  444. test("Truncate.DIR is allowed even when user denies external_directory per-agent", async () => {
  445. const { Truncate } = await import("../../src/tool/truncation")
  446. await using tmp = await tmpdir({
  447. config: {
  448. agent: {
  449. build: {
  450. permission: {
  451. external_directory: "deny",
  452. },
  453. },
  454. },
  455. },
  456. })
  457. await Instance.provide({
  458. directory: tmp.path,
  459. fn: async () => {
  460. const build = await Agent.get("build")
  461. expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
  462. expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
  463. expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
  464. },
  465. })
  466. })
  467. test("explicit Truncate.DIR deny is respected", async () => {
  468. const { Truncate } = await import("../../src/tool/truncation")
  469. await using tmp = await tmpdir({
  470. config: {
  471. permission: {
  472. external_directory: {
  473. "*": "deny",
  474. [Truncate.DIR]: "deny",
  475. },
  476. },
  477. },
  478. })
  479. await Instance.provide({
  480. directory: tmp.path,
  481. fn: async () => {
  482. const build = await Agent.get("build")
  483. expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
  484. expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
  485. },
  486. })
  487. })
  488. test("defaultAgent returns build when no default_agent config", async () => {
  489. await using tmp = await tmpdir()
  490. await Instance.provide({
  491. directory: tmp.path,
  492. fn: async () => {
  493. const agent = await Agent.defaultAgent()
  494. expect(agent).toBe("build")
  495. },
  496. })
  497. })
  498. test("defaultAgent respects default_agent config set to plan", async () => {
  499. await using tmp = await tmpdir({
  500. config: {
  501. default_agent: "plan",
  502. },
  503. })
  504. await Instance.provide({
  505. directory: tmp.path,
  506. fn: async () => {
  507. const agent = await Agent.defaultAgent()
  508. expect(agent).toBe("plan")
  509. },
  510. })
  511. })
  512. test("defaultAgent respects default_agent config set to custom agent with mode all", async () => {
  513. await using tmp = await tmpdir({
  514. config: {
  515. default_agent: "my_custom",
  516. agent: {
  517. my_custom: {
  518. description: "My custom agent",
  519. },
  520. },
  521. },
  522. })
  523. await Instance.provide({
  524. directory: tmp.path,
  525. fn: async () => {
  526. const agent = await Agent.defaultAgent()
  527. expect(agent).toBe("my_custom")
  528. },
  529. })
  530. })
  531. test("defaultAgent throws when default_agent points to subagent", async () => {
  532. await using tmp = await tmpdir({
  533. config: {
  534. default_agent: "explore",
  535. },
  536. })
  537. await Instance.provide({
  538. directory: tmp.path,
  539. fn: async () => {
  540. await expect(Agent.defaultAgent()).rejects.toThrow('default agent "explore" is a subagent')
  541. },
  542. })
  543. })
  544. test("defaultAgent throws when default_agent points to hidden agent", async () => {
  545. await using tmp = await tmpdir({
  546. config: {
  547. default_agent: "compaction",
  548. },
  549. })
  550. await Instance.provide({
  551. directory: tmp.path,
  552. fn: async () => {
  553. await expect(Agent.defaultAgent()).rejects.toThrow('default agent "compaction" is hidden')
  554. },
  555. })
  556. })
  557. test("defaultAgent throws when default_agent points to non-existent agent", async () => {
  558. await using tmp = await tmpdir({
  559. config: {
  560. default_agent: "does_not_exist",
  561. },
  562. })
  563. await Instance.provide({
  564. directory: tmp.path,
  565. fn: async () => {
  566. await expect(Agent.defaultAgent()).rejects.toThrow('default agent "does_not_exist" not found')
  567. },
  568. })
  569. })
  570. test("defaultAgent returns plan when build is disabled and default_agent not set", async () => {
  571. await using tmp = await tmpdir({
  572. config: {
  573. agent: {
  574. build: { disable: true },
  575. },
  576. },
  577. })
  578. await Instance.provide({
  579. directory: tmp.path,
  580. fn: async () => {
  581. const agent = await Agent.defaultAgent()
  582. // build is disabled, so it should return plan (next primary agent)
  583. expect(agent).toBe("plan")
  584. },
  585. })
  586. })
  587. test("defaultAgent throws when all primary agents are disabled", async () => {
  588. await using tmp = await tmpdir({
  589. config: {
  590. agent: {
  591. build: { disable: true },
  592. plan: { disable: true },
  593. },
  594. },
  595. })
  596. await Instance.provide({
  597. directory: tmp.path,
  598. fn: async () => {
  599. // build and plan are disabled, no primary-capable agents remain
  600. await expect(Agent.defaultAgent()).rejects.toThrow("no primary visible agent found")
  601. },
  602. })
  603. })