Spaces:
Running
Running
| import { AGENT_ENDPOINTS } from '../skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs'; | |
| import { enrichFailureWithAgentDiagnostics } from '../skills/gpt-image-playground-agent/scripts/lib/agent-diagnostics-summary.mjs'; | |
| import { AGENT_ENDPOINTS as SERVER_AGENT_ENDPOINTS } from '../src/lib/agent-api-paths.mjs'; | |
| import { | |
| parseRetryAfterValue, | |
| readCapabilitiesImageTransportTimeoutMs, | |
| resolveSameOriginUrl | |
| } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs'; | |
| import { FIXTURE_IMAGE_BASE64 } from './local-image-upstream-fixture.mjs'; | |
| import assert from 'node:assert/strict'; | |
| import { spawn, spawnSync } from 'node:child_process'; | |
| import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'; | |
| import { createServer } from 'node:http'; | |
| import { tmpdir } from 'node:os'; | |
| import { join } from 'node:path'; | |
| import { describe, it } from 'node:test'; | |
| import { fileURLToPath } from 'node:url'; | |
| const repoRoot = fileURLToPath(new URL('..', import.meta.url)); | |
| const skillRoot = join(repoRoot, 'skills/gpt-image-playground-agent'); | |
| const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts'); | |
| const localUpstreamProbeTimeoutMs = '5000'; | |
| function agentGenerateCapabilities(extra = {}) { | |
| return { | |
| orchestration: { | |
| supported: true, | |
| transport_selection: 'server_owned', | |
| endpoint: '/api/agent/image-requests' | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' }, | |
| limits: { | |
| generate_images: { min: 1, max: 4 }, | |
| partial_images: { min: 0, max: 4 }, | |
| partial_images_by_backend: { | |
| 'images-api': { min: 0, max: 4 }, | |
| 'responses-image-generation': { min: 1, max: 3 } | |
| }, | |
| upload_images: { max: 8 } | |
| }, | |
| defaults: { partial_images: 2 }, | |
| ...extra | |
| }; | |
| } | |
| describe('Agent skill script argument validation', () => { | |
| it('rejects invalid generate numeric options before dry-run output', () => { | |
| const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /--n 必须是正整数/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('rejects invalid edit timeout before reading the image path', () => { | |
| const result = runSkillScript('edit-image.mjs', ['--timeout-ms', 'abc', '/tmp/missing.png', 'prompt']); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /--timeout-ms 必须是正整数/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('accepts --image as an edit image path alias', () => { | |
| const result = runSkillScript('edit-image.mjs', ['--image', '/tmp/source.png', 'prompt']); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.request.image_path, '/tmp/source.png'); | |
| assert.equal(body.request.prompt, 'prompt'); | |
| }); | |
| it('rejects mixing --image with positional edit image path', () => { | |
| const result = runSkillScript('edit-image.mjs', ['/tmp/source.png', '--image', '/tmp/other.png', 'prompt']); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /--image 与位置参数 <image-path> 不能同时设置/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('rejects repeated --image edit image aliases', () => { | |
| const result = runSkillScript('edit-image.mjs', [ | |
| '--image', | |
| '/tmp/source.png', | |
| '--image', | |
| '/tmp/other.png', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /--image 只能设置一次/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('rejects invalid upstream probe timeout before network checks', () => { | |
| const result = runSkillScript('probe-upstream-image.mjs', ['--timeout-ms', 'abc']); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /--timeout-ms 必须是正整数/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('rejects malformed or non-positive image sizes before dry-run or network requests', () => { | |
| const generateResult = runSkillScript('generate-image.mjs', ['--size', '0x2048', 'prompt']); | |
| assert.equal(generateResult.status, 2); | |
| assert.match(generateResult.stderr, /--size 的宽度和高度必须是正数/); | |
| assert.equal(generateResult.stdout.trim(), ''); | |
| const editResult = runSkillScript('edit-image.mjs', ['--size', '2048x0', '/tmp/source.png', 'prompt']); | |
| assert.equal(editResult.status, 2); | |
| assert.match(editResult.stderr, /--size 的宽度和高度必须是正数/); | |
| assert.equal(editResult.stdout.trim(), ''); | |
| const probeResult = runSkillScript('probe-upstream-image.mjs', ['--size', 'invalid-size']); | |
| assert.equal(probeResult.status, 2); | |
| assert.match(probeResult.stderr, /--size 必须是 auto 或 WIDTHxHEIGHT/); | |
| assert.equal(probeResult.stdout.trim(), ''); | |
| const legacyForceResult = runSkillScript('generate-image.mjs', [ | |
| '--model', | |
| 'gpt-image-1', | |
| '--force-request', | |
| '--size', | |
| '512x512', | |
| 'prompt' | |
| ]); | |
| assert.equal(legacyForceResult.status, 2); | |
| assert.match(legacyForceResult.stderr, /非 gpt-image-2 只支持 auto、1024x1024、1536x1024、1024x1536/); | |
| assert.equal(legacyForceResult.stdout.trim(), ''); | |
| }); | |
| it('caps Agent diagnostics enrichment timeout independently of image request timeouts', async () => { | |
| const startedAt = Date.now(); | |
| const result = await enrichFailureWithAgentDiagnostics({ | |
| baseUrl: 'http://example.test/playground', | |
| authHeaders: () => ({}), | |
| idempotencyKey: 'slow-diagnostics-key', | |
| failureOutput: { ok: false, error: { code: 'network_error' } }, | |
| summary: { | |
| ok: false, | |
| retryable: true, | |
| next_action: 'retry_after_wait' | |
| }, | |
| timeoutMs: 420000, | |
| diagnosticsTimeoutMs: 25, | |
| fetchFn: (_url, init) => | |
| new Promise((_resolve, reject) => { | |
| init.signal.addEventListener('abort', () => { | |
| const error = new Error('aborted'); | |
| error.name = 'AbortError'; | |
| reject(error); | |
| }); | |
| }) | |
| }); | |
| assert.equal(result.summary.agent_diagnostics_checked, true); | |
| assert.equal(result.summary.agent_diagnostics_found, false); | |
| assert.equal(result.summary.agent_diagnostics_unavailable_reason, 'diagnostics_timeout'); | |
| assert.equal(result.failureOutput.agent_failure_diagnostics.unavailable_reason, 'diagnostics_timeout'); | |
| assert.equal(result.summary.retryable, true); | |
| assert.equal(result.summary.next_action, 'retry_after_wait'); | |
| assert.ok(Date.now() - startedAt < 1000); | |
| }); | |
| it('reports non-json Agent diagnostics responses distinctly', async () => { | |
| const result = await enrichFailureWithAgentDiagnostics({ | |
| baseUrl: 'http://example.test/playground', | |
| authHeaders: () => ({}), | |
| idempotencyKey: 'html-diagnostics-key', | |
| failureOutput: { ok: false, error: { code: 'network_error' } }, | |
| summary: { ok: false }, | |
| timeoutMs: 420000, | |
| fetchFn: async () => ({ | |
| ok: false, | |
| status: 502, | |
| headers: new Headers({ 'content-type': 'text/html' }), | |
| text: async () => '<html>bad gateway</html>' | |
| }) | |
| }); | |
| assert.equal(result.summary.agent_diagnostics_checked, true); | |
| assert.equal(result.summary.agent_diagnostics_found, false); | |
| assert.equal(result.summary.agent_diagnostics_unavailable_reason, 'non_json_response'); | |
| assert.equal(result.summary.agent_diagnostics_http_status, 502); | |
| assert.equal(result.failureOutput.agent_failure_diagnostics.unavailable_reason, 'non_json_response'); | |
| assert.equal(result.failureOutput.agent_failure_diagnostics.http_status, 502); | |
| }); | |
| it('treats found Agent diagnostics without payload as invalid', async () => { | |
| const result = await enrichFailureWithAgentDiagnostics({ | |
| baseUrl: 'http://example.test/playground', | |
| authHeaders: () => ({}), | |
| idempotencyKey: 'missing-diagnostics-key', | |
| failureOutput: { ok: false, error: { code: 'network_error' } }, | |
| summary: { ok: false }, | |
| timeoutMs: 420000, | |
| fetchFn: async () => ({ | |
| ok: true, | |
| status: 200, | |
| headers: new Headers({ 'content-type': 'application/json' }), | |
| text: async () => JSON.stringify({ found: true }) | |
| }) | |
| }); | |
| assert.equal(result.summary.agent_diagnostics_checked, true); | |
| assert.equal(result.summary.agent_diagnostics_found, false); | |
| assert.equal(result.summary.agent_diagnostics_unavailable_reason, 'invalid_response'); | |
| assert.equal(result.summary.agent_diagnostics_http_status, 200); | |
| assert.equal(result.failureOutput.agent_failure_diagnostics.found, false); | |
| assert.equal(result.failureOutput.agent_failure_diagnostics.unavailable_reason, 'invalid_response'); | |
| assert.equal(result.failureOutput.agent_failure_diagnostics.http_status, 200); | |
| assert.equal(result.failureOutput.agent_failure_diagnostics.request_id, undefined); | |
| }); | |
| it('converts local images to webp by default', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-convert-')); | |
| try { | |
| const inputPath = join(tempRoot, 'source.png'); | |
| writeFileSync(inputPath, validPngBuffer()); | |
| const result = runSkillScript('convert-image-format.mjs', [inputPath]); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.ok, true); | |
| assert.equal(body.output.format, 'webp'); | |
| assert.equal(body.output.quality, 100); | |
| assert.match(body.output.path, /source\.webp$/); | |
| const output = readFileSync(body.output.path); | |
| assert.equal(output.toString('ascii', 0, 4), 'RIFF'); | |
| assert.equal(output.toString('ascii', 8, 12), 'WEBP'); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects invalid conversion quality before writing output', () => { | |
| const result = runSkillScript('convert-image-format.mjs', ['--quality', '101', '/tmp/source.png']); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /--quality 必须是 1 到 100/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('rejects invalid retry attempt env values', () => { | |
| const result = runSkillScript('generate-image.mjs', ['prompt'], { | |
| GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc' | |
| }); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /GPT_IMAGE_AGENT_MAX_ATTEMPTS 必须是正整数/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('loads private agent env files for CLI scripts without overriding shell env', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'agent-env-load-')); | |
| try { | |
| mkdirSync(join(tempRoot, '.git')); | |
| writeFileSync( | |
| join(tempRoot, '.env.agent.local'), | |
| [ | |
| 'GPT_IMAGE_PLAYGROUND_URL=https://file-space.example.test', | |
| 'GPT_IMAGE_AGENT_MAX_ATTEMPTS=2', | |
| 'GPT_IMAGE_AGENT_TOKEN=file-token', | |
| 'GPT_IMAGE_APP_PASSWORD_HASH=file-hash', | |
| 'GPT_IMAGE_UPSTREAM_BASE_URL=https://upstream.example.test/v1', | |
| 'GPT_IMAGE_UPSTREAM_API_KEY=upstream-secret' | |
| ].join('\n') | |
| ); | |
| const loaded = runSkillScript( | |
| 'generate-image.mjs', | |
| ['prompt'], | |
| {}, | |
| { cwd: tempRoot, loadPrivateAgentEnv: true } | |
| ); | |
| assert.equal(loaded.status, 0); | |
| const loadedBody = JSON.parse(loaded.stdout); | |
| assert.equal(loadedBody.verification_scope.service_base_url, 'https://file-space.example.test'); | |
| assert.equal(loadedBody.verification_scope.service_base_url_source, 'GPT_IMAGE_PLAYGROUND_URL'); | |
| assert.equal(loadedBody.verification_scope.interactive_confirmation_required, true); | |
| assert.doesNotMatch(loaded.stdout, /file-token|file-hash|upstream-secret/); | |
| assert.equal(loaded.stderr.trim(), ''); | |
| const fromNestedCwd = runSkillScript( | |
| 'generate-image.mjs', | |
| ['prompt'], | |
| {}, | |
| { cwd: join(tempRoot, 'nested', 'scripts'), loadPrivateAgentEnv: true, createCwd: true } | |
| ); | |
| assert.equal(fromNestedCwd.status, 0); | |
| const nestedBody = JSON.parse(fromNestedCwd.stdout); | |
| assert.equal(nestedBody.verification_scope.service_base_url, 'https://file-space.example.test'); | |
| const parentRoot = mkdtempSync(join(tmpdir(), 'agent-env-parent-')); | |
| try { | |
| writeFileSync( | |
| join(parentRoot, '.env.agent.local'), | |
| 'GPT_IMAGE_PLAYGROUND_URL=https://parent-space.example.test' | |
| ); | |
| const childRoot = join(parentRoot, 'child-repo'); | |
| mkdirSync(join(childRoot, '.git'), { recursive: true }); | |
| const bounded = runSkillScript( | |
| 'generate-image.mjs', | |
| ['prompt'], | |
| {}, | |
| { cwd: join(childRoot, 'nested'), loadPrivateAgentEnv: true, createCwd: true } | |
| ); | |
| assert.equal(bounded.status, 0); | |
| const boundedBody = JSON.parse(bounded.stdout); | |
| assert.equal(boundedBody.verification_scope.service_base_url, 'http://localhost:4783'); | |
| const projectCopyRoot = join(parentRoot, 'project-copy'); | |
| mkdirSync(join(projectCopyRoot, 'skills/gpt-image-playground-agent'), { recursive: true }); | |
| mkdirSync(join(projectCopyRoot, 'nested'), { recursive: true }); | |
| writeFileSync(join(projectCopyRoot, 'package.json'), JSON.stringify({ name: 'gpt-image-playground' })); | |
| writeFileSync(join(projectCopyRoot, 'skills/gpt-image-playground-agent/SKILL.md'), '# skill\n'); | |
| const projectBounded = runSkillScript( | |
| 'generate-image.mjs', | |
| ['prompt'], | |
| {}, | |
| { cwd: join(projectCopyRoot, 'nested'), loadPrivateAgentEnv: true } | |
| ); | |
| assert.equal(projectBounded.status, 0); | |
| const projectBoundedBody = JSON.parse(projectBounded.stdout); | |
| assert.equal(projectBoundedBody.verification_scope.service_base_url, 'http://localhost:4783'); | |
| writeFileSync( | |
| join(projectCopyRoot, '.env.agent.local'), | |
| 'GPT_IMAGE_PLAYGROUND_URL=https://project-copy.example.test' | |
| ); | |
| const projectLoaded = runSkillScript( | |
| 'generate-image.mjs', | |
| ['prompt'], | |
| {}, | |
| { cwd: join(projectCopyRoot, 'nested'), loadPrivateAgentEnv: true } | |
| ); | |
| assert.equal(projectLoaded.status, 0); | |
| const projectLoadedBody = JSON.parse(projectLoaded.stdout); | |
| assert.equal( | |
| projectLoadedBody.verification_scope.service_base_url, | |
| 'https://project-copy.example.test' | |
| ); | |
| const projectSkillScriptsLoaded = runSkillScript( | |
| 'generate-image.mjs', | |
| ['prompt'], | |
| {}, | |
| { | |
| cwd: join(projectCopyRoot, 'skills/gpt-image-playground-agent/scripts'), | |
| loadPrivateAgentEnv: true, | |
| createCwd: true | |
| } | |
| ); | |
| assert.equal(projectSkillScriptsLoaded.status, 0); | |
| const projectSkillScriptsLoadedBody = JSON.parse(projectSkillScriptsLoaded.stdout); | |
| assert.equal( | |
| projectSkillScriptsLoadedBody.verification_scope.service_base_url, | |
| 'https://project-copy.example.test' | |
| ); | |
| } finally { | |
| rmSync(parentRoot, { recursive: true, force: true }); | |
| } | |
| const shellWins = runSkillScript( | |
| 'generate-image.mjs', | |
| ['prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: 'https://shell-space.example.test' }, | |
| { cwd: tempRoot, loadPrivateAgentEnv: true } | |
| ); | |
| assert.equal(shellWins.status, 0); | |
| const shellBody = JSON.parse(shellWins.stdout); | |
| assert.equal(shellBody.verification_scope.service_base_url, 'https://shell-space.example.test'); | |
| const disabled = runSkillScript( | |
| 'generate-image.mjs', | |
| ['prompt'], | |
| { GPT_IMAGE_AGENT_LOAD_ENV_FILE: '0' }, | |
| { cwd: tempRoot } | |
| ); | |
| assert.equal(disabled.status, 0); | |
| const disabledBody = JSON.parse(disabled.stdout); | |
| assert.equal(disabledBody.verification_scope.service_base_url, 'http://localhost:4783'); | |
| const probe = runSkillScript( | |
| 'probe-upstream-image.mjs', | |
| ['--timeout-ms', '1'], | |
| {}, | |
| { cwd: tempRoot, loadPrivateAgentEnv: true } | |
| ); | |
| assert.notEqual(probe.status, 2); | |
| const probeBody = JSON.parse(probe.stdout); | |
| assert.equal(probeBody.base_url, 'https://upstream.example.test/v1'); | |
| assert.equal(probeBody.api_key_configured, true); | |
| assert.doesNotMatch(probe.stdout, /upstream-secret/); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('inherits longer image transport timeout from capabilities by default', () => { | |
| assert.equal( | |
| readCapabilitiesImageTransportTimeoutMs({ image_transport: { upstream_timeout_ms: 900_000 } }, 420_000), | |
| 900_000 | |
| ); | |
| assert.equal( | |
| readCapabilitiesImageTransportTimeoutMs({ image_transport: { upstream_timeout_ms: 300_000 } }, 420_000), | |
| 420_000 | |
| ); | |
| }); | |
| it('rejects service base URLs with embedded credentials before dry-run output', () => { | |
| const result = runSkillScript('generate-image.mjs', ['prompt'], { | |
| GPT_IMAGE_PLAYGROUND_URL: 'https://user:secret@example.test' | |
| }); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /base URL/); | |
| assert.match(result.stderr, /不能包含凭据/); | |
| assert.doesNotMatch(result.stderr, /secret/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('lets explicit base-url override the environment service URL in dry-run output', () => { | |
| const generateResult = runSkillScript( | |
| 'generate-image.mjs', | |
| ['--base-url', 'https://space.example.test/', '--size', '1024x1024', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: 'http://localhost:4783' } | |
| ); | |
| assert.equal(generateResult.status, 0); | |
| const generateBody = JSON.parse(generateResult.stdout); | |
| assert.equal(generateBody.verification_scope.service_base_url, 'https://space.example.test'); | |
| assert.equal(generateBody.verification_scope.service_base_url_source, 'user_provided'); | |
| assert.equal(generateBody.verification_scope.interactive_confirmation_required, false); | |
| const editResult = runSkillScript( | |
| 'edit-image.mjs', | |
| ['--base-url', 'https://space.example.test/', '--image', '/tmp/source.png', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: 'http://localhost:4783' } | |
| ); | |
| assert.equal(editResult.status, 0); | |
| const editBody = JSON.parse(editResult.stdout); | |
| assert.equal(editBody.verification_scope.service_base_url, 'https://space.example.test'); | |
| assert.equal(editBody.verification_scope.service_base_url_source, 'user_provided'); | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'batch-base-url-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync(inputPath, JSON.stringify({ id: 'first', prompt: 'prompt' })); | |
| const batchResult = runSkillScript( | |
| 'batch-images.mjs', | |
| ['--base-url', 'https://space.example.test/', '--input', inputPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: 'http://localhost:4783' } | |
| ); | |
| assert.equal(batchResult.status, 0); | |
| const batchBody = JSON.parse(batchResult.stdout); | |
| assert.equal(batchBody.verification_scope.service_base_url, 'https://space.example.test'); | |
| assert.equal(batchBody.verification_scope.service_base_url_source, 'user_provided'); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects upstream probe base URLs with embedded credentials before network checks', () => { | |
| const result = runSkillScript('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| 'https://user:secret@example.test/v1' | |
| ]); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /base URL/); | |
| assert.match(result.stderr, /不能包含凭据/); | |
| assert.doesNotMatch(result.stderr, /secret/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('emits a non-billable upstream probe summary with stable request headers', async () => { | |
| let userAgent = ''; | |
| await withServer( | |
| (request, response) => { | |
| userAgent = String(request.headers['user-agent'] || ''); | |
| if (request.url === '/v1/models') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| `${baseUrl}/v1`, | |
| '--timeout-ms', | |
| localUpstreamProbeTimeoutMs | |
| ]); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.ok, true); | |
| assert.equal(body.billable, false); | |
| assert.equal(body.models.model_count, 1); | |
| assert.equal(body.models.first_model_id, 'gpt-image-2'); | |
| assert.equal(body.models.image_count, undefined); | |
| assert.equal(body.models.first_b64_length, undefined); | |
| assert.equal(body.summary.ok, true); | |
| assert.equal(body.summary.billable, false); | |
| assert.equal(body.summary.transport, 'upstream_probe'); | |
| assert.equal(body.summary.endpoint.endsWith('/v1/models'), true); | |
| assert.equal(body.summary.request_headers.user_agent_effective, 'visual-journal/probe'); | |
| assert.equal(body.summary.request_headers.has_extra_headers, false); | |
| assert.deepEqual(body.summary.request_headers.allowed_header_names, ['authorization', 'user-agent']); | |
| assert.deepEqual(body.summary.request_headers.configured_header_names, []); | |
| assert.equal(userAgent, 'visual-journal/probe'); | |
| } | |
| ); | |
| }); | |
| it('uses an explicit connection IP without changing the upstream hostname', async () => { | |
| let hostHeader = ''; | |
| await withServer( | |
| (request, response) => { | |
| hostHeader = String(request.headers.host || ''); | |
| if (request.url === '/v1/models') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const localUrl = new URL(baseUrl); | |
| const result = await runSkillScriptAsync('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| `http://upstream.invalid:${localUrl.port}/v1`, | |
| '--connect-ip', | |
| '127.0.0.1', | |
| '--timeout-ms', | |
| localUpstreamProbeTimeoutMs | |
| ]); | |
| assert.equal(result.status, 0, `${result.stderr}\n${result.stdout}`); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.connect_ip, '127.0.0.1'); | |
| assert.equal(body.summary.connect_ip, '127.0.0.1'); | |
| assert.equal(body.dns.reason, 'connect_ip_override'); | |
| assert.equal(body.dns.address, '127.0.0.1'); | |
| assert.equal(body.tls.reason, 'non_https_base_url'); | |
| assert.equal(body.models.first_model_id, 'gpt-image-2'); | |
| assert.equal(hostHeader, `upstream.invalid:${localUrl.port}`); | |
| } | |
| ); | |
| }); | |
| it('reports configured upstream probe authorization without exposing the key', async () => { | |
| await withServer( | |
| (request, response) => { | |
| assert.equal(request.headers.authorization, 'Bearer probe-secret'); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'probe-upstream-image.mjs', | |
| ['--base-url', `${baseUrl}/v1`, '--timeout-ms', localUpstreamProbeTimeoutMs], | |
| { GPT_IMAGE_UPSTREAM_API_KEY: 'probe-secret', OPENAI_UPSTREAM_USER_AGENT: 'custom-probe' } | |
| ); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.summary.request_headers.user_agent_effective, 'custom-probe'); | |
| assert.equal(body.summary.request_headers.has_extra_headers, true); | |
| assert.deepEqual(body.summary.request_headers.configured_header_names, ['authorization', 'user-agent']); | |
| assert.equal(JSON.stringify(body).includes('probe-secret'), false); | |
| } | |
| ); | |
| }); | |
| it('probes explicit request modes and classifies Responses 403 as unavailable', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push(`${request.method} ${request.url}`); | |
| if (request.url === '/v1/models') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| return; | |
| } | |
| if (request.url === '/v1/images/generations') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ b64_json: 'YmFzZTY0LWltYWdl' }] })); | |
| return; | |
| } | |
| if (request.url === '/v1/responses') { | |
| response.writeHead(403, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { | |
| code: 'permission_denied', | |
| message: 'Image generation is not enabled for this group', | |
| type: 'invalid_request_error' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| `${baseUrl}/v1`, | |
| '--allow-billable', | |
| '--request-mode', | |
| 'images-non-stream,responses-non-stream', | |
| '--responses-model', | |
| 'gpt-4.1-mini', | |
| '--timeout-ms', | |
| '1000' | |
| ]); | |
| assert.equal(result.status, 1); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.ok, false); | |
| assert.equal(body.billable, true); | |
| assert.deepEqual(body.request_modes.requested, ['images-non-stream', 'responses-non-stream']); | |
| assert.deepEqual(body.request_modes.passed, ['images-non-stream']); | |
| assert.deepEqual(body.request_modes.failed, ['responses-non-stream']); | |
| assert.deepEqual(body.request_modes.skipped, []); | |
| assert.deepEqual(body.request_modes.not_selected, ['images-sse', 'responses-sse']); | |
| assert.equal(body.request_modes.suggested_channel_config, 'images-non-stream'); | |
| assert.equal(body.request_modes.suggested_env_key, 'OPENAI_CHANNEL_N_REQUEST_MODES'); | |
| assert.equal(body.request_modes.modes['images-non-stream'].ok, true); | |
| assert.equal(body.request_modes.modes['responses-non-stream'].ok, false); | |
| assert.equal(body.request_modes.modes['responses-non-stream'].category, 'responses_disabled'); | |
| assert.match(body.request_modes.modes['responses-non-stream'].error, /Image generation is not enabled/); | |
| assert.equal(body.request_modes.modes['responses-non-stream'].responses_model, 'gpt-4.1-mini'); | |
| assert.deepEqual(requests, ['GET /v1/models', 'POST /v1/images/generations', 'POST /v1/responses']); | |
| } | |
| ); | |
| }); | |
| it('does not suggest remote URL-only Responses results as usable request modes', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push(`${request.method} ${request.url}`); | |
| if (request.url === '/v1/models') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| return; | |
| } | |
| if (request.url === '/v1/responses') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| output: [ | |
| { | |
| type: 'image_generation_call', | |
| status: 'completed', | |
| url: 'https://cdn.example.test/final.png' | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| `${baseUrl}/v1`, | |
| '--allow-billable', | |
| '--request-mode', | |
| 'responses-non-stream', | |
| '--responses-model', | |
| 'gpt-4.1-mini', | |
| '--timeout-ms', | |
| '1000' | |
| ]); | |
| assert.equal(result.status, 1); | |
| const body = JSON.parse(result.stdout); | |
| const mode = body.request_modes.modes['responses-non-stream']; | |
| assert.deepEqual(body.request_modes.passed, []); | |
| assert.deepEqual(body.request_modes.failed, ['responses-non-stream']); | |
| assert.equal(body.request_modes.suggested_channel_config, ''); | |
| assert.equal(mode.ok, false); | |
| assert.equal(mode.error, 'url_only_result'); | |
| assert.equal(mode.has_url_result, true); | |
| assert.equal(mode.has_remote_url_result, true); | |
| assert.equal(mode.has_consumable_image, false); | |
| assert.deepEqual(requests, ['GET /v1/models', 'POST /v1/responses']); | |
| } | |
| ); | |
| }); | |
| it('treats data URL image results as consumable in upstream probe output', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/v1/models') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| return; | |
| } | |
| if (request.url === '/v1/responses') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| output: [ | |
| { | |
| type: 'image_generation_call', | |
| status: 'completed', | |
| url: 'data:image/png;base64,YmFzZTY0LWltYWdl' | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| `${baseUrl}/v1`, | |
| '--allow-billable', | |
| '--request-mode', | |
| 'responses-non-stream', | |
| '--responses-model', | |
| 'gpt-4.1-mini', | |
| '--timeout-ms', | |
| '1000' | |
| ]); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| const mode = body.request_modes.modes['responses-non-stream']; | |
| assert.deepEqual(body.request_modes.passed, ['responses-non-stream']); | |
| assert.equal(body.request_modes.suggested_channel_config, 'responses-non-stream'); | |
| assert.equal(mode.ok, true); | |
| assert.equal(mode.has_inline_base64, true); | |
| assert.equal(mode.has_consumable_image, true); | |
| assert.equal(mode.has_remote_url_result, false); | |
| } | |
| ); | |
| }); | |
| it('does not mark pending Responses image calls as a usable request mode', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/v1/models') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| return; | |
| } | |
| if (request.url === '/v1/responses') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| output: [ | |
| { | |
| type: 'image_generation_call', | |
| status: 'pending', | |
| result: FIXTURE_IMAGE_BASE64 | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| `${baseUrl}/v1`, | |
| '--allow-billable', | |
| '--request-mode', | |
| 'responses-non-stream', | |
| '--responses-model', | |
| 'gpt-4.1-mini', | |
| '--timeout-ms', | |
| '1000' | |
| ]); | |
| assert.equal(result.status, 1); | |
| const body = JSON.parse(result.stdout); | |
| const mode = body.request_modes.modes['responses-non-stream']; | |
| assert.deepEqual(body.request_modes.passed, []); | |
| assert.deepEqual(body.request_modes.failed, ['responses-non-stream']); | |
| assert.equal(mode.ok, false); | |
| assert.equal(mode.error, 'missing_image_call_result'); | |
| assert.equal(mode.has_consumable_image, false); | |
| } | |
| ); | |
| }); | |
| it('saves the first final upstream SSE image only when explicitly requested', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'probe-upstream-save-')); | |
| const outputPath = join(tempRoot, 'probe.png'); | |
| const laterImageBase64 = fakePngBase64(2, 1); | |
| try { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/v1/models') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| return; | |
| } | |
| if (request.url === '/v1/images/generations') { | |
| response.writeHead(200, { | |
| 'content-type': 'text/event-stream', | |
| 'cache-control': 'no-cache', | |
| connection: 'keep-alive' | |
| }); | |
| response.write( | |
| 'event: image_generation.partial_image\n' + | |
| 'data: {"type":"image_generation.partial_image","b64_json":"YmFzZTY0LWltYWdl"}\n\n' | |
| ); | |
| response.write( | |
| 'event: image_generation.completed\n' + | |
| `data: {"type":"image_generation.completed","b64_json":"${FIXTURE_IMAGE_BASE64}"}\n\n` | |
| ); | |
| response.write( | |
| 'event: image_generation.completed\n' + | |
| `data: {"type":"image_generation.completed","b64_json":"${laterImageBase64}"}\n\n` | |
| ); | |
| response.end('data: [DONE]\n\n'); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| baseUrl + '/v1', | |
| '--allow-billable', | |
| '--request-mode', | |
| 'images-sse', | |
| '--save-first-image', | |
| outputPath, | |
| '--timeout-ms', | |
| '1000' | |
| ]); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| const mode = body.request_modes.modes['images-sse']; | |
| assert.equal(mode.saved_image.ok, true); | |
| assert.equal(mode.saved_image.path, outputPath); | |
| assert.equal(mode.saved_image.byte_length, Buffer.from(FIXTURE_IMAGE_BASE64, 'base64').byteLength); | |
| assert.deepEqual(mode.saved_image.dimensions, { width: 1, height: 1 }); | |
| assert.equal(mode.saved_image.source, 'b64_json'); | |
| assert.equal(readFileSync(outputPath).toString('base64'), FIXTURE_IMAGE_BASE64); | |
| assert.equal(result.stdout.includes(FIXTURE_IMAGE_BASE64), false); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('does not save partial-only upstream SSE previews as final images', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'probe-upstream-partial-only-')); | |
| const outputPath = join(tempRoot, 'probe.png'); | |
| try { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/v1/models') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ data: [{ id: 'gpt-image-2' }] })); | |
| return; | |
| } | |
| if (request.url === '/v1/images/generations') { | |
| response.writeHead(200, { | |
| 'content-type': 'text/event-stream', | |
| 'cache-control': 'no-cache', | |
| connection: 'keep-alive' | |
| }); | |
| response.write( | |
| 'event: image_generation.partial_image\n' + | |
| `data: {"type":"image_generation.partial_image","b64_json":"${FIXTURE_IMAGE_BASE64}"}\n\n` | |
| ); | |
| response.end('data: [DONE]\n\n'); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('probe-upstream-image.mjs', [ | |
| '--base-url', | |
| `${baseUrl}/v1`, | |
| '--allow-billable', | |
| '--request-mode', | |
| 'images-sse', | |
| '--save-first-image', | |
| outputPath, | |
| '--timeout-ms', | |
| '1000' | |
| ]); | |
| assert.equal(result.status, 1); | |
| const body = JSON.parse(result.stdout); | |
| const mode = body.request_modes.modes['images-sse']; | |
| assert.equal(mode.ok, false); | |
| assert.equal(mode.error, 'missing_final_image'); | |
| assert.equal(mode.has_partial_image, true); | |
| assert.equal(mode.saved_image, undefined); | |
| assert.equal(existsSync(outputPath), false); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('does not read prompt files during default generate dry-run', () => { | |
| const result = runSkillScript('generate-image.mjs', ['--prompt-file', '/tmp/missing-agent-prompt.txt']); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.dry_run, true); | |
| assert.equal(body.billable, false); | |
| assert.equal(body.verification_scope.mode, 'local_planning_only'); | |
| assert.equal(body.verification_scope.remote_capabilities_verified, false); | |
| assert.equal(body.verification_scope.runtime_capacity_verified, false); | |
| assert.equal(body.verification_scope.auth_verified, false); | |
| assert.equal(body.request.output_format, 'webp'); | |
| assert.equal(body.request.output_compression, 100); | |
| assert.equal(result.stderr.trim(), ''); | |
| }); | |
| it('includes explicit upstream streaming options in generate dry-run requests', () => { | |
| const result = runSkillScript('generate-image.mjs', [ | |
| '--image-backend', | |
| 'responses', | |
| '--stream-mode', | |
| 'stream', | |
| '--streaming-strategy', | |
| 'responses-sse', | |
| '--partial-images', | |
| '3', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.request.image_backend, 'responses'); | |
| assert.equal(body.request.stream_mode, 'stream'); | |
| assert.equal(body.request.streaming_strategy, 'responses-sse'); | |
| assert.equal(body.request.partial_images, 3); | |
| assert.equal(result.stderr.trim(), ''); | |
| }); | |
| it('keeps GPT2Image-compatible generate options on server orchestration dry-runs', () => { | |
| const result = runSkillScript('generate-image.mjs', [ | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--thinking', | |
| 'medium', | |
| '--prompt-optimization', | |
| 'false', | |
| '--force-web', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.endpoint, 'http://localhost:4783/api/agent/image-requests'); | |
| assert.equal(body.routing_guidance.transport, 'server_orchestrated'); | |
| assert.equal(body.request.image_backend, 'responses'); | |
| assert.equal(body.request.responsesModel, 'gpt-5.4-mini'); | |
| assert.equal(body.request.thinking, 'medium'); | |
| assert.equal(body.request.promptOptimization, false); | |
| assert.equal(body.request.force_web, true); | |
| assert.equal(result.stderr.trim(), ''); | |
| }); | |
| it('includes explicit upstream streaming options in edit dry-run requests', () => { | |
| const result = runSkillScript('edit-image.mjs', [ | |
| '--stream-mode', | |
| 'auto', | |
| '--streaming-strategy', | |
| 'force-sse', | |
| '--partial-images', | |
| '2', | |
| '/tmp/source.png', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.request.stream_mode, 'auto'); | |
| assert.equal(body.request.streaming_strategy, 'force-sse'); | |
| assert.equal(body.request.partial_images, 2); | |
| assert.equal(body.verification_scope.mode, 'local_planning_only'); | |
| assert.equal(body.verification_scope.remote_capabilities_verified, false); | |
| assert.equal(body.verification_scope.runtime_capacity_verified, false); | |
| assert.equal(body.verification_scope.auth_verified, false); | |
| assert.equal(result.stderr.trim(), ''); | |
| }); | |
| it('prints server orchestration guidance for high-resolution generate dry-runs', () => { | |
| const result = runSkillScript('generate-image.mjs', ['--size', '3072x2048', '--quality', 'high', 'prompt']); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.endpoint, 'http://localhost:4783/api/agent/image-requests'); | |
| assert.equal(body.routing_guidance.recommended_endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.routing_guidance.transport, 'server_orchestrated'); | |
| assert.equal(body.routing_guidance.result_mode, 'job_polling'); | |
| assert.match(body.routing_guidance.reason, /服务端负责选择内部执行路径/); | |
| assert.equal(result.stderr.trim(), ''); | |
| }); | |
| it('rejects single generate dimension-check without a fixed requested size before remote execution', () => { | |
| const result = runSkillScript('generate-image.mjs', ['--dimension-check', '--size', 'auto', 'prompt']); | |
| assert.equal(result.status, 2); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /--dimension-check 需要 --size 为 WIDTHxHEIGHT/); | |
| }); | |
| it('explains explicit page SSE dry-runs without blaming high-resolution routing', () => { | |
| const result = runSkillScript('generate-image.mjs', ['--page-sse', '--size', '1024x1024', 'prompt']); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.endpoint, 'http://localhost:4783/api/images'); | |
| assert.equal(body.routing_guidance.transport, 'page_sse'); | |
| assert.match(body.routing_guidance.reason, /Explicit --page-sse/); | |
| assert.doesNotMatch(body.routing_guidance.reason, /max_edge>2048/); | |
| assert.equal(result.stderr.trim(), ''); | |
| }); | |
| it('prints server orchestration guidance for remote HTTPS small generate dry-runs', () => { | |
| const result = runSkillScript('generate-image.mjs', ['--base-url', 'https://space.example.test', 'prompt']); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.endpoint, 'https://space.example.test/api/agent/image-requests'); | |
| assert.equal(body.routing_guidance.recommended_endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.routing_guidance.transport, 'server_orchestrated'); | |
| assert.equal(body.routing_guidance.strength, 'recommended'); | |
| assert.match(body.routing_guidance.reason, /只提交生成意图/); | |
| assert.equal(result.stderr.trim(), ''); | |
| }); | |
| it('can combine generate dry-run planning with non-billable remote checks', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify( | |
| agentGenerateCapabilities({ | |
| supported: { | |
| request_modes: ['images-non-stream', 'images-sse'] | |
| }, | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/runtime-capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| streamingBatch: { enabled: true, recommendedConcurrency: 2 }, | |
| channelRouting: { effectiveRequestModes: ['images-non-stream'] } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('generate-image.mjs', ['--check-remote', 'prompt'], { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| }); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.verification_scope.mode, 'remote_contract_and_local_planning'); | |
| assert.equal(body.verification_scope.remote_capabilities_verified, true); | |
| assert.equal(body.verification_scope.runtime_capacity_verified, true); | |
| assert.equal(body.verification_scope.auth_verified, true); | |
| assert.equal(body.verification_scope.billable_request_sent, false); | |
| assert.deepEqual(body.verification_scope.remote_check.capabilities.request_modes, [ | |
| 'images-non-stream', | |
| 'images-sse' | |
| ]); | |
| assert.deepEqual(body.verification_scope.remote_check.runtime.effective_request_modes, [ | |
| 'images-non-stream' | |
| ]); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'GET /api/runtime-capabilities'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('keeps default generate on server orchestration when request modes are declared', async () => { | |
| const requests = []; | |
| let imageRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify( | |
| agentGenerateCapabilities({ | |
| supported: { | |
| request_modes: [ | |
| 'images-non-stream', | |
| 'images-sse', | |
| 'responses-non-stream', | |
| 'responses-sse' | |
| ] | |
| }, | |
| upstream_request_headers: { | |
| channels: [ | |
| { | |
| id: 'images', | |
| request_modes: ['images-non-stream', 'images-sse'] | |
| }, | |
| { | |
| id: 'responses', | |
| request_modes: ['responses-sse'] | |
| } | |
| ] | |
| } | |
| }) | |
| ) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| imageRequestBody = await readRequestText(request); | |
| response.writeHead(202, { 'content-type': 'application/json', 'retry-after': '1' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-orchestrated-1', | |
| state: 'running', | |
| result_url: '/api/agent/jobs/job-orchestrated-1/result', | |
| retry_after_seconds: 1 | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/job-orchestrated-1/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'job-orchestrated-1', | |
| idempotency_key: 'orchestrated-key', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'artifact-orchestrated-1', | |
| filename: 'orchestrated.webp', | |
| content_url: '/api/agent/artifacts/artifact-orchestrated-1/content', | |
| width: 1254, | |
| height: 1254 | |
| } | |
| ], | |
| execution: { | |
| transport: 'agent_job_polling', | |
| endpoint: '/api/agent/image-requests', | |
| route_mode: 'job', | |
| image_backend: 'images-api', | |
| stream_mode: 'non_stream', | |
| streaming_strategy: 'off', | |
| channel_request_mode: 'images-non-stream', | |
| channel_request_mode_fallback_applied: false, | |
| route_decision: { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream', | |
| fallback_applied: false, | |
| selected_channel_id: 'channel-orchestrated', | |
| upstream_host: 'upstream.example.test' | |
| } | |
| }, | |
| timing: { server_elapsed_ms: 1234 } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate' || request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected client-selected route' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--idempotency-key', | |
| 'orchestrated-key', | |
| '--size', | |
| '3072x2048', | |
| '--streaming-strategy', | |
| 'off', | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--thinking', | |
| 'medium', | |
| '--prompt-optimization', | |
| 'false', | |
| '--force-web', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.deepEqual(body.routing, { | |
| transport: 'server_orchestrated', | |
| endpoint: '/api/agent/image-requests' | |
| }); | |
| assert.equal(body.summary.transport, 'agent_job_polling'); | |
| assert.equal(body.summary.endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.summary.route_mode, 'job'); | |
| assert.equal(body.summary.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.summary.channel_request_mode_fallback_applied, false); | |
| assert.deepEqual(body.summary.route_decision, { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream', | |
| fallback_applied: false, | |
| selected_channel_id: 'channel-orchestrated', | |
| upstream_host: 'upstream.example.test' | |
| }); | |
| assert.deepEqual(body.summary.content_urls, ['/api/agent/artifacts/artifact-orchestrated-1/content']); | |
| assert.deepEqual(body.summary.actual_dimensions, { width: 1254, height: 1254 }); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/image-requests', | |
| 'GET /api/agent/jobs/job-orchestrated-1/result' | |
| ] | |
| ); | |
| const requestBody = JSON.parse(imageRequestBody); | |
| assert.equal(requestBody.size, '3072x2048'); | |
| assert.equal(requestBody.streaming_strategy, 'off'); | |
| assert.equal(requestBody.image_backend, 'responses'); | |
| assert.equal(requestBody.responsesModel, 'gpt-5.4-mini'); | |
| assert.equal(requestBody.thinking, 'medium'); | |
| assert.equal(requestBody.promptOptimization, false); | |
| assert.equal(requestBody.force_web, true); | |
| } | |
| ); | |
| }); | |
| it('checks the server orchestration endpoint during generate contract checks', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify( | |
| agentGenerateCapabilities({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| client_request_id: { max_length: 128 } | |
| } | |
| } | |
| }) | |
| ) | |
| ); | |
| return; | |
| } | |
| if ( | |
| request.url === '/api/agent/images/generate' || | |
| request.url === '/api/agent/image-requests' || | |
| request.url === '/api/agent/jobs/images/generate' | |
| ) { | |
| response.writeHead(400, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { | |
| code: 'idempotency_key_required', | |
| message: 'missing key', | |
| retryable: false | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(400, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'clientRequestId 长度不能超过 128 个字符。' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('generate-image.mjs', ['--contract-check', 'contract check'], { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| }); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.deepEqual( | |
| body.checks.map((check) => check.endpoint), | |
| [ | |
| '/api/agent/images/generate', | |
| '/api/agent/image-requests', | |
| '/api/agent/jobs/images/generate', | |
| '/api/images' | |
| ] | |
| ); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/images/generate', | |
| 'POST /api/agent/image-requests', | |
| 'POST /api/agent/jobs/images/generate', | |
| 'POST /api/images' | |
| ] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('fails generate contract checks when the default orchestration contract is missing', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities({ orchestration: undefined }))); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(400, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { | |
| code: 'idempotency_key_required', | |
| message: 'missing key', | |
| retryable: false | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected contract probe' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('generate-image.mjs', ['--contract-check', 'contract check'], { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| }); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'orchestration_required'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/agent/images/generate'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('expands generate presets into concrete dry-run request fields', () => { | |
| const agentResult = runSkillScript('generate-image.mjs', ['--preset', '4k-agent-nonstream', 'prompt']); | |
| assert.equal(agentResult.status, 0); | |
| const agentBody = JSON.parse(agentResult.stdout); | |
| assert.equal(agentBody.endpoint, 'http://localhost:4783/api/agent/images/generate'); | |
| assert.equal(agentBody.request.size, '3840x2160'); | |
| assert.equal(agentBody.request.quality, 'high'); | |
| assert.equal(agentBody.request.stream_mode, 'non_stream'); | |
| assert.equal(agentBody.routing_guidance.transport, 'agent_json'); | |
| const upstreamSseResult = runSkillScript('generate-image.mjs', [ | |
| '--preset', | |
| '4k-upstream-sse-newapi', | |
| 'prompt' | |
| ]); | |
| assert.equal(upstreamSseResult.status, 0); | |
| const upstreamSseBody = JSON.parse(upstreamSseResult.stdout); | |
| assert.equal(upstreamSseBody.request.size, '3840x2160'); | |
| assert.equal(upstreamSseBody.request.stream_mode, 'stream'); | |
| assert.equal(upstreamSseBody.request.streaming_strategy, 'newapi-keepalive-sse'); | |
| assert.equal(upstreamSseBody.request.partial_images, 2); | |
| }); | |
| it('does not automatically fall back after an explicit billable page SSE generate failure', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end('data: {"type":"error","error":"stream failed","status":502}\n\n'); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected fallback' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_failed'); | |
| assert.match(body.error.message, /stream failed/); | |
| assert.match(body.next_step, /新的 Idempotency-Key/); | |
| assert.match(body.next_step, /服务端编排入口/); | |
| assert.equal(body.routing.fallback_endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('reports non-SSE page validation failures as non-billable', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(400, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'quality 对 gpt-image-2 无效。' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'invalid-quality', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.billable, false); | |
| assert.equal(body.error.code, 'page_sse_request_rejected'); | |
| assert.equal(body.error.status, 400); | |
| assert.match(body.error.message, /quality/); | |
| assert.equal(body.routing.fallback_mode, 'fix_request_before_retry'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('reports non-JSON page 4xx failures as non-billable request rejections', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(401, { 'content-type': 'text/plain' }); | |
| response.end('missing passwordHash'); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.billable, false); | |
| assert.equal(body.error.code, 'page_sse_request_rejected'); | |
| assert.equal(body.error.status, 401); | |
| assert.match(body.error.message, /missing passwordHash/); | |
| assert.equal(body.routing.fallback_mode, 'fix_request_before_retry'); | |
| } | |
| ); | |
| }); | |
| it('reports non-SSE page server failures as billable page SSE failures', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: { message: 'upstream page failed' } })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.billable, true); | |
| assert.equal(body.error.code, 'page_sse_failed'); | |
| assert.equal(body.error.status, 500); | |
| assert.match(body.error.message, /upstream page failed/); | |
| assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis'); | |
| } | |
| ); | |
| }); | |
| it('preserves object page SSE error messages', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"error","error":{"message":"bad request from page","code":"validation_error","status":400}}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_failed'); | |
| assert.equal(body.billable, true); | |
| assert.equal(body.error.status, 400); | |
| assert.match(body.error.message, /bad request from page/); | |
| assert.doesNotMatch(body.error.message, /\[object Object\]/); | |
| } | |
| ); | |
| }); | |
| it('fails large generate auto routing when page SSE capability is unavailable', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ agent_streaming: {}, agent_jobs: { supported: true, mode: 'job_polling' } }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected fallback' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_unavailable'); | |
| assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('fails page SSE output when the stream ends before the done event', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","path":"/api/image/image.png","output_format":"png"}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_failed'); | |
| assert.match(body.error.message, /缺少最终 done 事件/); | |
| assert.equal(body.error.diagnostics.partial_image_count, 0); | |
| assert.equal(body.error.diagnostics.completed_event_count, 1); | |
| assert.equal(body.error.diagnostics.done_received, false); | |
| assert.equal(body.error.diagnostics.final_image_count, 1); | |
| assert.equal(body.error.diagnostics.last_upstream_event_type, 'completed'); | |
| } | |
| ); | |
| }); | |
| it('reports page SSE partial image diagnostics when no final image arrives', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ agent_streaming: { page_sse: { supported: true, endpoint: '/api/images' } } }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"image_generation.partial_image","partial_image_b64":"partial-a"}', | |
| '', | |
| 'data: {"type":"done","images":[]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '1024x1024', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_failed'); | |
| assert.match(body.error.message, /未返回最终图片/); | |
| assert.equal(body.error.diagnostics.partial_image_count, 1); | |
| assert.equal(body.error.diagnostics.completed_event_count, 0); | |
| assert.equal(body.error.diagnostics.done_received, true); | |
| assert.equal(body.error.diagnostics.final_image_count, 0); | |
| assert.equal(body.error.diagnostics.last_upstream_event_type, 'done'); | |
| } | |
| ); | |
| }); | |
| it('saves raw page SSE events when a generate log path is configured', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-sse-log-')); | |
| try { | |
| const logPath = join(tempRoot, 'events.jsonl'); | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'event: image_generation.partial_image', | |
| 'data: {"type":"image_generation.partial_image","partial_image_b64":"abc"}', | |
| '', | |
| 'data: {"type":"completed","filename":"image.png","path":"/generated/image.png"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"image.png","path":"/generated/image.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--sse-log', logPath, '--size', '1024x1024', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const logLines = readFileSync(logPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); | |
| assert.equal(logLines.length, 5); | |
| assert.equal(logLines[0].event, 'request_started'); | |
| assert.equal(logLines[0].client_request_id.startsWith('agent-generate-'), true); | |
| assert.match(logLines[1].raw_event, /partial_image/); | |
| assert.match(logLines[3].raw_event, /"type":"done"/); | |
| assert.equal(logLines[4].event, 'request_completed'); | |
| assert.equal(typeof logLines[4].elapsed_ms, 'number'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('keeps generate page SSE successful when the optional raw log path is unwritable', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-sse-log-unwritable-')); | |
| try { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"image.png","path":"/generated/image.png"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"image.png","path":"/generated/image.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--sse-log', tempRoot, '--size', '1024x1024', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.match(result.stderr, /SSE log write failed/); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.images.length, 1); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('requires page access hash before calling page SSE when capabilities declare page auth', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { | |
| required: true, | |
| schemes: ['form-password-hash'], | |
| form_field: 'passwordHash' | |
| } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected page call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_auth_required'); | |
| assert.match(body.error.message, /GPT_IMAGE_APP_PASSWORD_HASH/); | |
| assert.match(body.error.message, /\.env\.agent\.local/); | |
| assert.match(body.error.message, /GPT_IMAGE_AGENT_TOKEN/); | |
| assert.equal(body.summary.ok, false); | |
| assert.equal(body.summary.billable, false); | |
| assert.equal(body.summary.request_id, null); | |
| assert.equal(body.summary.idempotency_key.startsWith('agent-generate-'), true); | |
| assert.deepEqual(body.summary.artifact_ids, []); | |
| assert.deepEqual(body.summary.content_urls, []); | |
| assert.deepEqual(body.summary.absolute_content_urls, []); | |
| assert.equal(body.summary.route_mode, 'page_sse'); | |
| assert.equal(body.summary.image_backend, null); | |
| assert.equal(body.summary.stream_mode, null); | |
| assert.equal(body.summary.streaming_strategy, null); | |
| assert.equal(body.summary.selected_channel_id, null); | |
| assert.equal(body.summary.upstream_host, null); | |
| assert.equal(body.summary.transport_error_kind, null); | |
| assert.equal(body.summary.retry_after_ms, null); | |
| assert.equal(body.summary.retry_after_seconds, null); | |
| assert.equal(body.summary.cooldown_until, null); | |
| assert.equal(body.summary.cooldown_target, null); | |
| assert.equal(body.summary.elapsed_source, 'client_script'); | |
| assert.equal(typeof body.summary.elapsed_breakdown.client_script_ms, 'number'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('rejects overlong page SSE client request ids before sending the stream request', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { required: false, schemes: [], form_field: 'passwordHash' } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected page call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl, | |
| GPT_IMAGE_AGENT_IDEMPOTENCY_KEY: 'x'.repeat(129) | |
| } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_client_request_id_too_long'); | |
| assert.match(body.error.message, /不能超过 128 个字符/); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('enriches failed Agent generate summaries with Agent state diagnostics', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { code: 'unexpected_error', message: 'fetch failed', retryable: false } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests?idempotency_key=diag-generate-key') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| found: true, | |
| diagnostics: { | |
| request: { | |
| request_id: 'req_generate_diag', | |
| idempotency_key: 'diag-generate-key', | |
| status: 'failed' | |
| }, | |
| error: { | |
| code: 'unexpected_error', | |
| retryable: false, | |
| diagnostics: { | |
| channel_request_mode: 'images-non-stream', | |
| channel_request_mode_fallback_applied: true, | |
| route_decision: { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream', | |
| fallback_applied: true, | |
| selected_channel_id: 'channel-a', | |
| upstream_host: 'upstream.example.test' | |
| }, | |
| selected_channel_id: 'channel-a', | |
| upstream_host: 'upstream.example.test', | |
| transport_error_kind: 'aborted', | |
| retry_after_ms: 30000, | |
| cooldown_until: '2026-06-11T12:00:00.000Z', | |
| cooldown_target: { | |
| channel_id: 'channel-a', | |
| request_mode: 'images-non-stream' | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--idempotency-key', | |
| 'diag-generate-key', | |
| '--size', | |
| '1024x1024', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.summary.request_id, 'req_generate_diag'); | |
| assert.equal(body.summary.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.summary.channel_request_mode_fallback_applied, true); | |
| assert.deepEqual(body.summary.route_decision, { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream', | |
| fallback_applied: true, | |
| selected_channel_id: 'channel-a', | |
| upstream_host: 'upstream.example.test' | |
| }); | |
| assert.equal(body.summary.selected_channel_id, 'channel-a'); | |
| assert.equal(body.summary.upstream_host, 'upstream.example.test'); | |
| assert.equal(body.summary.transport_error_kind, 'aborted'); | |
| assert.equal(body.summary.retry_after_ms, 30000); | |
| assert.equal(body.summary.cooldown_until, '2026-06-11T12:00:00.000Z'); | |
| assert.deepEqual(body.summary.cooldown_target, { | |
| channel_id: 'channel-a', | |
| request_mode: 'images-non-stream' | |
| }); | |
| assert.equal(body.summary.agent_diagnostics_checked, true); | |
| assert.equal(body.summary.agent_diagnostics_found, true); | |
| assert.equal(body.agent_failure_diagnostics.request_id, 'req_generate_diag'); | |
| assert.equal(body.agent_failure_diagnostics.status, 'failed'); | |
| assert.deepEqual(body.agent_failure_diagnostics.cooldown_target, { | |
| channel_id: 'channel-a', | |
| request_mode: 'images-non-stream' | |
| }); | |
| assert.deepEqual(body.agent_failure_diagnostics.route_decision, { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream', | |
| fallback_applied: true, | |
| selected_channel_id: 'channel-a', | |
| upstream_host: 'upstream.example.test' | |
| }); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/images/generate', | |
| 'GET /api/agent/diagnostics/requests?idempotency_key=diag-generate-key' | |
| ] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('preserves path prefixes when fetching Agent state diagnostics', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/playground/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/playground/api/agent/images/generate') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { code: 'unexpected_error', message: 'fetch failed', retryable: false } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/playground/api/agent/diagnostics/requests?idempotency_key=path-diag-key') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| found: true, | |
| diagnostics: { | |
| request: { | |
| request_id: 'req_path_diag', | |
| idempotency_key: 'path-diag-key', | |
| status: 'failed' | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--idempotency-key', | |
| 'path-diag-key', | |
| '--size', | |
| '1024x1024', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: `${baseUrl}/playground` } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.summary.request_id, 'req_path_diag'); | |
| assert.equal(body.summary.agent_diagnostics_checked, true); | |
| assert.equal(body.summary.agent_diagnostics_found, true); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /playground/api/agent/capabilities', | |
| 'POST /playground/api/agent/images/generate', | |
| 'GET /playground/api/agent/diagnostics/requests?idempotency_key=path-diag-key' | |
| ] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('replaces stale retry guidance when Agent diagnostics report a terminal failure', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(503, { 'content-type': 'application/json', 'retry-after': '1' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { code: 'network_error', message: 'temporary failure', retryable: true } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests?idempotency_key=terminal-diag-key') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| found: true, | |
| diagnostics: { | |
| request: { | |
| request_id: 'req_terminal_diag', | |
| idempotency_key: 'terminal-diag-key', | |
| status: 'failed' | |
| }, | |
| error: { | |
| code: 'upstream_failed', | |
| retryable: false | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--idempotency-key', | |
| 'terminal-diag-key', | |
| '--size', | |
| '1024x1024', | |
| 'prompt' | |
| ], | |
| { | |
| GPT_IMAGE_AGENT_MAX_ATTEMPTS: '1', | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.summary.request_id, 'req_terminal_diag'); | |
| assert.equal(body.summary.retryable, false); | |
| assert.equal(body.summary.next_action, 'diagnose_then_new_idempotency_key'); | |
| assert.equal(body.agent_failure_diagnostics.retryable, false); | |
| } | |
| ); | |
| }); | |
| it('does not report diagnostics lookup failures as upstream transport errors', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { code: 'unexpected_error', message: 'fetch failed', retryable: false } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests?idempotency_key=diag-unavailable-key') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'diagnostics unavailable' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--idempotency-key', | |
| 'diag-unavailable-key', | |
| '--size', | |
| '1024x1024', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.summary.transport_error_kind, null); | |
| assert.equal(body.summary.agent_diagnostics_checked, true); | |
| assert.equal(body.summary.agent_diagnostics_found, false); | |
| assert.equal(body.summary.agent_diagnostics_unavailable_reason, 'status_500'); | |
| assert.equal(body.agent_failure_diagnostics.unavailable_reason, 'status_500'); | |
| } | |
| ); | |
| }); | |
| it('uses page SSE client request id max length declared by capabilities', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { required: false, schemes: [], form_field: 'passwordHash' }, | |
| client_request_id: { max_length: 12 } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected page call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--page-sse', | |
| '--size', | |
| '3072x2048', | |
| '--quality', | |
| 'high', | |
| '--idempotency-key', | |
| 'too-long-page-key', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_client_request_id_too_long'); | |
| assert.match(body.error.message, /不能超过 12 个字符/); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('checks default path-mode page SSE generate dimensions from raw SSE image bytes before formatting', async () => { | |
| const finalImageBase64 = fakePngBase64(3072, 2048); | |
| let imageRouteHits = 0; | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { required: false, schemes: [], form_field: 'passwordHash' } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| `data: ${JSON.stringify({ | |
| type: 'completed', | |
| filename: 'image.png', | |
| b64_json: finalImageBase64, | |
| path: '/api/image/image.png', | |
| output_format: 'png' | |
| })}`, | |
| '', | |
| 'data: {"type":"done","client_request_id":"page-request-1","images":[{"filename":"image.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/image/image.png') { | |
| imageRouteHits += 1; | |
| response.writeHead(401, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'cookie required' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--page-sse', | |
| '--dimension-check', | |
| '--size', | |
| '3072x2048', | |
| '--quality', | |
| 'high', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.equal(imageRouteHits, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.images[0].filename, 'image.png'); | |
| assert.equal('b64_json' in body.images[0], false); | |
| assert.equal(body.images[0].path, '/api/image/image.png'); | |
| assert.equal(body.images[0].absolute_path, `${baseUrl}/api/image/image.png`); | |
| assert.equal(body.images[0].output_format, 'png'); | |
| assert.equal(body.images[0].clientRequestId, 'page-request-1'); | |
| assert.deepEqual(body.images[0].dimensions, { width: 3072, height: 2048 }); | |
| assert.deepEqual(body.summary.actual_dimensions, { width: 3072, height: 2048 }); | |
| } | |
| ); | |
| }); | |
| it('passes response mode through to page SSE form-data requests', async () => { | |
| let pageRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { required: false, schemes: [], form_field: 'passwordHash' } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}', | |
| '', | |
| 'data: {"type":"done","client_request_id":"page-request-response-mode"}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--page-sse', | |
| '--size', | |
| '3072x2048', | |
| '--quality', | |
| 'high', | |
| '--response-mode', | |
| 'base64', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.match(pageRequestBody, /name="response_mode"\r?\n\r?\nbase64/); | |
| } | |
| ); | |
| }); | |
| it('omits implicit page streaming controls unless explicitly requested', async () => { | |
| let pageRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { required: false, schemes: [], form_field: 'passwordHash' } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}', | |
| '', | |
| 'data: {"type":"done","client_request_id":"page-request-default-streaming"}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '1024x1024', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.doesNotMatch(pageRequestBody, /name="stream"/); | |
| assert.doesNotMatch(pageRequestBody, /name="image_streaming_strategy"/); | |
| } | |
| ); | |
| }); | |
| it('enforces timeout while waiting for page SSE body events', async () => { | |
| let pageSseRequestReceived = false; | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { required: false, schemes: [], form_field: 'passwordHash' } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageSseRequestReceived = true; | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.write(': keepalive\n\n'); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--page-sse', | |
| '--timeout-ms', | |
| '1000', | |
| '--size', | |
| '1024x1024', | |
| '--quality', | |
| 'high', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl }, | |
| { timeoutMs: 10_000 } | |
| ); | |
| assert.equal(result.timedOut, false); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.equal(pageSseRequestReceived, true); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'page_sse_failed'); | |
| assert.match(body.error.message, /\/api\/images/); | |
| } | |
| ); | |
| }); | |
| it('passes page access hash through to page SSE form-data requests when configured', async () => { | |
| let pageRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { | |
| required: true, | |
| schemes: ['form-password-hash'], | |
| form_field: 'passwordHash' | |
| } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}', | |
| '', | |
| 'data: {"type":"done","client_request_id":"page-request-password"}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl, | |
| GPT_IMAGE_APP_PASSWORD_HASH: 'hash-for-page-sse' | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.match(pageRequestBody, /name="passwordHash"\r?\n\r?\nhash-for-page-sse/); | |
| } | |
| ); | |
| }); | |
| it('keeps page SSE base64 in default path-mode output when no path is returned', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { required: false, schemes: [], form_field: 'passwordHash' } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"image.png","b64_json":"indexeddb-base64","output_format":"png"}', | |
| '', | |
| 'data: {"type":"done","client_request_id":"page-request-indexeddb"}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.images[0].filename, 'image.png'); | |
| assert.equal(body.images[0].b64_json, 'indexeddb-base64'); | |
| assert.equal('absolute_path' in body.images[0], false); | |
| assert.equal(body.images[0].clientRequestId, 'page-request-indexeddb'); | |
| } | |
| ); | |
| }); | |
| it('preserves completed page SSE images when the done event lists fewer images', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { | |
| supported: true, | |
| endpoint: '/api/images', | |
| auth: { required: false, schemes: [], form_field: 'passwordHash' } | |
| } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"image-a.png","b64_json":"base64-a","path":"/api/image/image-a.png","output_format":"png"}', | |
| '', | |
| 'data: {"type":"completed","filename":"image-b.png","b64_json":"base64-b","path":"/api/image/image-b.png","output_format":"png"}', | |
| '', | |
| 'data: {"type":"done","client_request_id":"page-request-2","images":[{"filename":"image-a.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.images.length, 2); | |
| assert.equal(body.images[0].filename, 'image-a.png'); | |
| assert.equal(body.images[1].filename, 'image-b.png'); | |
| assert.equal(body.images[1].path, '/api/image/image-b.png'); | |
| assert.equal(body.images[1].absolute_path, `${baseUrl}/api/image/image-b.png`); | |
| assert.equal(body.images[1].clientRequestId, 'page-request-2'); | |
| } | |
| ); | |
| }); | |
| it('passes streaming strategy off through explicit Agent JSON generate requests', async () => { | |
| const requests = []; | |
| let agentRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| agentRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| images: [ | |
| { | |
| filename: 'agent-off.png', | |
| content_url: '/api/agent/artifacts/artifact-off/content', | |
| metadata_url: '/api/agent/artifacts/artifact-off', | |
| width: 1254, | |
| height: 1254 | |
| } | |
| ], | |
| timing: { server_elapsed_ms: 4321 } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected page SSE call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--size', | |
| '3072x2048', | |
| '--quality', | |
| 'high', | |
| '--streaming-strategy', | |
| 'off', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.images[0].filename, 'agent-off.png'); | |
| assert.deepEqual(body.routing, { transport: 'agent_json', endpoint: '/api/agent/images/generate' }); | |
| assert.equal(body.summary.ok, true); | |
| assert.equal(body.summary.billable, true); | |
| assert.equal(body.summary.idempotency_key.startsWith('agent-generate-'), true); | |
| assert.equal(body.summary.transport, 'agent_json'); | |
| assert.equal(body.summary.endpoint, '/api/agent/images/generate'); | |
| assert.equal(body.summary.route_mode, 'agent'); | |
| assert.deepEqual(body.summary.content_urls, ['/api/agent/artifacts/artifact-off/content']); | |
| assert.deepEqual(body.summary.absolute_content_urls, [ | |
| `${baseUrl}/api/agent/artifacts/artifact-off/content` | |
| ]); | |
| assert.deepEqual(body.summary.actual_dimensions, { width: 1254, height: 1254 }); | |
| assert.deepEqual(body.summary.image_dimensions, [{ width: 1254, height: 1254 }]); | |
| assert.equal(typeof body.summary.elapsed_ms, 'number'); | |
| assert.equal(body.summary.elapsed_source, 'client_script'); | |
| assert.equal(body.summary.server_elapsed_ms, 4321); | |
| assert.equal(body.summary.elapsed_breakdown.upstream_or_server_ms, 4321); | |
| assert.equal(body.summary.next_action, 'done'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/agent/images/generate'] | |
| ); | |
| const requestBody = JSON.parse(agentRequestBody); | |
| assert.equal(requestBody.size, '3072x2048'); | |
| assert.equal(requestBody.streaming_strategy, 'off'); | |
| } | |
| ); | |
| }); | |
| it('uses Agent JSON for billable large generate requests when --agent is explicit', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| images: [ | |
| { | |
| filename: 'agent.png', | |
| content_url: '/api/agent/artifacts/artifact-1/content', | |
| metadata_url: '/api/agent/artifacts/artifact-1' | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected page SSE call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--agent', '--size', '3072x2048', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.images[0].filename, 'agent.png'); | |
| assert.equal(body.images[0].absolute_content_url, `${baseUrl}/api/agent/artifacts/artifact-1/content`); | |
| assert.deepEqual(body.routing, { transport: 'agent_json', endpoint: '/api/agent/images/generate' }); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/agent/images/generate'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('uses page SSE for billable small generate requests when --page-sse is explicit', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"small-page.png","path":"/api/image/small-page.png","output_format":"png"}', | |
| '', | |
| 'data: {"type":"done","client_request_id":"small-page-request","images":[{"filename":"small-page.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected Agent JSON call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--size', '1024x1024', '--quality', 'high', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.images[0].filename, 'small-page.png'); | |
| assert.equal(body.images[0].absolute_path, `${baseUrl}/api/image/small-page.png`); | |
| assert.deepEqual(body.routing, { | |
| transport: 'page_sse', | |
| endpoint: '/api/images', | |
| route_mode: 'page_sse', | |
| fallback_endpoint: '/api/agent/image-requests', | |
| fallback_mode: 'manual_after_diagnosis', | |
| stream_mode: null, | |
| streaming_strategy: null | |
| }); | |
| assert.equal(body.summary.ok, true); | |
| assert.equal(body.summary.billable, true); | |
| assert.equal(body.summary.transport, 'page_sse'); | |
| assert.equal(body.summary.endpoint, '/api/images'); | |
| assert.equal(body.summary.route_mode, 'page_sse'); | |
| assert.deepEqual(body.summary.content_urls, ['/api/image/small-page.png']); | |
| assert.deepEqual(body.summary.absolute_content_urls, [`${baseUrl}/api/image/small-page.png`]); | |
| assert.equal(typeof body.summary.elapsed_ms, 'number'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('passes GPT2Image-compatible generate options to page SSE only when explicitly requested', async () => { | |
| const requests = []; | |
| let pageSseRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ agent_streaming: { page_sse: { supported: true } } })); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageSseRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"generate.jpg","path":"/generated/generate.jpg"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"generate.jpg","path":"/generated/generate.jpg"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected Agent generate call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--page-sse', | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--thinking', | |
| 'medium', | |
| '--prompt-optimization', | |
| 'false', | |
| '--force-web', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| assert.match(pageSseRequestBody, /name="mode"\r?\n\r?\ngenerate/); | |
| assert.match(pageSseRequestBody, /name="image_backend"\r?\n\r?\nresponses-image-generation/); | |
| assert.match(pageSseRequestBody, /name="responsesModel"\r?\n\r?\ngpt-5\.4-mini/); | |
| assert.match(pageSseRequestBody, /name="thinking"\r?\n\r?\nmedium/); | |
| assert.match(pageSseRequestBody, /name="promptOptimization"\r?\n\r?\nfalse/); | |
| assert.match(pageSseRequestBody, /name="force_web"\r?\n\r?\ntrue/); | |
| } | |
| ); | |
| }); | |
| it('rejects generate page SSE partial images outside the selected backend capability before POST', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { page_sse: { supported: true } }, | |
| defaults: { partial_images: 2 }, | |
| limits: { | |
| partial_images: { min: 0, max: 4 }, | |
| partial_images_by_backend: { | |
| 'images-api': { min: 0, max: 4 }, | |
| 'responses-image-generation': { min: 1, max: 3 } | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'page SSE should not be called' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--partial-images', | |
| '0', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 2); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /partial_images 必须在当前 capabilities 允许的 1 到 3 之间/); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities'] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('rejects explicit page SSE when stream-mode is non_stream before network requests', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected request' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--page-sse', '--stream-mode', 'non_stream', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 2); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /stream_mode=non_stream/); | |
| assert.doesNotMatch(result.stderr, /ModuleJob|at buildGenerateRoutingGuidance/); | |
| assert.deepEqual(requests, []); | |
| } | |
| ); | |
| }); | |
| it('passes generate response controls through explicit Agent and job diagnostic routes', async () => { | |
| const requests = []; | |
| let agentRequestBody = ''; | |
| let jobRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/generate') { | |
| agentRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| images: [ | |
| { | |
| id: 'agent-artifact', | |
| filename: 'agent.webp', | |
| content_url: '/api/agent/artifacts/agent-artifact/content' | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/images/generate') { | |
| jobRequestBody = await readRequestText(request); | |
| response.writeHead(202, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-diagnostic-1', | |
| state: 'running', | |
| result_url: '/api/agent/jobs/job-diagnostic-1/result', | |
| retry_after_seconds: 1 | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/job-diagnostic-1/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'job-diagnostic-1', | |
| idempotency_key: 'job-diagnostic-key', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'job-artifact', | |
| filename: 'job.webp', | |
| content_url: '/api/agent/artifacts/job-artifact/content' | |
| } | |
| ], | |
| execution: { | |
| transport: 'agent_job_polling', | |
| endpoint: '/api/agent/jobs/images/generate', | |
| route_mode: 'job', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'auto', | |
| streaming_strategy: 'auto' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const agentBody = JSON.parse(agentRequestBody); | |
| assert.equal(agentBody.image_backend, 'responses'); | |
| assert.equal(agentBody.responsesModel, 'gpt-5.4-mini'); | |
| const jobResult = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--job', | |
| '--idempotency-key', | |
| 'job-diagnostic-key', | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(jobResult.status, 0); | |
| assert.equal(jobResult.stderr.trim(), ''); | |
| const jobBody = JSON.parse(jobRequestBody); | |
| assert.equal(jobBody.image_backend, 'responses'); | |
| assert.equal(jobBody.responsesModel, 'gpt-5.4-mini'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/images/generate', | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/jobs/images/generate', | |
| 'GET /api/agent/jobs/job-diagnostic-1/result' | |
| ] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('keeps generate response controls on server orchestration when streaming is disabled', async () => { | |
| const requests = []; | |
| let imageRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| imageRequestBody = await readRequestText(request); | |
| response.writeHead(202, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-nonstream-controls', | |
| state: 'running', | |
| result_url: '/api/agent/jobs/job-nonstream-controls/result', | |
| retry_after_seconds: 1 | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/job-nonstream-controls/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'job-nonstream-controls', | |
| idempotency_key: 'nonstream-controls-key', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'artifact-nonstream-controls', | |
| filename: 'nonstream.webp', | |
| content_url: '/api/agent/artifacts/artifact-nonstream-controls/content' | |
| } | |
| ], | |
| execution: { | |
| transport: 'agent_job_polling', | |
| endpoint: '/api/agent/image-requests', | |
| route_mode: 'job', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'non_stream', | |
| streaming_strategy: 'auto' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--idempotency-key', | |
| 'nonstream-controls-key', | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--stream-mode', | |
| 'non_stream', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const requestBody = JSON.parse(imageRequestBody); | |
| assert.equal(requestBody.image_backend, 'responses'); | |
| assert.equal(requestBody.responsesModel, 'gpt-5.4-mini'); | |
| assert.equal(requestBody.stream_mode, 'non_stream'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/image-requests', | |
| 'GET /api/agent/jobs/job-nonstream-controls/result' | |
| ] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('prints page SSE guidance for high-resolution edit dry-runs', () => { | |
| const result = runSkillScript('edit-image.mjs', [ | |
| '--size', | |
| '3072x2048', | |
| '--quality', | |
| 'high', | |
| '/tmp/source.png', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.routing_guidance.recommended_endpoint, '/api/images'); | |
| assert.equal(body.routing_guidance.transport, 'page_sse'); | |
| assert.equal(body.routing_guidance.strength, 'default'); | |
| assert.equal(result.stderr.trim(), ''); | |
| }); | |
| it('rejects edit dimension-check without a fixed requested size before remote execution', () => { | |
| const result = runSkillScript('edit-image.mjs', [ | |
| '--dimension-check', | |
| '--size', | |
| 'auto', | |
| '/tmp/source.png', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 2); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /--dimension-check 需要 --size 为 WIDTHxHEIGHT/); | |
| }); | |
| it('routes GPT2Image-compatible edit options through page SSE dry-runs', () => { | |
| const result = runSkillScript('edit-image.mjs', [ | |
| '--format', | |
| 'jpeg', | |
| '--output-compression', | |
| '85', | |
| '--moderation', | |
| 'auto', | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--thinking', | |
| 'medium', | |
| '--prompt-optimization', | |
| 'false', | |
| '--force-web', | |
| '/tmp/source.png', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.routing_guidance.transport, 'page_sse'); | |
| assert.equal(body.request.output_format, 'jpeg'); | |
| assert.equal(body.request.output_compression, 85); | |
| assert.equal(body.request.moderation, 'auto'); | |
| assert.equal(body.request.image_backend, 'responses-image-generation'); | |
| assert.equal(body.request.responsesModel, 'gpt-5.4-mini'); | |
| assert.equal(body.request.thinking, 'medium'); | |
| assert.equal(body.request.promptOptimization, false); | |
| assert.equal(body.request.force_web, true); | |
| }); | |
| it('shows edit dimension-check in dry-run requests', () => { | |
| const result = runSkillScript('edit-image.mjs', [ | |
| '--dimension-check', | |
| '--size', | |
| '1024x1024', | |
| '--agent', | |
| '/tmp/source.png', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.request.dimension_check, true); | |
| assert.equal(body.routing_guidance.transport, 'agent_json'); | |
| }); | |
| it('uses page SSE for billable high-resolution edit requests', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| const requests = []; | |
| let pageSseRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageSseRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"edit.png","path":"/generated/edit.png","clientRequestId":"edit-page-sse-key"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"edit.png","path":"/generated/edit.png"}],"clientRequestId":"edit-page-sse-key"}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--size', | |
| '3072x2048', | |
| '--idempotency-key', | |
| 'edit-page-sse-key', | |
| imagePath, | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.deepEqual(body.routing, { | |
| transport: 'page_sse', | |
| endpoint: '/api/images', | |
| fallback_endpoint: '/api/agent/images/edit', | |
| fallback_mode: 'manual_after_diagnosis' | |
| }); | |
| assert.equal(body.images[0].absolute_path, `${baseUrl}/generated/edit.png`); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| assert.match(pageSseRequestBody, /name="mode"\r?\n\r?\nedit/); | |
| assert.match(pageSseRequestBody, /name="clientRequestId"\r?\n\r?\nedit-page-sse-key/); | |
| assert.match(pageSseRequestBody, /name="image_0"; filename="source\.png"/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('checks edit page SSE output dimensions from raw SSE image bytes before formatting', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-dim-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| const finalImageBase64 = fakePngBase64(1024, 1024); | |
| let imageRouteHits = 0; | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify( | |
| agentGenerateCapabilities({ agent_streaming: { page_sse: { supported: true } } }) | |
| ) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| `data: ${JSON.stringify({ | |
| type: 'completed', | |
| filename: 'edit.png', | |
| b64_json: finalImageBase64, | |
| path: '/api/image/edit-ok.png' | |
| })}`, | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"edit.png","path":"/api/image/edit-ok.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/image/edit-ok.png') { | |
| imageRouteHits += 1; | |
| response.writeHead(401, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'cookie required' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--dimension-check', | |
| '--page-sse', | |
| '--size', | |
| '1024x1024', | |
| imagePath, | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.equal(imageRouteHits, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal('b64_json' in body.images[0], false); | |
| assert.equal(body.images[0].path, '/api/image/edit-ok.png'); | |
| assert.equal(body.images[0].absolute_path, `${baseUrl}/api/image/edit-ok.png`); | |
| assert.deepEqual(body.images[0].dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.summary.actual_dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.summary.image_dimensions, [{ width: 1024, height: 1024 }]); | |
| assert.equal(body.summary.transport, 'page_sse'); | |
| assert.equal(body.summary.endpoint, '/api/images'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('reports edit dimension-check artifact download failures as validation failures', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-dim-download-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify( | |
| agentGenerateCapabilities({ agent_streaming: { page_sse: { supported: true } } }) | |
| ) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"edit.png","path":"/artifact/missing-edit.png"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"edit.png","path":"/artifact/missing-edit.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing artifact' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--dimension-check', | |
| '--page-sse', | |
| '--size', | |
| '1024x1024', | |
| imagePath, | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'dimension_check_failed'); | |
| assert.match(body.error.message, /下载产物失败,状态码 404/); | |
| assert.equal(body.validation_failure_kind, 'generated_artifact_failed_dimension_check'); | |
| assert.equal(body.summary.dimension_check_failed, true); | |
| assert.deepEqual(body.summary.expected_dimensions, { width: 1024, height: 1024 }); | |
| assert.equal(body.summary.actual_dimensions, null); | |
| assert.equal(body.summary.transport, 'page_sse'); | |
| assert.equal(body.response.images[0].absolute_path, `${baseUrl}/artifact/missing-edit.png`); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('passes GPT2Image-compatible edit options to page SSE form-data', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-fields-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| let pageSseRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ agent_streaming: { page_sse: { supported: true } } })); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageSseRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"edit.jpg","path":"/generated/edit.jpg"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"edit.jpg","path":"/generated/edit.jpg"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--format', | |
| 'jpeg', | |
| '--output-compression', | |
| '85', | |
| '--moderation', | |
| 'auto', | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--thinking', | |
| 'medium', | |
| '--prompt-optimization', | |
| 'false', | |
| '--force-web', | |
| imagePath, | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.match(pageSseRequestBody, /name="output_format"\r?\n\r?\njpeg/); | |
| assert.match(pageSseRequestBody, /name="output_compression"\r?\n\r?\n85/); | |
| assert.match(pageSseRequestBody, /name="moderation"\r?\n\r?\nauto/); | |
| assert.match(pageSseRequestBody, /name="image_backend"\r?\n\r?\nresponses-image-generation/); | |
| assert.match(pageSseRequestBody, /name="responsesModel"\r?\n\r?\ngpt-5\.4-mini/); | |
| assert.match(pageSseRequestBody, /name="thinking"\r?\n\r?\nmedium/); | |
| assert.match(pageSseRequestBody, /name="promptOptimization"\r?\n\r?\nfalse/); | |
| assert.match(pageSseRequestBody, /name="force_web"\r?\n\r?\ntrue/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('keeps edit page SSE successful when the optional raw log path is unwritable', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-sse-log-unwritable-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ agent_streaming: { page_sse: { supported: true } } })); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"edit.png","path":"/generated/edit.png"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"edit.png","path":"/generated/edit.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| ['--allow-billable', '--page-sse', '--sse-log', tempRoot, imagePath, 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.match(result.stderr, /SSE log write failed/); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.images.length, 1); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('uses Agent edit for explicit high-resolution edit fallback requests', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-agent-fallback-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| const requests = []; | |
| let editRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| editRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ images: [{ id: 'edit-image', filename: 'edit.png' }] })); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected page SSE call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| ['--allow-billable', '--agent', '--size', '3072x2048', imagePath, 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/agent/images/edit'] | |
| ); | |
| assert.match(editRequestBody, /name="size"\r?\n\r?\n3072x2048/); | |
| assert.match(editRequestBody, /name="image_0"; filename="source\.png"/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('checks explicit Agent edit output dimensions from base64 images', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-agent-dim-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'edit-dimension-ok', | |
| idempotency_key: 'edit-dimension-ok-key', | |
| execution: { | |
| transport: 'agent_json', | |
| endpoint: '/api/agent/images/edit', | |
| route_mode: 'agent', | |
| channel_request_mode: 'images-non-stream', | |
| channel_request_mode_fallback_applied: false, | |
| route_decision: { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream' | |
| }, | |
| selected_channel_id: 'edit-dim-channel', | |
| upstream_host: 'edit-dim-upstream.example.test' | |
| }, | |
| images: [{ id: 'edit-ok', filename: 'edit.png', b64_json: fakePngBase64(1024, 1024) }] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--dimension-check', | |
| '--size', | |
| '1024x1024', | |
| '--idempotency-key', | |
| 'edit-dimension-ok-key', | |
| imagePath, | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.deepEqual(body.images[0].dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.summary.actual_dimensions, { width: 1024, height: 1024 }); | |
| assert.equal(body.summary.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.summary.selected_channel_id, 'edit-dim-channel'); | |
| assert.equal(body.summary.upstream_host, 'edit-dim-upstream.example.test'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('fails explicit Agent edit dimension-check mismatches with route diagnostics', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-agent-dim-bad-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'edit-dimension-bad', | |
| idempotency_key: 'edit-dimension-bad-key', | |
| execution: { | |
| transport: 'agent_json', | |
| endpoint: '/api/agent/images/edit', | |
| route_mode: 'agent', | |
| channel_request_mode: 'images-non-stream', | |
| channel_request_mode_fallback_applied: true, | |
| route_decision: { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream' | |
| }, | |
| selected_channel_id: 'edit-dim-bad-channel', | |
| upstream_host: 'edit-dim-bad-upstream.example.test' | |
| }, | |
| images: [{ id: 'edit-bad', filename: 'edit.png', b64_json: fakePngBase64(1254, 1254) }] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--dimension-check', | |
| '--size', | |
| '1024x1024', | |
| '--idempotency-key', | |
| 'edit-dimension-bad-key', | |
| imagePath, | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'dimension_check_failed'); | |
| assert.equal(body.validation_failure_kind, 'generated_artifact_failed_dimension_check'); | |
| assert.deepEqual(body.error.expected_dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.error.actual_dimensions, { width: 1254, height: 1254 }); | |
| assert.equal(body.response.images[0].b64_json, undefined); | |
| assert.equal(body.response.images[0].b64_json_length, fakePngBase64(1254, 1254).length); | |
| assert.deepEqual(body.response.images[0].dimensions, { width: 1254, height: 1254 }); | |
| assert.equal(body.summary.dimension_check_failed, true); | |
| assert.equal(body.summary.transport, 'agent_json'); | |
| assert.equal(body.summary.endpoint, '/api/agent/images/edit'); | |
| assert.equal(body.summary.route_mode, 'agent'); | |
| assert.equal(body.summary.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.summary.channel_request_mode_fallback_applied, true); | |
| assert.deepEqual(body.summary.route_decision, { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream' | |
| }); | |
| assert.equal(body.summary.selected_channel_id, 'edit-dim-bad-channel'); | |
| assert.equal(body.summary.upstream_host, 'edit-dim-bad-upstream.example.test'); | |
| assert.match(body.summary.next_action, /确认当前编辑渠道是否支持请求尺寸/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('enriches failed Agent edit summaries with Agent state diagnostics', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-diagnostics-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| await readRequestText(request); | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { code: 'unexpected_error', message: 'edit failed', retryable: false } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests?idempotency_key=diag-edit-key') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| found: true, | |
| diagnostics: { | |
| request: { | |
| request_id: 'req_edit_diag', | |
| idempotency_key: 'diag-edit-key', | |
| status: 'failed' | |
| }, | |
| error: { | |
| code: 'unexpected_error', | |
| retryable: false, | |
| diagnostics: { | |
| selected_channel_id: 'channel-edit', | |
| upstream_host: 'edit-upstream.example.test', | |
| transport_error_kind: 'socket_closed' | |
| } | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--idempotency-key', | |
| 'diag-edit-key', | |
| '--size', | |
| '3072x2048', | |
| imagePath, | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.summary.request_id, 'req_edit_diag'); | |
| assert.equal(body.summary.selected_channel_id, 'channel-edit'); | |
| assert.equal(body.summary.upstream_host, 'edit-upstream.example.test'); | |
| assert.equal(body.summary.transport_error_kind, 'socket_closed'); | |
| assert.equal(body.summary.agent_diagnostics_checked, true); | |
| assert.equal(body.summary.agent_diagnostics_found, true); | |
| assert.equal(body.agent_failure_diagnostics.request_id, 'req_edit_diag'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/images/edit', | |
| 'GET /api/agent/diagnostics/requests?idempotency_key=diag-edit-key' | |
| ] | |
| ); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects default WebP edit dry-runs when streaming is explicitly disabled', () => { | |
| const result = runSkillScript('edit-image.mjs', [ | |
| '--size', | |
| '3072x2048', | |
| '--stream-mode', | |
| 'non_stream', | |
| '/tmp/source.png', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 2); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /默认 WebP 图生图输出需要页面 SSE/); | |
| }); | |
| it('rejects explicit edit page SSE when stream-mode is non_stream before network requests', () => { | |
| const result = runSkillScript('edit-image.mjs', [ | |
| '--allow-billable', | |
| '--page-sse', | |
| '--stream-mode', | |
| 'non_stream', | |
| '/tmp/source.png', | |
| 'prompt' | |
| ]); | |
| assert.equal(result.status, 2); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /stream_mode=non_stream/); | |
| }); | |
| it('reports high-resolution edit page SSE failures as billable structured failures', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-fail-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| 'data: {"type":"error","error":{"message":"edit stream failed"},"status":502}\n\n' | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| ['--allow-billable', '--size', '3072x2048', imagePath, 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.billable, true); | |
| assert.equal(body.error.code, 'page_sse_failed'); | |
| assert.equal(body.error.status, 502); | |
| assert.match(body.error.message, /edit stream failed/); | |
| assert.deepEqual(body.routing, { | |
| transport: 'page_sse', | |
| endpoint: '/api/images', | |
| fallback_endpoint: '/api/agent/images/edit', | |
| fallback_mode: 'manual_after_diagnosis' | |
| }); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('reports high-resolution edit page validation failures as non-billable', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-page-sse-reject-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(400, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'invalid edit request' })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| ['--allow-billable', '--size', '3072x2048', imagePath, 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.billable, false); | |
| assert.equal(body.summary.billable, false); | |
| assert.equal(body.error.code, 'page_sse_request_rejected'); | |
| assert.equal(body.error.status, 400); | |
| assert.match(body.error.message, /invalid edit request/); | |
| assert.equal(body.routing.fallback_endpoint, '/api/agent/images/edit'); | |
| assert.equal(body.routing.fallback_mode, 'fix_request_before_retry'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('sends upstream streaming fields in billable edit multipart requests', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-')); | |
| try { | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| const requests = []; | |
| let editRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| editRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ images: [{ id: 'edit-image', filename: 'edit.png' }] })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'edit-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--agent', | |
| '--stream-mode', | |
| 'stream', | |
| '--streaming-strategy', | |
| 'responses-sse', | |
| '--partial-images', | |
| '3', | |
| '--force-request', | |
| imagePath, | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/agent/images/edit'] | |
| ); | |
| assert.match(editRequestBody, /name="stream_mode"\r?\n\r?\nstream/); | |
| assert.match(editRequestBody, /name="streaming_strategy"\r?\n\r?\nresponses-sse/); | |
| assert.match(editRequestBody, /name="partial_images"\r?\n\r?\n3/); | |
| assert.match(editRequestBody, /name="force_request"\r?\n\r?\ntrue/); | |
| assert.match(editRequestBody, /name="image_0"; filename="source\.png"/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects invalid generate upstream streaming options before dry-run output', () => { | |
| const invalidPartialImages = runSkillScript('generate-image.mjs', ['--partial-images', '5', 'prompt']); | |
| assert.equal(invalidPartialImages.status, 2); | |
| assert.match(invalidPartialImages.stderr, /--partial-images 必须是 0 到 4 的整数/); | |
| assert.equal(invalidPartialImages.stdout.trim(), ''); | |
| const invalidBackend = runSkillScript('generate-image.mjs', ['--image-backend', 'unknown-backend', 'prompt']); | |
| assert.equal(invalidBackend.status, 2); | |
| assert.match(invalidBackend.stderr, /--image-backend 必须是/); | |
| assert.equal(invalidBackend.stdout.trim(), ''); | |
| const invalidStrategy = runSkillScript('generate-image.mjs', [ | |
| '--streaming-strategy', | |
| 'unknown-strategy', | |
| 'prompt' | |
| ]); | |
| assert.equal(invalidStrategy.status, 2); | |
| assert.match(invalidStrategy.stderr, /--streaming-strategy 必须是/); | |
| assert.equal(invalidStrategy.stdout.trim(), ''); | |
| const invalidResponseMode = runSkillScript('generate-image.mjs', ['--response-mode', 'url', 'prompt']); | |
| assert.equal(invalidResponseMode.status, 2); | |
| assert.match(invalidResponseMode.stderr, /--response-mode 必须是 path、base64 或 both/); | |
| assert.equal(invalidResponseMode.stdout.trim(), ''); | |
| const invalidThinking = runSkillScript('generate-image.mjs', [ | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--thinking', | |
| 'unknown', | |
| 'prompt' | |
| ]); | |
| assert.equal(invalidThinking.status, 2); | |
| assert.match(invalidThinking.stderr, /--thinking 必须是/); | |
| assert.equal(invalidThinking.stdout.trim(), ''); | |
| const invalidPromptOptimization = runSkillScript('generate-image.mjs', [ | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| '--prompt-optimization', | |
| 'maybe', | |
| 'prompt' | |
| ]); | |
| assert.equal(invalidPromptOptimization.status, 2); | |
| assert.match(invalidPromptOptimization.stderr, /--prompt-optimization 必须是 true 或 false/); | |
| assert.equal(invalidPromptOptimization.stdout.trim(), ''); | |
| const emptyResponsesModel = runSkillScript('generate-image.mjs', [ | |
| '--image-backend', | |
| 'responses', | |
| '--responses-model', | |
| ' ', | |
| 'prompt' | |
| ]); | |
| assert.equal(emptyResponsesModel.status, 2); | |
| assert.match(emptyResponsesModel.stderr, /--responses-model 必须是非空字符串/); | |
| assert.equal(emptyResponsesModel.stdout.trim(), ''); | |
| const responsesModelWithoutBackend = runSkillScript('generate-image.mjs', [ | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| 'prompt' | |
| ]); | |
| assert.equal(responsesModelWithoutBackend.status, 2); | |
| assert.match(responsesModelWithoutBackend.stderr, /--responses-model 必须同时设置 --image-backend/); | |
| assert.equal(responsesModelWithoutBackend.stdout.trim(), ''); | |
| const responsesModelImagesBackend = runSkillScript('generate-image.mjs', [ | |
| '--image-backend', | |
| 'images-api', | |
| '--responses-model', | |
| 'gpt-5.4-mini', | |
| 'prompt' | |
| ]); | |
| assert.equal(responsesModelImagesBackend.status, 2); | |
| assert.match(responsesModelImagesBackend.stderr, /--responses-model 仅适用于 --image-backend/); | |
| assert.equal(responsesModelImagesBackend.stdout.trim(), ''); | |
| const disabledAutoPageSse = runSkillScript('generate-image.mjs', [ | |
| '--size', | |
| '3072x2048', | |
| '--streaming-strategy', | |
| 'off', | |
| 'prompt' | |
| ]); | |
| assert.equal(disabledAutoPageSse.status, 0); | |
| const disabledAutoPageSseBody = JSON.parse(disabledAutoPageSse.stdout); | |
| assert.equal(disabledAutoPageSseBody.endpoint, 'http://localhost:4783/api/agent/image-requests'); | |
| assert.equal(disabledAutoPageSseBody.routing_guidance.recommended_endpoint, '/api/agent/image-requests'); | |
| assert.equal(disabledAutoPageSseBody.routing_guidance.transport, 'server_orchestrated'); | |
| assert.equal(disabledAutoPageSse.stderr.trim(), ''); | |
| const disabledPageSse = runSkillScript('generate-image.mjs', [ | |
| '--page-sse', | |
| '--streaming-strategy', | |
| 'off', | |
| 'prompt' | |
| ]); | |
| assert.equal(disabledPageSse.status, 2); | |
| assert.match(disabledPageSse.stderr, /streaming_strategy=off/); | |
| assert.doesNotMatch(disabledPageSse.stderr, /ModuleJob|at buildGenerateRoutingGuidance/); | |
| assert.equal(disabledPageSse.stdout.trim(), ''); | |
| }); | |
| it('shows edit help without validating unrelated env values', () => { | |
| const result = runSkillScript('edit-image.mjs', ['--help'], { | |
| GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc' | |
| }); | |
| assert.equal(result.status, 0); | |
| assert.match(result.stderr, /用法:edit-image\.mjs/); | |
| assert.equal(result.stdout.trim(), ''); | |
| }); | |
| it('shows skill script help without validating service URL env values', () => { | |
| const env = { GPT_IMAGE_PLAYGROUND_URL: 'https://user:secret@example.test' }; | |
| const generateHelp = runSkillScript('generate-image.mjs', ['--help'], env); | |
| const editHelp = runSkillScript('edit-image.mjs', ['--help'], env); | |
| assert.equal(generateHelp.status, 0); | |
| assert.match(generateHelp.stderr, /用法:generate-image\.mjs/); | |
| assert.equal(generateHelp.stdout.trim(), ''); | |
| assert.equal(editHelp.status, 0); | |
| assert.match(editHelp.stderr, /用法:edit-image\.mjs/); | |
| assert.equal(editHelp.stdout.trim(), ''); | |
| }); | |
| it('keeps the skill package free of machine-specific and shell-specific paths', () => { | |
| const forbiddenPatterns = [ | |
| /\/Users\/[^`\\s)]+/, | |
| /\/Volumes\/[^`\\s)]+/, | |
| /\/home\/[^`\\s)]+/, | |
| /C:\\\\Users\\\\[^`\\s)]+/i, | |
| /\.\.\/\.\.\/\.\.\/src\//, | |
| /\.\.\/\.\.\/\.\.\/\.\.\/src\//, | |
| /```bash/, | |
| /(^|\n)[A-Z_][A-Z0-9_]*=.*\s+node\s/, | |
| /\\\r?\n\s+--/ | |
| ]; | |
| const matches = []; | |
| for (const filePath of listTextFiles(skillRoot)) { | |
| const content = readFileSync(filePath, 'utf8'); | |
| for (const pattern of forbiddenPatterns) { | |
| if (pattern.test(content)) { | |
| matches.push(filePath); | |
| break; | |
| } | |
| } | |
| } | |
| assert.deepEqual(matches, []); | |
| }); | |
| it('keeps skill frontmatter valid without requiring Python YAML tooling', () => { | |
| const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); | |
| const match = skillText.match(/^---\r?\n([\s\S]*?)\r?\n---/); | |
| assert.ok(match, 'SKILL.md must start with YAML frontmatter'); | |
| const frontmatter = Object.fromEntries( | |
| match[1] | |
| .split(/\r?\n/) | |
| .filter(Boolean) | |
| .map((line) => { | |
| const separator = line.indexOf(':'); | |
| assert.ok(separator > 0, `invalid frontmatter line: ${line}`); | |
| return [line.slice(0, separator).trim(), line.slice(separator + 1).trim()]; | |
| }) | |
| ); | |
| assert.deepEqual(Object.keys(frontmatter).sort(), ['description', 'name']); | |
| assert.match(frontmatter.name, /^[a-z0-9-]+$/); | |
| assert.ok(frontmatter.description.length > 0); | |
| assert.ok(frontmatter.description.length <= 1024); | |
| assert.doesNotMatch(frontmatter.description, /[<>]/); | |
| }); | |
| it('tells agents to use bundled scripts instead of ad hoc API callers', () => { | |
| const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); | |
| const openAiYaml = readFileSync(join(skillRoot, 'agents/openai.yaml'), 'utf8'); | |
| const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); | |
| assert.match(skillText, /必须优先运行本 Skill 内置 scripts\/generate-image\.mjs/); | |
| assert.match(skillText, /scripts\/channel-capability-matrix\.mjs/); | |
| assert.match(skillText, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/); | |
| assert.match(openAiYaml, /先选择并运行内置脚本/); | |
| assert.match(openAiYaml, /不要临时编写 API 调用脚本/); | |
| assert.match(apiReference, /先使用这些内置脚本/); | |
| assert.match(apiReference, /scripts\/channel-capability-matrix\.mjs/); | |
| assert.match(apiReference, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/); | |
| }); | |
| it('documents backend-specific partial image limits in dedicated Agent docs', () => { | |
| const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); | |
| const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); | |
| assert.match(skillText, /limits\.partial_images_by_backend\[image_backend\]/); | |
| assert.match( | |
| skillText, | |
| /Agent edit 不接受 `image_backend`,其内部上游流式字段按默认 Images API\/profile 范围校验/ | |
| ); | |
| assert.match(skillText, /Responses image_generation edit 属于页面 SSE 路径/); | |
| assert.match( | |
| skillText, | |
| /Docker compose 本身不设置这两个默认值,未配置 `\.env\.local` 时仍是 `images-api` 和 `auto`/ | |
| ); | |
| assert.match(skillText, /脚本参数仍写作 `--streaming-strategy responses-sse`/); | |
| assert.match(skillText, /batch JSONL 字段是 `streaming_strategy`/); | |
| assert.match(skillText, /`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段/); | |
| assert.match(skillText, /`capabilities` 里声明的 `page_sse_supported=true`/); | |
| assert.match(skillText, /`selected_channel_id`、`upstream_host` 为空/); | |
| assert.match(skillText, /支持位置参数 `<image-path> <prompt>`,也支持 `--image <path> <prompt>` 别名/); | |
| assert.match(skillText, /`--image-backend responses-image-generation` 只用于页面 SSE edit/); | |
| assert.match(skillText, /不要把 Matsca `limits\.partial_images=0\.\.4` 误套到 `responses-image-generation`/); | |
| assert.match(apiReference, /limits\.partial_images_by_backend\[image_backend\]/); | |
| assert.match(apiReference, /只代表“声明支持”,不代表当前渠道每次实测都能成功/); | |
| assert.match(apiReference, /如果 `selected_channel_id`、`upstream_host` 为空/); | |
| assert.match(apiReference, /Agent edit 不接收 `image_backend`、`output_format` 或 `output_compression`/); | |
| assert.match( | |
| apiReference, | |
| /强制 Agent edit 时由服务端固定使用 WebP 输出契约,`partial_images` 按默认 Images API\/profile 范围校验/ | |
| ); | |
| assert.match( | |
| apiReference, | |
| /图片路径可以用位置参数 `<image-path> <prompt>`,也可以用 `--image <path> <prompt>`/ | |
| ); | |
| assert.match( | |
| apiReference, | |
| /Agent edit 不接受 `image_backend`;需要 Responses backend edit 字段时必须使用页面端 `\/api\/images` form-data SSE 路径/ | |
| ); | |
| assert.match(apiReference, /Responses image_generation edit 必须走页面 SSE/); | |
| assert.match(apiReference, /JSONL 字段名必须使用 `streaming_strategy`/); | |
| assert.match(apiReference, /`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段/); | |
| assert.match(apiReference, /Responses image_generation 后端仅在页面 SSE `\/api\/images` 中支持编辑功能/); | |
| assert.doesNotMatch(apiReference, /Responses image_generation 后端当前只支持 generate/); | |
| assert.match(apiReference, /选择 `responses-image-generation` 或兼容别名 `responses` 时必须优先使用该字段/); | |
| }); | |
| it('documents batch capacity and dimension guardrails for real Agent runs', () => { | |
| const readmeText = readFileSync(join(repoRoot, 'README.md'), 'utf8'); | |
| const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); | |
| const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); | |
| assert.match(readmeText, /npm run first-run/); | |
| assert.match(readmeText, /npm run first-run -- --json/); | |
| assert.match(readmeText, /\.env\.agent\.local\.example/); | |
| assert.match(readmeText, /--base-url http:\/\/localhost:4783/); | |
| assert.match(skillText, /npm run first-run/); | |
| assert.match(skillText, /-- --json/); | |
| assert.match(skillText, /--base-url <url>/); | |
| assert.match(skillText, /\.env\.agent\.local\.example/); | |
| assert.match(skillText, /page_sse_real_smoke_status/); | |
| assert.match(skillText, /responses_image_backend_real_smoke_status/); | |
| assert.match(skillText, /summary\.page_sse_real_smoke/); | |
| assert.match(skillText, /兼容聚合状态/); | |
| assert.match(skillText, /summary\.responses_page_sse_generate_smoke/); | |
| assert.match(skillText, /summary\.responses_agent_generate_smoke/); | |
| assert.match(skillText, /responses_agent_generate_1k/); | |
| assert.match(skillText, /request_mode_controls/); | |
| assert.match(skillText, /summary\.real_smoke_checks/); | |
| assert.match(apiReference, /npm run first-run/); | |
| assert.match(apiReference, /npm run first-run -- --json/); | |
| assert.match(apiReference, /--base-url <url>/); | |
| assert.match(apiReference, /\.env\.agent\.local\.example/); | |
| assert.match(apiReference, /page_sse_real_smoke_status/); | |
| assert.match(apiReference, /responses_image_backend_real_smoke_status/); | |
| assert.match(apiReference, /summary\.page_sse_real_smoke/); | |
| assert.match(apiReference, /兼容聚合状态/); | |
| assert.match(apiReference, /summary\.responses_page_sse_generate_smoke/); | |
| assert.match(apiReference, /summary\.responses_agent_generate_smoke/); | |
| assert.match(apiReference, /responses_agent_generate_1k/); | |
| assert.match(apiReference, /request_mode_controls/); | |
| assert.match(apiReference, /summary\.real_smoke_checks/); | |
| assert.match(skillText, /公开分享可直接打开 `direct_content_urls`,设置访问码时优先给用户 `share_urls`/); | |
| assert.match(apiReference, /公开分享可直接打开 `direct_content_urls`,设置访问码时优先给用户 `share_urls`/); | |
| assert.match(apiReference, /顶层 `shares`、`summary\.share_urls` 和 `summary\.direct_content_urls` 输出结果/); | |
| assert.match(skillText, /不要手动并行启动多个单张脚本/); | |
| assert.match(skillText, /capacity_feedback/); | |
| assert.match(skillText, /channelQueue\.capacityPerCredential/); | |
| assert.match(skillText, /输出格式固定为 Agent WebP 契约/); | |
| assert.match(skillText, /Agent edit 只是对照路径,不保证与页面 SSE 的像素尺寸完全一致/); | |
| assert.match(skillText, /复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时/); | |
| assert.match(skillText, /Codex 会话日志会持久保存命令输出/); | |
| assert.match(skillText, /npm run env:summary/); | |
| assert.match(skillText, /verification_scope\.mode=local_planning_only/); | |
| assert.match(skillText, /--check-remote/); | |
| assert.match(skillText, /remote_contract_and_local_planning/); | |
| assert.match(skillText, /manifest_written=false/); | |
| assert.match(skillText, /Hugging Face Space Secrets 只能写入和列出名称/); | |
| assert.match(apiReference, /不要手动并行启动多个单张脚本来绕过 `capacity_feedback`/); | |
| assert.match(apiReference, /streamingBatch\.recommendedConcurrency/); | |
| assert.match(apiReference, /channelQueue\.capacityPerCredential/); | |
| assert.match(apiReference, /尺寸敏感任务必须使用生成\/编辑\/批量 `--dimension-check` 或下载后校验/); | |
| assert.match(apiReference, /编辑 `--dimension-check` 会读取响应 `b64_json` 或同 origin `content_url`/); | |
| assert.match(apiReference, /channel_capacity_queue_aborted/); | |
| assert.match(apiReference, /npm run env:summary/); | |
| assert.match(apiReference, /verification_scope\.mode=local_planning_only/); | |
| assert.match(apiReference, /--check-remote/); | |
| assert.match(apiReference, /remote_contract_and_local_planning/); | |
| assert.match(apiReference, /manifest_written=false/); | |
| assert.match(apiReference, /Hugging Face Space Secrets 只能写入和列出名称/); | |
| assert.match(apiReference, /这个失败表示上游已生成但本地验收未通过/); | |
| assert.match(apiReference, /`validation_failure_count>0` 而 `request_failure_count=0`/); | |
| }); | |
| it('keeps WebUI page APIs out of the Agent OpenAPI contract', () => { | |
| const readmeText = readFileSync(join(repoRoot, 'README.md'), 'utf8'); | |
| const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); | |
| const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); | |
| const openApiSource = readFileSync(join(repoRoot, 'src/lib/agent-openapi.ts'), 'utf8'); | |
| const agentEndpointValues = Object.values(AGENT_ENDPOINTS); | |
| const pageApiBoundaries = [ | |
| '/api/images', | |
| '/api/runtime-capabilities', | |
| '/api/feedback', | |
| '/api/shares', | |
| '/api/logs', | |
| '/api/image-delete' | |
| ]; | |
| const agentReadableDiagnostics = [ | |
| '/api/agent/page-requests/feedback', | |
| '/api/agent/page-requests/{id}/feedback', | |
| '/api/agent/diagnostics/page-requests', | |
| '/api/agent/diagnostics/page-requests/{id}', | |
| '/api/agent/diagnostics/channel-health' | |
| ]; | |
| const localWorkbenchFeatures = ['灵感相册', '历史复用']; | |
| for (const endpoint of pageApiBoundaries) { | |
| assert.match(skillText, new RegExp(escapeRegExp(endpoint))); | |
| assert.match(apiReference, new RegExp(escapeRegExp(endpoint))); | |
| assert.equal(agentEndpointValues.includes(endpoint), false); | |
| } | |
| for (const feature of localWorkbenchFeatures) { | |
| assert.match(readmeText, new RegExp(escapeRegExp(feature))); | |
| assert.match(skillText, new RegExp(escapeRegExp(feature))); | |
| assert.match(apiReference, new RegExp(escapeRegExp(feature))); | |
| assert.match(apiReference, new RegExp(`${escapeRegExp(feature)}[\\s\\S]*不作为机器 API 契约承诺`)); | |
| } | |
| for (const endpoint of agentReadableDiagnostics) { | |
| assert.match(skillText, new RegExp(escapeRegExp(endpoint))); | |
| assert.match(apiReference, new RegExp(escapeRegExp(endpoint))); | |
| assert.equal(agentEndpointValues.includes(endpoint), true); | |
| } | |
| assert.match(readmeText, /边界矩阵/); | |
| assert.match(apiReference, /### 边界矩阵/); | |
| assert.match(openApiSource, /AGENT_ENDPOINTS\.page_request_feedback_batch/); | |
| assert.match(openApiSource, /AGENT_ENDPOINTS\.page_request_feedback/); | |
| assert.match(openApiSource, /AGENT_ENDPOINTS\.page_request_diagnostics_batch/); | |
| assert.match(openApiSource, /AGENT_ENDPOINTS\.page_request_diagnostics/); | |
| assert.match(openApiSource, /高分辨率 edit 默认优先使用页面端 \/api\/images form-data SSE/); | |
| }); | |
| it('requires new probe and diagnostics work to keep API contracts ahead of Skill wrappers', () => { | |
| const readmeText = readFileSync(join(repoRoot, 'README.md'), 'utf8'); | |
| const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); | |
| const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); | |
| assert.match( | |
| readmeText, | |
| /新增 probe、diagnostics 或路由可观测能力时,先落 API \/ capabilities \/ OpenAPI 契约/ | |
| ); | |
| assert.match(readmeText, /Skill 脚本做薄封装/); | |
| assert.match(skillText, /新增 probe、diagnostics、路由健康或请求旅程能力时/); | |
| assert.match(skillText, /GET \/api\/agent\/capabilities/); | |
| assert.match(skillText, /GET \/api\/agent\/openapi\.json/); | |
| assert.match(skillText, /\/api\/agent\/diagnostics\/\*/); | |
| assert.match(skillText, /Skill 脚本只做薄封装/); | |
| assert.match(skillText, /不能复制页面 API、运行态 API 和 Agent API 的边界判断/); | |
| assert.match(apiReference, /新增 probe、diagnostics 或健康摘要时/); | |
| assert.match(apiReference, /capabilities、OpenAPI 或明确的 Agent 只读端点/); | |
| assert.match(apiReference, /不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑/); | |
| }); | |
| it('runs from a copied standalone skill directory outside the repository', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-playground-agent-')); | |
| const copiedSkillRoot = join(tempRoot, 'gpt-image-playground-agent'); | |
| try { | |
| cpSync(skillRoot, copiedSkillRoot, { recursive: true }); | |
| writeFileSync( | |
| join(tempRoot, '.env.agent.local'), | |
| 'GPT_IMAGE_PLAYGROUND_URL=https://parent-space.example.test' | |
| ); | |
| const standaloneRun = spawnSync( | |
| process.execPath, | |
| [join(copiedSkillRoot, 'scripts/generate-image.mjs'), 'prompt'], | |
| { | |
| cwd: join(copiedSkillRoot, 'scripts'), | |
| encoding: 'utf8', | |
| env: { | |
| ...buildIsolatedSkillScriptEnv({ loadPrivateAgentEnv: true }), | |
| GPT_IMAGE_AGENT_LOAD_ENV_FILE: '1' | |
| } | |
| } | |
| ); | |
| assert.equal(standaloneRun.status, 0); | |
| const standaloneBody = JSON.parse(standaloneRun.stdout); | |
| assert.equal(standaloneBody.verification_scope.service_base_url, 'http://localhost:4783'); | |
| const result = spawnSync( | |
| process.execPath, | |
| [join(copiedSkillRoot, 'scripts/generate-image.mjs'), '--help'], | |
| { | |
| cwd: tmpdir(), | |
| encoding: 'utf8', | |
| env: { | |
| ...process.env, | |
| GPT_IMAGE_PLAYGROUND_URL: 'not a url' | |
| } | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.match(result.stderr, /用法:generate-image\.mjs/); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /ERR_MODULE_NOT_FOUND|src\/lib/); | |
| const convertHelp = spawnSync( | |
| process.execPath, | |
| [join(copiedSkillRoot, 'scripts/convert-image-format.mjs'), '--help'], | |
| { | |
| cwd: tmpdir(), | |
| encoding: 'utf8', | |
| env: { | |
| ...process.env, | |
| NODE_PATH: '' | |
| } | |
| } | |
| ); | |
| assert.equal(convertHelp.status, 0); | |
| assert.match(convertHelp.stderr, /用法:convert-image-format\.mjs/); | |
| assert.equal(convertHelp.stdout.trim(), ''); | |
| assert.doesNotMatch(`${convertHelp.stdout}\n${convertHelp.stderr}`, /ERR_MODULE_NOT_FOUND|sharp/); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('prints batch dry-run JSONL plans without contacting the service', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| [ | |
| JSON.stringify({ | |
| id: 'first item', | |
| prompt: 'first prompt', | |
| size: '1024x1024', | |
| output_format: 'jpg', | |
| output_compression: '80' | |
| }), | |
| JSON.stringify({ | |
| mode: 'edit', | |
| id: 'edit item', | |
| prompt: 'edit prompt', | |
| image_paths: ['/tmp/source-a.png', '/tmp/source-b.png'], | |
| mask_path: '/tmp/mask.png' | |
| }) | |
| ].join('\n') | |
| ); | |
| const result = runSkillScript('batch-images.mjs', ['--input', inputPath, '--ordered-prefix', 'demo']); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.dry_run, true); | |
| assert.equal(body.billable, false); | |
| assert.equal(body.verification_scope.mode, 'local_planning_only'); | |
| assert.equal(body.verification_scope.remote_capabilities_verified, false); | |
| assert.equal(body.verification_scope.runtime_capacity_verified, false); | |
| assert.equal(body.manifest_written, false); | |
| assert.equal(body.manifest_write_reason, 'dry_run'); | |
| assert.equal(body.total, 2); | |
| assert.equal(body.concurrency, 1); | |
| assert.equal(body.guardrails.ordered_prefix, 'demo'); | |
| assert.equal(body.guardrails.repeat_ordered_prefix_on_real_run, true); | |
| assert.equal(body.guardrails.dimension_check_recommended, true); | |
| assert.match(body.guardrails.dimension_check_reason, /--dimension-check/); | |
| assert.equal(body.tasks[0].endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.tasks[0].idempotency_key, 'demo-0001-first-item'); | |
| assert.equal(body.tasks[0].request.model, 'gpt-image-2'); | |
| assert.equal(body.tasks[0].request.output_format, 'jpeg'); | |
| assert.equal(body.tasks[0].request.output_compression, 80); | |
| assert.equal(body.tasks[1].endpoint, '/api/images'); | |
| assert.equal(body.tasks[1].routing.transport, 'page_sse'); | |
| assert.equal(body.tasks[1].idempotency_key, 'demo-0002-edit-item'); | |
| assert.deepEqual(body.tasks[1].request.image_fields, ['image_0', 'image_1']); | |
| assert.equal(body.tasks[1].request.mask, 'provided'); | |
| assert.equal(body.tasks[1].request.output_format, 'webp'); | |
| assert.equal(body.tasks[1].request.output_compression, 100); | |
| assert.equal('image_path' in body.tasks[1].request, false); | |
| assert.equal('image_paths' in body.tasks[1].request, false); | |
| assert.equal('mask_path' in body.tasks[1].request, false); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('prints stdin batch dry-run plans without inventing a /dev/stdin manifest path', () => { | |
| const input = `${JSON.stringify({ | |
| id: 'stdin-task', | |
| prompt: 'stdin prompt', | |
| size: '1024x1024' | |
| })}\n`; | |
| const result = runSkillScript('batch-images.mjs', ['--input', '-'], {}, { input }); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.input, '/dev/stdin'); | |
| assert.equal(body.manifest, null); | |
| assert.equal(body.manifest_written, false); | |
| assert.equal(body.total, 1); | |
| assert.equal(body.tasks[0].id, 'stdin-task'); | |
| }); | |
| it('records enriched Agent diagnostics for failed batch tasks and manifests', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-diagnostics-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ id: 'diag-task', prompt: 'prompt', idempotency_key: 'batch-diag-key' }) | |
| ); | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| error: { code: 'unexpected_error', message: 'fetch failed', retryable: false } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests?idempotency_key=batch-diag-key') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| found: true, | |
| diagnostics: { | |
| request: { | |
| request_id: 'req_batch_diag', | |
| idempotency_key: 'batch-diag-key', | |
| status: 'failed' | |
| }, | |
| error: { | |
| code: 'unexpected_error', | |
| retryable: false, | |
| diagnostics: { | |
| selected_channel_id: 'channel-b', | |
| upstream_host: 'batch-upstream.example.test' | |
| } | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '1'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].summary.request_id, 'req_batch_diag'); | |
| assert.equal(body.results[0].summary.selected_channel_id, 'channel-b'); | |
| assert.equal(body.results[0].summary.upstream_host, 'batch-upstream.example.test'); | |
| assert.equal(body.results[0].summary.agent_diagnostics_checked, true); | |
| assert.equal(body.results[0].agent_failure_diagnostics.request_id, 'req_batch_diag'); | |
| const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); | |
| assert.equal(manifestLines[0].summary.request_id, 'req_batch_diag'); | |
| assert.equal(manifestLines[0].agent_failure_diagnostics.request_id, 'req_batch_diag'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects invalid batch concurrency before dry-run output', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-concurrency-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync(inputPath, JSON.stringify({ id: 'first', prompt: 'prompt' })); | |
| const result = runSkillScript('batch-images.mjs', ['--input', inputPath, '--concurrency', '0']); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /--concurrency 必须是正整数/); | |
| assert.equal(result.stdout.trim(), ''); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects batch concurrency with strict consecutive failure stopping', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-concurrency-stop-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync(inputPath, JSON.stringify({ id: 'first', prompt: 'prompt' })); | |
| const result = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| inputPath, | |
| '--concurrency', | |
| '2', | |
| '--max-consecutive-failures', | |
| '1' | |
| ]); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /不能同时使用 --max-consecutive-failures/); | |
| assert.equal(result.stdout.trim(), ''); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('preserves explicit zero partial image requests in batch dry-run output', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync(inputPath, JSON.stringify({ id: 'zero-partial', prompt: 'prompt', partial_images: 0 })); | |
| const result = runSkillScript('batch-images.mjs', ['--input', inputPath]); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.tasks[0].request.partial_images, 0); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('preserves explicit falsy batch task ids in dry-run plans', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync(inputPath, JSON.stringify({ id: 0, prompt: 'prompt' })); | |
| const result = runSkillScript('batch-images.mjs', ['--input', inputPath, '--ordered-prefix', 'demo']); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.tasks[0].id, '0'); | |
| assert.equal(body.tasks[0].idempotency_key, 'demo-0001-0'); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('normalizes batch output compression before sending Agent JSON', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-compression-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'compressed', | |
| prompt: 'prompt', | |
| output_format: 'jpeg', | |
| output_compression: '80' | |
| }) | |
| ); | |
| let agentRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| agentRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ images: [{ id: 'compressed-image', filename: 'compressed.jpg' }] }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const requestBody = JSON.parse(agentRequestBody); | |
| assert.equal(requestBody.output_format, 'jpeg'); | |
| assert.equal(requestBody.output_compression, 80); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects batch Agent JSON requests outside the advertised partial image capability', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-partial-capability-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'too-many-partials', | |
| prompt: 'prompt', | |
| partial_images: 4 | |
| }) | |
| ); | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| limits: { | |
| partial_images: { min: 1, max: 3 } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'generate should not be called' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities'] | |
| ); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].status, 'failed'); | |
| assert.match(body.results[0].error, /partial_images 必须在当前 capabilities 允许的 1 到 3 之间/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects batch page SSE partial images outside the selected backend capability before POST', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-page-partial-capability-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'responses-zero-partial', | |
| prompt: 'prompt', | |
| image_backend: 'responses-image-generation', | |
| responsesModel: 'gpt-5.4-mini', | |
| partial_images: 0 | |
| }) | |
| ); | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { page_sse: { supported: true } }, | |
| defaults: { partial_images: 2 }, | |
| limits: { | |
| partial_images: { min: 0, max: 4 }, | |
| partial_images_by_backend: { | |
| 'images-api': { min: 0, max: 4 }, | |
| 'responses-image-generation': { min: 1, max: 3 } | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'page SSE should not be called' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities'] | |
| ); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.failure_summary.non_billable_count, 1); | |
| assert.equal(body.failure_summary.billable_count, 0); | |
| assert.equal(body.results[0].billable, false); | |
| assert.equal(body.results[0].status, 'failed'); | |
| assert.match(body.results[0].error, /partial_images 必须在当前 capabilities 允许的 1 到 3 之间/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('appends batch manifests and skips succeeded tasks during resume', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| [ | |
| JSON.stringify({ id: 'first', prompt: 'first prompt', size: '1024x1024' }), | |
| JSON.stringify({ id: 'second', prompt: 'second prompt', size: '1024x1024' }) | |
| ].join('\n') | |
| ); | |
| writeFileSync( | |
| manifestPath, | |
| `${JSON.stringify({ id: 'first', idempotency_key: 'batch-0001-first', status: 'succeeded' })}\n` | |
| ); | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| images: [ | |
| { | |
| id: 'image-second', | |
| filename: 'second.png', | |
| b64_json: fakePngBase64(2, 1) | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--resume', '--input', inputPath, '--manifest', manifestPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].status, 'skipped'); | |
| assert.equal(body.results[1].status, 'succeeded'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/agent/image-requests'] | |
| ); | |
| const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); | |
| assert.equal(manifestLines.length, 3); | |
| assert.equal(manifestLines[1].status, 'skipped'); | |
| assert.equal(manifestLines[2].status, 'succeeded'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('runs batch tasks with the requested concurrency while preserving result order', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-concurrent-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| [ | |
| JSON.stringify({ id: 'first', prompt: 'first prompt', idempotency_key: 'first-key' }), | |
| JSON.stringify({ id: 'second', prompt: 'second prompt', idempotency_key: 'second-key' }), | |
| JSON.stringify({ id: 'third', prompt: 'third prompt', idempotency_key: 'third-key' }) | |
| ].join('\n') | |
| ); | |
| let activeGenerateRequests = 0; | |
| let maxActiveGenerateRequests = 0; | |
| let enteredGenerateRequests = 0; | |
| const requestKeys = []; | |
| const twoGenerateRequestsEntered = createDeferred(); | |
| const releaseGenerateResponses = createDeferred(); | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| activeGenerateRequests += 1; | |
| enteredGenerateRequests += 1; | |
| maxActiveGenerateRequests = Math.max(maxActiveGenerateRequests, activeGenerateRequests); | |
| requestKeys.push(request.headers['idempotency-key']); | |
| if (enteredGenerateRequests === 2) { | |
| twoGenerateRequestsEntered.resolve(); | |
| } | |
| await releaseGenerateResponses.promise; | |
| activeGenerateRequests -= 1; | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| images: [ | |
| { | |
| id: request.headers['idempotency-key'], | |
| filename: `${request.headers['idempotency-key']}.png`, | |
| b64_json: fakePngBase64(2, 1) | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const resultPromise = runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath, '--concurrency', '2'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl }, | |
| { timeoutMs: 10_000 } | |
| ); | |
| let result; | |
| try { | |
| await waitWithTimeout( | |
| twoGenerateRequestsEntered.promise, | |
| 2_000, | |
| 'expected two generate requests to enter before any response was released' | |
| ); | |
| assert.equal(maxActiveGenerateRequests, 2); | |
| releaseGenerateResponses.resolve(); | |
| result = await resultPromise; | |
| } finally { | |
| releaseGenerateResponses.resolve(); | |
| await resultPromise; | |
| } | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.concurrency, 2); | |
| assert.equal(maxActiveGenerateRequests, 2); | |
| assert.deepEqual([...requestKeys].sort(), ['first-key', 'second-key', 'third-key']); | |
| assert.deepEqual( | |
| body.results.map((item) => item.id), | |
| ['first', 'second', 'third'] | |
| ); | |
| assert.deepEqual( | |
| body.results.map((item) => item.response.images[0].filename), | |
| ['first-key.png', 'second-key.png', 'third-key.png'] | |
| ); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('limits batch worker concurrency to runtime capacity feedback when requested concurrency is higher', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-capacity-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| [ | |
| JSON.stringify({ id: 'first', prompt: 'first prompt', idempotency_key: 'first-key' }), | |
| JSON.stringify({ id: 'second', prompt: 'second prompt', idempotency_key: 'second-key' }) | |
| ].join('\n') | |
| ); | |
| let activeGenerateRequests = 0; | |
| let maxActiveGenerateRequests = 0; | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/runtime-capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| streamingBatch: { recommendedConcurrency: 1 }, | |
| channelQueue: { | |
| enabled: true, | |
| capacityPerCredential: 1, | |
| maxWaitMs: 420000, | |
| maxSize: 50, | |
| active: 0, | |
| queued: 0 | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| activeGenerateRequests += 1; | |
| maxActiveGenerateRequests = Math.max(maxActiveGenerateRequests, activeGenerateRequests); | |
| await new Promise((resolve) => setTimeout(resolve, 20)); | |
| activeGenerateRequests -= 1; | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| images: [ | |
| { | |
| id: request.headers['idempotency-key'], | |
| filename: `${request.headers['idempotency-key']}.png`, | |
| b64_json: fakePngBase64(2, 1) | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath, '--concurrency', '2'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.requested_concurrency, 2); | |
| assert.equal(body.effective_concurrency, 1); | |
| assert.equal(body.capacity_feedback.adjusted, true); | |
| assert.equal(body.capacity_feedback.recommended_concurrency, 1); | |
| assert.equal(body.capacity_feedback.channel_queue.enabled, true); | |
| assert.equal(maxActiveGenerateRequests, 1); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/runtime-capabilities', | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/image-requests', | |
| 'POST /api/agent/image-requests' | |
| ] | |
| ); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('does not require capabilities when resume skips every batch task', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-resume-skip-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| writeFileSync(inputPath, JSON.stringify({ id: 'done', prompt: 'already done', size: '1024x1024' })); | |
| writeFileSync( | |
| manifestPath, | |
| `${JSON.stringify({ id: 'done', idempotency_key: 'batch-0001-done', status: 'succeeded' })}\n` | |
| ); | |
| const result = runSkillScript( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--resume', '--input', inputPath, '--manifest', manifestPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: 'http://127.0.0.1:9' } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].status, 'skipped'); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('skips malformed batch manifest lines during resume', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| writeFileSync(inputPath, JSON.stringify({ id: 'first', prompt: 'first prompt', size: '1024x1024' })); | |
| writeFileSync( | |
| manifestPath, | |
| `${JSON.stringify({ id: 'first', idempotency_key: 'batch-0001-first', status: 'succeeded' })}\n{"truncated"` | |
| ); | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected resumed request' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--resume', '--input', inputPath, '--manifest', manifestPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].status, 'skipped'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('runs default WebP batch edit tasks through page SSE multipart input', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| mode: 'edit', | |
| id: 'edit-one', | |
| prompt: 'edit prompt', | |
| image_path: imagePath, | |
| size: '1024x1024' | |
| }) | |
| ); | |
| const requests = []; | |
| let editRequestBody = ''; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { page_sse: { supported: true, endpoint: '/api/images' } } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| editRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"edit-one.webp","path":"/generated/edit-one.webp"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"edit-one.webp","path":"/generated/edit-one.webp"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].routing.transport, 'page_sse'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| assert.match(editRequestBody, /name="prompt"\r?\n\r?\nedit prompt/); | |
| assert.match(editRequestBody, /name="image_0"; filename="source\.png"/); | |
| assert.match(editRequestBody, /name="output_format"\r?\n\r?\nwebp/); | |
| assert.match(editRequestBody, /name="output_compression"\r?\n\r?\n100/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('routes high-resolution batch edit tasks through page SSE', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-page-sse-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| mode: 'edit', | |
| id: 'edit-large', | |
| prompt: 'edit prompt', | |
| image_path: imagePath, | |
| size: '3072x2048', | |
| idempotency_key: 'batch-edit-page-sse-key' | |
| }) | |
| ); | |
| let pageSseRequestBody = ''; | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageSseRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"batch-edit.png","path":"/generated/batch-edit.png"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"batch-edit.png","path":"/generated/batch-edit.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].routing.transport, 'page_sse'); | |
| assert.equal(body.results[0].routing.strength, 'default'); | |
| assert.equal(body.results[0].summary.ok, true); | |
| assert.equal(body.results[0].summary.transport, 'page_sse'); | |
| assert.equal(body.results[0].summary.endpoint, '/api/images'); | |
| assert.equal(typeof body.results[0].summary.elapsed_ms, 'number'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| assert.match(pageSseRequestBody, /name="mode"\r?\n\r?\nedit/); | |
| assert.match(pageSseRequestBody, /name="clientRequestId"\r?\n\r?\nbatch-edit-page-sse-key/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('routes batch generate requests with responsesModel through server orchestration', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-responses-model-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'responses-generate', | |
| prompt: 'prompt', | |
| image_backend: 'responses-image-generation', | |
| streaming_strategy: 'responses-sse', | |
| responsesModel: 'gpt-4.1-responses', | |
| idempotency_key: 'batch-responses-model-key' | |
| }) | |
| ); | |
| let pageSseRequestBody = ''; | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| orchestration: { | |
| supported: true, | |
| transport_selection: 'server_owned', | |
| endpoint: '/api/agent/image-requests' | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' }, | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| pageSseRequestBody = await readRequestText(request); | |
| response.writeHead(202, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'batch-job-responses', | |
| state: 'running', | |
| result_url: '/api/agent/jobs/batch-job-responses/result', | |
| retry_after_seconds: 1 | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/batch-job-responses/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'batch-job-responses', | |
| idempotency_key: 'batch-responses-model-key', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'responses-artifact', | |
| filename: 'responses.png', | |
| content_url: '/generated/responses.png' | |
| } | |
| ], | |
| execution: { | |
| transport: 'agent_job_polling', | |
| endpoint: '/api/agent/image-requests', | |
| route_mode: 'job', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'auto', | |
| streaming_strategy: 'auto', | |
| channel_request_mode: 'responses-sse', | |
| channel_request_mode_fallback_applied: false, | |
| route_decision: { | |
| requested_backend: 'responses-image-generation', | |
| preferred_channel_request_mode: 'responses-sse', | |
| selected_channel_request_mode: 'responses-sse', | |
| fallback_applied: false, | |
| selected_channel_id: 'responses-channel', | |
| upstream_host: 'upstream.example.test' | |
| } | |
| }, | |
| timing: { server_elapsed_ms: 1234 } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.deepEqual(body.results[0].routing, { | |
| strength: 'default', | |
| transport: 'server_orchestrated', | |
| endpoint: '/api/agent/image-requests', | |
| route_mode: 'job', | |
| result_mode: 'job_polling', | |
| reason: 'Batch generate tasks submit intent to the server orchestration endpoint; the service owns internal route selection.' | |
| }); | |
| assert.equal(body.results[0].summary.transport, 'agent_job_polling'); | |
| assert.equal(body.results[0].summary.endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.results[0].summary.route_mode, 'job'); | |
| assert.equal(body.results[0].summary.channel_request_mode, 'responses-sse'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/image-requests', | |
| 'GET /api/agent/jobs/batch-job-responses/result' | |
| ] | |
| ); | |
| const requestBody = JSON.parse(pageSseRequestBody); | |
| assert.equal(requestBody.image_backend, 'responses-image-generation'); | |
| assert.equal(requestBody.responsesModel, 'gpt-4.1-responses'); | |
| assert.equal(requestBody.streaming_strategy, 'responses-sse'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('passes GPT2Image-compatible edit options to batch page SSE form-data', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-edit-advanced-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| mode: 'edit', | |
| id: 'edit-advanced', | |
| prompt: 'edit prompt', | |
| image_path: imagePath, | |
| size: '1024x1024', | |
| output_format: 'jpeg', | |
| output_compression: 85, | |
| moderation: 'auto', | |
| image_backend: 'responses', | |
| responsesModel: 'gpt-5.4-mini', | |
| thinking: 'medium', | |
| promptOptimization: false, | |
| force_web: true, | |
| idempotency_key: 'batch-edit-advanced-key' | |
| }) | |
| ); | |
| let pageSseRequestBody = ''; | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| pageSseRequestBody = await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"edit.jpg","path":"/generated/edit.jpg"}', | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"edit.jpg","path":"/generated/edit.jpg"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].routing.transport, 'page_sse'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| assert.match(pageSseRequestBody, /name="mode"\r?\n\r?\nedit/); | |
| assert.match(pageSseRequestBody, /name="output_format"\r?\n\r?\njpeg/); | |
| assert.match(pageSseRequestBody, /name="output_compression"\r?\n\r?\n85/); | |
| assert.match(pageSseRequestBody, /name="moderation"\r?\n\r?\nauto/); | |
| assert.match(pageSseRequestBody, /name="image_backend"\r?\n\r?\nresponses-image-generation/); | |
| assert.match(pageSseRequestBody, /name="responsesModel"\r?\n\r?\ngpt-5\.4-mini/); | |
| assert.match(pageSseRequestBody, /name="thinking"\r?\n\r?\nmedium/); | |
| assert.match(pageSseRequestBody, /name="promptOptimization"\r?\n\r?\nfalse/); | |
| assert.match(pageSseRequestBody, /name="force_web"\r?\n\r?\ntrue/); | |
| assert.match(pageSseRequestBody, /name="image_0"/); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('keeps batch responsesModel routing on server orchestration when page SSE capability is unavailable', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-responses-model-no-sse-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'responses-no-page-sse', | |
| prompt: 'prompt', | |
| image_backend: 'responses-image-generation', | |
| responsesModel: 'gpt-4.1-responses' | |
| }) | |
| ); | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| orchestration: { | |
| supported: true, | |
| transport_selection: 'server_owned', | |
| endpoint: '/api/agent/image-requests' | |
| }, | |
| agent_jobs: { supported: true, mode: 'job_polling' } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| response.writeHead(202, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'batch-no-page-sse-job', | |
| state: 'running', | |
| result_url: '/api/agent/jobs/batch-no-page-sse-job/result', | |
| retry_after_seconds: 1 | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/batch-no-page-sse-job/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'batch-no-page-sse-job', | |
| idempotency_key: 'responses-no-page-sse', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'batch-no-page-sse-artifact', | |
| filename: 'responses.png', | |
| content_url: '/generated/responses.png' | |
| } | |
| ], | |
| execution: { | |
| transport: 'agent_job_polling', | |
| endpoint: '/api/agent/image-requests', | |
| route_mode: 'job', | |
| image_backend: 'responses-image-generation', | |
| stream_mode: 'auto', | |
| streaming_strategy: 'auto', | |
| channel_request_mode: 'images-non-stream', | |
| channel_request_mode_fallback_applied: false, | |
| route_decision: { | |
| requested_backend: 'responses-image-generation', | |
| preferred_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream', | |
| fallback_applied: false, | |
| selected_channel_id: 'images-channel', | |
| upstream_host: 'upstream.example.test' | |
| } | |
| }, | |
| timing: { server_elapsed_ms: 987 } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].ok, true); | |
| assert.deepEqual(body.results[0].routing, { | |
| strength: 'default', | |
| transport: 'server_orchestrated', | |
| endpoint: '/api/agent/image-requests', | |
| route_mode: 'job', | |
| result_mode: 'job_polling', | |
| reason: 'Batch generate tasks submit intent to the server orchestration endpoint; the service owns internal route selection.' | |
| }); | |
| assert.equal(body.results[0].summary.transport, 'agent_job_polling'); | |
| assert.equal(body.results[0].summary.channel_request_mode, 'images-non-stream'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/image-requests', | |
| 'GET /api/agent/jobs/batch-no-page-sse-job/result' | |
| ] | |
| ); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('does not route batch generate tasks through page SSE when streaming is explicitly disabled', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-disabled-page-sse-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'large-generate-non-stream', | |
| prompt: 'prompt', | |
| size: '3072x2048', | |
| stream_mode: 'non_stream' | |
| }) | |
| ); | |
| const result = runSkillScript('batch-images.mjs', ['--input', inputPath]); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.tasks[0].routing.transport, 'server_orchestrated'); | |
| assert.equal(body.tasks[0].routing.endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.tasks[0].routing.result_mode, 'job_polling'); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects explicit batch page SSE when streaming is explicitly disabled', () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-disabled-explicit-page-sse-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'large-generate-forced-page-sse', | |
| prompt: 'prompt', | |
| size: '3072x2048', | |
| page_sse: true, | |
| stream_mode: 'non_stream' | |
| }) | |
| ); | |
| const result = runSkillScript('batch-images.mjs', ['--input', inputPath]); | |
| assert.equal(result.status, 2); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /large-generate-forced-page-sse/); | |
| assert.match(result.stderr, /stream_mode=non_stream/); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('records structured page SSE failures in batch output and manifest', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-page-sse-fail-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| mode: 'edit', | |
| id: 'edit-large-fail', | |
| prompt: 'edit prompt', | |
| image_path: imagePath, | |
| size: '3072x2048', | |
| idempotency_key: 'batch-edit-page-sse-fail-key' | |
| }) | |
| ); | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(400, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'invalid edit request' })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/images/edit') { | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected Agent edit call' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath, '--manifest', manifestPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.ok, false); | |
| assert.equal(body.failed, 1); | |
| assert.equal(body.results[0].billable, false); | |
| assert.equal(body.results[0].error.code, 'page_sse_request_rejected'); | |
| assert.equal(body.results[0].error.status, 400); | |
| assert.equal(body.results[0].routing.fallback_endpoint, '/api/agent/images/edit'); | |
| assert.equal(body.results[0].routing.fallback_mode, 'fix_request_before_retry'); | |
| assert.match(body.results[0].next_step, /请求参数或鉴权/); | |
| assert.equal(body.results[0].summary.ok, false); | |
| assert.equal(body.results[0].summary.billable, false); | |
| assert.equal(body.results[0].summary.transport, 'page_sse'); | |
| assert.equal(body.results[0].summary.endpoint, '/api/images'); | |
| const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); | |
| assert.equal(manifestLines.length, 1); | |
| assert.equal(manifestLines[0].status, 'failed'); | |
| assert.equal(manifestLines[0].billable, false); | |
| assert.equal(manifestLines[0].error.code, 'page_sse_request_rejected'); | |
| assert.equal(manifestLines[0].routing.transport, 'page_sse'); | |
| assert.equal(manifestLines[0].summary.ok, false); | |
| assert.equal(manifestLines[0].summary.billable, false); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('re-fetches batch page SSE capabilities after a transient capabilities failure', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-page-sse-capability-retry-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| const imagePath = join(tempRoot, 'source.png'); | |
| writeFileSync(imagePath, fakePngBuffer(2, 1)); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| mode: 'edit', | |
| id: 'edit-large-capability-retry', | |
| prompt: 'edit prompt', | |
| image_path: imagePath, | |
| size: '3072x2048', | |
| idempotency_key: 'capability-retry-key' | |
| }) | |
| ); | |
| let capabilitiesRequests = 0; | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ method: request.method, url: request.url }); | |
| if (request.url === '/api/agent/capabilities') { | |
| capabilitiesRequests += 1; | |
| if (capabilitiesRequests === 1) { | |
| response.writeHead(503, { 'content-type': 'text/plain' }); | |
| response.end('capabilities maintenance'); | |
| return; | |
| } | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { | |
| page_sse: { supported: true, endpoint: '/api/images' } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| 'data: {"type":"completed","filename":"retry-edit.png","path":"/generated/retry-edit.png","output_format":"png"}', | |
| '', | |
| 'data: {"type":"done"}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '2'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| ['GET /api/agent/capabilities', 'GET /api/agent/capabilities', 'POST /api/images'] | |
| ); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].attempt, 2); | |
| assert.equal(body.results[0].root_idempotency_key, 'capability-retry-key'); | |
| assert.equal(body.results[0].response.images[0].filename, 'retry-edit.png'); | |
| const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); | |
| assert.equal(manifestLines.length, 2); | |
| assert.equal(manifestLines[0].status, 'failed'); | |
| assert.equal(manifestLines[0].idempotency_key, 'capability-retry-key'); | |
| assert.equal(manifestLines[1].status, 'succeeded'); | |
| assert.equal(manifestLines[1].idempotency_key, 'capability-retry-key-attempt-2'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('retries batch failures with fresh attempt idempotency keys and reports a fix list', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-retry-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'retry-generate', | |
| prompt: 'prompt', | |
| idempotency_key: 'retry-key' | |
| }) | |
| ); | |
| const idempotencyKeys = []; | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| idempotencyKeys.push(request.headers['idempotency-key']); | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ error: { message: 'upstream failed', code: 'upstream_failed' } }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '2'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.deepEqual(idempotencyKeys, ['retry-key', 'retry-key-attempt-2']); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.failure_summary.count, 1); | |
| assert.equal(body.failure_summary.tasks[0].id, 'retry-generate'); | |
| assert.equal(body.resume_fix_list[0].previous_idempotency_key, 'retry-key'); | |
| assert.equal(body.resume_fix_list[0].suggested_idempotency_key, 'retry-key-attempt-3'); | |
| assert.equal(body.results[0].attempt, 2); | |
| assert.equal(body.results[0].root_idempotency_key, 'retry-key'); | |
| const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); | |
| assert.equal(manifestLines.length, 2); | |
| assert.equal(manifestLines[0].idempotency_key, 'retry-key'); | |
| assert.equal(manifestLines[0].attempt, 1); | |
| assert.equal(manifestLines[1].idempotency_key, 'retry-key-attempt-2'); | |
| assert.equal(manifestLines[1].root_idempotency_key, 'retry-key'); | |
| assert.equal(manifestLines[1].attempt, 2); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('preserves the retry suffix when long batch idempotency keys are truncated', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-retry-long-key-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| const longKey = 'k'.repeat(198); | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ | |
| id: 'retry-generate-long-key', | |
| prompt: 'prompt', | |
| idempotency_key: longKey | |
| }) | |
| ); | |
| const idempotencyKeys = []; | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| idempotencyKeys.push(request.headers['idempotency-key']); | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ error: { message: 'upstream failed', code: 'upstream_failed' } }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '3'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(idempotencyKeys.length, 3); | |
| assert.equal(idempotencyKeys[1].length, 200); | |
| assert.equal(idempotencyKeys[1].endsWith('-attempt-2'), true); | |
| assert.equal(idempotencyKeys[2].length, 200); | |
| assert.equal(idempotencyKeys[2].endsWith('-attempt-3'), true); | |
| assert.notEqual(idempotencyKeys[1], idempotencyKeys[2]); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.resume_fix_list[0].suggested_idempotency_key.endsWith('-attempt-4'), true); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('keeps truncated batch retry idempotency keys distinct when long roots share a prefix', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-retry-collision-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| const sharedPrefix = 'shared-key-'.padEnd(199, 'x'); | |
| const firstKey = `${sharedPrefix}a`; | |
| const secondKey = `${sharedPrefix}b`; | |
| writeFileSync( | |
| inputPath, | |
| [ | |
| JSON.stringify({ id: 'first-long-key', prompt: 'first', idempotency_key: firstKey }), | |
| JSON.stringify({ id: 'second-long-key', prompt: 'second', idempotency_key: secondKey }) | |
| ].join('\n') | |
| ); | |
| const idempotencyKeys = []; | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| idempotencyKeys.push(request.headers['idempotency-key']); | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ error: { message: 'upstream failed', code: 'upstream_failed' } }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '2'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(idempotencyKeys.length, 4); | |
| assert.equal(idempotencyKeys[1].length, 200); | |
| assert.equal(idempotencyKeys[3].length, 200); | |
| assert.equal(idempotencyKeys[1].endsWith('-attempt-2'), true); | |
| assert.equal(idempotencyKeys[3].endsWith('-attempt-2'), true); | |
| assert.notEqual(idempotencyKeys[1], idempotencyKeys[3]); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('stops batch execution after max consecutive failures and leaves later tasks resumable', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-circuit-breaker-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const manifestPath = join(tempRoot, 'manifest.jsonl'); | |
| writeFileSync( | |
| inputPath, | |
| [ | |
| JSON.stringify({ id: 'first-fail', prompt: 'first', idempotency_key: 'first-key' }), | |
| JSON.stringify({ id: 'second-skip', prompt: 'second', idempotency_key: 'second-key' }) | |
| ].join('\n') | |
| ); | |
| const idempotencyKeys = []; | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| idempotencyKeys.push(request.headers['idempotency-key']); | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'upstream failed' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| [ | |
| '--allow-billable', | |
| '--input', | |
| inputPath, | |
| '--manifest', | |
| manifestPath, | |
| '--max-consecutive-failures', | |
| '1' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.deepEqual(idempotencyKeys, ['first-key']); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.failed, 1); | |
| assert.equal(body.results[0].status, 'failed'); | |
| assert.equal(body.results[1].status, 'skipped'); | |
| assert.equal(body.results[1].skipped_reason, 'max_consecutive_failures'); | |
| assert.equal(body.results[1].billable, false); | |
| assert.equal(body.failure_summary.count, 1); | |
| const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse); | |
| assert.equal(manifestLines.length, 2); | |
| assert.equal(manifestLines[0].status, 'failed'); | |
| assert.equal(manifestLines[1].status, 'skipped'); | |
| assert.equal(manifestLines[1].skipped_reason, 'max_consecutive_failures'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('checks batch output dimensions from same-origin artifact URLs', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| writeFileSync(inputPath, JSON.stringify({ id: 'dim-ok', prompt: 'prompt', size: '1024x1024' })); | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| images: [{ id: 'dim-image', filename: 'dim.png', content_url: '/artifact/dim.png' }] | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/artifact/dim.png') { | |
| response.writeHead(200, { 'content-type': 'image/png' }); | |
| response.end(fakePngBuffer(1024, 1024)); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--dimension-check', '--input', inputPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].status, 'succeeded'); | |
| assert.deepEqual(body.results[0].summary.actual_dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.results[0].summary.image_dimensions, [{ width: 1024, height: 1024 }]); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('checks batch page SSE path-mode dimensions from raw SSE image bytes before formatting', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-page-sse-dim-')); | |
| try { | |
| const inputPath = join(tempRoot, 'tasks.jsonl'); | |
| const finalImageBase64 = fakePngBase64(1024, 1024); | |
| let imageRouteHits = 0; | |
| writeFileSync( | |
| inputPath, | |
| JSON.stringify({ id: 'dim-page-sse', prompt: 'prompt', size: '1024x1024', page_sse: true }) | |
| ); | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| agent_streaming: { page_sse: { supported: true, endpoint: '/api/images' } } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/images') { | |
| await readRequestText(request); | |
| response.writeHead(200, { 'content-type': 'text/event-stream' }); | |
| response.end( | |
| [ | |
| `data: ${JSON.stringify({ | |
| type: 'completed', | |
| filename: 'batch.png', | |
| b64_json: finalImageBase64, | |
| path: '/api/image/batch.png', | |
| output_format: 'png' | |
| })}`, | |
| '', | |
| 'data: {"type":"done","images":[{"filename":"batch.png","path":"/api/image/batch.png"}]}', | |
| '', | |
| '' | |
| ].join('\n') | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/image/batch.png') { | |
| imageRouteHits += 1; | |
| response.writeHead(401, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'cookie required' })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--dimension-check', '--input', inputPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.equal(imageRouteHits, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.results[0].status, 'succeeded'); | |
| assert.equal(body.results[0].routing.transport, 'page_sse'); | |
| assert.equal('b64_json' in body.results[0].response.images[0], false); | |
| assert.equal(body.results[0].response.images[0].path, '/api/image/batch.png'); | |
| assert.equal(body.results[0].response.images[0].absolute_path, `${baseUrl}/api/image/batch.png`); | |
| assert.deepEqual(body.results[0].response.images[0].dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.results[0].summary.actual_dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.results[0].summary.image_dimensions, [{ width: 1024, height: 1024 }]); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('fails batch dimension-check mismatches and invalid size parameters', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-')); | |
| try { | |
| const invalidInputPath = join(tempRoot, 'invalid.jsonl'); | |
| writeFileSync(invalidInputPath, JSON.stringify({ id: 'bad-size', prompt: 'prompt', size: 'wide' })); | |
| const invalidResult = runSkillScript('batch-images.mjs', ['--input', invalidInputPath]); | |
| assert.equal(invalidResult.status, 2); | |
| assert.match(invalidResult.stderr, /bad-size\.size 必须是 auto 或 WIDTHxHEIGHT/); | |
| assert.equal(invalidResult.stdout.trim(), ''); | |
| const nonPositiveInputPath = join(tempRoot, 'non-positive.jsonl'); | |
| writeFileSync( | |
| nonPositiveInputPath, | |
| JSON.stringify({ id: 'non-positive-size', prompt: 'prompt', size: '0x3840' }) | |
| ); | |
| const nonPositiveResult = runSkillScript('batch-images.mjs', ['--input', nonPositiveInputPath]); | |
| assert.equal(nonPositiveResult.status, 2); | |
| assert.match(nonPositiveResult.stderr, /non-positive-size\.size 的宽度和高度必须是正数/); | |
| assert.equal(nonPositiveResult.stdout.trim(), ''); | |
| const invalidModelInputPath = join(tempRoot, 'invalid-model.jsonl'); | |
| writeFileSync( | |
| invalidModelInputPath, | |
| JSON.stringify({ | |
| id: 'invalid-model', | |
| prompt: 'prompt', | |
| model: 'bad-model', | |
| size: '2048x2048' | |
| }) | |
| ); | |
| const invalidModelResult = runSkillScript('batch-images.mjs', ['--input', invalidModelInputPath]); | |
| assert.equal(invalidModelResult.status, 2); | |
| assert.match(invalidModelResult.stderr, /invalid-model\.model 的值无效:bad-model/); | |
| assert.equal(invalidModelResult.stdout.trim(), ''); | |
| const pngCompressionInputPath = join(tempRoot, 'png-compression.jsonl'); | |
| writeFileSync( | |
| pngCompressionInputPath, | |
| JSON.stringify({ | |
| id: 'png-compression', | |
| prompt: 'prompt', | |
| output_format: 'png', | |
| output_compression: 80 | |
| }) | |
| ); | |
| const pngCompressionResult = runSkillScript('batch-images.mjs', ['--input', pngCompressionInputPath]); | |
| assert.equal(pngCompressionResult.status, 0); | |
| assert.equal(pngCompressionResult.stderr.trim(), ''); | |
| const pngCompressionBody = JSON.parse(pngCompressionResult.stdout); | |
| assert.equal(pngCompressionBody.tasks[0].request.normalizations.output_compression_ignored_for_png, true); | |
| assert.equal(pngCompressionBody.tasks[0].request.output_compression, undefined); | |
| const conflictingFormatInputPath = join(tempRoot, 'conflicting-format.jsonl'); | |
| writeFileSync( | |
| conflictingFormatInputPath, | |
| JSON.stringify({ | |
| id: 'conflicting-format', | |
| prompt: 'prompt', | |
| output_format: 'png', | |
| format: 'webp' | |
| }) | |
| ); | |
| const conflictingFormatResult = runSkillScript('batch-images.mjs', ['--input', conflictingFormatInputPath]); | |
| assert.equal(conflictingFormatResult.status, 2); | |
| assert.match(conflictingFormatResult.stderr, /conflicting-format\.output_format 与 format 不能同时设置/); | |
| assert.equal(conflictingFormatResult.stdout.trim(), ''); | |
| const editFormatInputPath = join(tempRoot, 'edit-format.jsonl'); | |
| writeFileSync( | |
| editFormatInputPath, | |
| JSON.stringify({ | |
| id: 'edit-format', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_path: 'source.png', | |
| size: '1024x1024', | |
| output_format: 'jpeg' | |
| }) | |
| ); | |
| const editFormatResult = runSkillScript('batch-images.mjs', ['--input', editFormatInputPath]); | |
| assert.equal(editFormatResult.status, 0); | |
| assert.equal(editFormatResult.stderr.trim(), ''); | |
| const editFormatBody = JSON.parse(editFormatResult.stdout); | |
| assert.equal(editFormatBody.tasks[0].routing.transport, 'page_sse'); | |
| assert.match(editFormatBody.tasks[0].routing.reason, /GPT2Image-compatible edit options/); | |
| assert.equal(editFormatBody.tasks[0].request.output_format, 'jpeg'); | |
| const editBackendInputPath = join(tempRoot, 'edit-backend.jsonl'); | |
| writeFileSync( | |
| editBackendInputPath, | |
| JSON.stringify({ | |
| id: 'edit-backend', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_path: 'source.png', | |
| image_backend: 'responses-image-generation' | |
| }) | |
| ); | |
| const editBackendResult = runSkillScript('batch-images.mjs', ['--input', editBackendInputPath]); | |
| assert.equal(editBackendResult.status, 0); | |
| assert.equal(editBackendResult.stderr.trim(), ''); | |
| const editBackendBody = JSON.parse(editBackendResult.stdout); | |
| assert.equal(editBackendBody.tasks[0].routing.transport, 'page_sse'); | |
| assert.equal(editBackendBody.tasks[0].request.image_backend, 'responses-image-generation'); | |
| const editBackgroundInputPath = join(tempRoot, 'edit-background.jsonl'); | |
| writeFileSync( | |
| editBackgroundInputPath, | |
| JSON.stringify({ | |
| id: 'edit-background', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_path: 'source.png', | |
| background: 'opaque' | |
| }) | |
| ); | |
| const editBackgroundResult = runSkillScript('batch-images.mjs', ['--input', editBackgroundInputPath]); | |
| assert.equal(editBackgroundResult.status, 2); | |
| assert.match(editBackgroundResult.stderr, /edit-background\.background 仅适用于 generate 任务/); | |
| assert.equal(editBackgroundResult.stdout.trim(), ''); | |
| const editResponsesModelInputPath = join(tempRoot, 'edit-responses-model.jsonl'); | |
| writeFileSync( | |
| editResponsesModelInputPath, | |
| JSON.stringify({ | |
| id: 'edit-responses-model', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_path: 'source.png', | |
| responsesModel: 'gpt-4.1' | |
| }) | |
| ); | |
| const editResponsesModelResult = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| editResponsesModelInputPath | |
| ]); | |
| assert.equal(editResponsesModelResult.status, 2); | |
| assert.match( | |
| editResponsesModelResult.stderr, | |
| /edit-responses-model\.responsesModel 必须同时设置 image_backend=responses-image-generation/ | |
| ); | |
| assert.equal(editResponsesModelResult.stdout.trim(), ''); | |
| const generateImagePathInputPath = join(tempRoot, 'generate-image-path.jsonl'); | |
| writeFileSync( | |
| generateImagePathInputPath, | |
| JSON.stringify({ | |
| id: 'generate-image-path', | |
| mode: 'generate', | |
| prompt: 'prompt', | |
| image_path: 'source.png' | |
| }) | |
| ); | |
| const generateImagePathResult = runSkillScript('batch-images.mjs', ['--input', generateImagePathInputPath]); | |
| assert.equal(generateImagePathResult.status, 2); | |
| assert.match(generateImagePathResult.stderr, /generate-image-path\.image_path 仅适用于 edit 任务/); | |
| assert.equal(generateImagePathResult.stdout.trim(), ''); | |
| const camelCaseBackendInputPath = join(tempRoot, 'camel-case-backend.jsonl'); | |
| writeFileSync( | |
| camelCaseBackendInputPath, | |
| JSON.stringify({ | |
| id: 'camel-case-backend', | |
| prompt: 'prompt', | |
| imageBackend: 'responses' | |
| }) | |
| ); | |
| const camelCaseBackendResult = runSkillScript('batch-images.mjs', ['--input', camelCaseBackendInputPath]); | |
| assert.equal(camelCaseBackendResult.status, 2); | |
| assert.match(camelCaseBackendResult.stderr, /camel-case-backend\.imageBackend 不是支持的 batch JSONL 字段/); | |
| assert.equal(camelCaseBackendResult.stdout.trim(), ''); | |
| const formFieldStreamingInputPath = join(tempRoot, 'form-field-streaming.jsonl'); | |
| writeFileSync( | |
| formFieldStreamingInputPath, | |
| JSON.stringify({ | |
| id: 'form-field-streaming', | |
| prompt: 'prompt', | |
| image_backend: 'responses-image-generation', | |
| image_streaming_strategy: 'responses-sse' | |
| }) | |
| ); | |
| const formFieldStreamingResult = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| formFieldStreamingInputPath | |
| ]); | |
| assert.equal(formFieldStreamingResult.status, 2); | |
| assert.match( | |
| formFieldStreamingResult.stderr, | |
| /form-field-streaming\.image_streaming_strategy 不是支持的 batch JSONL 字段/ | |
| ); | |
| assert.equal(formFieldStreamingResult.stdout.trim(), ''); | |
| const camelCaseResponseModeInputPath = join(tempRoot, 'camel-case-response-mode.jsonl'); | |
| writeFileSync( | |
| camelCaseResponseModeInputPath, | |
| JSON.stringify({ | |
| id: 'camel-case-response-mode', | |
| prompt: 'prompt', | |
| responseMode: 'both' | |
| }) | |
| ); | |
| const camelCaseResponseModeResult = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| camelCaseResponseModeInputPath | |
| ]); | |
| assert.equal(camelCaseResponseModeResult.status, 2); | |
| assert.match( | |
| camelCaseResponseModeResult.stderr, | |
| /camel-case-response-mode\.responseMode 不是支持的 batch JSONL 字段/ | |
| ); | |
| assert.equal(camelCaseResponseModeResult.stdout.trim(), ''); | |
| const numericImagePathInputPath = join(tempRoot, 'numeric-image-path.jsonl'); | |
| writeFileSync( | |
| numericImagePathInputPath, | |
| JSON.stringify({ | |
| id: 'numeric-image-path', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_path: 123 | |
| }) | |
| ); | |
| const numericImagePathResult = runSkillScript('batch-images.mjs', ['--input', numericImagePathInputPath]); | |
| assert.equal(numericImagePathResult.status, 2); | |
| assert.match(numericImagePathResult.stderr, /numeric-image-path\.image_path 必须是非空字符串/); | |
| assert.equal(numericImagePathResult.stdout.trim(), ''); | |
| const emptyImagePathsInputPath = join(tempRoot, 'empty-image-paths.jsonl'); | |
| writeFileSync( | |
| emptyImagePathsInputPath, | |
| JSON.stringify({ | |
| id: 'empty-image-paths', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_paths: [] | |
| }) | |
| ); | |
| const emptyImagePathsResult = runSkillScript('batch-images.mjs', ['--input', emptyImagePathsInputPath]); | |
| assert.equal(emptyImagePathsResult.status, 2); | |
| assert.match(emptyImagePathsResult.stderr, /empty-image-paths\.image_paths 必须是非空字符串数组/); | |
| assert.equal(emptyImagePathsResult.stdout.trim(), ''); | |
| const invalidImagePathsInputPath = join(tempRoot, 'invalid-image-paths.jsonl'); | |
| writeFileSync( | |
| invalidImagePathsInputPath, | |
| JSON.stringify({ | |
| id: 'invalid-image-paths', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_paths: ['source.png', 123] | |
| }) | |
| ); | |
| const invalidImagePathsResult = runSkillScript('batch-images.mjs', ['--input', invalidImagePathsInputPath]); | |
| assert.equal(invalidImagePathsResult.status, 2); | |
| assert.match(invalidImagePathsResult.stderr, /invalid-image-paths\.image_paths\[1\] 必须是非空字符串/); | |
| assert.equal(invalidImagePathsResult.stdout.trim(), ''); | |
| const conflictingImagePathsInputPath = join(tempRoot, 'conflicting-image-paths.jsonl'); | |
| writeFileSync( | |
| conflictingImagePathsInputPath, | |
| JSON.stringify({ | |
| id: 'conflicting-image-paths', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_path: 'source.png', | |
| image_paths: ['source-a.png'] | |
| }) | |
| ); | |
| const conflictingImagePathsResult = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| conflictingImagePathsInputPath | |
| ]); | |
| assert.equal(conflictingImagePathsResult.status, 2); | |
| assert.match( | |
| conflictingImagePathsResult.stderr, | |
| /conflicting-image-paths\.image_path 与 image_paths 不能同时设置/ | |
| ); | |
| assert.equal(conflictingImagePathsResult.stdout.trim(), ''); | |
| const invalidMaskInputPath = join(tempRoot, 'invalid-mask.jsonl'); | |
| writeFileSync( | |
| invalidMaskInputPath, | |
| JSON.stringify({ | |
| id: 'invalid-mask', | |
| mode: 'edit', | |
| prompt: 'prompt', | |
| image_path: 'source.png', | |
| mask_path: {} | |
| }) | |
| ); | |
| const invalidMaskResult = runSkillScript('batch-images.mjs', ['--input', invalidMaskInputPath]); | |
| assert.equal(invalidMaskResult.status, 2); | |
| assert.match(invalidMaskResult.stderr, /invalid-mask\.mask_path 必须是非空字符串/); | |
| assert.equal(invalidMaskResult.stdout.trim(), ''); | |
| const transparentInputPath = join(tempRoot, 'transparent.jsonl'); | |
| writeFileSync( | |
| transparentInputPath, | |
| JSON.stringify({ | |
| id: 'transparent-background', | |
| prompt: 'prompt', | |
| background: 'transparent' | |
| }) | |
| ); | |
| const transparentResult = runSkillScript('batch-images.mjs', ['--input', transparentInputPath]); | |
| assert.equal(transparentResult.status, 0); | |
| assert.equal(transparentResult.stderr.trim(), ''); | |
| const transparentBody = JSON.parse(transparentResult.stdout); | |
| assert.equal(transparentBody.tasks[0].request.background, 'transparent'); | |
| const stringPageSseInputPath = join(tempRoot, 'string-page-sse.jsonl'); | |
| writeFileSync( | |
| stringPageSseInputPath, | |
| JSON.stringify({ | |
| id: 'string-page-sse', | |
| prompt: 'prompt', | |
| page_sse: 'true' | |
| }) | |
| ); | |
| const stringPageSseResult = runSkillScript('batch-images.mjs', ['--input', stringPageSseInputPath]); | |
| assert.equal(stringPageSseResult.status, 2); | |
| assert.match(stringPageSseResult.stderr, /string-page-sse\.page_sse 必须是布尔值/); | |
| assert.equal(stringPageSseResult.stdout.trim(), ''); | |
| const stringComplexUiInputPath = join(tempRoot, 'string-complex-ui.jsonl'); | |
| writeFileSync( | |
| stringComplexUiInputPath, | |
| JSON.stringify({ | |
| id: 'string-complex-ui', | |
| prompt: 'prompt', | |
| complex_ui: 'true' | |
| }) | |
| ); | |
| const stringComplexUiResult = runSkillScript('batch-images.mjs', ['--input', stringComplexUiInputPath]); | |
| assert.equal(stringComplexUiResult.status, 2); | |
| assert.match(stringComplexUiResult.stderr, /string-complex-ui\.complex_ui 必须是布尔值/); | |
| assert.equal(stringComplexUiResult.stdout.trim(), ''); | |
| const numericResumeInputPath = join(tempRoot, 'numeric-resume.jsonl'); | |
| writeFileSync( | |
| numericResumeInputPath, | |
| JSON.stringify({ | |
| id: 'numeric-resume', | |
| prompt: 'prompt', | |
| resume_or_recover: 1 | |
| }) | |
| ); | |
| const numericResumeResult = runSkillScript('batch-images.mjs', ['--input', numericResumeInputPath]); | |
| assert.equal(numericResumeResult.status, 2); | |
| assert.match(numericResumeResult.stderr, /numeric-resume\.resume_or_recover 必须是布尔值/); | |
| assert.equal(numericResumeResult.stdout.trim(), ''); | |
| const unsupportedTransportInputPath = join(tempRoot, 'unsupported-transport.jsonl'); | |
| writeFileSync( | |
| unsupportedTransportInputPath, | |
| JSON.stringify({ | |
| id: 'unsupported-transport', | |
| prompt: 'prompt', | |
| transport: 'agent_json' | |
| }) | |
| ); | |
| const unsupportedTransportResult = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| unsupportedTransportInputPath | |
| ]); | |
| assert.equal(unsupportedTransportResult.status, 2); | |
| assert.match(unsupportedTransportResult.stderr, /unsupported-transport\.transport 必须是 page_sse/); | |
| assert.equal(unsupportedTransportResult.stdout.trim(), ''); | |
| const responsesModelNonStreamInputPath = join(tempRoot, 'responses-model-non-stream.jsonl'); | |
| writeFileSync( | |
| responsesModelNonStreamInputPath, | |
| JSON.stringify({ | |
| id: 'responses-model-non-stream', | |
| prompt: 'prompt', | |
| image_backend: 'responses-image-generation', | |
| responsesModel: 'gpt-4.1', | |
| stream_mode: 'non_stream' | |
| }) | |
| ); | |
| const responsesModelNonStreamResult = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| responsesModelNonStreamInputPath | |
| ]); | |
| assert.equal(responsesModelNonStreamResult.status, 0); | |
| assert.equal(responsesModelNonStreamResult.stderr.trim(), ''); | |
| const responsesModelNonStreamBody = JSON.parse(responsesModelNonStreamResult.stdout); | |
| assert.equal(responsesModelNonStreamBody.tasks[0].routing.transport, 'server_orchestrated'); | |
| assert.equal(responsesModelNonStreamBody.tasks[0].routing.endpoint, '/api/agent/image-requests'); | |
| assert.equal(responsesModelNonStreamBody.tasks[0].request.responsesModel, 'gpt-4.1'); | |
| assert.equal(responsesModelNonStreamBody.tasks[0].request.stream_mode, 'non_stream'); | |
| const responsesModelWithoutBackendInputPath = join(tempRoot, 'responses-model-without-backend.jsonl'); | |
| writeFileSync( | |
| responsesModelWithoutBackendInputPath, | |
| JSON.stringify({ | |
| id: 'responses-model-without-backend', | |
| prompt: 'prompt', | |
| responsesModel: 'gpt-4.1' | |
| }) | |
| ); | |
| const responsesModelWithoutBackendResult = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| responsesModelWithoutBackendInputPath | |
| ]); | |
| assert.equal(responsesModelWithoutBackendResult.status, 2); | |
| assert.match( | |
| responsesModelWithoutBackendResult.stderr, | |
| /responses-model-without-backend\.responsesModel 必须同时设置 image_backend=responses-image-generation/ | |
| ); | |
| assert.equal(responsesModelWithoutBackendResult.stdout.trim(), ''); | |
| const responsesModelImagesBackendInputPath = join(tempRoot, 'responses-model-images-backend.jsonl'); | |
| writeFileSync( | |
| responsesModelImagesBackendInputPath, | |
| JSON.stringify({ | |
| id: 'responses-model-images-backend', | |
| prompt: 'prompt', | |
| image_backend: 'images-api', | |
| responsesModel: 'gpt-4.1' | |
| }) | |
| ); | |
| const responsesModelImagesBackendResult = runSkillScript('batch-images.mjs', [ | |
| '--input', | |
| responsesModelImagesBackendInputPath | |
| ]); | |
| assert.equal(responsesModelImagesBackendResult.status, 2); | |
| assert.match( | |
| responsesModelImagesBackendResult.stderr, | |
| /responses-model-images-backend\.responsesModel 仅适用于 image_backend=responses-image-generation/ | |
| ); | |
| assert.equal(responsesModelImagesBackendResult.stdout.trim(), ''); | |
| const mismatchInputPath = join(tempRoot, 'mismatch.jsonl'); | |
| writeFileSync(mismatchInputPath, JSON.stringify({ id: 'dim-bad', prompt: 'prompt', size: '1024x1024' })); | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ ok: true })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| execution: { | |
| transport: 'agent_job_polling', | |
| endpoint: '/api/agent/image-requests', | |
| route_mode: 'job', | |
| channel_request_mode: 'images-non-stream', | |
| channel_request_mode_fallback_applied: true, | |
| route_decision: { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream' | |
| }, | |
| selected_channel_id: 'dimension-channel', | |
| upstream_host: 'dimension-upstream.example.test' | |
| }, | |
| images: [ | |
| { id: 'dim-image-bad', filename: 'dim-bad.png', b64_json: fakePngBase64(512, 512) }, | |
| { id: 'dim-image-ok', filename: 'dim-ok.png', b64_json: fakePngBase64(1024, 1024) } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'batch-images.mjs', | |
| ['--allow-billable', '--dimension-check', '--input', mismatchInputPath], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.ok, false); | |
| assert.equal(body.results[0].error.code, 'dimension_check_failed'); | |
| assert.match(body.results[0].error.message, /尺寸校验失败/); | |
| assert.deepEqual(body.results[0].error.expected_dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.results[0].error.actual_dimensions, { width: 512, height: 512 }); | |
| assert.equal(body.results[0].validation_failure_kind, 'generated_artifact_failed_dimension_check'); | |
| assert.equal(body.results[0].response.images[0].b64_json, undefined); | |
| assert.equal(body.results[0].response.images[0].b64_json_length, fakePngBase64(512, 512).length); | |
| assert.deepEqual(body.results[0].response.images[0].dimensions, { width: 512, height: 512 }); | |
| assert.equal(body.results[0].response.images[1].b64_json, undefined); | |
| assert.equal(body.results[0].response.images[1].b64_json_length, fakePngBase64(1024, 1024).length); | |
| assert.deepEqual(body.results[0].response.images[1].dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.results[0].summary.artifact_ids, ['dim-image-bad', 'dim-image-ok']); | |
| assert.deepEqual(body.results[0].summary.image_dimensions, [ | |
| { width: 512, height: 512 }, | |
| { width: 1024, height: 1024 } | |
| ]); | |
| assert.deepEqual(body.results[0].summary.expected_dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.results[0].summary.actual_dimensions, { width: 512, height: 512 }); | |
| assert.equal(body.results[0].summary.dimension_check_failed, true); | |
| assert.equal(body.results[0].summary.transport, 'agent_job_polling'); | |
| assert.equal(body.results[0].summary.endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.results[0].summary.route_mode, 'job'); | |
| assert.equal(body.results[0].summary.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.results[0].summary.channel_request_mode_fallback_applied, true); | |
| assert.deepEqual(body.results[0].summary.route_decision, { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream' | |
| }); | |
| assert.equal(body.results[0].summary.selected_channel_id, 'dimension-channel'); | |
| assert.equal(body.results[0].summary.upstream_host, 'dimension-upstream.example.test'); | |
| assert.equal(body.failure_summary.validation_failure_count, 1); | |
| assert.equal(body.failure_summary.request_failure_count, 0); | |
| assert.equal( | |
| body.failure_summary.tasks[0].failure_kind, | |
| 'generated_artifact_failed_dimension_check' | |
| ); | |
| assert.deepEqual(body.failure_summary.tasks[0].artifact_ids, ['dim-image-bad', 'dim-image-ok']); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('rejects cross-origin job result URLs before sending auth headers', () => { | |
| assert.throws( | |
| () => | |
| resolveSameOriginUrl( | |
| 'https://space.example.test', | |
| 'https://evil.example.test/result', | |
| 'job.result_url' | |
| ), | |
| /不同 origin/ | |
| ); | |
| assert.equal( | |
| resolveSameOriginUrl('https://space.example.test', '/api/agent/jobs/abc/result', 'job.result_url'), | |
| 'https://space.example.test/api/agent/jobs/abc/result' | |
| ); | |
| }); | |
| it('caps retry-after values before sleeping', () => { | |
| assert.equal(parseRetryAfterValue('5'), 5); | |
| assert.equal(parseRetryAfterValue('0'), 1); | |
| assert.equal(parseRetryAfterValue('999999999999999999999'), 60); | |
| assert.equal(parseRetryAfterValue('not-a-number', 7), 7); | |
| }); | |
| it('preserves non-JSON capabilities status and body in generate failures', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(503, { 'content-type': 'text/plain' }); | |
| response.end('maintenance window'); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'text/plain' }); | |
| response.end('missing'); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('generate-image.mjs', ['--allow-billable', 'prompt'], { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| }); | |
| assert.equal(result.status, 1); | |
| assert.match(result.stderr, /capabilities 请求失败,状态码 503:maintenance window/); | |
| } | |
| ); | |
| }); | |
| it('checks single generate output dimensions from same-origin artifact URLs', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-dimension-ok', | |
| state: 'succeeded', | |
| result_url: '/api/agent/jobs/job-dimension-ok/result' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/job-dimension-ok/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'request-dimension-ok', | |
| idempotency_key: 'dimension-ok-key', | |
| images: [ | |
| { | |
| id: 'artifact-dimension-ok', | |
| content_url: '/artifact/dimension-ok.png' | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/artifact/dimension-ok.png') { | |
| response.writeHead(200, { 'content-type': 'image/png' }); | |
| response.end(fakePngBuffer(1024, 1024)); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--dimension-check', | |
| '--size', | |
| '1024x1024', | |
| '--idempotency-key', | |
| 'dimension-ok-key', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.deepEqual(body.images[0].dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.summary.actual_dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.summary.image_dimensions, [{ width: 1024, height: 1024 }]); | |
| } | |
| ); | |
| }); | |
| it('fails single generate dimension-check mismatches with route diagnostics', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-dimension-bad', | |
| state: 'succeeded', | |
| result_url: '/api/agent/jobs/job-dimension-bad/result' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/job-dimension-bad/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'request-dimension-bad', | |
| idempotency_key: 'dimension-bad-key', | |
| execution: { | |
| transport: 'agent_job_polling', | |
| endpoint: '/api/agent/image-requests', | |
| route_mode: 'job', | |
| channel_request_mode: 'images-non-stream', | |
| channel_request_mode_fallback_applied: true, | |
| route_decision: { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream' | |
| }, | |
| selected_channel_id: 'single-dimension-channel', | |
| upstream_host: 'single-dimension-upstream.example.test' | |
| }, | |
| images: [ | |
| { | |
| id: 'artifact-dimension-bad', | |
| b64_json: fakePngBase64(1254, 1254) | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--dimension-check', | |
| '--size', | |
| '1024x1024', | |
| '--idempotency-key', | |
| 'dimension-bad-key', | |
| 'prompt' | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const body = JSON.parse(result.stderr); | |
| assert.equal(body.error.code, 'dimension_check_failed'); | |
| assert.equal(body.validation_failure_kind, 'generated_artifact_failed_dimension_check'); | |
| assert.deepEqual(body.error.expected_dimensions, { width: 1024, height: 1024 }); | |
| assert.deepEqual(body.error.actual_dimensions, { width: 1254, height: 1254 }); | |
| assert.equal(body.response.images[0].b64_json, undefined); | |
| assert.equal(body.response.images[0].b64_json_length, fakePngBase64(1254, 1254).length); | |
| assert.deepEqual(body.response.images[0].dimensions, { width: 1254, height: 1254 }); | |
| assert.equal(body.summary.dimension_check_failed, true); | |
| assert.equal(body.summary.transport, 'agent_job_polling'); | |
| assert.equal(body.summary.endpoint, '/api/agent/image-requests'); | |
| assert.equal(body.summary.route_mode, 'job'); | |
| assert.equal(body.summary.channel_request_mode, 'images-non-stream'); | |
| assert.equal(body.summary.channel_request_mode_fallback_applied, true); | |
| assert.deepEqual(body.summary.route_decision, { | |
| requested_backend: 'images-api', | |
| preferred_channel_request_mode: 'images-sse', | |
| fallback_channel_request_mode: 'images-non-stream', | |
| selected_channel_request_mode: 'images-non-stream' | |
| }); | |
| assert.equal(body.summary.selected_channel_id, 'single-dimension-channel'); | |
| assert.equal(body.summary.upstream_host, 'single-dimension-upstream.example.test'); | |
| assert.match(body.summary.next_action, /确认当前渠道是否支持请求尺寸/); | |
| } | |
| ); | |
| }); | |
| it('creates artifact share links through the Agent share endpoint when requested', async () => { | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ | |
| method: request.method, | |
| url: request.url, | |
| authorization: request.headers.authorization | |
| }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-share', | |
| state: 'succeeded', | |
| result_url: '/api/agent/jobs/job-share/result' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/job-share/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'request-share', | |
| idempotency_key: 'share-key', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'artifact-share', | |
| content_url: '/api/agent/artifacts/artifact-share/content', | |
| metadata_url: '/api/agent/artifacts/artifact-share', | |
| output_format: 'png', | |
| mime_type: 'image/png', | |
| size_bytes: 12, | |
| width: 1024, | |
| height: 1024 | |
| } | |
| ], | |
| created_at: '2026-06-27T00:00:00.000Z' | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/artifacts/artifact-share/share' && request.method === 'POST') { | |
| assert.equal(request.headers.authorization, 'Bearer share-token'); | |
| assert.deepEqual(JSON.parse(await readRequestText(request)), { | |
| expires_in_minutes: 60, | |
| access_code: 'share-code' | |
| }); | |
| response.writeHead(201, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| artifact_id: 'artifact-share', | |
| token: '0123456789abcdef01234567', | |
| share_url: `${serverOrigin(request)}/share/0123456789abcdef01234567`, | |
| direct_content_url: `${serverOrigin(request)}/api/shares/0123456789abcdef01234567/content`, | |
| expires_at: '2026-06-28T00:00:00.000Z', | |
| access_code_required: true | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| [ | |
| '--allow-billable', | |
| '--share', | |
| '--share-expires-minutes', | |
| '60', | |
| '--idempotency-key', | |
| 'share-key', | |
| 'prompt' | |
| ], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl, | |
| GPT_IMAGE_AGENT_TOKEN: 'share-token', | |
| GPT_IMAGE_SHARE_ACCESS_CODE: 'share-code' | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.shares.length, 1); | |
| assert.equal(body.shares[0].artifact_id, 'artifact-share'); | |
| assert.equal(body.shares[0].access_code_required, true); | |
| assert.equal(body.summary.share_urls[0], `${baseUrl}/share/0123456789abcdef01234567`); | |
| assert.equal( | |
| body.summary.direct_content_urls[0], | |
| `${baseUrl}/api/shares/0123456789abcdef01234567/content` | |
| ); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/image-requests', | |
| 'GET /api/agent/jobs/job-share/result', | |
| 'POST /api/agent/artifacts/artifact-share/share' | |
| ] | |
| ); | |
| } | |
| ); | |
| }); | |
| it('resolves relative share URLs against the configured playground base url', async () => { | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-relative-share', | |
| state: 'succeeded', | |
| result_url: '/api/agent/jobs/job-relative-share/result' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/job-relative-share/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'request-relative-share', | |
| idempotency_key: 'relative-share-key', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'artifact-relative-share', | |
| content_url: '/api/agent/artifacts/artifact-relative-share/content', | |
| metadata_url: '/api/agent/artifacts/artifact-relative-share', | |
| output_format: 'png', | |
| mime_type: 'image/png', | |
| size_bytes: 12, | |
| width: 1024, | |
| height: 1024 | |
| } | |
| ], | |
| created_at: '2026-06-27T00:00:00.000Z' | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/artifacts/artifact-relative-share/share' && request.method === 'POST') { | |
| response.writeHead(201, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| artifact_id: 'artifact-relative-share', | |
| token: 'fedcba9876543210fedcba98', | |
| share_url: '/share/fedcba9876543210fedcba98', | |
| direct_content_url: '/api/shares/fedcba9876543210fedcba98/content', | |
| expires_at: null, | |
| access_code_required: false | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--share', '--idempotency-key', 'relative-share-key', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl, GPT_IMAGE_AGENT_TOKEN: 'share-token' } | |
| ); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.summary.share_urls[0], `${baseUrl}/share/fedcba9876543210fedcba98`); | |
| assert.equal( | |
| body.summary.direct_content_urls[0], | |
| `${baseUrl}/api/shares/fedcba9876543210fedcba98/content` | |
| ); | |
| } | |
| ); | |
| }); | |
| it('rejects share-only generate flags without share mode', async () => { | |
| const result = await runSkillScriptAsync('generate-image.mjs', ['--share-expires-minutes', '60', 'prompt'], {}); | |
| assert.equal(result.status, 2); | |
| assert.match(result.stderr, /--share-expires-minutes 需要和 --share 一起使用/); | |
| }); | |
| it('keeps share URLs under configured playground subpaths', async () => { | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/playground/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/playground/api/agent/image-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-subpath-share', | |
| state: 'succeeded', | |
| result_url: '/playground/api/agent/jobs/job-subpath-share/result' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/playground/api/agent/jobs/job-subpath-share/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'request-subpath-share', | |
| idempotency_key: 'subpath-share-key', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'artifact-subpath-share', | |
| content_url: '/playground/api/agent/artifacts/artifact-subpath-share/content', | |
| metadata_url: '/playground/api/agent/artifacts/artifact-subpath-share', | |
| output_format: 'png', | |
| mime_type: 'image/png', | |
| size_bytes: 12, | |
| width: 1024, | |
| height: 1024 | |
| } | |
| ], | |
| created_at: '2026-06-27T00:00:00.000Z' | |
| }) | |
| ); | |
| return; | |
| } | |
| if ( | |
| request.url === '/playground/api/agent/artifacts/artifact-subpath-share/share' && | |
| request.method === 'POST' | |
| ) { | |
| response.writeHead(201, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| artifact_id: 'artifact-subpath-share', | |
| token: 'abcdefabcdefabcdefabcdef', | |
| share_url: '/playground/share/abcdefabcdefabcdefabcdef', | |
| direct_content_url: '/playground/api/shares/abcdefabcdefabcdefabcdef/content', | |
| expires_at: null, | |
| access_code_required: false | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--share', '--idempotency-key', 'subpath-share-key', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: `${baseUrl}/playground`, GPT_IMAGE_AGENT_TOKEN: 'share-token' } | |
| ); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.summary.share_urls[0], `${baseUrl}/playground/share/abcdefabcdefabcdefabcdef`); | |
| assert.equal( | |
| body.summary.direct_content_urls[0], | |
| `${baseUrl}/playground/api/shares/abcdefabcdefabcdefabcdef/content` | |
| ); | |
| } | |
| ); | |
| }); | |
| it('treats blank share access code env as unset', async () => { | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify(agentGenerateCapabilities())); | |
| return; | |
| } | |
| if (request.url === '/api/agent/image-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| job: { | |
| id: 'job-share-blank-access-code', | |
| state: 'succeeded', | |
| result_url: '/api/agent/jobs/job-share-blank-access-code/result' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/jobs/job-share-blank-access-code/result') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| request_id: 'request-share-blank-access-code', | |
| idempotency_key: 'share-blank-access-code-key', | |
| cached: false, | |
| images: [ | |
| { | |
| id: 'artifact-share-blank-access-code', | |
| content_url: '/api/agent/artifacts/artifact-share-blank-access-code/content', | |
| metadata_url: '/api/agent/artifacts/artifact-share-blank-access-code', | |
| output_format: 'png', | |
| mime_type: 'image/png', | |
| size_bytes: 12, | |
| width: 1024, | |
| height: 1024 | |
| } | |
| ], | |
| created_at: '2026-06-27T00:00:00.000Z' | |
| }) | |
| ); | |
| return; | |
| } | |
| if ( | |
| request.url === '/api/agent/artifacts/artifact-share-blank-access-code/share' && | |
| request.method === 'POST' | |
| ) { | |
| response.writeHead(201, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| artifact_id: 'artifact-share-blank-access-code', | |
| token: '0123456789abcdef01234567', | |
| share_url: '/share/0123456789abcdef01234567', | |
| direct_content_url: '/api/shares/0123456789abcdef01234567/content', | |
| expires_at: null, | |
| access_code_required: false | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--share', '--idempotency-key', 'share-blank-access-code-key', 'prompt'], | |
| { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl, | |
| GPT_IMAGE_AGENT_TOKEN: 'share-token', | |
| GPT_IMAGE_SHARE_ACCESS_CODE: ' ' | |
| } | |
| ); | |
| assert.equal(result.status, 0); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.shares[0].access_code_required, false); | |
| assert.equal(body.summary.share_urls[0], `${baseUrl}/share/0123456789abcdef01234567`); | |
| } | |
| ); | |
| }); | |
| it('does not duplicate network failure prefixes in generate capability errors', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'text/plain' }); | |
| response.end('missing'); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'generate-image.mjs', | |
| ['--allow-billable', '--timeout-ms', '50', 'prompt'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| const prefixes = result.stderr.match(/请求失败:/g) || []; | |
| assert.equal(prefixes.length, 1); | |
| assert.match(result.stderr, /\/api\/agent\/capabilities/); | |
| } | |
| ); | |
| }); | |
| it('diagnoses page request feedback and log summaries through Agent endpoints', async () => { | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ | |
| method: request.method, | |
| url: request.url, | |
| authorization: request.headers.authorization | |
| }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| page_request_feedback_batch: '/api/agent/page-requests/feedback', | |
| page_request_feedback: '/api/agent/page-requests/{id}/feedback', | |
| page_request_diagnostics: '/api/agent/diagnostics/page-requests/{id}' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/page-requests/feedback' && request.method === 'POST') { | |
| assert.deepEqual(JSON.parse(await readRequestText(request)), { ids: ['web-diag'] }); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| feedback: [ | |
| { | |
| target_type: 'page_request', | |
| target_id: 'web-diag', | |
| value: 'usable', | |
| source: 'webui', | |
| updated_at: '2026-05-12T00:00:00.000Z', | |
| note: 'approved' | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/page-requests/web-diag?filename=hero.png') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| scope: { | |
| request_ids: ['web-diag'], | |
| filenames: ['hero.png'], | |
| filename_matched_request_ids: [], | |
| copy_text: 'requestIds=web-diag' | |
| }, | |
| matched_log_count: 1, | |
| events: [{ id: 1, at: '2026-05-12T00:00:01.000Z', level: 'info', message: 'ok' }] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'diagnose-request.mjs', | |
| ['--client-request-id', 'web-diag', '--filename', 'hero.png'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl, GPT_IMAGE_AGENT_TOKEN: 'diag-token' } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.client_request_id, 'web-diag'); | |
| assert.equal(body.found, true); | |
| assert.equal(body.page_request_query_count, 1); | |
| assert.equal(body.page_request_found_count, 1); | |
| assert.equal(body.feedback.value, 'usable'); | |
| assert.equal(body.diagnostics.matched_log_count, 1); | |
| assert.equal(body.request_count, 1); | |
| assert.equal(body.requests[0].client_request_id, 'web-diag'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/page-requests/feedback', | |
| 'GET /api/agent/diagnostics/page-requests/web-diag?filename=hero.png' | |
| ] | |
| ); | |
| assert.equal(requests[1].authorization, 'Bearer diag-token'); | |
| assert.equal(requests[2].authorization, 'Bearer diag-token'); | |
| } | |
| ); | |
| }); | |
| it('diagnoses repeated request ids and batch manifests without duplicate feedback calls', async () => { | |
| const tempRoot = mkdtempSync(join(tmpdir(), 'diagnose-request-')); | |
| const manifestPath = join(tempRoot, 'batch.manifest.jsonl'); | |
| const outputPath = join(tempRoot, 'diagnosis.json'); | |
| writeFileSync( | |
| manifestPath, | |
| [ | |
| JSON.stringify({ id: 'task-a', idempotency_key: 'web-diag-a', status: 'succeeded' }), | |
| JSON.stringify({ id: 'task-b', client_request_id: 'web-diag-b', status: 'succeeded' }) | |
| ].join('\n') | |
| ); | |
| const requests = []; | |
| try { | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ | |
| method: request.method, | |
| url: request.url, | |
| authorization: request.headers.authorization | |
| }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| page_request_feedback_batch: '/api/agent/page-requests/feedback', | |
| page_request_diagnostics_batch: '/api/agent/diagnostics/page-requests', | |
| page_request_diagnostics: '/api/agent/diagnostics/page-requests/{id}' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/page-requests/feedback' && request.method === 'POST') { | |
| assert.deepEqual(JSON.parse(await readRequestText(request)), { | |
| ids: ['web-diag-a', 'web-diag-b'] | |
| }); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| feedback: [ | |
| { | |
| target_type: 'page_request', | |
| target_id: 'web-diag-b', | |
| value: 'needs_revision', | |
| source: 'webui', | |
| updated_at: '2026-05-12T00:00:00.000Z' | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/page-requests' && request.method === 'POST') { | |
| assert.deepEqual(JSON.parse(await readRequestText(request)), { | |
| ids: ['web-diag-a', 'web-diag-b'], | |
| filenames: ['hero.png'] | |
| }); | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| diagnostics: ['web-diag-a', 'web-diag-b'].map((requestId) => ({ | |
| client_request_id: requestId, | |
| scope: { | |
| request_ids: [requestId], | |
| filenames: ['hero.png'], | |
| filename_matched_request_ids: [], | |
| copy_text: `requestIds=${requestId}` | |
| }, | |
| matched_log_count: 1, | |
| events: [{ id: 1, at: '2026-05-12T00:00:01.000Z', level: 'info', message: 'ok' }] | |
| })) | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'diagnose-request.mjs', | |
| [ | |
| '--client-request-id', | |
| 'web-diag-a', | |
| '--manifest', | |
| manifestPath, | |
| '--filename', | |
| 'hero.png', | |
| '--output', | |
| outputPath | |
| ], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl, GPT_IMAGE_AGENT_TOKEN: 'diag-token' } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.request_count, 2); | |
| assert.deepEqual( | |
| body.requests.map((item) => item.client_request_id), | |
| ['web-diag-a', 'web-diag-b'] | |
| ); | |
| assert.equal(body.requests[0].feedback, null); | |
| assert.equal(body.requests[1].feedback.value, 'needs_revision'); | |
| assert.deepEqual(JSON.parse(readFileSync(outputPath, 'utf8')), body); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'POST /api/agent/page-requests/feedback', | |
| 'POST /api/agent/diagnostics/page-requests' | |
| ] | |
| ); | |
| assert.equal(requests[1].authorization, 'Bearer diag-token'); | |
| assert.equal(requests[2].authorization, 'Bearer diag-token'); | |
| } | |
| ); | |
| } finally { | |
| rmSync(tempRoot, { recursive: true, force: true }); | |
| } | |
| }); | |
| it('reads Agent state diagnostics by request id and idempotency key without page feedback calls', async () => { | |
| const requests = []; | |
| await withServer( | |
| async (request, response) => { | |
| requests.push({ | |
| method: request.method, | |
| url: request.url, | |
| authorization: request.headers.authorization | |
| }); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| agent_request_diagnostics: '/api/agent/diagnostics/requests/{id}', | |
| agent_request_diagnostics_lookup: '/api/agent/diagnostics/requests' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests/req_1') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| found: true, | |
| diagnostics: { | |
| request: { | |
| request_id: 'req_1', | |
| idempotency_key: 'idem_req_1', | |
| mode: 'generate', | |
| status: 'succeeded' | |
| }, | |
| response: { | |
| image_count: 1, | |
| timing: { elapsed_ms: 61234, server_elapsed_ms: 61000 }, | |
| execution: { | |
| transport: 'agent_json', | |
| endpoint: '/api/agent/images/generate', | |
| request_headers: { | |
| user_agent_effective: 'visual-journal/2.2.0', | |
| has_extra_headers: false | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests?idempotency_key=idem_2') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| found: true, | |
| diagnostics: { | |
| request: { | |
| request_id: 'req_2', | |
| idempotency_key: 'idem_2', | |
| mode: 'generate', | |
| status: 'failed' | |
| }, | |
| error: { | |
| code: 'upstream_unavailable', | |
| retryable: true, | |
| diagnostics: { | |
| transport_error_kind: 'dns', | |
| cooldown_until: '2026-06-11T12:00:00.000Z', | |
| cooldown_target: { | |
| channel_id: 'channel-a', | |
| request_mode: 'images-non-stream' | |
| } | |
| } | |
| } | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'diagnose-request.mjs', | |
| ['--agent-request-id', 'req_1', '--idempotency-key', 'idem_2'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl, GPT_IMAGE_AGENT_TOKEN: 'diag-token' } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.billable, false); | |
| assert.equal(body.page_request_count, 0); | |
| assert.equal(body.request_count, 0); | |
| assert.equal(body.agent_request_count, 2); | |
| assert.deepEqual(body.requests, []); | |
| assert.equal(body.agent_requests[0].lookup.type, 'request_id'); | |
| assert.equal(body.agent_requests[0].diagnostics.response.timing.elapsed_ms, 61234); | |
| assert.equal( | |
| body.agent_requests[0].diagnostics.response.execution.request_headers.user_agent_effective, | |
| 'visual-journal/2.2.0' | |
| ); | |
| assert.equal(body.agent_requests[1].lookup.type, 'idempotency_key'); | |
| assert.equal(body.agent_requests[1].diagnostics.error.diagnostics.transport_error_kind, 'dns'); | |
| assert.equal( | |
| body.agent_requests[1].diagnostics.error.diagnostics.cooldown_target.request_mode, | |
| 'images-non-stream' | |
| ); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /api/agent/capabilities', | |
| 'GET /api/agent/diagnostics/requests/req_1', | |
| 'GET /api/agent/diagnostics/requests?idempotency_key=idem_2' | |
| ] | |
| ); | |
| assert.equal(requests[1].authorization, 'Bearer diag-token'); | |
| assert.equal(requests[2].authorization, 'Bearer diag-token'); | |
| } | |
| ); | |
| }); | |
| it('lets diagnose-request explicit base-url override the environment service URL', async () => { | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ endpoints: {} })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests/req_base_url') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ found: false })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'diagnose-request.mjs', | |
| ['--base-url', baseUrl, '--agent-request-id', 'req_base_url'], | |
| { GPT_IMAGE_PLAYGROUND_URL: 'http://127.0.0.1:9' } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.service_base_url, baseUrl); | |
| assert.equal(body.service_base_url_source, 'user_provided'); | |
| assert.equal(body.interactive_confirmation_required, false); | |
| assert.equal(body.agent_found, false); | |
| } | |
| ); | |
| }); | |
| it('reports missing Agent state diagnostics as found false without failing the script', async () => { | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ endpoints: {} })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/requests/missing-request') { | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ found: false })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'diagnose-request.mjs', | |
| ['--agent-request-id', 'missing-request'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.agent_request_count, 1); | |
| assert.equal(body.agent_found, false); | |
| assert.equal(body.agent_diagnostics, null); | |
| assert.equal(body.agent_requests[0].found, false); | |
| } | |
| ); | |
| }); | |
| it('fails diagnostics when the feedback batch response violates the contract', async () => { | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| page_request_feedback_batch: '/api/agent/page-requests/feedback', | |
| page_request_diagnostics_batch: '/api/agent/diagnostics/page-requests', | |
| page_request_diagnostics: '/api/agent/diagnostics/page-requests/{id}' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/page-requests/feedback' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ targets: [{ type: 'page_request', id: 'web-diag' }] })); | |
| return; | |
| } | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ diagnostics: [] })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('diagnose-request.mjs', ['--client-request-id', 'web-diag'], { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| }); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /批量反馈响应缺少 feedback 数组/); | |
| } | |
| ); | |
| }); | |
| it('fails diagnostics when the diagnostics batch response violates the contract', async () => { | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| page_request_feedback_batch: '/api/agent/page-requests/feedback', | |
| page_request_diagnostics_batch: '/api/agent/diagnostics/page-requests', | |
| page_request_diagnostics: '/api/agent/diagnostics/page-requests/{id}' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/page-requests/feedback' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ feedback: [] })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/page-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ targets: [{ type: 'page_request', id: 'web-diag' }] })); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('diagnose-request.mjs', ['--client-request-id', 'web-diag'], { | |
| GPT_IMAGE_PLAYGROUND_URL: baseUrl | |
| }); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /批量诊断响应缺少 diagnostics 数组/); | |
| } | |
| ); | |
| }); | |
| it('explains empty page request diagnostics with the advertised log retention boundary', async () => { | |
| const retention = { | |
| storage: 'bounded_local_jsonl', | |
| max_entries: 123, | |
| default_max_entries: 300, | |
| min_entries: 100, | |
| max_configured_entries: 5000, | |
| configured_by: 'APP_LOG_MAX_ENTRIES', | |
| persisted_across_process_restart: true, | |
| loss_modes: ['entry_evicted_by_max_entries', 'log_level_filter', 'local_log_file_missing_or_cleared'], | |
| bounded: true, | |
| not_agent_state_backend: true | |
| }; | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| page_request_feedback_batch: '/api/agent/page-requests/feedback', | |
| page_request_diagnostics_batch: '/api/agent/diagnostics/page-requests', | |
| page_request_diagnostics: '/api/agent/diagnostics/page-requests/{id}' | |
| }, | |
| page_request_diagnostics: { | |
| supported: true, | |
| source: 'app_log', | |
| retention | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/page-requests/feedback' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ feedback: [] })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/page-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| diagnostics: [ | |
| { | |
| client_request_id: 'missing-log', | |
| scope: { | |
| request_ids: ['missing-log'], | |
| filenames: [], | |
| filename_matched_request_ids: [], | |
| copy_text: 'requestIds=missing-log' | |
| }, | |
| matched_log_count: 0, | |
| events: [] | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'diagnose-request.mjs', | |
| ['--client-request-id', 'missing-log'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.deepEqual(body.diagnostics_retention, retention); | |
| assert.equal(body.found, false); | |
| assert.equal(body.page_request_query_count, 1); | |
| assert.equal(body.page_request_found_count, 0); | |
| assert.equal(body.diagnostics_note.code, 'no_matching_logs_in_retention_window'); | |
| assert.equal(body.requests[0].found, false); | |
| assert.equal(body.requests[0].diagnostics_note.code, 'no_matching_logs_in_retention_window'); | |
| assert.equal(body.requests[0].diagnostics_note.retention.max_entries, 123); | |
| assert.match(body.requests[0].diagnostics_note.message, /最近 123 条本地应用日志/); | |
| } | |
| ); | |
| }); | |
| it('uses diagnostics API retention when capabilities omit diagnostics metadata', async () => { | |
| const retention = { | |
| storage: 'bounded_local_jsonl', | |
| max_entries: 456, | |
| default_max_entries: 300, | |
| min_entries: 100, | |
| max_configured_entries: 5000, | |
| configured_by: 'APP_LOG_MAX_ENTRIES', | |
| persisted_across_process_restart: true, | |
| loss_modes: ['entry_evicted_by_max_entries', 'log_level_filter', 'local_log_file_missing_or_cleared'], | |
| bounded: true, | |
| not_agent_state_backend: true | |
| }; | |
| await withServer( | |
| async (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| page_request_feedback_batch: '/api/agent/page-requests/feedback', | |
| page_request_diagnostics_batch: '/api/agent/diagnostics/page-requests', | |
| page_request_diagnostics: '/api/agent/diagnostics/page-requests/{id}' | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/api/agent/page-requests/feedback' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ feedback: [] })); | |
| return; | |
| } | |
| if (request.url === '/api/agent/diagnostics/page-requests' && request.method === 'POST') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| diagnostics_retention: retention, | |
| diagnostics: [ | |
| { | |
| client_request_id: 'api-retention-only', | |
| scope: { | |
| request_ids: ['api-retention-only'], | |
| filenames: [], | |
| filename_matched_request_ids: [], | |
| copy_text: 'requestIds=api-retention-only' | |
| }, | |
| matched_log_count: 0, | |
| events: [] | |
| } | |
| ] | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'diagnose-request.mjs', | |
| ['--client-request-id', 'api-retention-only'], | |
| { GPT_IMAGE_PLAYGROUND_URL: baseUrl } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| const body = JSON.parse(result.stdout); | |
| assert.deepEqual(body.diagnostics_retention, retention); | |
| assert.equal(body.diagnostics_note.retention.max_entries, 456); | |
| assert.equal(body.requests[0].diagnostics_note.retention.max_entries, 456); | |
| assert.match(body.requests[0].diagnostics_note.message, /最近 456 条本地应用日志/); | |
| } | |
| ); | |
| }); | |
| it('reads the process-local channel health snapshot through the declared Agent capability', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push({ | |
| method: request.method, | |
| url: request.url, | |
| authorization: request.headers.authorization | |
| }); | |
| if (request.url === '/playground/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| channel_health_diagnostics: '/api/agent/diagnostics/channel-health' | |
| }, | |
| channel_health_diagnostics: { | |
| supported: true, | |
| endpoint: '/api/agent/diagnostics/channel-health', | |
| source: 'in_process_channel_router', | |
| state_scope: 'process_local', | |
| billable: false | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| if (request.url === '/playground/api/agent/diagnostics/channel-health') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| ok: true, | |
| billable: false, | |
| source: 'in_process_channel_router', | |
| state_scope: 'process_local', | |
| state_initialized: true, | |
| snapshot: { | |
| observed_at: 1_000, | |
| channels: [ | |
| { | |
| channel_id: 'primary', | |
| credential_count: 1, | |
| healthy_credential_count: 1, | |
| unhealthy_credential_count: 0, | |
| state: 'healthy', | |
| probe_required: false, | |
| credentials: [ | |
| { | |
| credential_id: 'primary#0', | |
| state: 'healthy', | |
| probe_required: false, | |
| request_modes: [ | |
| { | |
| mode: 'images-non-stream', | |
| state: 'healthy', | |
| probe_required: false | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(404, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'missing' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync( | |
| 'diagnose-channel-health.mjs', | |
| ['--base-url', `${baseUrl}/playground`], | |
| { GPT_IMAGE_AGENT_TOKEN: 'channel-health-token' } | |
| ); | |
| assert.equal(result.status, 0); | |
| assert.equal(result.stderr.trim(), ''); | |
| assert.doesNotMatch(result.stdout, /channel-health-token/); | |
| const body = JSON.parse(result.stdout); | |
| assert.equal(body.ok, true); | |
| assert.equal(body.billable, false); | |
| assert.equal(body.state_initialized, true); | |
| assert.equal(body.service_base_url, `${baseUrl}/playground`); | |
| assert.equal(body.service_base_url_source, 'user_provided'); | |
| assert.equal(body.snapshot.channels[0].channel_id, 'primary'); | |
| assert.deepEqual( | |
| requests.map((item) => `${item.method} ${item.url}`), | |
| [ | |
| 'GET /playground/api/agent/capabilities', | |
| 'GET /playground/api/agent/diagnostics/channel-health' | |
| ] | |
| ); | |
| assert.equal(requests[0].authorization, 'Bearer channel-health-token'); | |
| assert.equal(requests[1].authorization, 'Bearer channel-health-token'); | |
| } | |
| ); | |
| }); | |
| it('does not guess a channel health endpoint when capabilities do not declare support', async () => { | |
| await withServer( | |
| (request, response) => { | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ endpoints: {} })); | |
| return; | |
| } | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected request' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('diagnose-channel-health.mjs', ['--base-url', baseUrl]); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /channel_health_diagnostics/); | |
| } | |
| ); | |
| }); | |
| it('rejects inconsistent channel health endpoint declarations before reading diagnostics', async () => { | |
| const requests = []; | |
| await withServer( | |
| (request, response) => { | |
| requests.push([request.method, request.url].join(' ')); | |
| if (request.url === '/api/agent/capabilities') { | |
| response.writeHead(200, { 'content-type': 'application/json' }); | |
| response.end( | |
| JSON.stringify({ | |
| endpoints: { | |
| channel_health_diagnostics: '/api/agent/diagnostics/other-channel-health' | |
| }, | |
| channel_health_diagnostics: { | |
| supported: true, | |
| endpoint: '/api/agent/diagnostics/channel-health', | |
| source: 'in_process_channel_router', | |
| state_scope: 'process_local', | |
| billable: false | |
| } | |
| }) | |
| ); | |
| return; | |
| } | |
| response.writeHead(500, { 'content-type': 'application/json' }); | |
| response.end(JSON.stringify({ error: 'unexpected request' })); | |
| }, | |
| async (baseUrl) => { | |
| const result = await runSkillScriptAsync('diagnose-channel-health.mjs', ['--base-url', baseUrl]); | |
| assert.equal(result.status, 1); | |
| assert.equal(result.stdout.trim(), ''); | |
| assert.match(result.stderr, /端点声明不一致/); | |
| assert.deepEqual(requests, ['GET /api/agent/capabilities']); | |
| } | |
| ); | |
| }); | |
| }); | |
| describe('Agent endpoint path drift guards', () => { | |
| it('keeps the Skill endpoint registry identical to the server registry', () => { | |
| assert.deepEqual(AGENT_ENDPOINTS, SERVER_AGENT_ENDPOINTS); | |
| }); | |
| it('keeps channel health Skill documentation aligned with the Agent-only contract', () => { | |
| const readmeText = readFileSync(join(repoRoot, 'README.md'), 'utf8'); | |
| const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8'); | |
| const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8'); | |
| const openApiSource = readFileSync(join(repoRoot, 'src/lib/agent-openapi.ts'), 'utf8'); | |
| assert.equal(AGENT_ENDPOINTS.channel_health_diagnostics, '/api/agent/diagnostics/channel-health'); | |
| assert.match(readmeText, /\/api\/agent\/diagnostics\/channel-health/); | |
| assert.match(skillText, /diagnose-channel-health\.mjs/); | |
| assert.match(apiReference, /diagnose-channel-health\.mjs/); | |
| assert.match(skillText, /\/api\/agent\/diagnostics\/channel-health/); | |
| assert.match(apiReference, /\/api\/agent\/diagnostics\/channel-health/); | |
| assert.match(readmeText, /不触发上游探测或图片生成/); | |
| assert.match(readmeText, /不替代页面/); | |
| assert.match(readmeText, /runtime-capabilities/); | |
| assert.match(skillText, /不触发上游探测或图片生成/); | |
| assert.match(apiReference, /不触发上游探测或图片生成/); | |
| assert.match(skillText, /state_initialized=false/); | |
| assert.match(apiReference, /state_initialized=false/); | |
| assert.match(skillText, /不替代页面/); | |
| assert.match(skillText, /runtime-capabilities/); | |
| assert.match(apiReference, /不替代页面/); | |
| assert.match(apiReference, /runtime-capabilities/); | |
| assert.match(openApiSource, /AgentChannelHealthDiagnosticsResponse/); | |
| assert.match(openApiSource, /不会触发上游探测或图片生成/); | |
| }); | |
| }); | |
| function runSkillScript(filename, args, env = {}, options = {}) { | |
| const baseEnv = buildIsolatedSkillScriptEnv({ loadPrivateAgentEnv: options.loadPrivateAgentEnv }); | |
| if (options.createCwd) mkdirSync(options.cwd, { recursive: true }); | |
| return spawnSync(process.execPath, [join(skillScriptsRoot, filename), ...args], { | |
| cwd: options.cwd || repoRoot, | |
| encoding: 'utf8', | |
| env: { ...baseEnv, ...env }, | |
| ...(options.input !== undefined ? { input: options.input } : {}) | |
| }); | |
| } | |
| function buildIsolatedSkillScriptEnv(options = {}) { | |
| const keepNames = ['HOME', 'PATH', 'SystemRoot', 'TEMP', 'TMP', 'TMPDIR', 'USERPROFILE']; | |
| const isolated = {}; | |
| for (const name of keepNames) { | |
| if (process.env[name] !== undefined) isolated[name] = process.env[name]; | |
| } | |
| if (!options.loadPrivateAgentEnv) isolated.GPT_IMAGE_AGENT_LOAD_ENV_FILE = '0'; | |
| return isolated; | |
| } | |
| function listTextFiles(root) { | |
| const result = []; | |
| for (const name of readdirSync(root)) { | |
| const filePath = join(root, name); | |
| const stat = statSync(filePath); | |
| if (stat.isDirectory()) { | |
| result.push(...listTextFiles(filePath)); | |
| } else if (/\.(md|mjs|yaml)$/.test(name)) { | |
| result.push(filePath); | |
| } | |
| } | |
| return result; | |
| } | |
| function runSkillScriptAsync(filename, args, env = {}, options = {}) { | |
| return new Promise((resolve) => { | |
| const baseEnv = buildIsolatedSkillScriptEnv({ loadPrivateAgentEnv: options.loadPrivateAgentEnv }); | |
| const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], { | |
| cwd: repoRoot, | |
| env: { ...baseEnv, ...env }, | |
| stdio: ['ignore', 'pipe', 'pipe'] | |
| }); | |
| let stdout = ''; | |
| let stderr = ''; | |
| let timedOut = false; | |
| const timeout = options.timeoutMs | |
| ? setTimeout(() => { | |
| timedOut = true; | |
| child.kill('SIGTERM'); | |
| }, options.timeoutMs) | |
| : undefined; | |
| child.stdout.setEncoding('utf8'); | |
| child.stderr.setEncoding('utf8'); | |
| child.stdout.on('data', (chunk) => { | |
| stdout += chunk; | |
| }); | |
| child.stderr.on('data', (chunk) => { | |
| stderr += chunk; | |
| }); | |
| child.on('close', (status, signal) => { | |
| if (timeout) clearTimeout(timeout); | |
| resolve({ status, signal, stdout, stderr, timedOut }); | |
| }); | |
| }); | |
| } | |
| function createDeferred() { | |
| let resolve; | |
| let reject; | |
| const promise = new Promise((promiseResolve, promiseReject) => { | |
| resolve = promiseResolve; | |
| reject = promiseReject; | |
| }); | |
| return { promise, resolve, reject }; | |
| } | |
| async function waitWithTimeout(promise, timeoutMs, message) { | |
| let timeout; | |
| const timeoutPromise = new Promise((resolve, reject) => { | |
| timeout = setTimeout(() => { | |
| reject(new Error(message)); | |
| }, timeoutMs); | |
| }); | |
| try { | |
| return await Promise.race([promise, timeoutPromise]); | |
| } finally { | |
| clearTimeout(timeout); | |
| } | |
| } | |
| async function withServer(handler, run) { | |
| const server = createServer(handler); | |
| await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); | |
| const address = server.address(); | |
| try { | |
| assert.equal(typeof address, 'object'); | |
| assert.ok(address); | |
| await run(`http://127.0.0.1:${address.port}`); | |
| } finally { | |
| await new Promise((resolve) => server.close(resolve)); | |
| } | |
| } | |
| function readRequestText(request) { | |
| return new Promise((resolve, reject) => { | |
| let body = ''; | |
| request.setEncoding('utf8'); | |
| request.on('data', (chunk) => { | |
| body += chunk; | |
| }); | |
| request.on('end', () => { | |
| resolve(body); | |
| }); | |
| request.on('error', reject); | |
| }); | |
| } | |
| function escapeRegExp(value) { | |
| return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | |
| } | |
| function serverOrigin(request) { | |
| return `http://${request.headers.host}`; | |
| } | |
| function fakePngBuffer(width, height) { | |
| const buffer = Buffer.alloc(24); | |
| buffer[0] = 0x89; | |
| buffer.write('PNG', 1, 'ascii'); | |
| buffer[4] = 0x0d; | |
| buffer[5] = 0x0a; | |
| buffer[6] = 0x1a; | |
| buffer[7] = 0x0a; | |
| buffer.writeUInt32BE(13, 8); | |
| buffer.write('IHDR', 12, 'ascii'); | |
| buffer.writeUInt32BE(width, 16); | |
| buffer.writeUInt32BE(height, 20); | |
| return buffer; | |
| } | |
| function fakePngBase64(width, height) { | |
| return fakePngBuffer(width, height).toString('base64'); | |
| } | |
| function validPngBuffer() { | |
| return Buffer.from( | |
| 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADUlEQVQImWP4z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==', | |
| 'base64' | |
| ); | |
| } | |