codexIdle.itest.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. import { createServer, type Server } from 'node:http';
  2. import { mkdir, mkdtemp, rm } from 'node:fs/promises';
  3. import { tmpdir } from 'node:os';
  4. import { join } from 'node:path';
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  6. import { CodexRuntime, type CodexProviderSpec } from './codexRuntime';
  7. /**
  8. * 集成冒烟(不进 npm test,跑法:npm run smoke:codex):
  9. * 真 codex.exe + 只说 Responses 的本地 mock,钉住"Codex 在长时间静默后会把回合判死"这条机制。
  10. *
  11. * 病根(源码 rust-v0.155.1):codex-api/src/sse/responses.rs:580-606 在流建立之后,每次轮询都
  12. * `timeout(stream_idle_timeout_ms, stream.next())`,到期给出可重试的 CodexErr::Stream
  13. * (protocol/src/error.rs:372-413),默认窗口 300 秒(model-provider-info/src/lib.rs:29)。
  14. * 端点若在响应头之后长时间静默(自部署单槽推理的 prefill 就是这样),回合就会被判死并重发整轮。
  15. * 客户端不做协议翻译,所以这条窗口只能靠 provider 参数放宽来治 —— 见 providerService.PROVIDER_DEFAULTS。
  16. *
  17. * 这里把静默与空闲窗口都压缩到几十秒内,让同一条机制能在 CI 里被观测,而不是靠推理。
  18. */
  19. const SILENCE_MS = 30_000;
  20. let server: Server | null = null;
  21. let baseUrl = '';
  22. let root = '';
  23. /** 上游被真正问了几次:一次回合只该付一次 prefill,重试就是放大器 */
  24. let posts = 0;
  25. let homes = 0;
  26. let headers: 'late' | 'eager' = 'late';
  27. /**
  28. * 静默 SILENCE_MS 后正常吐完一轮 Responses SSE。两种形态的差别是本测试的全部意义:
  29. * - late:连响应头都不发(流还没建立);
  30. * - eager:先发响应头 + response.created,再静默(流已建立,长 prefill 的真实形态)。
  31. * Codex 的 `timeout(stream_idle_timeout_ms, stream.next())` 只在流已建立后武装,
  32. * 所以只有 eager 形态会在 prefill 静默中被判死 —— 见 rust-v0.155.1
  33. * codex-api/src/sse/responses.rs:580-606。
  34. */
  35. async function listen(): Promise<void> {
  36. server = createServer((req, res) => {
  37. let body = '';
  38. req.on('data', (chunk) => (body += chunk));
  39. req.on('end', () => {
  40. const route = req.url?.split('?')[0] ?? '';
  41. if (route !== '/v1/responses' || req.method !== 'POST') {
  42. res.writeHead(404, { 'content-type': 'application/json' });
  43. res.end(JSON.stringify({ error: { message: 'no route' } }));
  44. return;
  45. }
  46. posts += 1;
  47. const model = (() => {
  48. try {
  49. return String(JSON.parse(body).model ?? '');
  50. } catch {
  51. return '';
  52. }
  53. })();
  54. const send = (type: string, extra: Record<string, unknown>) =>
  55. res.write(`event: ${type}\ndata: ${JSON.stringify({ type, ...extra })}\n\n`);
  56. const created = () =>
  57. send('response.created', {
  58. response: { id: 'resp_1', object: 'response', model, status: 'in_progress', output: [] },
  59. });
  60. const finish = () => {
  61. send('response.output_item.added', {
  62. output_index: 0,
  63. item: { type: 'message', id: 'msg_1', role: 'assistant', status: 'in_progress', content: [] },
  64. });
  65. send('response.output_text.delta', { item_id: 'msg_1', output_index: 0, content_index: 0, delta: '久等了' });
  66. send('response.output_text.done', { item_id: 'msg_1', output_index: 0, content_index: 0, text: '久等了' });
  67. send('response.output_item.done', {
  68. output_index: 0,
  69. item: {
  70. type: 'message',
  71. id: 'msg_1',
  72. role: 'assistant',
  73. status: 'completed',
  74. content: [{ type: 'output_text', text: '久等了' }],
  75. },
  76. });
  77. send('response.completed', {
  78. response: {
  79. id: 'resp_1',
  80. object: 'response',
  81. model,
  82. status: 'completed',
  83. output: [
  84. {
  85. type: 'message',
  86. id: 'msg_1',
  87. role: 'assistant',
  88. status: 'completed',
  89. content: [{ type: 'output_text', text: '久等了' }],
  90. },
  91. ],
  92. usage: { input_tokens: 1000, output_tokens: 3, total_tokens: 1003 },
  93. },
  94. });
  95. res.end();
  96. };
  97. const head = () => res.writeHead(200, { 'content-type': 'text/event-stream' });
  98. if (headers === 'eager') {
  99. head();
  100. created();
  101. setTimeout(finish, SILENCE_MS);
  102. return;
  103. }
  104. setTimeout(() => {
  105. head();
  106. created();
  107. finish();
  108. }, SILENCE_MS);
  109. });
  110. });
  111. await new Promise<void>((resolve) => server?.listen(0, '127.0.0.1', resolve));
  112. const address = server.address();
  113. const port = typeof address === 'object' && address ? address.port : 0;
  114. baseUrl = `http://127.0.0.1:${port}/v1`;
  115. }
  116. function spec(extra: Record<string, number>): CodexProviderSpec {
  117. return {
  118. id: 'idletest',
  119. name: '静默端点',
  120. baseUrl,
  121. model: 'mock-responses',
  122. apiKey: 'sk-idletest-secret-0123456789',
  123. extra,
  124. };
  125. }
  126. async function runOneTurn(mode: 'late' | 'eager', extra: Record<string, number>) {
  127. headers = mode;
  128. const home = join(root, `home-${++homes}`);
  129. await mkdir(home, { recursive: true });
  130. const runtime = new CodexRuntime({ codexHome: home, provider: spec(extra) });
  131. const events: Array<{ method: string; params?: unknown }> = [];
  132. runtime.on('notification', (n: { method: string; params?: unknown }) => events.push(n));
  133. try {
  134. await runtime.start();
  135. const threadId = await runtime.startThread({ cwd: root, approvalPolicy: 'never', sandbox: 'read-only' });
  136. const postsBefore = posts;
  137. const result = await runtime.runTurn({ threadId, prompt: '说句话', approvalPolicy: 'never' });
  138. return { result, postsInTurn: posts - postsBefore, events };
  139. } finally {
  140. await runtime.dispose();
  141. }
  142. }
  143. beforeAll(async () => {
  144. root = await mkdtemp(join(tmpdir(), 'zsjz-idle-'));
  145. await listen();
  146. }, 120_000);
  147. afterAll(async () => {
  148. await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
  149. await rm(root, { recursive: true, force: true }).catch(() => undefined);
  150. });
  151. /**
  152. * 把错误文案取回来:失败的回合由 turn/completed{status:'failed',error} 收尾,
  153. * 但 'error' 通知先到,而 runTurn 要等带 turnId 的 turn/completed —— 按协议形状从通知里取。
  154. */
  155. function firstErrorOf(events: Array<{ method: string; params?: unknown }>): string {
  156. for (const n of events) {
  157. if (n.method !== 'error') continue;
  158. const params = (n.params ?? {}) as { message?: string; error?: { message?: string } };
  159. const message = params.message ?? params.error?.message ?? '';
  160. if (message) return message;
  161. }
  162. return '';
  163. }
  164. describe('Codex 的 SSE 空闲窗口决定长回合的生死', () => {
  165. it(
  166. '迟发响应头(流没建立)→ 空闲窗口再小也不会判死:计时器只在流建立后武装',
  167. async () => {
  168. const { result, postsInTurn } = await runOneTurn('late', {
  169. stream_idle_timeout_ms: 5_000,
  170. request_max_retries: 0,
  171. stream_max_retries: 0,
  172. });
  173. expect(result.status).toBe('completed');
  174. expect(postsInTurn).toBe(1);
  175. },
  176. 120_000,
  177. );
  178. it(
  179. '先建流再静默(长 prefill 的真实形态)→ 空闲窗口到点就把回合判死,且 stream_max_retries=0 时上游只被问一次',
  180. async () => {
  181. const { result, postsInTurn, events } = await runOneTurn('eager', {
  182. stream_idle_timeout_ms: 5_000,
  183. request_max_retries: 0,
  184. stream_max_retries: 0,
  185. });
  186. expect(result.status).toBe('failed');
  187. expect(firstErrorOf(events)).toMatch(/idle timeout waiting for SSE/iu);
  188. expect(postsInTurn).toBe(1);
  189. },
  190. 120_000,
  191. );
  192. it(
  193. '同样的静默,把空闲窗口放宽到大于 prefill → 回合正常完成,仍然只问一次',
  194. async () => {
  195. const { result, postsInTurn } = await runOneTurn('eager', {
  196. stream_idle_timeout_ms: 120_000,
  197. request_max_retries: 0,
  198. stream_max_retries: 0,
  199. });
  200. expect(result.status).toBe('completed');
  201. expect(result.text).toContain('久等了');
  202. expect(postsInTurn).toBe(1);
  203. },
  204. 120_000,
  205. );
  206. });