adapter.ts 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. // Codex 适配器:codex app-server(JSON-RPC)→ 统一事件(docs/design/03-providers.md §3,ADR-0003)
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import type { AppSettings, ProviderStatusDto, UsagePayload } from '@agent-server/shared'
  5. import { checkAccess } from '../../services/access-policy'
  6. import { ProviderRunError, type AgentProvider, type RunContext, type RunResult } from '../types'
  7. import { bundledCodexPath, codexHome } from './cli'
  8. import { buildThreadConfig } from './permissions'
  9. import type {
  10. AskForApproval, CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, GetAccountResponse,
  11. ItemCompletedNotification, ItemStartedNotification, LoginAccountResponse, ModelListResponse,
  12. PermissionsRequestApprovalParams, ThreadItem, ThreadStartResponse, ThreadTokenUsageUpdatedNotification,
  13. ToolRequestUserInputParams, TurnCompletedNotification, TurnStartResponse,
  14. } from './protocol/v2'
  15. import { AppServerClient, RpcRequestError } from './rpc'
  16. const MAX_TOOL_OUTPUT = 64 * 1024
  17. const CLIENT_INFO = { name: 'agent_server', title: 'AgentServer', version: '0.1.0' }
  18. interface Logger { info(o: object, m?: string): void; warn(o: object, m?: string): void; error(o: object, m?: string): void }
  19. export interface CodexLoginState {
  20. loginId: string
  21. verificationUrl: string
  22. userCode: string
  23. status: 'pending' | 'success' | 'failed' | 'cancelled'
  24. error: string | null
  25. }
  26. export class CodexProvider implements AgentProvider {
  27. readonly id = 'codex' as const
  28. private client: AppServerClient | null = null
  29. private starting: Promise<AppServerClient> | null = null
  30. private clientKey = ''
  31. private runs = new Map<string, ThreadRun>()
  32. private models: string[] = []
  33. private login: CodexLoginState | null = null
  34. private version: string | null = null
  35. constructor(private log: Logger) {}
  36. knownModels(): string[] {
  37. return this.models
  38. }
  39. private bin(settings: AppSettings) {
  40. return settings.providers.codex.cliPath
  41. }
  42. /** 启动(或复用)app-server;CLI 路径或配置覆盖变化时重启 */
  43. private async ensure(settings: AppSettings): Promise<AppServerClient> {
  44. const s = settings.providers.codex
  45. const key = JSON.stringify([s.cliPath, s.configOverrides])
  46. if (this.client?.alive && this.clientKey === key) return this.client
  47. if (this.starting && this.clientKey === key) return this.starting
  48. this.client?.stop()
  49. this.clientKey = key
  50. this.starting = (async () => {
  51. const home = codexHome()
  52. fs.mkdirSync(home, { recursive: true, mode: 0o700 })
  53. const env: NodeJS.ProcessEnv = { ...process.env, CODEX_HOME: home }
  54. delete env.OPENAI_API_KEY
  55. delete env.CODEX_API_KEY
  56. const client = new AppServerClient({ bin: s.cliPath, env, args: s.configOverrides.flatMap(o => ['-c', o]) })
  57. client.on('notification', (method, params) => this.onNotification(method, params))
  58. client.onServerRequest = (method, params) => this.onServerRequest(method, params)
  59. client.on('stderr', line => {
  60. if (!/WARN|INFO|DEBUG/.test(line)) this.log.warn({ stderr: line.trim().slice(0, 500) }, 'codex stderr')
  61. })
  62. client.on('exit', code => {
  63. this.log.warn({ code }, 'codex app-server 已退出')
  64. if (this.client === client) this.client = null
  65. for (const run of this.runs.values()) run.fail(new ProviderRunError('codex_exited', `codex app-server 意外退出(${code})`))
  66. })
  67. const init = await client.start(CLIENT_INFO) as { userAgent?: string }
  68. // userAgent 形如 "agent_server/0.164.0 (...)",斜杠后是 codex 版本
  69. this.version = /\/([\d.]+)/.exec(init.userAgent ?? '')?.[1] ?? null
  70. this.client = client
  71. return client
  72. })()
  73. try {
  74. return await this.starting
  75. } finally {
  76. this.starting = null
  77. }
  78. }
  79. async run(ctx: RunContext): Promise<RunResult> {
  80. const client = await this.ensure(ctx.settings)
  81. fs.mkdirSync(path.join(ctx.workspaceDir, '.agent', 'tmp'), { recursive: true })
  82. const config = buildThreadConfig({
  83. access: ctx.access, permissionMode: ctx.permissionMode, settings: ctx.settings,
  84. codexBin: this.bin(ctx.settings), codexHome: codexHome(),
  85. })
  86. // 需审批:沙箱内的命令自动执行,越过沙箱边界时请求审批;自动/只读:从不请求,越界直接失败
  87. const approvalPolicy: AskForApproval = ctx.permissionMode === 'ask' ? 'on-request' : 'never'
  88. const threadParams = {
  89. cwd: ctx.workspaceDir, model: ctx.model, approvalPolicy, config,
  90. developerInstructions: ctx.systemPromptAppend,
  91. }
  92. let threadId = ctx.providerSessionId
  93. if (threadId) {
  94. try {
  95. await client.request('thread/resume', { ...threadParams, threadId, excludeTurns: true })
  96. } catch (err) {
  97. ctx.log.warn({ err: (err as Error).message, threadId }, 'codex 会话恢复失败,以新会话继续')
  98. threadId = null
  99. }
  100. }
  101. if (!threadId) {
  102. const r = await client.request<ThreadStartResponse>('thread/start', { ...threadParams, serviceName: 'agent-server' })
  103. threadId = r.thread.id
  104. ctx.setProviderSessionId(threadId)
  105. }
  106. const run = new ThreadRun(ctx, threadId)
  107. this.runs.set(threadId, run)
  108. const onAbort = () => {
  109. if (run.turnId) void client.request('turn/interrupt', { threadId, turnId: run.turnId }).catch(() => {})
  110. run.fail(new ProviderRunError('interrupted', '已中断'))
  111. }
  112. ctx.signal.addEventListener('abort', onAbort, { once: true })
  113. try {
  114. const r = await client.request<TurnStartResponse>('turn/start', { threadId, model: ctx.model, input: buildInput(ctx) })
  115. run.turnId = r.turn.id
  116. return await run.done
  117. } finally {
  118. ctx.signal.removeEventListener('abort', onAbort)
  119. this.runs.delete(threadId)
  120. }
  121. }
  122. private onNotification(method: string, params: unknown) {
  123. if (method === 'account/login/completed') {
  124. const p = params as { loginId: string | null; success: boolean; error: string | null }
  125. if (this.login && (!p.loginId || p.loginId === this.login.loginId)) {
  126. Object.assign(this.login, { status: p.success ? 'success' : 'failed', error: p.error })
  127. }
  128. return
  129. }
  130. const threadId = (params as { threadId?: string } | null)?.threadId
  131. if (threadId) this.runs.get(threadId)?.onNotification(method, params)
  132. }
  133. private async onServerRequest(method: string, params: unknown): Promise<unknown> {
  134. const threadId = (params as { threadId?: string } | null)?.threadId
  135. const run = threadId ? this.runs.get(threadId) : undefined
  136. if (!run) throw new Error(`没有对应的运行:${method}`)
  137. return run.onServerRequest(method, params)
  138. }
  139. // ---- 账户与登录(设备码方式:在 OpenAI 官方页面输入短码完成登录,AgentServer 不接触凭证) ----
  140. async startLogin(settings: AppSettings): Promise<CodexLoginState> {
  141. const client = await this.ensure(settings)
  142. if (this.login?.status === 'pending') await this.cancelLogin(settings).catch(() => {})
  143. const r = await client.request<LoginAccountResponse>('account/login/start', { type: 'chatgptDeviceCode' })
  144. if (r.type !== 'chatgptDeviceCode') throw new Error('codex 没有返回设备码')
  145. this.login = { loginId: r.loginId, verificationUrl: r.verificationUrl, userCode: r.userCode, status: 'pending', error: null }
  146. return this.login
  147. }
  148. loginState(): CodexLoginState | null {
  149. return this.login
  150. }
  151. async cancelLogin(settings: AppSettings) {
  152. if (!this.login || this.login.status !== 'pending') return
  153. const client = await this.ensure(settings)
  154. await client.request('account/login/cancel', { loginId: this.login.loginId }).catch(() => {})
  155. this.login.status = 'cancelled'
  156. }
  157. async logout(settings: AppSettings) {
  158. const client = await this.ensure(settings)
  159. await client.request('account/logout', {})
  160. }
  161. async status(settings: AppSettings): Promise<ProviderStatusDto> {
  162. const s = settings.providers.codex
  163. const base: ProviderStatusDto = {
  164. id: 'codex', enabled: s.enabled, available: false, loggedIn: null, account: null,
  165. cliPath: s.cliPath, version: null, models: s.models, defaultModel: s.defaultModel, message: null,
  166. }
  167. try {
  168. const client = await this.ensure(settings)
  169. const acc = await client.request<GetAccountResponse>('account/read', {}, 30_000)
  170. const loggedIn = !!acc.account
  171. let models = s.models
  172. if (loggedIn) {
  173. const list = await client.request<ModelListResponse>('model/list', {}, 30_000).catch(() => null)
  174. if (list) {
  175. this.models = list.data.filter(m => !m.hidden).map(m => m.model)
  176. if (!models.length) models = this.models
  177. }
  178. }
  179. const account = acc.account?.type === 'chatgpt'
  180. ? [acc.account.email, `(${acc.account.planType})`].filter(Boolean).join(' ')
  181. : acc.account?.type ?? null
  182. return {
  183. ...base, version: this.version, loggedIn, account, models,
  184. defaultModel: s.defaultModel || models[0] || '',
  185. available: s.enabled && loggedIn,
  186. message: loggedIn ? (s.enabled ? null : '已登录,尚未在"设置 → 引擎"中启用') : '未登录:点击"登录"按钮,用 ChatGPT 账号完成设备码登录',
  187. }
  188. } catch (err) {
  189. return { ...base, message: `无法启动 codex app-server:${(err as Error).message}` }
  190. }
  191. }
  192. }
  193. export function defaultCodexPath() {
  194. return bundledCodexPath()
  195. }
  196. function buildInput(ctx: RunContext) {
  197. let text = ctx.input.text
  198. if (ctx.input.attachments.length) {
  199. const list = ctx.input.attachments.map(a => `- ${a.path}(${a.mime})`).join('\n')
  200. text += `\n\n<attachments>\n用户上传了以下文件,位于工作区中:\n${list}\n</attachments>`
  201. }
  202. const images = ctx.input.attachments
  203. .filter(a => ctx.input.images.some(i => i.name === a.name))
  204. .map(a => ({ type: 'localImage' as const, path: path.join(ctx.workspaceDir, a.path) }))
  205. return [{ type: 'text' as const, text, text_elements: [] }, ...images]
  206. }
  207. /** 一次 Run 对应的一个 turn:把通知映射为统一事件,处理审批与提问 */
  208. class ThreadRun {
  209. turnId: string | null = null
  210. readonly done: Promise<RunResult>
  211. private resolve!: (r: RunResult) => void
  212. private reject!: (e: Error) => void
  213. private settled = false
  214. private items = new Map<string, ThreadItem>()
  215. private denied = new Set<string>()
  216. private usageBase: UsagePayload | null = null
  217. private usageLatest: UsagePayload | null = null
  218. constructor(private ctx: RunContext, private threadId: string) {
  219. this.done = new Promise((res, rej) => {
  220. this.resolve = res
  221. this.reject = rej
  222. })
  223. }
  224. fail(err: Error) {
  225. if (this.settled) return
  226. this.settled = true
  227. this.reject(err)
  228. }
  229. onNotification(method: string, params: unknown) {
  230. const emit = this.ctx.emit
  231. switch (method) {
  232. case 'item/started': {
  233. const { item } = params as ItemStartedNotification
  234. this.items.set(item.id, item)
  235. const tool = describeTool(item, this.ctx.workspaceDir)
  236. if (tool) emit('tool.started', { toolCallId: item.id, ...tool })
  237. if (item.type === 'commandExecution') this.ctx.audit('command', item.command.slice(0, 500), { itemId: item.id })
  238. break
  239. }
  240. case 'item/agentMessage/delta': {
  241. const p = params as { itemId: string; delta: string }
  242. emit('message.delta', { messageId: p.itemId, text: p.delta })
  243. break
  244. }
  245. case 'item/reasoning/summaryTextDelta': {
  246. const p = params as { itemId: string; delta: string }
  247. emit('reasoning.delta', { blockId: p.itemId, text: p.delta })
  248. break
  249. }
  250. case 'item/completed': {
  251. const { item } = params as ItemCompletedNotification
  252. this.items.set(item.id, item)
  253. this.onItemCompleted(item)
  254. break
  255. }
  256. case 'thread/tokenUsage/updated': {
  257. const { tokenUsage } = params as ThreadTokenUsageUpdatedNotification
  258. const toUsage = (b: typeof tokenUsage.total): UsagePayload => ({
  259. inputTokens: b.inputTokens - b.cachedInputTokens, outputTokens: b.outputTokens,
  260. cacheReadTokens: b.cachedInputTokens, cacheWriteTokens: b.cacheWriteInputTokens,
  261. })
  262. // 本次 Run 的用量 = 最新累计 −(第一次通知时的累计 − 那一次的增量)
  263. if (!this.usageBase) {
  264. const t = toUsage(tokenUsage.total), l = toUsage(tokenUsage.last)
  265. this.usageBase = {
  266. inputTokens: t.inputTokens - l.inputTokens, outputTokens: t.outputTokens - l.outputTokens,
  267. cacheReadTokens: t.cacheReadTokens - l.cacheReadTokens, cacheWriteTokens: t.cacheWriteTokens - l.cacheWriteTokens,
  268. }
  269. }
  270. this.usageLatest = toUsage(tokenUsage.total)
  271. break
  272. }
  273. case 'turn/completed': {
  274. const { turn } = params as TurnCompletedNotification
  275. if (this.settled) return
  276. if (turn.status === 'completed') {
  277. this.settled = true
  278. this.resolve({ usage: this.runUsage() })
  279. } else if (turn.status === 'interrupted') {
  280. this.fail(new ProviderRunError('interrupted', '已中断'))
  281. } else {
  282. this.fail(new ProviderRunError('codex_failed', turn.error?.message ?? '运行失败'))
  283. }
  284. break
  285. }
  286. case 'error': {
  287. const p = params as { error: { message: string }; willRetry: boolean }
  288. this.ctx.log.warn({ error: p.error.message, willRetry: p.willRetry }, 'codex error')
  289. break
  290. }
  291. }
  292. }
  293. private runUsage(): UsagePayload | null {
  294. if (!this.usageLatest || !this.usageBase) return null
  295. const b = this.usageBase, l = this.usageLatest
  296. return {
  297. inputTokens: l.inputTokens - b.inputTokens, outputTokens: l.outputTokens - b.outputTokens,
  298. cacheReadTokens: l.cacheReadTokens - b.cacheReadTokens, cacheWriteTokens: l.cacheWriteTokens - b.cacheWriteTokens,
  299. }
  300. }
  301. private onItemCompleted(item: ThreadItem) {
  302. const emit = this.ctx.emit
  303. const done = (status: 'ok' | 'error' | 'denied', output: string) => emit('tool.completed', {
  304. toolCallId: item.id, status: this.denied.has(item.id) ? 'denied' : status,
  305. output: output.slice(0, MAX_TOOL_OUTPUT), truncated: output.length > MAX_TOOL_OUTPUT,
  306. })
  307. switch (item.type) {
  308. case 'agentMessage':
  309. if (item.text) emit('message.completed', { messageId: item.id, text: item.text })
  310. break
  311. case 'reasoning': {
  312. const text = item.summary.join('\n').trim()
  313. if (text) emit('reasoning.completed', { blockId: item.id, text })
  314. break
  315. }
  316. case 'commandExecution':
  317. done(item.status === 'declined' ? 'denied' : item.status === 'completed' && (item.exitCode ?? 0) === 0 ? 'ok' : 'error',
  318. `${item.aggregatedOutput ?? ''}${item.exitCode ? `\n(exit ${item.exitCode})` : ''}`.trim())
  319. break
  320. case 'fileChange':
  321. done(item.status === 'declined' ? 'denied' : item.status === 'completed' ? 'ok' : 'error',
  322. item.changes.map(c => `${c.kind} ${c.path}`).join('\n'))
  323. break
  324. case 'mcpToolCall':
  325. done(item.status === 'completed' ? 'ok' : 'error',
  326. item.error ? JSON.stringify(item.error) : (item.result?.content ?? []).map(c => (c as { text?: string }).text ?? '').join('\n'))
  327. break
  328. case 'webSearch':
  329. case 'imageView':
  330. done('ok', '')
  331. break
  332. }
  333. }
  334. async onServerRequest(method: string, params: unknown): Promise<unknown> {
  335. const ctx = this.ctx
  336. switch (method) {
  337. case 'item/commandExecution/requestApproval': {
  338. const p = params as CommandExecutionRequestApprovalParams
  339. // 网络访问不放开:Codex 的命令一旦联网就是无限制的(可访问内网)
  340. if (p.networkApprovalContext) {
  341. this.denied.add(p.itemId)
  342. ctx.audit('network_denied', `拒绝命令联网:${p.command ?? ''}`.slice(0, 500), { networkApprovalContext: p.networkApprovalContext })
  343. return { decision: 'decline' }
  344. }
  345. if (ctx.permissionMode === 'readonly') return { decision: 'decline' }
  346. const command = p.command ?? (this.items.get(p.itemId) as { command?: string } | undefined)?.command ?? ''
  347. const outcome = await ctx.interact({
  348. kind: 'approval', tool: 'Bash', title: commandTitle(command) || '执行命令',
  349. input: { command, cwd: p.cwd }, detail: { command, reason: p.reason ?? undefined },
  350. options: ['allow_once', 'allow_session', 'deny'],
  351. })
  352. ctx.audit('approval', `codex 命令:${outcome.resolution}`, { command })
  353. if (outcome.resolution === 'allowed') return { decision: outcome.scope === 'session' ? 'acceptForSession' : 'accept' }
  354. this.denied.add(p.itemId)
  355. return { decision: outcome.resolution === 'denied' ? 'decline' : 'cancel' }
  356. }
  357. case 'item/fileChange/requestApproval': {
  358. const p = params as FileChangeRequestApprovalParams
  359. const item = this.items.get(p.itemId)
  360. const paths = item?.type === 'fileChange' ? item.changes.map(c => path.resolve(ctx.workspaceDir, c.path)) : []
  361. if (p.grantRoot) paths.push(path.resolve(ctx.workspaceDir, p.grantRoot))
  362. const blocked = paths.map(f => ({ f, d: checkAccess(ctx.access, f, 'write') })).find(x => !x.d.allowed)
  363. if (ctx.permissionMode === 'readonly' || blocked || !paths.length) {
  364. this.denied.add(p.itemId)
  365. if (blocked && !blocked.d.allowed) ctx.audit('file_access_denied', blocked.d.reason, { tool: 'codex fileChange', paths })
  366. return { decision: 'decline' }
  367. }
  368. const outcome = await ctx.interact({
  369. kind: 'approval', tool: 'Edit', title: paths.map(f => path.relative(ctx.workspaceDir, f)).join('、'),
  370. input: { paths }, detail: { path: paths[0], reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'],
  371. })
  372. if (outcome.resolution === 'allowed') return { decision: outcome.scope === 'session' ? 'acceptForSession' : 'accept' }
  373. this.denied.add(p.itemId)
  374. return { decision: outcome.resolution === 'denied' ? 'decline' : 'cancel' }
  375. }
  376. case 'item/permissions/requestApproval': {
  377. // 申请额外权限:联网一律拒绝;文件路径必须在 Profile 的访问策略之内,然后由人审批
  378. const p = params as PermissionsRequestApprovalParams
  379. const reqFs = p.permissions.fileSystem
  380. const reads = (reqFs?.read ?? []).map(f => path.resolve(p.cwd, f))
  381. const writes = (reqFs?.write ?? []).map(f => path.resolve(p.cwd, f))
  382. const outside = [...reads.map(f => checkAccess(ctx.access, f, 'read')), ...writes.map(f => checkAccess(ctx.access, f, 'write'))].find(d => !d.allowed)
  383. const wantsNetwork = !!p.permissions.network?.enabled
  384. if (wantsNetwork) ctx.audit('network_denied', '拒绝 codex 申请联网权限', { reason: p.reason })
  385. if (outside && !outside.allowed) ctx.audit('file_access_denied', outside.reason, { reads, writes })
  386. if (wantsNetwork || outside || (!reads.length && !writes.length) || ctx.permissionMode !== 'ask') {
  387. return { permissions: {}, scope: 'turn' }
  388. }
  389. const outcome = await ctx.interact({
  390. kind: 'approval', tool: 'Permissions', title: `申请访问:${[...reads, ...writes].join('、')}`.slice(0, 300),
  391. input: p.permissions, detail: { reason: p.reason ?? undefined }, options: ['allow_once', 'allow_session', 'deny'],
  392. })
  393. if (outcome.resolution !== 'allowed') return { permissions: {}, scope: 'turn' }
  394. return { permissions: { fileSystem: { read: reads, write: writes } }, scope: outcome.scope === 'session' ? 'session' : 'turn' }
  395. }
  396. case 'item/tool/requestUserInput': {
  397. const p = params as ToolRequestUserInputParams
  398. const outcome = await ctx.interact({
  399. kind: 'question',
  400. questions: p.questions.map(q => ({
  401. id: q.id, question: q.question, header: q.header, multiSelect: false,
  402. options: (q.options ?? []).map(o => ({ label: o.label, description: o.description })),
  403. })),
  404. })
  405. const answers: Record<string, { answers: string[] }> = {}
  406. if (outcome.resolution === 'answered') {
  407. for (const q of p.questions) {
  408. const a = outcome.answers[q.id]
  409. if (a !== undefined) answers[q.id] = { answers: Array.isArray(a) ? a : [a] }
  410. }
  411. }
  412. return { answers }
  413. }
  414. case 'mcpServer/elicitation/request':
  415. return { action: 'decline', content: null, _meta: null }
  416. default:
  417. throw new RpcRequestError(method, { code: -32601, message: `AgentServer 不支持:${method}` })
  418. }
  419. }
  420. }
  421. /** Codex 把命令包成 `/usr/bin/bash -lc "..."`,显示时去掉外层,只保留第一行 */
  422. export function commandTitle(command: string): string {
  423. let c = command.trim()
  424. const m = /^(?:\/usr)?(?:\/bin\/)?(?:ba|z)?sh\s+-l?c\s+([\s\S]*)$/.exec(c)
  425. if (m) {
  426. c = m[1]!.trim()
  427. if ((c.startsWith('"') && c.endsWith('"')) || (c.startsWith("'") && c.endsWith("'"))) c = c.slice(1, -1)
  428. }
  429. return c.split('\n')[0]!.slice(0, 200)
  430. }
  431. function describeTool(item: ThreadItem, cwd: string): { name: string; title: string; input: unknown } | null {
  432. const rel = (p: string) => {
  433. const r = path.relative(cwd, path.resolve(cwd, p))
  434. return r.startsWith('..') ? p : r
  435. }
  436. switch (item.type) {
  437. case 'commandExecution':
  438. return { name: 'Bash', title: commandTitle(item.command), input: { command: item.command, cwd: item.cwd } }
  439. case 'fileChange':
  440. return { name: 'Edit', title: item.changes.map(c => rel(c.path)).join('、').slice(0, 200), input: { changes: item.changes.map(c => ({ path: c.path, kind: c.kind, diff: c.diff })) } }
  441. case 'mcpToolCall':
  442. return { name: `mcp__${item.server}__${item.tool}`, title: '', input: item.arguments }
  443. case 'webSearch':
  444. return { name: 'WebSearch', title: (item as { query?: string }).query ?? '', input: { query: (item as { query?: string }).query } }
  445. case 'imageView':
  446. return { name: 'Read', title: rel(item.path), input: { path: item.path } }
  447. default:
  448. return null
  449. }
  450. }