codex-cli-transport.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. /**
  2. * Codex CLI subprocess transport.
  3. *
  4. * Rust-side counterpart: src-tauri/src/commands/codex_cli.rs. The Rust
  5. * command spawns `codex exec --json`, sends a single reconstructed prompt
  6. * over stdin, and emits each JSONL stdout line back as `codex-cli:{streamId}`.
  7. */
  8. import { invoke } from "@tauri-apps/api/core"
  9. import { listen, type UnlistenFn } from "@tauri-apps/api/event"
  10. import type { LlmConfig } from "@/stores/wiki-store"
  11. import type { ChatMessage, ContentBlock, RequestOverrides } from "./llm-providers"
  12. import type { StreamCallbacks } from "./llm-client"
  13. export function parseCodexCliLine(rawLine: string): string | null {
  14. const line = rawLine.trim()
  15. if (!line) return null
  16. let evt: unknown
  17. try {
  18. evt = JSON.parse(line)
  19. } catch {
  20. return null
  21. }
  22. if (!evt || typeof evt !== "object") return null
  23. const obj = evt as Record<string, unknown>
  24. if (obj.type !== "item.completed") return null
  25. const item = obj.item as Record<string, unknown> | undefined
  26. if (item?.type !== "agent_message") return null
  27. return typeof item.text === "string" && item.text.length > 0 ? item.text : null
  28. }
  29. export function extractCodexCliError(rawOutput: string): string {
  30. let lastError = ""
  31. for (const line of rawOutput.split(/\r?\n/)) {
  32. const trimmed = line.trim()
  33. if (!trimmed) continue
  34. try {
  35. const parsed = JSON.parse(trimmed) as {
  36. type?: string
  37. message?: unknown
  38. error?: { message?: unknown }
  39. }
  40. const message = typeof parsed.error?.message === "string"
  41. ? parsed.error.message
  42. : typeof parsed.message === "string"
  43. ? parsed.message
  44. : ""
  45. if (parsed.type === "turn.failed" && message) return message
  46. if (parsed.type === "error" && message && !/^Reconnecting\.\.\./i.test(message)) {
  47. lastError = message
  48. }
  49. } catch {
  50. // Keep the original output as fallback below.
  51. }
  52. }
  53. return lastError || rawOutput.trim()
  54. }
  55. function contentToText(content: string | ContentBlock[]): string {
  56. if (typeof content === "string") return content
  57. return content
  58. .map((block) => {
  59. if (block.type === "text") return block.text
  60. return `[Image omitted: ${block.mediaType}]`
  61. })
  62. .join("\n")
  63. }
  64. function escapePromptContent(text: string): string {
  65. return text.replace(/<\/?[A-Z_][A-Z0-9_]*>/gi, (tag) =>
  66. tag.replace(/</g, "&lt;").replace(/>/g, "&gt;"),
  67. )
  68. }
  69. export function buildPrompt(messages: ChatMessage[]): string {
  70. return messages
  71. .map((message) => {
  72. const role = message.role.toUpperCase()
  73. return `<${role}>\n${escapePromptContent(contentToText(message.content))}\n</${role}>`
  74. })
  75. .join("\n\n")
  76. }
  77. type SpawnPayload = Record<string, unknown> & {
  78. streamId: string
  79. model: string
  80. prompt: string
  81. isolateLocalConfig: boolean
  82. timeoutMinutes?: number
  83. }
  84. export async function streamCodexCli(
  85. config: LlmConfig,
  86. messages: ChatMessage[],
  87. callbacks: StreamCallbacks,
  88. signal?: AbortSignal,
  89. overrides?: RequestOverrides,
  90. ): Promise<void> {
  91. const { onToken, onDone, onError } = callbacks
  92. if (import.meta.env?.DEV && overrides) {
  93. for (const key of ["temperature", "top_p", "top_k", "max_tokens", "stop"] as const) {
  94. if (overrides[key] !== undefined) {
  95. // eslint-disable-next-line no-console
  96. console.warn(`[codex-cli] ignoring unsupported override "${key}": CLI has no equivalent flag`)
  97. }
  98. }
  99. }
  100. const streamId = crypto.randomUUID()
  101. let unlistenData: UnlistenFn | (() => void) | undefined
  102. let unlistenDone: UnlistenFn | (() => void) | undefined
  103. let finished = false
  104. let aborted = signal?.aborted ?? false
  105. let emittedAgentMessage = false
  106. let resolveCompletion: () => void = () => {}
  107. const completion = new Promise<void>((resolve) => {
  108. resolveCompletion = resolve
  109. })
  110. const unparsedLines: string[] = []
  111. let unparsedSize = 0
  112. function captureUnparsed(line: string) {
  113. if (unparsedSize >= 4096) return
  114. const trimmed = line.trim()
  115. if (!trimmed) return
  116. unparsedLines.push(line)
  117. unparsedSize += line.length + 1
  118. }
  119. const cleanup = () => {
  120. unlistenData?.()
  121. unlistenDone?.()
  122. }
  123. const finishWith = (cb: () => void) => {
  124. if (finished) return
  125. finished = true
  126. cleanup()
  127. cb()
  128. resolveCompletion()
  129. }
  130. const replayAgentMessagesFromStdout = (stdout: string | undefined) => {
  131. if (!stdout) return
  132. for (const line of stdout.split(/\r?\n/)) {
  133. const token = parseCodexCliLine(line)
  134. if (token !== null) {
  135. emittedAgentMessage = true
  136. onToken(token)
  137. }
  138. }
  139. }
  140. const abortListener = () => {
  141. aborted = true
  142. void invoke("codex_cli_kill", { streamId }).catch(() => {})
  143. finishWith(onDone)
  144. }
  145. if (aborted) {
  146. finishWith(onDone)
  147. return
  148. }
  149. signal?.addEventListener("abort", abortListener)
  150. try {
  151. // ── Tauri mode: use Tauri listen + invoke ──
  152. unlistenData = await listen<string>(`codex-cli:${streamId}`, (event) => {
  153. const token = parseCodexCliLine(event.payload)
  154. if (token !== null) {
  155. emittedAgentMessage = true
  156. onToken(token)
  157. } else {
  158. captureUnparsed(event.payload)
  159. }
  160. })
  161. if (aborted || finished) {
  162. cleanup()
  163. return
  164. }
  165. unlistenDone = await listen<{ code: number | null; stderr: string; stdout?: string }>(
  166. `codex-cli:${streamId}:done`,
  167. (event) => {
  168. const code = event.payload?.code
  169. const stderr = event.payload?.stderr?.trim() ?? ""
  170. const stdout = event.payload?.stdout ?? ""
  171. if (code !== null && code !== undefined && code !== 0) {
  172. const details = stderr || extractCodexCliError(stdout) || extractCodexCliError(unparsedLines.join("\n"))
  173. finishWith(() =>
  174. onError(new Error(
  175. details
  176. ? `Codex CLI exited with code ${code}:\n${details}`
  177. : `Codex CLI exited with code ${code}. Run \`codex\` in a terminal to inspect the problem.`,
  178. )),
  179. )
  180. } else {
  181. if (!emittedAgentMessage) replayAgentMessagesFromStdout(stdout)
  182. if (!emittedAgentMessage) {
  183. const details = stdout.trim() || unparsedLines.join("\n").trim()
  184. finishWith(() =>
  185. onError(new Error(
  186. details
  187. ? `Codex CLI completed but did not emit an agent_message. Raw output:\n${details}`
  188. : "Codex CLI completed but did not emit an agent_message. Run `codex exec --json` in a terminal to inspect the provider output.",
  189. )),
  190. )
  191. } else {
  192. finishWith(onDone)
  193. }
  194. }
  195. },
  196. )
  197. if (aborted || finished) {
  198. cleanup()
  199. return
  200. }
  201. const payload: SpawnPayload = {
  202. streamId,
  203. model: config.model,
  204. prompt: buildPrompt(messages),
  205. isolateLocalConfig: config.localCliIsolation === true,
  206. timeoutMinutes: config.codexCliTimeoutMinutes,
  207. }
  208. await invoke("codex_cli_spawn", payload)
  209. if (aborted || signal?.aborted) {
  210. aborted = true
  211. await invoke("codex_cli_kill", { streamId }).catch(() => {})
  212. finishWith(onDone)
  213. return
  214. }
  215. await completion
  216. } catch (err) {
  217. finishWith(() => {
  218. const message = err instanceof Error ? err.message : String(err)
  219. if (/not found|No such file|executable file not found/i.test(message)) {
  220. onError(new Error(
  221. "Codex CLI not found. Install `codex` with `npm install -g @openai/codex` or pick a different provider.",
  222. ))
  223. } else {
  224. onError(err instanceof Error ? err : new Error(message))
  225. }
  226. })
  227. } finally {
  228. signal?.removeEventListener("abort", abortListener)
  229. }
  230. }