forked from garrytan/gstack
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcodex-e2e-plan-format.test.ts
More file actions
320 lines (268 loc) · 14.3 KB
/
Copy pathcodex-e2e-plan-format.test.ts
File metadata and controls
320 lines (268 loc) · 14.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
/**
* AskUserQuestion format regression test for /plan-ceo-review and /plan-eng-review
* running under Codex CLI (GPT-5.4).
*
* Context: GPT-class models under the "No preamble / Prefer doing over listing"
* gpt.md overlay tend to skip the Simplify (ELI10) paragraph and the RECOMMENDATION
* line on AskUserQuestion calls. The user has to manually re-prompt "ELI10 and don't
* forget to recommend" almost every time. This test pins that behavior so future
* regressions surface automatically.
*
* Mirrors test/skill-e2e-plan-format.test.ts (the Claude version) but uses
* test/helpers/codex-session-runner.ts to drive `codex exec` instead of `claude -p`.
*
* Four cases:
* 1. plan-ceo-review mode selection (kind-differentiated)
* 2. plan-ceo-review approach menu (coverage-differentiated)
* 3. plan-eng-review per-issue coverage decision
* 4. plan-eng-review per-issue architectural choice (kind-differentiated)
*
* Assertions on captured AskUserQuestion text:
* - RECOMMENDATION: Choose present (all cases)
* - Completeness: N/10 present on coverage cases, absent on kind cases
* - "options differ in kind" note present on kind cases
* - ELI10-style plain-English explanation present (length floor + no raw jargon)
*
* Periodic tier (Codex non-determinism). Cost: ~$2-3 per full run.
*/
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
import { runCodexSkill, installSkillToTempHome } from './helpers/codex-session-runner';
import type { CodexResult } from './helpers/codex-session-runner';
import { EvalCollector } from './helpers/eval-store';
import type { EvalTestEntry } from './helpers/eval-store';
import { selectTests, detectBaseBranch, getChangedFiles, GLOBAL_TOUCHFILES } from './helpers/touchfiles';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { spawnSync } from 'child_process';
const ROOT = path.resolve(import.meta.dir, '..');
// --- Prerequisites ---
const CODEX_AVAILABLE = (() => {
try {
const result = Bun.spawnSync(['which', 'codex']);
return result.exitCode === 0;
} catch { return false; }
})();
const evalsEnabled = !!process.env.EVALS;
const SKIP = !CODEX_AVAILABLE || !evalsEnabled;
const describeCodex = SKIP ? describe.skip : describe;
// --- Touchfiles ---
const CODEX_FORMAT_TOUCHFILES: Record<string, string[]> = {
'codex-plan-ceo-format-mode': ['.agents/skills/gstack-plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'model-overlays/gpt.md', 'model-overlays/gpt-5.4.md'],
'codex-plan-ceo-format-approach': ['.agents/skills/gstack-plan-ceo-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'model-overlays/gpt.md', 'model-overlays/gpt-5.4.md'],
'codex-plan-eng-format-coverage': ['.agents/skills/gstack-plan-eng-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'model-overlays/gpt.md', 'model-overlays/gpt-5.4.md'],
'codex-plan-eng-format-kind': ['.agents/skills/gstack-plan-eng-review/**', 'scripts/resolvers/preamble/generate-ask-user-format.ts', 'scripts/resolvers/preamble/generate-completeness-section.ts', 'model-overlays/gpt.md', 'model-overlays/gpt-5.4.md'],
};
let selectedTests: string[] | null = null;
if (evalsEnabled && !process.env.EVALS_ALL) {
const baseBranch = process.env.EVALS_BASE || detectBaseBranch(ROOT) || 'main';
const changedFiles = getChangedFiles(baseBranch, ROOT);
if (changedFiles.length > 0) {
const selection = selectTests(changedFiles, CODEX_FORMAT_TOUCHFILES, GLOBAL_TOUCHFILES);
selectedTests = selection.selected;
}
}
function testIfSelected(name: string, fn: () => Promise<void>, timeout?: number) {
if (selectedTests !== null && !selectedTests.includes(name)) {
test.skip(name, fn, timeout);
} else {
test(name, fn, timeout);
}
}
// --- Eval collector ---
let evalCollector: EvalCollector | null = null;
if (!SKIP) {
evalCollector = new EvalCollector('codex-e2e-plan-format');
}
function recordCodexResult(testName: string, result: CodexResult, passed: boolean) {
evalCollector?.addTest({
name: testName,
suite: 'codex-e2e-plan-format',
tier: 'e2e',
passed,
duration_ms: result.durationMs,
cost_usd: 0, // Codex doesn't report cost in the same way; tokens tracked separately
output: result.output?.slice(0, 2000),
turns_used: result.toolCalls.length,
exit_reason: result.exitCode === 0 ? 'success' : `exit_code_${result.exitCode}`,
});
}
afterAll(async () => {
if (evalCollector) {
await evalCollector.finalize();
}
});
// --- Fixtures ---
const SAMPLE_PLAN = `# Plan: Add User Dashboard
## Context
We're building a new user dashboard that shows recent activity, notifications, and quick actions.
## Changes
1. New React component \`UserDashboard\` in \`src/components/\`
2. REST API endpoint \`GET /api/dashboard\` returning user stats
3. PostgreSQL query for activity aggregation
4. Redis cache layer for dashboard data (5min TTL)
## Architecture
- Frontend: React + TailwindCSS
- Backend: Express.js REST API
- Database: PostgreSQL with existing user/activity tables
- Cache: Redis for dashboard aggregates
`;
function setupCodexSkillDir(tmpPrefix: string, skillName: 'plan-ceo-review' | 'plan-eng-review'): { skillDir: string; planDir: string; outFile: string } {
const planDir = fs.mkdtempSync(path.join(os.tmpdir(), tmpPrefix));
const run = (cmd: string, args: string[]) =>
spawnSync(cmd, args, { cwd: planDir, stdio: 'pipe', timeout: 5000 });
run('git', ['init', '-b', 'main']);
run('git', ['config', 'user.email', '[email protected]']);
run('git', ['config', 'user.name', 'Test']);
fs.writeFileSync(path.join(planDir, 'plan.md'), SAMPLE_PLAN);
run('git', ['add', '.']);
run('git', ['commit', '-m', 'add plan']);
// Codex skill lives in .agents/skills/gstack-{name}/ per the gstack host convention.
const codexSkillSource = path.join(ROOT, '.agents', 'skills', `gstack-${skillName}`);
const skillDir = path.join(planDir, '.agents', 'skills', `gstack-${skillName}`);
fs.mkdirSync(skillDir, { recursive: true });
fs.cpSync(codexSkillSource, skillDir, { recursive: true });
const outFile = path.join(planDir, 'ask-capture.md');
return { skillDir, planDir, outFile };
}
// Capture instruction — same shape as the Claude version. Codex may ignore tool calls,
// so we tell it to write prose to the file directly.
function captureInstruction(outFile: string): string {
return `Write the verbatim text of every AskUserQuestion you would have presented to the user to the file ${outFile} (one question per session, full text including the re-ground, ELI10 paragraph, RECOMMENDATION line, and options). Do NOT ask the user interactively. Do NOT paraphrase. This is a format-capture test, not an interactive session.`;
}
// --- Regex predicates ---
// Match RECOMMENDATION lenient to markdown bolding around it.
const RECOMMENDATION_RE = /RECOMMENDATION:[*\s]*Choose/;
const COMPLETENESS_RE = /Completeness:\s*\d{1,2}\/10/;
const KIND_NOTE_RE = /options differ in kind/i;
// ELI10 signal: some plain-English explanation must exist. Weak proxy: >= 200 chars
// of narrative prose between the re-ground and the options, AND at least one of the
// plain-English hints ("plain English", "16-year-old", or "what this means").
// We test for the length floor and absence of a bare options-list-only output.
const ELI10_LENGTH_FLOOR = 400; // full AskUserQuestion content should be at least this long
// --- Tests ---
describeCodex('Codex Plan Format — CEO Mode Selection', () => {
let skillDir: string, planDir: string, outFile: string;
beforeAll(() => {
({ skillDir, planDir, outFile } = setupCodexSkillDir('codex-e2e-plan-format-ceo-mode-', 'plan-ceo-review'));
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
testIfSelected('codex-plan-ceo-format-mode', async () => {
const result = await runCodexSkill({
skillDir,
prompt: `Read the plan-ceo-review skill. Read plan.md (the plan to review). Proceed to Step 0F (Mode Selection) where the skill presents 4 mode options (SCOPE EXPANSION, SELECTIVE EXPANSION, HOLD SCOPE, SCOPE REDUCTION) via AskUserQuestion. These options differ in kind (review posture), not coverage. ${captureInstruction(outFile)}`,
timeoutMs: 300_000,
cwd: planDir,
skillName: 'gstack-plan-ceo-review',
sandbox: 'workspace-write',
});
recordCodexResult('codex-plan-ceo-format-mode', result, result.exitCode === 0);
console.log(`codex-plan-ceo-format-mode: ${result.tokens}t, ${Math.round(result.durationMs/1000)}s, exit=${result.exitCode}`);
// Codex may timeout — accept as non-fatal (same pattern as existing codex-e2e tests)
if (result.exitCode === 124 || result.exitCode === 137) {
console.warn(`codex timed out (exit ${result.exitCode}) — skipping assertions`);
return;
}
expect(fs.existsSync(outFile)).toBe(true);
const captured = fs.readFileSync(outFile, 'utf-8');
expect(captured.length).toBeGreaterThan(ELI10_LENGTH_FLOOR);
expect(captured).toMatch(RECOMMENDATION_RE);
// kind-differentiated: no fabricated score, must have note
expect(captured).not.toMatch(COMPLETENESS_RE);
expect(captured).toMatch(KIND_NOTE_RE);
}, 360_000);
});
describeCodex('Codex Plan Format — CEO Approach Menu', () => {
let skillDir: string, planDir: string, outFile: string;
beforeAll(() => {
({ skillDir, planDir, outFile } = setupCodexSkillDir('codex-e2e-plan-format-ceo-approach-', 'plan-ceo-review'));
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
testIfSelected('codex-plan-ceo-format-approach', async () => {
const result = await runCodexSkill({
skillDir,
prompt: `Read the plan-ceo-review skill. Read plan.md. Proceed to Step 0C-bis (Implementation Alternatives / Approach Menu) where the skill generates 2-3 approaches (minimal viable vs ideal architecture) and presents them via AskUserQuestion. These options differ in coverage so Completeness: N/10 applies. ${captureInstruction(outFile)}`,
timeoutMs: 300_000,
cwd: planDir,
skillName: 'gstack-plan-ceo-review',
sandbox: 'workspace-write',
});
recordCodexResult('codex-plan-ceo-format-approach', result, result.exitCode === 0);
console.log(`codex-plan-ceo-format-approach: ${result.tokens}t, ${Math.round(result.durationMs/1000)}s, exit=${result.exitCode}`);
if (result.exitCode === 124 || result.exitCode === 137) {
console.warn(`codex timed out (exit ${result.exitCode}) — skipping assertions`);
return;
}
expect(fs.existsSync(outFile)).toBe(true);
const captured = fs.readFileSync(outFile, 'utf-8');
expect(captured.length).toBeGreaterThan(ELI10_LENGTH_FLOOR);
expect(captured).toMatch(RECOMMENDATION_RE);
expect(captured).toMatch(COMPLETENESS_RE);
}, 360_000);
});
describeCodex('Codex Plan Format — Eng Coverage Issue', () => {
let skillDir: string, planDir: string, outFile: string;
beforeAll(() => {
({ skillDir, planDir, outFile } = setupCodexSkillDir('codex-e2e-plan-format-eng-cov-', 'plan-eng-review'));
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
testIfSelected('codex-plan-eng-format-coverage', async () => {
const result = await runCodexSkill({
skillDir,
prompt: `Read the plan-eng-review skill. Read plan.md. In your Section 3 Test Review, generate ONE AskUserQuestion about test coverage depth where options are clearly coverage-differentiated: A) full coverage incl. edge + error paths (Completeness 10/10), B) happy path only (7/10), C) smoke test (3/10). ${captureInstruction(outFile)}`,
timeoutMs: 300_000,
cwd: planDir,
skillName: 'gstack-plan-eng-review',
sandbox: 'workspace-write',
});
recordCodexResult('codex-plan-eng-format-coverage', result, result.exitCode === 0);
console.log(`codex-plan-eng-format-coverage: ${result.tokens}t, ${Math.round(result.durationMs/1000)}s, exit=${result.exitCode}`);
if (result.exitCode === 124 || result.exitCode === 137) {
console.warn(`codex timed out (exit ${result.exitCode}) — skipping assertions`);
return;
}
expect(fs.existsSync(outFile)).toBe(true);
const captured = fs.readFileSync(outFile, 'utf-8');
expect(captured.length).toBeGreaterThan(ELI10_LENGTH_FLOOR);
expect(captured).toMatch(RECOMMENDATION_RE);
expect(captured).toMatch(COMPLETENESS_RE);
}, 360_000);
});
describeCodex('Codex Plan Format — Eng Kind Issue', () => {
let skillDir: string, planDir: string, outFile: string;
beforeAll(() => {
({ skillDir, planDir, outFile } = setupCodexSkillDir('codex-e2e-plan-format-eng-kind-', 'plan-eng-review'));
});
afterAll(() => {
try { fs.rmSync(planDir, { recursive: true, force: true }); } catch {}
});
testIfSelected('codex-plan-eng-format-kind', async () => {
const result = await runCodexSkill({
skillDir,
prompt: `Read the plan-eng-review skill. Read plan.md. In your Section 1 Architecture review, generate ONE AskUserQuestion about an architectural choice where the options differ in kind (e.g. Redis vs Postgres materialized view vs in-process cache — different kinds of systems with different tradeoffs, NOT more-or-less-complete versions of the same thing). ${captureInstruction(outFile)}`,
timeoutMs: 300_000,
cwd: planDir,
skillName: 'gstack-plan-eng-review',
sandbox: 'workspace-write',
});
recordCodexResult('codex-plan-eng-format-kind', result, result.exitCode === 0);
console.log(`codex-plan-eng-format-kind: ${result.tokens}t, ${Math.round(result.durationMs/1000)}s, exit=${result.exitCode}`);
if (result.exitCode === 124 || result.exitCode === 137) {
console.warn(`codex timed out (exit ${result.exitCode}) — skipping assertions`);
return;
}
expect(fs.existsSync(outFile)).toBe(true);
const captured = fs.readFileSync(outFile, 'utf-8');
expect(captured.length).toBeGreaterThan(ELI10_LENGTH_FLOOR);
expect(captured).toMatch(RECOMMENDATION_RE);
// kind-differentiated: no fabricated score
expect(captured).not.toMatch(COMPLETENESS_RE);
expect(captured).toMatch(KIND_NOTE_RE);
}, 360_000);
});