fixtures-fake-codex.mjs 7.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. #!/usr/bin/env node
  2. // 模拟 codex app-server 的 JSON-RPC(每行一个 JSON),用于测试 CodexProvider
  3. import readline from 'node:readline'
  4. const send = m => process.stdout.write(JSON.stringify(m) + '\n')
  5. const pending = new Map()
  6. let sid = 1000
  7. const ask = (method, params) => new Promise(r => { const id = ++sid; pending.set(id, r); send({ id, method, params }) })
  8. const notify = (method, params) => send({ method, params })
  9. const threads = new Set()
  10. let loggedIn = false
  11. readline.createInterface({ input: process.stdin }).on('line', async line => {
  12. const msg = JSON.parse(line)
  13. if (msg.id !== undefined && !msg.method) { pending.get(msg.id)?.(msg.result); pending.delete(msg.id); return }
  14. const { id, method, params } = msg
  15. const reply = result => send({ id, result })
  16. switch (method) {
  17. case 'initialize': return reply({ userAgent: 'agent_server/9.9.9 (test)', codexHome: process.env.CODEX_HOME })
  18. case 'initialized': return
  19. case 'account/read': return reply({ account: loggedIn ? { type: 'chatgpt', email: 'me@example.com', planType: 'plus' } : null, requiresOpenaiAuth: true })
  20. case 'model/list': return reply({ data: [{ model: 'gpt-test', hidden: false }, { model: 'gpt-hidden', hidden: true }], nextCursor: null })
  21. case 'account/login/start':
  22. setTimeout(() => { loggedIn = true; notify('account/login/completed', { loginId: 'L1', success: true, error: null }) }, 100)
  23. return reply({ type: 'chatgptDeviceCode', loginId: 'L1', verificationUrl: 'https://auth.example.com/device', userCode: 'ABCD-1234' })
  24. case 'thread/start': {
  25. const tid = 't-' + (threads.size + 1); threads.add(tid)
  26. globalThis.lastConfig = params.config
  27. return reply({ thread: { id: tid }, model: params.model })
  28. }
  29. case 'thread/resume':
  30. if (!threads.has(params.threadId)) return send({ id, error: { code: -32000, message: 'thread not found' } })
  31. return reply({ thread: { id: params.threadId } })
  32. case 'thread/fork': {
  33. if (!threads.has(params.threadId)) return send({ id, error: { code: -32000, message: 'thread not found' } })
  34. const tid = 'f-' + (threads.size + 1); threads.add(tid)
  35. globalThis.lastConfig = params.config
  36. globalThis.forks = [...(globalThis.forks ?? []), params.threadId]
  37. return reply({ thread: { id: tid }, model: params.model })
  38. }
  39. case 'turn/interrupt': return reply({})
  40. case 'skills/extraRoots/set': globalThis.extraRoots = params.extraRoots; return reply({})
  41. case 'skills/list': return reply({ data: [{ cwd: params.cwds[0], errors: [], skills: [
  42. { name: 'imagegen', description: '', scope: 'system', path: '/sys/imagegen/SKILL.md', enabled: true, pluginId: null },
  43. { name: 'skill-installer', description: '', scope: 'system', path: '/sys/skill-installer/SKILL.md', enabled: true, pluginId: null },
  44. { name: 'google-drive:google-docs', description: '', scope: 'user', path: '/plugins/google-drive/skills/google-docs/SKILL.md', enabled: true, pluginId: 'google-drive@openai-curated-remote' },
  45. ] }] })
  46. case 'plugin/list': case 'plugin/installed': {
  47. const p = (name, installed, extra = {}) => ({ id: `${name}@openai-curated-remote`, name, installed, enabled: installed, installPolicy: 'AVAILABLE', availability: 'AVAILABLE', source: { type: 'remote' }, interface: { displayName: name.toUpperCase(), shortDescription: `${name} desc`, category: 'Productivity', developerName: 'OpenAI' }, ...extra })
  48. const plugins = method === 'plugin/installed' ? [p('gmail', globalThis.gmail ?? true)] : [p('gmail', globalThis.gmail ?? true), p('notion', false, { interface: { displayName: 'Notion', shortDescription: 'docs', category: 'Docs' } }), p('blocked', false, { availability: 'DISABLED_BY_ADMIN' })]
  49. return reply({ marketplaces: [{ name: 'openai-curated-remote', path: null, interface: null, plugins }, { name: 'other', path: '/x', interface: null, plugins: [p('other-plugin', false)] }], marketplaceLoadErrors: [], featuredPluginIds: [] })
  50. }
  51. case 'plugin/install': globalThis.installed = params; return reply({ authPolicy: 'ON_USE', appsNeedingAuth: [{ id: 'connector_n', name: 'Notion', description: null, installUrl: 'https://chatgpt.com/apps/notion', category: null }] })
  52. case 'plugin/uninstall': globalThis.gmail = false; return reply({})
  53. case 'turn/start': {
  54. const threadId = params.threadId, turnId = 'turn-1'
  55. reply({ turn: { id: turnId, status: 'inProgress' } })
  56. const text = params.input[0].text
  57. // 回显收到的权限配置,便于测试断言
  58. notify('item/completed', { threadId, turnId, item: { type: 'agentMessage', id: 'cfg', text: JSON.stringify(globalThis.lastConfig) } })
  59. if (text.includes('命令')) {
  60. notify('item/started', { threadId, turnId, item: { type: 'commandExecution', id: 'c1', command: 'ls -la', cwd: '/w', status: 'inProgress', aggregatedOutput: null, exitCode: null } })
  61. const r = await ask('item/commandExecution/requestApproval', { threadId, turnId, itemId: 'c1', command: 'ls -la', cwd: '/w', reason: '需要越过沙箱' })
  62. const ok = r.decision === 'accept' || r.decision === 'acceptForSession'
  63. notify('item/completed', { threadId, turnId, item: { type: 'commandExecution', id: 'c1', command: 'ls -la', cwd: '/w', status: ok ? 'completed' : 'declined', aggregatedOutput: ok ? 'a.csv' : '', exitCode: ok ? 0 : null } })
  64. }
  65. if (text.includes('联网')) {
  66. const r = await ask('item/commandExecution/requestApproval', { threadId, turnId, itemId: 'c2', command: 'curl x', networkApprovalContext: { host: 'x' } })
  67. notify('item/completed', { threadId, turnId, item: { type: 'agentMessage', id: 'net', text: 'network:' + r.decision } })
  68. }
  69. if (text.includes('提问')) {
  70. const r = await ask('item/tool/requestUserInput', { threadId, turnId, itemId: 'q1', isBlocking: true, autoResolutionMs: null, questions: [{ id: 'chart', header: '图表', question: '用哪种图?', isOther: true, isSecret: false, options: [{ label: '柱状图', description: '' }, { label: '折线图', description: '' }] }] })
  71. notify('item/completed', { threadId, turnId, item: { type: 'agentMessage', id: 'ans', text: 'answer:' + JSON.stringify(r.answers) } })
  72. }
  73. if (text.includes('应用')) {
  74. const r = await ask('mcpServer/elicitation/request', { threadId, turnId, serverName: 'codex_apps', mode: 'form', _meta: null, message: '允许调用 Gmail 的 search_emails?', requestedSchema: { type: 'object', properties: {} } })
  75. notify('item/completed', { threadId, turnId, item: { type: 'agentMessage', id: 'app', text: 'app:' + r.action } })
  76. }
  77. if (text.includes('等待')) return // 不结束,等中断
  78. notify('item/agentMessage/delta', { threadId, turnId, itemId: 'm1', delta: '完' })
  79. notify('item/agentMessage/delta', { threadId, turnId, itemId: 'm1', delta: '成' })
  80. notify('item/completed', { threadId, turnId, item: { type: 'agentMessage', id: 'm1', text: '完成' } })
  81. const b = (i, o) => ({ totalTokens: i + o, inputTokens: i, cachedInputTokens: 0, cacheWriteInputTokens: 0, outputTokens: o, reasoningOutputTokens: 0 })
  82. notify('thread/tokenUsage/updated', { threadId, turnId, tokenUsage: { total: b(150, 30), last: b(50, 10), modelContextWindow: null } })
  83. notify('thread/tokenUsage/updated', { threadId, turnId, tokenUsage: { total: b(220, 45), last: b(70, 15), modelContextWindow: null } })
  84. notify('turn/completed', { threadId, turn: { id: turnId, status: 'completed', error: null } })
  85. return
  86. }
  87. default: return send({ id, error: { code: -32601, message: 'unknown ' + method } })
  88. }
  89. })