Files
Genarrative/apps/ai-game-creator-shell/scripts/direct-execution-production-fixture.mjs
T
kdletters 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
AGC 七项交付效率优化并升级捆绑 Codex 到 0.155.1 (#439)
## 背景

净月潭案例(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
2026-09-21 02:29:29 +08:00

976 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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),
);