codex-cli-transport.spec.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. import { beforeEach, describe, expect, it, vi } from "vitest"
  2. import type { LlmConfig } from "@/stores/wiki-store"
  3. const clientMock = vi.hoisted(() => ({
  4. call: vi.fn(),
  5. handler: null as null | { onEnvelope?: (envelope: Record<string, unknown>) => void },
  6. interrupt: vi.fn(async () => undefined),
  7. }))
  8. vi.mock("./codex-app-server-client", () => ({
  9. getCodexAppServerClient: () => ({
  10. isolatedCwd: "/tmp/qmai/workspace",
  11. ensureStarted: vi.fn(async () => undefined),
  12. call: clientMock.call,
  13. interrupt: clientMock.interrupt,
  14. registerThread: (_threadId: string, handler: typeof clientMock.handler) => {
  15. clientMock.handler = handler
  16. return () => {
  17. clientMock.handler = null
  18. }
  19. },
  20. }),
  21. }))
  22. import {
  23. buildCodexTurnInput,
  24. codexNativeBoundaryError,
  25. restrictedCodexConfig,
  26. streamCodexCli,
  27. } from "./codex-cli-transport"
  28. const config: LlmConfig = {
  29. provider: "codex-cli",
  30. apiKey: "",
  31. model: "gpt-test",
  32. ollamaUrl: "",
  33. customEndpoint: "",
  34. maxContextSize: 8192,
  35. codexCliTimeoutMinutes: 10,
  36. }
  37. describe("codex app-server transport", () => {
  38. beforeEach(() => {
  39. clientMock.call.mockReset()
  40. clientMock.handler = null
  41. clientMock.interrupt.mockClear()
  42. })
  43. it("disables native extension, shell, web, image, and project-rule surfaces", () => {
  44. const restricted = restrictedCodexConfig()
  45. expect(restricted).toEqual(expect.objectContaining({
  46. web_search: "disabled",
  47. project_doc_max_bytes: 0,
  48. project_root_markers: [],
  49. tools: { view_image: false, web_search: false },
  50. features: expect.objectContaining({
  51. apps: false,
  52. plugins: false,
  53. shell_tool: false,
  54. multi_agent: false,
  55. skill_search: false,
  56. }),
  57. }))
  58. })
  59. it("serializes trimmed chat history and images into turn input", () => {
  60. const input = buildCodexTurnInput([
  61. { role: "system", content: "system" },
  62. { role: "user", content: [
  63. { type: "text", text: "看图" },
  64. { type: "image", mediaType: "image/png", dataBase64: "YWJj" },
  65. ] },
  66. { role: "assistant", content: "收到" },
  67. ])
  68. expect(input[0].text).toContain("<USER>\n看图")
  69. expect(input[0].text).toContain("<ASSISTANT>\n收到")
  70. expect(input).toContainEqual({ type: "image", url: "data:image/png;base64,YWJj" })
  71. })
  72. it("allows only QMAI dynamic tool server requests and rejects native MCP notifications", () => {
  73. const dynamic = { id: 1, method: "item/tool/call", params: {} }
  74. expect(codexNativeBoundaryError(dynamic, true)).toBeNull()
  75. expect(codexNativeBoundaryError(dynamic)).toEqual(expect.objectContaining({
  76. message: expect.stringContaining("禁止 Codex 原生能力"),
  77. }))
  78. expect(codexNativeBoundaryError({
  79. method: "mcpServer/startupStatus/updated",
  80. params: {},
  81. })).toEqual(expect.objectContaining({
  82. message: expect.stringContaining("mcpServer/startupStatus/updated"),
  83. }))
  84. expect(codexNativeBoundaryError({
  85. method: "hook/started",
  86. params: {},
  87. })).toEqual(expect.objectContaining({
  88. message: expect.stringContaining("hook/started"),
  89. }))
  90. })
  91. it("uses app-server for plain streamChat without exposing dynamic tools", async () => {
  92. clientMock.call.mockImplementation(async (method: string, params: Record<string, unknown>) => {
  93. if (method === "thread/start") {
  94. expect(params).not.toHaveProperty("dynamicTools")
  95. return { thread: { id: "thread-text" }, instructionSources: [] }
  96. }
  97. if (method === "turn/start") {
  98. queueMicrotask(() => {
  99. clientMock.handler?.onEnvelope?.({
  100. method: "item/agentMessage/delta",
  101. params: { threadId: "thread-text", delta: "纯文本结果" },
  102. })
  103. clientMock.handler?.onEnvelope?.({
  104. method: "turn/completed",
  105. params: { threadId: "thread-text", turn: { status: "completed" } },
  106. })
  107. })
  108. return { turn: { id: "turn-text" } }
  109. }
  110. throw new Error(`unexpected method: ${method}`)
  111. })
  112. const callbacks = {
  113. onToken: vi.fn(),
  114. onDone: vi.fn(),
  115. onError: vi.fn(),
  116. }
  117. await streamCodexCli(config, [
  118. { role: "system", content: "只输出正文" },
  119. { role: "user", content: "生成大纲" },
  120. ], callbacks)
  121. expect(callbacks.onToken).toHaveBeenCalledWith("纯文本结果")
  122. expect(callbacks.onDone).toHaveBeenCalledOnce()
  123. expect(callbacks.onError).not.toHaveBeenCalled()
  124. const starts = clientMock.call.mock.calls.filter(([method]) =>
  125. method === "thread/start" || method === "turn/start"
  126. )
  127. expect(starts).toHaveLength(2)
  128. expect(starts[0][1]).not.toHaveProperty("serviceTier")
  129. expect(starts[1][1]).not.toHaveProperty("serviceTier")
  130. })
  131. it("passes Fast mode to both app-server starts as the priority service tier", async () => {
  132. clientMock.call.mockImplementation(async (method: string) => {
  133. if (method === "thread/start") {
  134. return { thread: { id: "thread-fast" }, instructionSources: [] }
  135. }
  136. if (method === "turn/start") {
  137. queueMicrotask(() => {
  138. clientMock.handler?.onEnvelope?.({
  139. method: "turn/completed",
  140. params: { threadId: "thread-fast", turn: { status: "completed" } },
  141. })
  142. })
  143. return { turn: { id: "turn-fast" } }
  144. }
  145. throw new Error(`unexpected method: ${method}`)
  146. })
  147. await streamCodexCli(
  148. { ...config, codexSpeedMode: "fast" },
  149. [{ role: "user", content: "测试快速档位" }],
  150. { onToken: vi.fn(), onDone: vi.fn(), onError: vi.fn() },
  151. )
  152. const threadStart = clientMock.call.mock.calls.find(([method]) => method === "thread/start")
  153. const turnStart = clientMock.call.mock.calls.find(([method]) => method === "turn/start")
  154. expect(threadStart?.[1]).toEqual(expect.objectContaining({ serviceTier: "priority" }))
  155. expect(turnStart?.[1]).toEqual(expect.objectContaining({ serviceTier: "priority" }))
  156. })
  157. it("fails closed when app-server reports inherited instruction sources", async () => {
  158. clientMock.call.mockResolvedValueOnce({
  159. thread: { id: "thread-bad" },
  160. instructionSources: ["/Users/test/.codex/AGENTS.md"],
  161. })
  162. const callbacks = {
  163. onToken: vi.fn(),
  164. onDone: vi.fn(),
  165. onError: vi.fn(),
  166. }
  167. await streamCodexCli(config, [{ role: "user", content: "测试" }], callbacks)
  168. expect(callbacks.onError).toHaveBeenCalledWith(expect.objectContaining({
  169. message: expect.stringContaining("禁止 Codex 加载本机或项目规则"),
  170. }))
  171. expect(callbacks.onDone).not.toHaveBeenCalled()
  172. })
  173. })