mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-11 11:10:13 +01:00
test/codex-e2e-multiblock-live.test.ts runs ./setup --host codex once from the job's checkout into a fresh HOME and CODEX_HOME (auth.json copied from the CI login home), asserts codex debug prompt-input lists the gstack router as a real file, seeds one learning, and runs /learn's Show recent block through codex exec: that later block must root itself through its prelude, run the installed gstack-learnings-search and print the seeded learning; no block may report a missing install or an unresolved gstack helper path. The file joins CODEX_CI_FILES so the planner gives it to the host-run Codex job. paid-test-durations.json carries a 180 s packing estimate (refreshed by --write-durations after the first census) so the plan keeps every slice within max-parallel. Oracles live in test/helpers/codex-multiblock.ts with free replays in test/codex-multiblock.test.ts. Registration (E2E_TOUCHFILES, E2E_TIERS periodic, E2E_KINDS rule) is the integrator's.
69 lines
3.6 KiB
TypeScript
69 lines
3.6 KiB
TypeScript
/**
|
|
* Oracles for the live Codex multi-block case (E6,
|
|
* test/codex-e2e-multiblock-live.test.ts). Codex runs every shell block in a
|
|
* fresh shell, so a gstack skill works there only when each later block
|
|
* resolves the install on its own (the env-var host prelude). The case
|
|
* installs gstack with `./setup --host codex` into a fresh CODEX_HOME, seeds
|
|
* one learning, and runs /learn's "Show recent" block through `codex exec`.
|
|
* Pure functions: the free test replays synthetic `exec --json` events.
|
|
*/
|
|
|
|
export const MULTIBLOCK_SENTINEL_KEY = 'e6-multiblock-sentinel';
|
|
|
|
interface CommandEvent { command: string; output: string; exitCode: number | null }
|
|
|
|
function completedCommands(rawLines: readonly string[]): CommandEvent[] {
|
|
const commands: CommandEvent[] = [];
|
|
for (const line of rawLines) {
|
|
try {
|
|
const event = JSON.parse(line);
|
|
if (event?.type !== 'item.completed' || event.item?.type !== 'command_execution') continue;
|
|
commands.push({ command: String(event.item.command ?? ''), output: String(event.item.aggregated_output ?? ''),
|
|
exitCode: typeof event.item.exit_code === 'number' ? event.item.exit_code : null });
|
|
} catch { /* not an event line */ }
|
|
}
|
|
return commands;
|
|
}
|
|
|
|
/**
|
|
* Problems with a live run: any block that could not find the install, a
|
|
* gstack helper path that did not resolve, or no later block (the skill's
|
|
* learnings-search block, which only the prelude can root) that ran the
|
|
* installed helper and printed the seeded learning.
|
|
*/
|
|
export function multiblockProblems(rawLines: readonly string[], sentinel = MULTIBLOCK_SENTINEL_KEY): string[] {
|
|
const commands = completedCommands(rawLines);
|
|
const problems: string[] = [];
|
|
for (const { command, output } of commands) {
|
|
if (output.includes('gstack: no install found')) problems.push(`a block could not find the install: ${output.trim().slice(0, 200)}`);
|
|
const unresolved = /\S*\/bin\/gstack-[\w-]+: (?:No such file or directory|command not found|not found)/.exec(output);
|
|
if (unresolved) problems.push(`a gstack helper path did not resolve: ${unresolved[0]} (in: ${command.slice(0, 120)})`);
|
|
}
|
|
const search = commands.filter(c => c.command.includes('gstack-learnings-search'));
|
|
if (search.length === 0) problems.push('no block ran gstack-learnings-search (the skill\'s "Show recent" block)');
|
|
else if (!search.some(c => c.exitCode === 0 && c.output.includes(sentinel) && !/GSTACK_ROOT=["']?\//.test(c.command))) {
|
|
problems.push(`no learnings-search block rooted by its own prelude printed the seeded learning ${sentinel}: `
|
|
+ search.map(c => `exit ${c.exitCode}, ${c.output.includes(sentinel) ? 'sentinel present' : 'sentinel absent'}`
|
|
+ `${/GSTACK_ROOT=["']?\//.test(c.command) ? ', GSTACK_ROOT set by hand' : ''}`).join('; '));
|
|
}
|
|
return problems;
|
|
}
|
|
|
|
/**
|
|
* The `gstack` router entry from `codex debug prompt-input` output (a JSON
|
|
* array of input items): the skill path it names, resolved through the skill
|
|
* roots table, or null when Codex does not list the router.
|
|
*/
|
|
export function routerPathFromPromptInput(promptInput: string): string | null {
|
|
let text = promptInput;
|
|
try {
|
|
const items = JSON.parse(promptInput.slice(promptInput.indexOf('[')));
|
|
text = (Array.isArray(items) ? items : []).flatMap((item: { content?: Array<{ text?: string }> }) => item.content ?? [])
|
|
.map(part => part.text ?? '').join('\n');
|
|
} catch { /* scan the raw text */ }
|
|
const entry = /^- gstack: .*\(file: (r\d+)\/gstack\/SKILL\.md\)$/m.exec(text);
|
|
if (!entry) return null;
|
|
const root = new RegExp(`^- \`${entry[1]}\` = \`([^\`]+)\`$`, 'm').exec(text);
|
|
return root ? `${root[1]}/gstack/SKILL.md` : null;
|
|
}
|