agentpaas-client.test.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575
  1. import { expect, test } from "bun:test"
  2. import path from "node:path"
  3. import { pathToFileURL } from "node:url"
  4. import { tmpdir } from "../fixture/fixture"
  5. import run from "../../../../.opencode/tool/agentpaas_run"
  6. import { agents, task, upload, download } from "../../../../.opencode/tool/agentpaas"
  7. async function fixture(handler: (request: Request) => Response | Promise<Response>) {
  8. const temp = await tmpdir()
  9. const saved = Object.fromEntries(
  10. ["AGENTPAAS_URL", "AGENTPAAS_API_KEY", "AGENTPAAS_BRIDGE_STATE_DIR"].map((key) => [key, process.env[key]]),
  11. )
  12. const requests: Array<{ method: string; path: string }> = []
  13. const approvals: string[] = []
  14. const server = Bun.serve({
  15. hostname: "127.0.0.1",
  16. port: 0,
  17. fetch(request) {
  18. expect(request.headers.get("Authorization")).toBe("Bearer client-fixture")
  19. requests.push({ method: request.method, path: new URL(request.url).pathname })
  20. if (new URL(request.url).pathname.endsWith("/verify")) return Response.json({ status: "verified", checks: [] })
  21. return handler(request)
  22. },
  23. })
  24. process.env.AGENTPAAS_URL = server.url.origin
  25. process.env.AGENTPAAS_API_KEY = "client-fixture"
  26. process.env.AGENTPAAS_BRIDGE_STATE_DIR = path.join(temp.path, "state")
  27. return {
  28. path: temp.path,
  29. requests,
  30. approvals,
  31. context: {
  32. sessionID: crypto.randomUUID(),
  33. messageID: "m1",
  34. agent: "paas",
  35. directory: temp.path,
  36. worktree: temp.path,
  37. abort: new AbortController().signal,
  38. metadata() {},
  39. async ask(request: { permission: string }) {
  40. approvals.push(request.permission)
  41. },
  42. },
  43. async [Symbol.asyncDispose]() {
  44. await server.stop(true)
  45. for (const [key, value] of Object.entries(saved)) {
  46. if (value === undefined) delete process.env[key]
  47. else process.env[key] = value
  48. }
  49. await temp[Symbol.asyncDispose]()
  50. },
  51. }
  52. }
  53. const catalog = () =>
  54. Response.json({ agents: [{ id: "ag_data", name: "Data67", runtime_mode: "local" }], discovery: { status: "ok" } })
  55. const stream = (status: string, extra: Record<string, unknown> = {}) =>
  56. new Response(`event: done\ndata: ${JSON.stringify({ run_id: "run_one", status, output: "result", ...extra })}\n\n`, {
  57. headers: { "Content-Type": "text/event-stream" },
  58. })
  59. test("cold discovery retries reads and resolves a stable ID before submitting once", async () => {
  60. let discovery = 0
  61. await using env = await fixture((request) => {
  62. if (new URL(request.url).pathname === "/api/v1/agents") {
  63. if (++discovery === 1) return Response.json({ agents: [], discovery: { status: "refreshing" } })
  64. return catalog()
  65. }
  66. return stream("completed")
  67. })
  68. const result = await run.execute(
  69. { input: "data task", agent_id: "ag_data", mode: "iterate", conversation: "new" },
  70. env.context,
  71. )
  72. if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
  73. expect(result.metadata.status).toBe("completed")
  74. expect(discovery).toBe(2)
  75. expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
  76. expect(result.metadata.research67_progress).toBeUndefined()
  77. })
  78. test("catalog preserves unavailable discovery and all agent types without executing", async () => {
  79. await using env = await fixture(() =>
  80. Response.json({ agents: [{ id: "ag_data", name: "Data67" }], discovery: { status: "unavailable" } }),
  81. )
  82. const result = await agents.execute({}, env.context)
  83. if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
  84. expect(result.output).toContain("Data67")
  85. expect(result.output).toContain("unavailable")
  86. expect(env.requests.every((item) => item.method === "GET")).toBe(true)
  87. })
  88. for (const [status, label] of [
  89. ["partial", "部分完成"],
  90. ["needs_clarification", "需要补充信息"],
  91. ]) {
  92. test(`preserves ${status} and artifacts without reporting execution failure`, async () => {
  93. await using env = await fixture((request) =>
  94. new URL(request.url).pathname === "/api/v1/agents"
  95. ? catalog()
  96. : stream(status, {
  97. artifacts: [{ artifact_id: "art_1", name: "report.md" }],
  98. }),
  99. )
  100. const result = await run.execute(
  101. { agent_name: "Data67", input: "analyze", mode: "iterate", conversation: "new" },
  102. env.context,
  103. )
  104. if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
  105. expect(result.title).toContain(label)
  106. expect(result.output).toContain("art_1")
  107. expect(result.output).not.toContain("执行失败")
  108. })
  109. }
  110. test("dropped SSE reads original run state without resubmitting or cancelling", async () => {
  111. await using env = await fixture(async (request) => {
  112. if (new URL(request.url).pathname === "/api/v1/agents") return catalog()
  113. if (request.method === "POST") {
  114. expect((await request.json()).cancel_on_disconnect).toBe(false)
  115. return new Response('event: started\ndata: {"run_id":"run_one","thread_id":"thread_1"}\n\n')
  116. }
  117. return Response.json({ run_id: "run_one", status: "running" })
  118. })
  119. const result = await run.execute(
  120. { agent_name: "Data67", input: "analyze", mode: "iterate", conversation: "new" },
  121. env.context,
  122. )
  123. if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
  124. expect(result.metadata.status).toBe("running")
  125. expect(result.metadata.connection_recovered).toBe(true)
  126. expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
  127. expect(result.output).toContain("watch")
  128. })
  129. test("watch replays durable events and never submits or cancels work", async () => {
  130. let reads = 0
  131. await using env = await fixture((request) => {
  132. const url = new URL(request.url)
  133. if (url.pathname.endsWith("/events")) {
  134. expect(url.searchParams.get("source")).toBe("service")
  135. return Response.json({ events: [{ sequence: 4, event_type: "stage_completed", data: { stage_id: "analysis" } }] })
  136. }
  137. return Response.json({ run_id: "run_one", status: ++reads >= 2 ? "completed" : "running", output: "done" })
  138. })
  139. const result = await task.execute(
  140. { agent_id: "ag_data", run_id: "run_one", action: "watch", wait_seconds: 1 },
  141. env.context,
  142. )
  143. if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
  144. expect(result.metadata.status).toBe("completed")
  145. expect(result.metadata.event_cursor).toBe(4)
  146. expect(env.requests.every((item) => item.method === "GET")).toBe(true)
  147. })
  148. test("resume uses the existing checkpoint and one control mutation", async () => {
  149. let resumed = false
  150. await using env = await fixture(async (request) => {
  151. if (request.method === "POST") {
  152. expect(new URL(request.url).pathname).toEndWith("/run_one/resume")
  153. expect(await request.json()).toEqual({ checkpoint_id: "cp_original" })
  154. resumed = true
  155. }
  156. if (new URL(request.url).pathname.endsWith("/events")) return Response.json({ events: [] })
  157. return Response.json({ run_id: "run_one", status: resumed ? "running" : "paused", checkpoint_id: "cp_original" })
  158. })
  159. const result = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "resume" }, env.context)
  160. if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
  161. expect(result.metadata.status).toBe("running")
  162. expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
  163. })
  164. test("approval requires a user decision and binds the exact approval ID", async () => {
  165. let approved = false
  166. await using env = await fixture(async (request) => {
  167. if (request.method === "POST") {
  168. const body = await request.json()
  169. expect(body.approval_id).toBe("approval_1")
  170. expect(body.approved).toBe(true)
  171. expect(body.idempotency_key).toStartWith("opencode-control-")
  172. approved = true
  173. }
  174. if (new URL(request.url).pathname.endsWith("/events")) return Response.json({ events: [] })
  175. return Response.json({
  176. run_id: "run_one",
  177. status: approved ? "running" : "waiting_human",
  178. pending_approval: { approval_id: "approval_1", prompt: "review plan" },
  179. })
  180. })
  181. await expect(
  182. task.execute(
  183. { agent_id: "ag_data", run_id: "run_one", action: "approve" },
  184. {
  185. ...env.context,
  186. ask: async () => {
  187. throw new Error("user denied")
  188. },
  189. },
  190. ),
  191. ).rejects.toThrow("user denied")
  192. expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(0)
  193. await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "approve" }, env.context)
  194. expect(env.approvals).toContain("agentpaas_confirm")
  195. expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
  196. })
  197. test("upload, submit references, and authenticated download preserve file bytes", async () => {
  198. const content = "col1,col2\n1,2\n"
  199. const hash = new Bun.CryptoHasher("sha256").update(content).digest("hex")
  200. const ref = { file_id: "file_1", name: "input.csv", status: "available", sha256: hash, size_bytes: content.length }
  201. await using env = await fixture(async (request) => {
  202. const url = new URL(request.url)
  203. if (url.pathname === "/api/v1/agents") return catalog()
  204. if (url.pathname === "/api/v1/files") {
  205. expect(await request.text()).toBe(content)
  206. expect(request.headers.get("X-File-SHA256")).toBe(hash)
  207. return Response.json(ref)
  208. }
  209. if (url.pathname === "/api/v1/files/capabilities")
  210. return Response.json({ direct_upload_max_bytes: 256 * 1024 * 1024 })
  211. if (url.pathname === "/api/v1/files/file_1") return Response.json(ref)
  212. if (url.pathname.endsWith("/run/stream")) {
  213. expect((await request.json()).input_files).toEqual([ref])
  214. return stream("completed")
  215. }
  216. if (url.pathname.endsWith("/artifacts"))
  217. return Response.json({
  218. artifacts: [{ artifact_id: "art_1", name: "../../output.csv", sha256: hash, size_bytes: content.length }],
  219. })
  220. return new Response(content)
  221. })
  222. await Bun.write(path.join(env.path, "input.csv"), content)
  223. const uploaded = await upload.execute({ paths: ["input.csv"] }, env.context)
  224. if (typeof uploaded === "string" || !uploaded.metadata) throw new Error("Expected structured tool output")
  225. expect(uploaded.metadata.files[0].file_id).toBe("file_1")
  226. await run.execute(
  227. { input: "analyze", agent_name: "Data67", mode: "iterate", conversation: "new", input_file_ids: ["file_1"] },
  228. env.context,
  229. )
  230. const result = await download.execute({ agent_id: "ag_data", run_id: "run_one", artifact_id: "art_1" }, env.context)
  231. if (typeof result === "string" || !result.metadata) throw new Error("Expected structured tool output")
  232. expect(await Bun.file(result.metadata.local_path).text()).toBe(content)
  233. expect(result.metadata.local_path).toStartWith(path.join(env.path, ".agentpaas-artifacts") + path.sep)
  234. expect(env.approvals).toEqual(["agentpaas_upload", "agentpaas_download"])
  235. await expect(
  236. download.execute({ agent_id: "ag_data", run_id: "run_one", artifact_id: "art_1" }, env.context),
  237. ).rejects.toThrow()
  238. expect(await Bun.file(result.metadata.local_path).text()).toBe(content)
  239. })
  240. test("download rejects corrupt bytes before writing", async () => {
  241. await using env = await fixture((request) =>
  242. new URL(request.url).pathname.endsWith("/artifacts")
  243. ? Response.json({ artifacts: [{ artifact_id: "art_1", name: "report", sha256: "0".repeat(64) }] })
  244. : new Response("corrupted"),
  245. )
  246. await expect(
  247. download.execute(
  248. { agent_id: "ag_data", run_id: "run_one", artifact_id: "art_1", output_path: "result.txt" },
  249. env.context,
  250. ),
  251. ).rejects.toThrow("摘要不匹配")
  252. expect(await Bun.file(path.join(env.path, "result.txt")).exists()).toBe(false)
  253. })
  254. test("failed control mutation is never retried", async () => {
  255. await using env = await fixture((request) =>
  256. request.method === "POST"
  257. ? Response.json({ error: "unavailable" }, { status: 503 })
  258. : Response.json({ run_id: "run_one", status: "running" }),
  259. )
  260. const result = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "cancel" }, env.context)
  261. if (typeof result === "string") throw new Error("Expected structured result")
  262. expect(result.metadata?.cancellation_status).toBe("unconfirmed")
  263. expect(result.metadata?.status).toBe("running")
  264. const reread = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "status" }, env.context)
  265. if (typeof reread === "string") throw new Error("Expected structured result")
  266. expect(reread.metadata?.cancellation_status).toBe("unconfirmed")
  267. expect(env.requests.filter((item) => item.method === "POST" && !item.path.endsWith("/verify"))).toHaveLength(1)
  268. })
  269. test("tool confirmation remains actionable after reconnect even while worker status is running", async () => {
  270. let decided = false
  271. await using env = await fixture(async (request) => {
  272. if (request.method === "POST") {
  273. expect((await request.json()).approved).toBe(false)
  274. decided = true
  275. }
  276. if (new URL(request.url).pathname.endsWith("/events")) return Response.json({ events: [] })
  277. return Response.json({
  278. run_id: "run_one",
  279. status: "running",
  280. pending_confirmation: decided
  281. ? null
  282. : { approval_id: "tc_first", status: "pending", tool: "Shell", input_text: "command" },
  283. })
  284. })
  285. const pending = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "status" }, env.context)
  286. if (typeof pending === "string" || !pending.metadata) throw new Error("Expected structured result")
  287. expect(pending.metadata.status).toBe("waiting_confirmation")
  288. await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "reject" }, env.context)
  289. expect(decided).toBe(true)
  290. })
  291. test("status survives an unavailable event journal; watch returns the known result with a warning", async () => {
  292. await using env = await fixture((request) =>
  293. new URL(request.url).pathname.endsWith("/events")
  294. ? new Response("journal offline", { status: 503 })
  295. : Response.json({
  296. run_id: "run_old",
  297. session_id: "thread_old",
  298. status: "completed",
  299. output: "verified answer",
  300. artifacts: [{ artifact_id: "artifact_old", name: "result.csv" }],
  301. }),
  302. )
  303. const status = await task.execute({ agent_id: "ag_data", run_id: "run_old", action: "status" }, env.context)
  304. if (typeof status === "string") throw new Error("Expected structured output")
  305. expect(status.output).toContain("verified answer")
  306. expect(env.requests.some((request) => request.path.endsWith("/events"))).toBe(false)
  307. const watched = await task.execute(
  308. { agent_id: "ag_data", run_id: "run_old", action: "watch", wait_seconds: 0 },
  309. env.context,
  310. )
  311. if (typeof watched === "string" || !watched.metadata) throw new Error("Expected structured output")
  312. expect(watched.metadata.status).toBe("completed")
  313. expect(watched.output).toContain("artifact_old")
  314. expect(watched.output).toContain("事件读取暂不可用")
  315. })
  316. test("explicit historical selection persists the thread and parent for the next revision", async () => {
  317. let submission: Record<string, unknown> = {}
  318. await using env = await fixture(async (request) => {
  319. const route = new URL(request.url).pathname
  320. if (route === "/api/v1/agents") return catalog()
  321. if (request.method === "POST") {
  322. submission = await request.json()
  323. return stream("completed")
  324. }
  325. return Response.json({ run_id: "run_old", session_id: "thread_old", status: "completed" })
  326. })
  327. const state = path.join(env.path, "state", `${env.context.sessionID}.json`)
  328. await task.execute({ agent_id: "ag_data", run_id: "run_old", action: "status" }, env.context)
  329. expect(await Bun.file(state).exists()).toBe(false)
  330. await task.execute({ agent_id: "ag_data", run_id: "run_old", action: "select" }, env.context)
  331. expect(Object.values(await Bun.file(state).json())).toContainEqual({ thread_id: "thread_old", run_id: "run_old" })
  332. await run.execute(
  333. { agent_id: "ag_data", input: "Revise the selected research", mode: "iterate", conversation: "continue" },
  334. env.context,
  335. )
  336. expect(submission.thread_id).toBe("thread_old")
  337. expect(submission.context).toMatchObject({ run_id: "run_old" })
  338. expect(env.requests.filter((request) => request.method === "POST" && !request.path.endsWith("/verify"))).toHaveLength(
  339. 1,
  340. )
  341. })
  342. test("replaying resolved tool confirmations never prompts or posts another decision", async () => {
  343. await using env = await fixture((request) => {
  344. const route = new URL(request.url).pathname
  345. if (route === "/api/v1/agents") return catalog()
  346. if (route.endsWith("/run/stream"))
  347. return new Response(
  348. [
  349. 'event: started\ndata: {"run_id":"run_old","thread_id":"thread_old"}\n\n',
  350. 'event: confirm_required\ndata: {"run_id":"run_old","approval_id":"tc_old","tool":"Shell"}\n\n',
  351. 'event: done\ndata: {"run_id":"run_old","status":"completed","output":"done"}\n\n',
  352. ].join(""),
  353. )
  354. return Response.json({ run_id: "run_old", status: "completed", pending_confirmation: null })
  355. })
  356. const result = await run.execute(
  357. { agent_id: "ag_data", input: "existing task", mode: "iterate", conversation: "continue" },
  358. env.context,
  359. )
  360. if (typeof result === "string" || !result.metadata) throw new Error("Expected structured output")
  361. expect(result.metadata.status).toBe("completed")
  362. expect(env.approvals).toEqual([])
  363. expect(env.requests.filter((request) => request.path.endsWith("/confirm"))).toEqual([])
  364. })
  365. test("ordinary confirmation rejects stale IDs before any mutation", async () => {
  366. await using env = await fixture(() =>
  367. Response.json({
  368. status: "running",
  369. pending_confirmation: { approval_id: "tc_new", status: "pending", tool: "Shell" },
  370. }),
  371. )
  372. await expect(
  373. task.execute({ agent_id: "ag_data", run_id: "run_one", action: "approve", approval_id: "tc_old" }, env.context),
  374. ).rejects.toThrow("已过期")
  375. expect(env.requests.every((request) => request.method === "GET")).toBe(true)
  376. expect(env.approvals).toEqual([])
  377. })
  378. test("upload uses the server limit and refuses oversized inputs before upload", async () => {
  379. await using env = await fixture(() => Response.json({ direct_upload_max_bytes: 3 }))
  380. await Bun.write(path.join(env.path, "large.txt"), "four")
  381. await expect(upload.execute({ paths: ["large.txt"] }, env.context)).rejects.toThrow("3 字节")
  382. expect(env.requests.every((request) => request.method === "GET")).toBe(true)
  383. expect(env.approvals).toEqual([])
  384. })
  385. test("installer works outside source tree and preserves existing user configuration", async () => {
  386. await using directory = await tmpdir()
  387. const config = path.join(directory.path, ".opencode/opencode.jsonc")
  388. await Bun.write(config, '{"model":"user/model"}')
  389. const script = path.resolve(import.meta.dir, "../../../../script/install-agentpaas.ts")
  390. for (const extra of [[], ["--check"]]) {
  391. const child = Bun.spawn([process.execPath, script, "--target", directory.path, ...extra], {
  392. stdout: "pipe",
  393. stderr: "pipe",
  394. })
  395. expect(await child.exited, await new Response(child.stderr).text()).toBe(0)
  396. }
  397. expect(await Bun.file(config).text()).toBe('{"model":"user/model"}')
  398. expect(await Bun.file(path.join(directory.path, ".opencode/lib/agentpaas.ts")).exists()).toBe(true)
  399. const installed = await import(pathToFileURL(path.join(directory.path, ".opencode/agentpaas-manifest.json")).href)
  400. expect(installed.default.files).toContain("tool/agentpaas.ts")
  401. })
  402. test("lost started response is recovered by key without a second submission", async () => {
  403. await using env = await fixture((request) => {
  404. const url = new URL(request.url)
  405. if (url.pathname === "/api/v1/agents") return catalog()
  406. if (url.pathname.includes("/requests/"))
  407. return Response.json({ found: true, run: { run_id: "run_one", session_id: "thread_one", status: "running" } })
  408. return new Response("", { headers: { "Content-Type": "text/event-stream" } })
  409. })
  410. const result = await run.execute(
  411. { input: "task", agent_id: "ag_data", mode: "iterate", conversation: "new", background: true },
  412. env.context,
  413. )
  414. if (typeof result === "string") throw new Error("Expected structured result")
  415. expect(result.metadata?.run_id).toBe("run_one")
  416. expect(result.metadata?.admission_recovered).toBe(true)
  417. expect(env.requests.filter((row) => row.method === "POST" && !row.path.endsWith("/verify"))).toHaveLength(1)
  418. })
  419. test("pending admission survives reload and only queries the original key", async () => {
  420. const { pendingRequest, recoverPendingRequests } = await import("../../../../.opencode/lib/recovery")
  421. let found = false
  422. await using env = await fixture(() =>
  423. Response.json(
  424. found ? { found: true, run: { run_id: "r_recovered", status: "running" } } : { found: false, retry_safe: false },
  425. ),
  426. )
  427. const config = { url: process.env.AGENTPAAS_URL!, apiKey: "client-fixture" }
  428. await pendingRequest(config, "ag_data", "receipt_one")
  429. expect(await recoverPendingRequests(config, env.context.abort)).toHaveLength(1)
  430. found = true
  431. expect(await recoverPendingRequests(config, env.context.abort)).toHaveLength(0)
  432. const count = env.requests.length
  433. await recoverPendingRequests(config, env.context.abort)
  434. expect(env.requests).toHaveLength(count)
  435. expect(env.requests.every((row) => row.method === "GET")).toBe(true)
  436. })
  437. test("lost cancellation response is confirmed through authoritative snapshot", async () => {
  438. await using env = await fixture((request) =>
  439. request.method === "POST"
  440. ? Response.json({ error: "response lost" }, { status: 503 })
  441. : Response.json({ run_id: "run_one", status: "cancelled" }),
  442. )
  443. const result = await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "cancel" }, env.context)
  444. if (typeof result === "string") throw new Error("Expected structured result")
  445. expect(result.metadata?.cancellation_status).toBe("confirmed")
  446. expect(env.requests.filter((row) => row.method === "POST")).toHaveLength(1)
  447. })
  448. test("watch persists its event cursor and explicit after zero replays", async () => {
  449. const cursors: string[] = []
  450. await using env = await fixture((request) => {
  451. const url = new URL(request.url)
  452. if (url.pathname.endsWith("/events")) {
  453. cursors.push(url.searchParams.get("after")!)
  454. return Response.json({ events: [{ sequence: 7, event: "stage_completed", data: { stage_id: "evidence" } }] })
  455. }
  456. return Response.json({ run_id: "run_one", status: "completed" })
  457. })
  458. for (const after of [undefined, undefined, 0])
  459. await task.execute({ agent_id: "ag_data", run_id: "run_one", action: "watch", after }, env.context)
  460. expect(cursors).toEqual(["0", "7", "0"])
  461. })
  462. test("approval reminders deduplicate decisions, warn before expiry, and never mutate", async () => {
  463. const { approvalMonitor } = await import("../../../../.opencode/lib/approval-monitor")
  464. const { trackRun } = await import("../../../../.opencode/lib/agentpaas")
  465. let id = "approval_one",
  466. expires = new Date(Date.now() + 120000).toISOString()
  467. await using env = await fixture(() =>
  468. Response.json({
  469. status: "running",
  470. pending_confirmation: {
  471. status: "pending",
  472. approval_id: id,
  473. expires_at: expires,
  474. },
  475. }),
  476. )
  477. const config = { url: process.env.AGENTPAAS_URL!, apiKey: "client-fixture" }
  478. await trackRun(config, { agent_id: "ag_data", agent_name: "Data67", run_id: "run_one", thread_id: "thread_one" })
  479. const messages: string[] = []
  480. const monitor = approvalMonitor(config, async (message) => {
  481. messages.push(message)
  482. })
  483. try {
  484. await monitor.poll()
  485. await monitor.poll()
  486. expect(messages).toHaveLength(1)
  487. expires = new Date(Date.now() + 30000).toISOString()
  488. await monitor.poll()
  489. await monitor.poll()
  490. expect(messages).toHaveLength(2)
  491. expect(messages[1]).toContain("即将到期")
  492. id = "approval_two"
  493. await monitor.poll()
  494. expect(messages).toHaveLength(3)
  495. monitor.dispose()
  496. await monitor.poll()
  497. expect(messages).toHaveLength(3)
  498. expect(env.requests.every((row) => row.method === "GET")).toBe(true)
  499. } finally {
  500. monitor.dispose()
  501. }
  502. })
  503. test("unsupported client cost budget blocks before submitting", async () => {
  504. await using env = await fixture(() => catalog())
  505. await expect(
  506. run.execute(
  507. { input: "task", agent_id: "ag_data", mode: "iterate", conversation: "new", max_cost_usd: 0.1 },
  508. env.context,
  509. ),
  510. ).rejects.toThrow("未声明支持客户端费用预算")
  511. expect(env.requests.filter((row) => row.method === "POST" && !row.path.endsWith("/verify"))).toHaveLength(0)
  512. })
  513. test("preflight cache is short-lived and version-scoped; explicit probes bypass it", async () => {
  514. const { resolveAgentID, verifyAgent } = await import("../../../../.opencode/lib/agentpaas")
  515. let version = 1
  516. await using env = await fixture(() =>
  517. Response.json({ agents: [{ id: "ag_data", name: "Data67", current_version: version }] }),
  518. )
  519. const config = { url: process.env.AGENTPAAS_URL!, apiKey: "client-fixture" }
  520. await resolveAgentID(config, "ag_data", env.context.abort)
  521. await verifyAgent(config, "ag_data", env.context.abort, undefined, true)
  522. expect((await verifyAgent(config, "ag_data", env.context.abort, undefined, true)).cache_hit).toBe(true)
  523. await verifyAgent(config, "ag_data", env.context.abort)
  524. version = 2
  525. await resolveAgentID(config, "ag_data", env.context.abort)
  526. expect((await verifyAgent(config, "ag_data", env.context.abort, undefined, true)).cache_hit).toBeUndefined()
  527. expect(env.requests.filter((row) => row.path.endsWith("/verify"))).toHaveLength(3)
  528. })
  529. test("approval notification plugin exports a compatible lifecycle", async () => {
  530. const plugin = await import("../../../../.opencode/plugin/agentpaas-notify")
  531. expect(typeof plugin.default).toBe("function")
  532. })
  533. test("local journal failure cannot prevent cancellation reaching the server", async () => {
  534. const { cancelAndObserve } = await import("../../../../.opencode/lib/recovery")
  535. await using env = await fixture(() => Response.json({ status: "cancelled" }))
  536. const blocked = path.join(env.path, "not-a-directory")
  537. await Bun.write(blocked, "file")
  538. process.env.AGENTPAAS_BRIDGE_STATE_DIR = blocked
  539. const result = await cancelAndObserve(
  540. { url: process.env.AGENTPAAS_URL!, apiKey: "client-fixture" },
  541. "ag_data",
  542. "run_one",
  543. )
  544. expect(result.cancellation_status).toBe("confirmed")
  545. expect(result.persistence_warning).toContain("未保存")
  546. expect(env.requests.filter((row) => row.method === "POST")).toHaveLength(1)
  547. })