485ed50b26
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (push) Has been cancelled
Project CI / AI game creator shell Rust shard 4/4 (push) Has been cancelled
## 背景 净月潭案例(2026-09-19 21:53 → 09-20 01:26,3 小时 33 分)的问题不是模型慢,而是环境未就绪、验收靠模型自述、预算只约束单个工具入口、工具调用被 SDK 全局串行闸门卡住。本 PR 落地确认后的七项交付效率合同,并把捆绑 Codex 升到当前 npm latest。 ## 结果 - 新建 Web 游戏在正式生成前由宿主自动预检:捆绑 Node/npm、真实 Vite 构建、受限浏览器桌面/移动截图;失败不启动生成或付费素材。 - 首个副作用前冻结交付合同,宿主保存权威证据与预算;证据齐全后先封口、排空并取得执行器完整退出证明,再产出交付报告,模型回复不再当作验收。 - 原生 shell、内置浏览器与托管命令、第三方 MCP 共用同一执行许可和累计执行时间;`validation.maxRuns` 改为执行/返修批次语义,另设 `maxTurnSeconds` 墙钟上限。 - 所有工具可并发:移除 SDK 全局串行的 `apply_patch`/`update_plan` 注册,改由宿主 `agc_apply_patch`(官方 parser + 当前回合写许可 + 受控进程树 + 短项目事务)和 `agc_update_plan` 提供等价能力;真实请求目录里已无串行注册,长 MCP 与补丁/计划实测在同一响应内重叠。 - 付费提交与本地写入绑定原回合原租约:容量与同动作锁等待可取消,封口、取消或预算耗尽后零新增提交;已越过提交边界的请求保留 operation ID 走 GET 对账,不自动重放。 - 新增请求与工具分段计时、有界并行批读和首轮上下文预取,未知耗时不补零。 - 捆绑 Codex 0.147.0 → 0.155.1:固定版本收敛到 `build_support/codex_bundle.rs` 单一声明,vendor 解析源码按 `rust-v0.155.1` 逐字节重取并更新 UPSTREAM 证据,适配 0.155 统一 exec(`exec_command`/`write_stdin`);macOS 侧车最小系统版本仍为 15.0。 ## 验证 - 生产 CLI → 捆绑 Codex 0.155.1 → 本地 Responses/MCP 夹具 9/9:补丁、完成收尾、批次、只读/可写 MCP 并发、原生命令并发、原生资源并发、统一 exec 会话、期限终止(真实重叠 775 / 764 / 999 ms)。 - 真实模型目录 2/2;vendor 上游库 111 项;宿主定向与回归 106 项;真实 Windows 进程与取消 21 项;Node 侧门禁 52 项。 - 发行载荷:artifact-only 重建 NSIS,解包验证侧车清单 `codex-cli 0.155.1`、6 个组件哈希、打包后 `bin/codex.exe --version`、Node 运行时 2130 文件与双端 PNG、`--environment-check` ready。 - `check-config`、TypeScript、编码(4991 文件)、文档索引、`git diff --check`、`cargo fmt --check` 全部通过。 ## 未覆盖 - 真实陶泥儿登录态 Provider 生成尚未执行(本机 `--llm-status` 返回 `authentication-required`)。 - macOS 侧车只在本机做静态 Mach-O 与清单解析,未在 macOS 上跑 `check-macos-bundle.mjs`。 - 全仓库聚合套件在本机仍因负载敏感的后台 mock-provider 用例而红,与本次改动无关:改动前的旧二进制同样失败,换单线程后相关用例 3/3 通过。 --------- Co-authored-by: kdletters <61648117+kdletters@users.noreply.github.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/439
976 lines
32 KiB
JavaScript
976 lines
32 KiB
JavaScript
// Real AGC CLI -> bundled app-server -> loopback Responses/MCP fixtures.
|
||
// No account credentials, installed AppData, or paid Provider are used.
|
||
import assert from 'node:assert/strict';
|
||
import { spawn, spawnSync } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
import fs from 'node:fs/promises';
|
||
import http from 'node:http';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const repo = path.resolve(
|
||
path.dirname(fileURLToPath(import.meta.url)),
|
||
'../../..',
|
||
);
|
||
const args = process.argv.slice(2);
|
||
const option = (name) => {
|
||
const i = args.indexOf(name);
|
||
return i < 0 ? undefined : args[i + 1];
|
||
};
|
||
const executable = option('--agc-exe');
|
||
assert(
|
||
executable && path.isAbsolute(executable),
|
||
'pass --agc-exe with the newly built debug AGC binary',
|
||
);
|
||
const codex = path.resolve(
|
||
option('--codex-exe') ??
|
||
path.join(
|
||
repo,
|
||
'node_modules/@openai/codex-win32-x64/vendor/x86_64-pc-windows-msvc/bin/codex.exe',
|
||
),
|
||
);
|
||
const cases = (
|
||
option('--cases') ?? 'completed,passes,mcp,mcp-write,native,deadline'
|
||
).split(',');
|
||
const root = await fs.mkdtemp(
|
||
path.join(os.tmpdir(), 'agc-execution-production-'),
|
||
);
|
||
console.log(JSON.stringify({ evidenceRoot: root }));
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
||
const exists = async (file) =>
|
||
fs.stat(file).then(
|
||
() => true,
|
||
() => false,
|
||
);
|
||
const quote = (text) =>
|
||
process.platform === 'win32'
|
||
? "'" + text.replaceAll("'", "''") + "'"
|
||
: "'" + text.replaceAll("'", "'\\''") + "'";
|
||
const nodeCommand = (source) =>
|
||
(process.platform === 'win32' ? '& ' : '') +
|
||
quote(process.execPath) +
|
||
' -e ' +
|
||
quote(
|
||
process.platform === 'win32'
|
||
? // Windows PowerShell 5 removes nested double quotes from native argv.
|
||
"eval(Buffer.from('" +
|
||
Buffer.from(source).toString('base64') +
|
||
"','base64').toString())"
|
||
: source,
|
||
);
|
||
const call = (id, name, arguments_, namespace) => ({
|
||
type: 'function_call',
|
||
id: 'item-' + id,
|
||
call_id: id,
|
||
name,
|
||
...(namespace ? { namespace } : {}),
|
||
arguments: JSON.stringify(arguments_),
|
||
});
|
||
// Codex 0.155 起原生执行入口是统一 exec:`exec_command` + `write_stdin`(旧 `shell_command`
|
||
// 已不再注册)。命令在 yield_time_ms 内结束时不返回 session,保持与旧用例同样的同步语义。
|
||
const native = (id, source) =>
|
||
call(id, 'exec_command', {
|
||
cmd: nodeCommand(source),
|
||
yield_time_ms: 30_000,
|
||
});
|
||
const register = (artifact) =>
|
||
call(
|
||
'contract',
|
||
'agc_register_delivery_contract',
|
||
{
|
||
scope:
|
||
'仅测试客户端执行许可,修改临时已有工程的标记文件,不生成素材或运行游戏。',
|
||
changeKind: 'project',
|
||
requirements: [{ kind: 'artifact', id: 'marker', path: artifact }],
|
||
},
|
||
'mcp__agc_tools',
|
||
);
|
||
const final = {
|
||
type: 'message',
|
||
id: 'fixture-final',
|
||
role: 'assistant',
|
||
content: [
|
||
{
|
||
type: 'output_text',
|
||
text: 'Fixture actions finished; use the host delivery result.',
|
||
},
|
||
],
|
||
};
|
||
// 工具回执正文既可能是字符串,也可能是 input_text 分片数组。
|
||
const toolOutputText = (value) =>
|
||
typeof value === 'string'
|
||
? value
|
||
: Array.isArray(value)
|
||
? value.map((part) => part?.text ?? '').join('')
|
||
: '';
|
||
// 原生工具的输入 schema 只随第一份请求回执留档一次,用于版本升级后核对参数形状。
|
||
const NATIVE_SCHEMA_TOOLS = new Set([
|
||
'exec_command',
|
||
'write_stdin',
|
||
'shell_command',
|
||
'view_image',
|
||
'list_mcp_resources',
|
||
'list_mcp_resource_templates',
|
||
'read_mcp_resource',
|
||
]);
|
||
const describeTool = (tool, withSchema = false) => ({
|
||
type: tool.type,
|
||
name: tool.name ?? tool.function?.name,
|
||
...(tool.namespace ? { namespace: tool.namespace } : {}),
|
||
...(withSchema && tool.parameters ? { parameters: tool.parameters } : {}),
|
||
...(Array.isArray(tool.tools)
|
||
? { tools: tool.tools.map((entry) => describeTool(entry, false)) }
|
||
: {}),
|
||
});
|
||
|
||
async function walk(directory) {
|
||
const output = [];
|
||
for (const item of await fs
|
||
.readdir(directory, { withFileTypes: true })
|
||
.catch(() => [])) {
|
||
const file = path.join(directory, item.name);
|
||
if (item.isDirectory()) output.push(...(await walk(file)));
|
||
else if (item.isFile()) output.push(file);
|
||
}
|
||
return output;
|
||
}
|
||
|
||
async function readLedger(host) {
|
||
for (const file of await walk(path.join(host, 'direct-executions'))) {
|
||
if (!file.endsWith('.json')) continue;
|
||
const value = await fs
|
||
.readFile(file, 'utf8')
|
||
.then(JSON.parse)
|
||
.catch(() => null);
|
||
if (value?.schemaVersion === 'agc-direct-execution.v1')
|
||
return { file, value };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function ownFixtureTree(directory) {
|
||
if (process.platform !== 'win32') return;
|
||
assert(path.resolve(directory).startsWith(path.resolve(root) + path.sep));
|
||
// Elevated Windows shells otherwise create Administrators-owned objects.
|
||
// Only this newly created fixture subtree is adjusted to its launching user.
|
||
const result = spawnSync(
|
||
'powershell.exe',
|
||
[
|
||
'-NoProfile',
|
||
'-NonInteractive',
|
||
'-Command',
|
||
"$ErrorActionPreference='Stop'; $fixtureRoot=$env:AGC_FIXTURE_OWNER_ROOT; " +
|
||
'$fixtureSid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; ' +
|
||
'$fixtureItems=@(Get-Item -LiteralPath $fixtureRoot)+@(Get-ChildItem -LiteralPath $fixtureRoot -Recurse -Force); ' +
|
||
'foreach($fixtureItem in $fixtureItems){$fixtureAcl=$fixtureItem.GetAccessControl(); $fixtureAcl.SetOwner($fixtureSid); $fixtureItem.SetAccessControl($fixtureAcl)}',
|
||
],
|
||
{
|
||
env: { ...process.env, AGC_FIXTURE_OWNER_ROOT: directory },
|
||
windowsHide: true,
|
||
encoding: 'utf8',
|
||
},
|
||
);
|
||
assert.equal(
|
||
result.status,
|
||
0,
|
||
'fixture ownership setup failed: ' + result.stderr,
|
||
);
|
||
}
|
||
|
||
async function installFixtureMcp(
|
||
host,
|
||
directory,
|
||
readOnlyHint,
|
||
delayMs = 800,
|
||
doneMarker = '',
|
||
) {
|
||
const source = path.join(host, 'extensions', 'sources', 'fixture');
|
||
await fs.mkdir(source, { recursive: true });
|
||
const program = path.join(source, 'server.cjs');
|
||
const dispatch = path.join(directory, 'mcp-dispatch.jsonl');
|
||
await fs.writeFile(
|
||
program,
|
||
[
|
||
"const rl=require('node:readline').createInterface({input:process.stdin});",
|
||
"const fs=require('node:fs'); const target=process.argv[2];",
|
||
"rl.on('line',async(line)=>{let x;try{x=JSON.parse(line)}catch{return}if(x.id===undefined)return;",
|
||
'let result;switch(x.method){',
|
||
"case 'initialize':result={protocolVersion:x.params.protocolVersion,capabilities:{tools:{},resources:{}},serverInfo:{name:'fixture',version:'1'}};break;",
|
||
"case 'tools/list':result={tools:[{name:'execute',description:'Fixture execution with explicit MCP annotations',inputSchema:{type:'object',properties:{step:{type:'integer'}},required:['step']},annotations:{readOnlyHint:" +
|
||
readOnlyHint +
|
||
',idempotentHint:true}}]};break;',
|
||
"case 'tools/call':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,arguments:x.params.arguments,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r," +
|
||
delayMs +
|
||
"));if(process.argv[3])fs.writeFileSync(process.argv[3],'done');fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result={content:[{type:'text',text:'fixture dispatched'}]};break;",
|
||
"case 'resources/list':case 'resources/templates/list':case 'resources/read':fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'start',method:x.method,at:Date.now()})+'\\n');await new Promise(r=>setTimeout(r,800));fs.appendFileSync(target,JSON.stringify({id:x.id,phase:'end',at:Date.now()})+'\\n');result=x.method==='resources/list'?{resources:[{uri:'fixture://state',name:'state',mimeType:'text/plain'}]}:x.method==='resources/templates/list'?{resourceTemplates:[{uriTemplate:'fixture://{name}',name:'fixture',mimeType:'text/plain'}]}:{contents:[{uri:x.params.uri,text:'fixture resource',mimeType:'text/plain'}]};break;",
|
||
"case 'ping':result={};break;",
|
||
"default:process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,error:{code:-32601,message:'unsupported fixture method'}})+'\\n');return;",
|
||
"}process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:x.id,result})+'\\n');});",
|
||
].join('\n'),
|
||
);
|
||
const config = {
|
||
command: process.execPath,
|
||
args: [program, dispatch, doneMarker],
|
||
tool_timeout_sec: 10,
|
||
};
|
||
const document = JSON.stringify({ mcpServers: { fixture: config } });
|
||
await fs.writeFile(path.join(source, 'mcp.json'), document);
|
||
await fs.writeFile(
|
||
path.join(host, 'extensions', 'index.json'),
|
||
JSON.stringify({
|
||
schemaVersion: 'direct-project-client-extensions.v1',
|
||
sources: [
|
||
{
|
||
id: 'fixture-source',
|
||
originalName: 'fixture',
|
||
storagePath: 'sources/fixture',
|
||
fingerprint: hash(document),
|
||
},
|
||
],
|
||
items: [
|
||
{
|
||
id: 'fixture-mcp',
|
||
sourceId: 'fixture-source',
|
||
extensionType: 'mcp',
|
||
name: 'fixture',
|
||
originalName: 'fixture',
|
||
sourceRelativePath: 'mcp.json',
|
||
enabled: true,
|
||
fingerprint: hash(document),
|
||
lastError: null,
|
||
mcpConfig: config,
|
||
},
|
||
],
|
||
}),
|
||
);
|
||
return dispatch;
|
||
}
|
||
|
||
async function runScenario(name) {
|
||
assert(
|
||
[
|
||
'completed',
|
||
'passes',
|
||
'mcp',
|
||
'mcp-write',
|
||
'native',
|
||
'native-resources',
|
||
'patch',
|
||
'deadline',
|
||
'native-session',
|
||
].includes(name),
|
||
'unknown fixture case',
|
||
);
|
||
const directory = path.join(root, name);
|
||
const project = path.join(directory, 'project');
|
||
const host = path.join(directory, 'host');
|
||
const home = path.join(directory, 'home');
|
||
await Promise.all(
|
||
[
|
||
project,
|
||
host,
|
||
home,
|
||
path.join(home, 'appdata'),
|
||
path.join(home, 'local'),
|
||
].map((dir) => fs.mkdir(dir, { recursive: true })),
|
||
);
|
||
// An existing editor project avoids turning this execution-boundary fixture
|
||
// into a new-Web-game/bootstrap/visual-quality test.
|
||
await fs.writeFile(
|
||
path.join(project, 'project.godot'),
|
||
'config_version=5\n[application]\nconfig/name="AGC execution fixture"\n',
|
||
);
|
||
const isMcp = name === 'mcp' || name === 'mcp-write';
|
||
const mcpDispatch =
|
||
isMcp || name === 'native-resources' || name === 'patch'
|
||
? await installFixtureMcp(
|
||
host,
|
||
directory,
|
||
name === 'mcp',
|
||
name === 'patch' ? 4_000 : 800,
|
||
name === 'patch' ? path.join(project, 'mcp-done.txt') : '',
|
||
)
|
||
: null;
|
||
const writes = (filename, text) =>
|
||
"require('node:fs').writeFileSync(" +
|
||
JSON.stringify(filename) +
|
||
',' +
|
||
JSON.stringify(text) +
|
||
');';
|
||
const nativeOverlapCall = (id) =>
|
||
native(
|
||
id,
|
||
"const f=require('node:fs');const file=" +
|
||
JSON.stringify(id + '.jsonl') +
|
||
";const mark=phase=>f.appendFileSync(file,JSON.stringify({phase,at:Date.now()})+'\\n');mark('start');setTimeout(()=>mark('end'),1000);",
|
||
);
|
||
const plan =
|
||
name === 'completed'
|
||
? [
|
||
[register('marker.txt')],
|
||
[native('marker', writes('marker.txt', 'verified'))],
|
||
[
|
||
native(
|
||
'forbidden-after-completion',
|
||
writes('forbidden.txt', 'must not run'),
|
||
),
|
||
],
|
||
]
|
||
: name === 'deadline'
|
||
? [
|
||
[register('never.txt')],
|
||
[
|
||
native(
|
||
'slow',
|
||
writes('started.txt', 'started') +
|
||
'setTimeout(()=>{' +
|
||
writes('late.txt', 'must not run') +
|
||
'},8000);',
|
||
),
|
||
],
|
||
]
|
||
: isMcp
|
||
? [
|
||
[register('never.txt')],
|
||
[
|
||
call('mcp-a', 'execute', { step: 1 }, 'mcp__fixture'),
|
||
call('mcp-b', 'execute', { step: 1 }, 'mcp__fixture'),
|
||
],
|
||
[native('fail', 'process.exit(1);')],
|
||
[call('forbidden-mcp', 'execute', { step: 2 }, 'mcp__fixture')],
|
||
]
|
||
: name === 'native'
|
||
? [
|
||
[register('never.txt')],
|
||
[nativeOverlapCall('native-a'), nativeOverlapCall('native-b')],
|
||
[native('fail', 'process.exit(1);')],
|
||
[
|
||
native(
|
||
'forbidden-native',
|
||
writes('forbidden.txt', 'must not run'),
|
||
),
|
||
],
|
||
]
|
||
: name === 'native-resources'
|
||
? [
|
||
[register('never.txt')],
|
||
[
|
||
'list_mcp_resources',
|
||
'list_mcp_resource_templates',
|
||
'read_mcp_resource',
|
||
].flatMap((tool) =>
|
||
[1, 2].map((i) =>
|
||
call(tool + '-' + i, tool, {
|
||
server: 'fixture',
|
||
...(tool === 'read_mcp_resource'
|
||
? { uri: 'fixture://state' }
|
||
: {}),
|
||
}),
|
||
),
|
||
),
|
||
[native('fail', 'process.exit(1);')],
|
||
]
|
||
: name === 'native-session'
|
||
? [
|
||
[register('session.txt')],
|
||
[
|
||
call('session', 'exec_command', {
|
||
cmd: nodeCommand(
|
||
"const f=require('node:fs');setTimeout(()=>f.writeFileSync('session.txt','session completed'),12000);",
|
||
),
|
||
yield_time_ms: 10_000,
|
||
}),
|
||
],
|
||
(body) => {
|
||
// 统一 exec 超过 yield_time_ms 会返回会话号;用 write_stdin 轮询到会话结束。
|
||
const output = (body.input ?? [])
|
||
.filter((item) => item.call_id === 'session')
|
||
.map((item) => toolOutputText(item.output))
|
||
.join('\n');
|
||
const match = /session ID (\d+)/.exec(output);
|
||
assert(
|
||
match,
|
||
'exec_command did not return a unified exec session id: ' +
|
||
output.slice(0, 400),
|
||
);
|
||
return [
|
||
call('poll', 'write_stdin', {
|
||
session_id: Number(match[1]),
|
||
chars: '',
|
||
yield_time_ms: 20_000,
|
||
}),
|
||
];
|
||
},
|
||
[final],
|
||
]
|
||
: name === 'patch'
|
||
? [
|
||
[
|
||
call(
|
||
'contract',
|
||
'agc_register_delivery_contract',
|
||
{
|
||
scope:
|
||
'验收并发补丁、计划与慢工具的正常完成;两个必需产物都必须出现。',
|
||
changeKind: 'project',
|
||
requirements: [
|
||
{
|
||
kind: 'artifact',
|
||
id: 'patch',
|
||
path: 'patch-proof.txt',
|
||
},
|
||
{
|
||
kind: 'artifact',
|
||
id: 'mcp',
|
||
path: 'mcp-done.txt',
|
||
},
|
||
],
|
||
},
|
||
'mcp__agc_tools',
|
||
),
|
||
],
|
||
[
|
||
call(
|
||
'slow-mcp',
|
||
'execute',
|
||
{ step: 1 },
|
||
'mcp__fixture',
|
||
),
|
||
call(
|
||
'patch',
|
||
'agc_apply_patch',
|
||
{
|
||
patch:
|
||
'*** Begin Patch\n*** Environment ID: local\n*** Add File: patch-proof.txt\n+parallel patch proof\n*** End Patch',
|
||
},
|
||
'mcp__agc_tools',
|
||
),
|
||
call(
|
||
'plan',
|
||
'agc_update_plan',
|
||
{
|
||
explanation: '并行计划回执',
|
||
plan: [
|
||
{
|
||
step: 'parallel-plan-proof',
|
||
status: 'completed',
|
||
},
|
||
],
|
||
},
|
||
'mcp__agc_tools',
|
||
),
|
||
],
|
||
[
|
||
native(
|
||
'forbidden-patch-expansion',
|
||
writes('forbidden.txt', 'must not run'),
|
||
),
|
||
],
|
||
]
|
||
: [
|
||
[register('never.txt')],
|
||
[native('build', 'console.log(1+1);')],
|
||
[native('test', 'console.log(2+2);')],
|
||
[native('fail', 'process.exit(1);')],
|
||
[
|
||
native(
|
||
'forbidden-after-budget',
|
||
writes('forbidden.txt', 'must not run'),
|
||
),
|
||
],
|
||
];
|
||
const requests = [];
|
||
const responses = [];
|
||
const httpArrivals = [];
|
||
const fixtureErrors = [];
|
||
const server = http.createServer(async (request, response) => {
|
||
httpArrivals.push({
|
||
at: Date.now(),
|
||
method: request.method,
|
||
url: request.url,
|
||
expectedAuthorization:
|
||
request.headers.authorization === 'Bearer agc-loopback-fixture',
|
||
});
|
||
try {
|
||
assert.equal(request.method, 'POST');
|
||
assert(request.url.endsWith('/responses'));
|
||
assert.equal(
|
||
request.headers.authorization,
|
||
'Bearer agc-loopback-fixture',
|
||
);
|
||
const chunks = [];
|
||
let bytes = 0;
|
||
for await (const chunk of request) {
|
||
bytes += chunk.length;
|
||
assert(bytes <= 32 * 1024 * 1024, 'oversized fixture request');
|
||
chunks.push(chunk);
|
||
}
|
||
const requestBody = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||
const index = requests.length;
|
||
requests.push({
|
||
index,
|
||
at: Date.now(),
|
||
model: requestBody.model,
|
||
parallelToolCalls: requestBody.parallel_tool_calls,
|
||
...(index === 0
|
||
? {
|
||
toolCatalogue: (requestBody.tools ?? []).map((tool) =>
|
||
describeTool(tool, NATIVE_SCHEMA_TOOLS.has(tool.name)),
|
||
),
|
||
}
|
||
: {}),
|
||
// 只留档工具回执的有界原文,用于核对统一 exec 的会话/退出结果契约。
|
||
inputs: (requestBody.input ?? []).map((item) => ({
|
||
type: item.type,
|
||
name: item.name,
|
||
call_id: item.call_id,
|
||
raw: JSON.stringify(item).slice(0, 1500),
|
||
})),
|
||
});
|
||
assert(index < 20, 'fixture model loop exceeded bound');
|
||
if (index === plan.length - 1 && name !== 'deadline') {
|
||
// Allow host receipt settlement/sealing to win before an intentionally
|
||
// unwanted expansion. A closed transport is a valid pre-dispatch stop.
|
||
await sleep(300);
|
||
}
|
||
const step = plan[index] ?? [final];
|
||
const output = typeof step === 'function' ? step(requestBody) : step;
|
||
|
||
responses.push({
|
||
index,
|
||
items: output.map((item) => JSON.stringify(item).slice(0, 300)),
|
||
});
|
||
const body = {
|
||
id: 'response-' + index,
|
||
object: 'response',
|
||
model: 'gpt-5.1-codex',
|
||
status: 'completed',
|
||
output,
|
||
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
|
||
};
|
||
const events = [
|
||
{
|
||
type: 'response.created',
|
||
response: { ...body, status: 'in_progress', output: [] },
|
||
},
|
||
];
|
||
output.forEach((item, output_index) => {
|
||
events.push({ type: 'response.output_item.added', output_index, item });
|
||
events.push({ type: 'response.output_item.done', output_index, item });
|
||
});
|
||
events.push({ type: 'response.completed', response: body });
|
||
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
||
if (name === 'patch' && index === 1) {
|
||
// One response, streamed incrementally: prove the long operation really
|
||
// started before offering independent patch/plan calls to the scheduler.
|
||
response.write(
|
||
events
|
||
.slice(0, 3)
|
||
.map((event) => 'data: ' + JSON.stringify(event) + '\n\n')
|
||
.join(''),
|
||
);
|
||
const startedDeadline = Date.now() + 5_000;
|
||
while (Date.now() < startedDeadline) {
|
||
const log = await fs.readFile(mcpDispatch, 'utf8').catch(() => '');
|
||
if (
|
||
log
|
||
.split('\n')
|
||
.some(
|
||
(line) =>
|
||
line.includes('"method":"tools/call"') &&
|
||
line.includes('"phase":"start"'),
|
||
)
|
||
)
|
||
break;
|
||
await sleep(20);
|
||
}
|
||
response.end(
|
||
events
|
||
.slice(3)
|
||
.map((event) => 'data: ' + JSON.stringify(event) + '\n\n')
|
||
.join(''),
|
||
);
|
||
return;
|
||
}
|
||
response.end(
|
||
events
|
||
.map((event) => 'data: ' + JSON.stringify(event) + '\n\n')
|
||
.join(''),
|
||
);
|
||
} catch (error) {
|
||
fixtureErrors.push(String(error));
|
||
if (!response.destroyed) {
|
||
response.writeHead(400);
|
||
response.end(String(error));
|
||
}
|
||
}
|
||
});
|
||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||
const address = server.address();
|
||
await fs.writeFile(
|
||
path.join(host, 'game-creator.config.json'),
|
||
JSON.stringify({
|
||
schemaVersion: 'game-creator-config.v2',
|
||
agentMode: 'codex_app_server',
|
||
llm: {
|
||
customEnabled: true,
|
||
apiKey: 'agc-loopback-fixture',
|
||
baseUrl: 'http://127.0.0.1:' + address.port + '/v1',
|
||
model: 'gpt-5.1-codex',
|
||
visibleModels: ['gpt-5.1-codex'],
|
||
apiKind: 'openai_responses',
|
||
stream: true,
|
||
webSearchEnabled: false,
|
||
reasoningEffort: 'low',
|
||
maxRetries: 0,
|
||
requestTimeoutMs: 60_000,
|
||
},
|
||
validation: {
|
||
maxRuns: 1,
|
||
maxExecutionSeconds: name === 'deadline' ? 2 : 30,
|
||
maxTurnSeconds: 60,
|
||
},
|
||
}),
|
||
);
|
||
ownFixtureTree(directory);
|
||
const env = Object.fromEntries(
|
||
Object.entries(process.env).filter(
|
||
([key]) =>
|
||
!/TOKEN|SECRET|PASSWORD|API_KEY|AUTHORIZATION|COOKIE/i.test(key),
|
||
),
|
||
);
|
||
Object.assign(env, {
|
||
GENARRATIVE_AGC_DEBUG_PROVIDER_E2E: '1',
|
||
HOME: home,
|
||
USERPROFILE: home,
|
||
APPDATA: path.join(home, 'appdata'),
|
||
LOCALAPPDATA: path.join(home, 'local'),
|
||
PATH: path.dirname(codex) + path.delimiter + (env.PATH ?? env.Path ?? ''),
|
||
});
|
||
let stdout = '',
|
||
stderr = '';
|
||
let planObservedAt = null;
|
||
let observingPlan = false;
|
||
let planObservationTask = Promise.resolve();
|
||
const planWatch =
|
||
name === 'patch'
|
||
? setInterval(() => {
|
||
if (planObservedAt !== null || observingPlan) return;
|
||
observingPlan = true;
|
||
planObservationTask = readLedger(host)
|
||
.then((entry) => {
|
||
if (
|
||
JSON.stringify(entry?.value.plan ?? null).includes(
|
||
'parallel-plan-proof',
|
||
)
|
||
)
|
||
planObservedAt = Date.now();
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
observingPlan = false;
|
||
});
|
||
}, 20)
|
||
: null;
|
||
const child = spawn(
|
||
executable,
|
||
[
|
||
'--config-dir',
|
||
host,
|
||
'--direct-codex-chat',
|
||
project,
|
||
'测试现有临时工程的宿主执行许可;只按fixture合同操作,不生成平台素材。',
|
||
],
|
||
{
|
||
cwd: directory,
|
||
env,
|
||
windowsHide: true,
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
},
|
||
);
|
||
child.stdout.on('data', (data) => {
|
||
stdout = (stdout + data.toString('utf8')).slice(-2 * 1024 * 1024);
|
||
});
|
||
child.stderr.on('data', (data) => {
|
||
stderr = (stderr + data.toString('utf8')).slice(-2 * 1024 * 1024);
|
||
});
|
||
const timeout = setTimeout(() => child.kill(), 90_000);
|
||
const exit = await new Promise((resolve, reject) => {
|
||
child.once('error', reject);
|
||
child.once('exit', (code, signal) => resolve({ code, signal }));
|
||
}).finally(() => clearTimeout(timeout));
|
||
if (planWatch) clearInterval(planWatch);
|
||
await planObservationTask;
|
||
server.closeAllConnections();
|
||
await new Promise((resolve) => server.close(resolve));
|
||
if (name === 'deadline') await sleep(9_000);
|
||
const ledger = await readLedger(host);
|
||
const dispatchEvents = mcpDispatch
|
||
? (await fs.readFile(mcpDispatch, 'utf8').catch(() => ''))
|
||
.trim()
|
||
.split('\n')
|
||
.filter(Boolean)
|
||
.map(JSON.parse)
|
||
: [];
|
||
const dispatched = dispatchEvents.filter(
|
||
(entry) => entry.phase === 'start' && entry.method === 'tools/call',
|
||
);
|
||
const intervals = dispatched.map((entry) => ({
|
||
id: entry.id,
|
||
start: entry.at,
|
||
end: dispatchEvents.find(
|
||
(event) => event.id === entry.id && event.phase === 'end',
|
||
)?.at,
|
||
}));
|
||
const overlapMs =
|
||
intervals.length === 2 && intervals.every((entry) => entry.end)
|
||
? Math.min(...intervals.map((entry) => entry.end)) -
|
||
Math.max(...intervals.map((entry) => entry.start))
|
||
: null;
|
||
const nativeIntervals = [];
|
||
if (name === 'native')
|
||
for (const id of ['native-a', 'native-b']) {
|
||
const entries = (
|
||
await fs
|
||
.readFile(path.join(project, id + '.jsonl'), 'utf8')
|
||
.catch(() => '')
|
||
)
|
||
.trim()
|
||
.split('\n')
|
||
.filter(Boolean)
|
||
.map(JSON.parse);
|
||
nativeIntervals.push({
|
||
id,
|
||
start: entries.find((entry) => entry.phase === 'start')?.at,
|
||
end: entries.find((entry) => entry.phase === 'end')?.at,
|
||
});
|
||
}
|
||
const nativeOverlapMs =
|
||
nativeIntervals.length === 2 && nativeIntervals.every((entry) => entry.end)
|
||
? Math.min(...nativeIntervals.map((entry) => entry.end)) -
|
||
Math.max(...nativeIntervals.map((entry) => entry.start))
|
||
: null;
|
||
const resourceIntervals = dispatchEvents
|
||
.filter(
|
||
(entry) =>
|
||
entry.phase === 'start' &&
|
||
entry.method.startsWith('resources/') &&
|
||
entry.at >= (requests[1]?.at ?? Infinity),
|
||
)
|
||
.map((entry) => ({
|
||
id: entry.id,
|
||
method: entry.method,
|
||
start: entry.at,
|
||
end: dispatchEvents.find(
|
||
(event) => event.id === entry.id && event.phase === 'end',
|
||
)?.at,
|
||
}));
|
||
const resourceOverlaps = Object.fromEntries(
|
||
['resources/list', 'resources/templates/list', 'resources/read'].map(
|
||
(method) => {
|
||
const matching = resourceIntervals.filter(
|
||
(entry) => entry.method === method,
|
||
);
|
||
return [
|
||
method,
|
||
matching.length === 2 && matching.every((entry) => entry.end)
|
||
? Math.min(...matching.map((entry) => entry.end)) -
|
||
Math.max(...matching.map((entry) => entry.start))
|
||
: null,
|
||
];
|
||
},
|
||
),
|
||
);
|
||
const report = {
|
||
scenario: name,
|
||
directory,
|
||
exit,
|
||
requests,
|
||
httpArrivals,
|
||
fixtureErrors,
|
||
ledger: ledger?.value,
|
||
responses,
|
||
dispatched,
|
||
dispatchEvents,
|
||
intervals,
|
||
overlapMs,
|
||
mcpReadOnlyHint: mcpDispatch ? name === 'mcp' : undefined,
|
||
nativeIntervals,
|
||
nativeOverlapMs,
|
||
resourceIntervals,
|
||
resourceOverlaps,
|
||
planObservedAt,
|
||
patchModifiedAt: await fs.stat(path.join(project, 'patch-proof.txt')).then(
|
||
(stat) => stat.mtimeMs,
|
||
() => null,
|
||
),
|
||
stdout,
|
||
stderr,
|
||
markers: {
|
||
ready: await exists(path.join(project, 'marker.txt')),
|
||
forbidden: await exists(path.join(project, 'forbidden.txt')),
|
||
started: await exists(path.join(project, 'started.txt')),
|
||
late: await exists(path.join(project, 'late.txt')),
|
||
},
|
||
};
|
||
await fs.writeFile(
|
||
path.join(directory, 'result.json'),
|
||
JSON.stringify(report, null, 2),
|
||
);
|
||
assert(
|
||
requests.length > 0,
|
||
name + ': AGC did not reach the loopback fixture',
|
||
);
|
||
assert(
|
||
requests.every((request) => request.parallelToolCalls === true),
|
||
name +
|
||
': Direct Responses requests must allow multiple tool calls without changing the selected model',
|
||
);
|
||
assert(ledger, name + ': missing host-authoritative ledger');
|
||
assert.equal(exit.code, 0, name + ': CLI failed; inspect result.json');
|
||
assert.equal(
|
||
report.markers.forbidden,
|
||
false,
|
||
name + ': effect ran after host terminal',
|
||
);
|
||
assert.equal(
|
||
ledger.value.usedPasses,
|
||
1,
|
||
name + ': ordinary commands must share one pass',
|
||
);
|
||
assert.equal(
|
||
ledger.value.phase,
|
||
['completed', 'patch', 'native-session'].includes(name)
|
||
? 'completed'
|
||
: 'exhausted',
|
||
);
|
||
assert.equal(
|
||
ledger.value.executorStopped,
|
||
true,
|
||
name + ': missing full executor exit proof',
|
||
);
|
||
if (name === 'completed') assert.equal(report.markers.ready, true);
|
||
if (isMcp) {
|
||
assert.equal(
|
||
dispatched.length,
|
||
2,
|
||
'untrusted readonly hints cannot authorize a post-terminal MCP call',
|
||
);
|
||
assert(dispatched.every((entry) => entry.arguments.step === 1));
|
||
assert(
|
||
overlapMs > 0,
|
||
name +
|
||
': real Codex MCP calls ran serially; inspect start/end evidence before changing dispatch policy',
|
||
);
|
||
}
|
||
if (name === 'deadline') {
|
||
assert.equal(
|
||
report.markers.started,
|
||
true,
|
||
'must prove the real child started before the cutoff',
|
||
);
|
||
assert.equal(
|
||
report.markers.late,
|
||
false,
|
||
'turn interruption alone did not stop the child',
|
||
);
|
||
}
|
||
if (name === 'native')
|
||
assert(
|
||
nativeOverlapMs > 0,
|
||
'independent native write commands must overlap',
|
||
);
|
||
if (name === 'native-resources')
|
||
for (const [method, overlap] of Object.entries(resourceOverlaps))
|
||
assert(
|
||
overlap > 0,
|
||
method + ': real native resource reads did not overlap',
|
||
);
|
||
if (name === 'patch') {
|
||
const catalogue = requests[0].toolCatalogue;
|
||
assert(
|
||
!catalogue.some((tool) =>
|
||
['apply_patch', 'update_plan', 'request_user_input'].includes(
|
||
tool.name,
|
||
),
|
||
),
|
||
'serial native registrations must be absent',
|
||
);
|
||
const owned =
|
||
catalogue.find((tool) => tool.name === 'mcp__agc_tools')?.tools ?? [];
|
||
for (const tool of ['agc_apply_patch', 'agc_update_plan'])
|
||
assert(owned.some((entry) => entry.name === tool));
|
||
assert.equal(
|
||
intervals.length,
|
||
1,
|
||
'exactly one slow third-party call must execute',
|
||
);
|
||
assert.equal(
|
||
await fs.readFile(path.join(project, 'patch-proof.txt'), 'utf8'),
|
||
'parallel patch proof\n',
|
||
);
|
||
assert.equal(
|
||
await fs.readFile(path.join(project, 'mcp-done.txt'), 'utf8'),
|
||
'done',
|
||
);
|
||
for (const [action, at] of Object.entries({
|
||
patch: report.patchModifiedAt,
|
||
plan: planObservedAt,
|
||
}))
|
||
assert(
|
||
at > intervals[0].start && at < intervals[0].end,
|
||
action + ' did not complete while the slow MCP call was running',
|
||
);
|
||
}
|
||
if (name === 'native-session') {
|
||
const execOutput = requests
|
||
.flatMap((entry) => entry.inputs ?? [])
|
||
.filter((item) => item.call_id === 'session')
|
||
.map((item) => {
|
||
try {
|
||
return toolOutputText(JSON.parse(item.raw).output);
|
||
} catch {
|
||
return '';
|
||
}
|
||
})
|
||
.join('\n');
|
||
assert(
|
||
/Process running with session ID \d+/.test(execOutput),
|
||
'exec_command must return a unified exec session id',
|
||
);
|
||
assert(
|
||
await exists(path.join(project, 'session.txt')),
|
||
'write_stdin polling must let the unified exec session finish',
|
||
);
|
||
assert.equal(
|
||
ledger?.value?.phase,
|
||
'completed',
|
||
'host acceptance must complete after the session artifact appears',
|
||
);
|
||
}
|
||
console.log(
|
||
JSON.stringify({
|
||
scenario: name,
|
||
passed: true,
|
||
result: path.join(directory, 'result.json'),
|
||
}),
|
||
);
|
||
}
|
||
|
||
const failures = [];
|
||
for (const name of cases) {
|
||
try {
|
||
await runScenario(name);
|
||
} catch (error) {
|
||
failures.push({ scenario: name, error: String(error) });
|
||
console.error(JSON.stringify(failures.at(-1)));
|
||
}
|
||
}
|
||
assert.equal(
|
||
failures.length,
|
||
0,
|
||
'production fixture failures: ' + JSON.stringify(failures),
|
||
);
|