mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-12 03:30:28 +01:00
fix(autoplan): run binds complete api results, names a truncated response, --max-tokens; DRIVER documents run (GBRA-223 claim 6)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
78a43a3c96
commit
e79db1f7a3
10 files changed
+168
-23
No files matched your search
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* `gstack-autoplan run` with the api runner for both voices (GBRA-223 claim 6):
|
||||
* a complete autoplan result (INPUT receipt plus the findings fence) binds and
|
||||
* the run reaches its gate; a response stopped at the output limit is named
|
||||
* truncated and interrupts the run. The provider is a local server, so no key
|
||||
* or network is used.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { AUTOPLAN, ROOT, SMALL_PLAN, env } from './helpers/autoplan-run-fixture';
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'autoplan-run-api-'));
|
||||
let mode: 'ok' | 'cut' = 'ok';
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const body = await req.json();
|
||||
const prompt = String(body.messages[0].content);
|
||||
const m = /INPUT: ([a-z]+) ([0-9a-f]{64})/.exec(prompt.slice(prompt.lastIndexOf('RESULT FORMAT')))!;
|
||||
const text = mode === 'cut'
|
||||
? `INPUT: ${m[1]} ${m[2]}\n\nA long review that ran out of output tokens before the findings fen`
|
||||
: `INPUT: ${m[1]} ${m[2]}\n\nComplete review.\n\n\`\`\`gstack-findings\n{"id":"F1","severity":"Medium","title":"Name the retry metric","fix":"add retries_total"}\n\`\`\`\n`;
|
||||
if (new URL(req.url).pathname.endsWith('/messages')) return Response.json({ model: body.model, stop_reason: mode === 'cut' ? 'max_tokens' : 'end_turn', content: [{ type: 'text', text }], usage: { input_tokens: 10, output_tokens: 20 } });
|
||||
return Response.json({ model: body.model, choices: [{ finish_reason: mode === 'cut' ? 'length' : 'stop', message: { content: text } }], usage: { prompt_tokens: 10, completion_tokens: 20 } });
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(() => { server.stop(true); fs.rmSync(TMP, { recursive: true, force: true }); });
|
||||
|
||||
async function run(name: string) {
|
||||
const dir = path.join(TMP, name);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'plan.md'), SMALL_PLAN);
|
||||
const out = path.join(dir, 'run');
|
||||
const base = `http://127.0.0.1:${server.port}`;
|
||||
const proc = Bun.spawn(['bun', AUTOPLAN, 'run', '--out', out, '--plan', path.join(dir, 'plan.md'), '--no-ui', '--no-dx', '--native-model', 'anthropic/claude-opus-5-5', '--outside-model', 'openai/gpt-6-astra', '--runner', 'api', '--native-runner', 'api'], {
|
||||
cwd: ROOT, stdout: 'pipe', stderr: 'pipe',
|
||||
env: env({ ANTHROPIC_API_KEY: 'ak-test', OPENAI_API_KEY: 'sk-test', ANTHROPIC_BASE_URL: base, OPENAI_BASE_URL: `${base}/v1`, GSTACK_AUTOPLAN_CHECKPOINT_DIR: '', GSTACK_STATE_ROOT: path.join(TMP, 'state') }),
|
||||
});
|
||||
const timer = setTimeout(() => proc.kill('SIGKILL'), 120_000);
|
||||
const [status, stdout, stderr] = await Promise.all([proc.exited, new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
|
||||
clearTimeout(timer);
|
||||
return { status, stdout, stderr, out, state: JSON.parse(fs.readFileSync(path.join(out, 'run.json'), 'utf8')) };
|
||||
}
|
||||
|
||||
describe('gstack-autoplan run --native-runner api --runner api', () => {
|
||||
test('a complete result binds both voices in every phase and the run reaches its gate', async () => {
|
||||
mode = 'ok';
|
||||
const r = await run('ok');
|
||||
expect(r.status, r.stderr).toBe(0);
|
||||
expect(r.stdout).toContain(`GSTACK_RESULT: skill=autoplan status=gate_pending run=${r.out}`);
|
||||
for (const phase of ['ceo', 'eng']) for (const voice of ['native', 'outside']) expect(r.state.phases[phase][voice]).toMatchObject({ status: 'completed', findings: 1 });
|
||||
}, 150_000);
|
||||
|
||||
test('a response stopped at the output limit is named truncated and interrupts the run', async () => {
|
||||
mode = 'cut';
|
||||
const r = await run('cut');
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/native voice unavailable: truncated: .*output limit/);
|
||||
expect(r.state.status).toBe('interrupted');
|
||||
}, 150_000);
|
||||
});
|
||||
@@ -240,3 +240,46 @@ describe('api runner (supplied-input review)', () => {
|
||||
expect(nokey.stderr).toMatch(/OPENAI_API_KEY is not set.*\(OUTSIDE_RUNNER_UNAVAILABLE\)$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('api runner output limits (GBRA-223 claim 6)', () => {
|
||||
let server: ReturnType<typeof Bun.serve>;
|
||||
const bodies: Array<{ url: string; body: any }> = [];
|
||||
beforeAll(() => {
|
||||
server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
const body = await req.json();
|
||||
bodies.push({ url: url.pathname, body });
|
||||
const cut = String(body.model).includes('cut');
|
||||
if (url.pathname === '/v1/messages') return Response.json({ model: body.model, stop_reason: cut ? 'max_tokens' : 'end_turn', content: [{ type: 'text', text: cut ? 'INPUT: ceo abc\n\nA long review cut off mid-sent' : REVIEW }], usage: { input_tokens: 10, output_tokens: body.max_tokens } });
|
||||
return Response.json({ model: body.model, choices: [{ finish_reason: cut ? 'length' : 'stop', message: { content: cut ? 'INPUT: ceo abc\n\nA long review cut off' : REVIEW } }], usage: { prompt_tokens: 10, completion_tokens: 50 } });
|
||||
},
|
||||
});
|
||||
});
|
||||
afterAll(() => server.stop(true));
|
||||
const anthropic = () => ({ ...cleanEnv(emptyBin), ANTHROPIC_API_KEY: 'ak-test', ANTHROPIC_BASE_URL: `http://127.0.0.1:${server.port}` });
|
||||
const openai = () => ({ ...cleanEnv(emptyBin), OPENAI_API_KEY: 'sk-test', OPENAI_BASE_URL: `http://127.0.0.1:${server.port}/v1` });
|
||||
const out = (name: string) => path.join(TMP, 'limits', name);
|
||||
|
||||
test('anthropic asks for 16384 output tokens by default and --max-tokens sets it; openai sends max_completion_tokens only when set', async () => {
|
||||
expect((await runAsync(['run', '--runner', 'api', '--prompt', promptFile, '--out', out('a1.md'), '--model', 'claude-opus-5-5'], anthropic())).status).toBe(0);
|
||||
expect((await runAsync(['run', '--runner', 'api', '--prompt', promptFile, '--out', out('a2.md'), '--model', 'claude-opus-5-5', '--max-tokens', '4000'], anthropic())).status).toBe(0);
|
||||
expect(bodies.filter(b => b.url === '/v1/messages').map(b => b.body.max_tokens)).toEqual([16384, 4000]);
|
||||
expect((await runAsync(['run', '--runner', 'api', '--prompt', promptFile, '--out', out('o1.md'), '--model', 'gpt-6-astra'], openai())).status).toBe(0);
|
||||
expect((await runAsync(['run', '--runner', 'api', '--prompt', promptFile, '--out', out('o2.md'), '--model', 'gpt-6-astra', '--max-tokens', '4000'], openai())).status).toBe(0);
|
||||
expect(bodies.filter(b => b.url === '/v1/chat/completions').map(b => b.body.max_completion_tokens ?? null)).toEqual([null, 4000]);
|
||||
});
|
||||
|
||||
test('a response stopped at the output limit is truncated (exit 1), never a completed or missing-markers review', async () => {
|
||||
for (const [model, env, tokens] of [['claude-cut', anthropic(), '16384'], ['gpt-cut', openai(), '50']] as const) {
|
||||
const file = out(`${model}.md`);
|
||||
const r = await runAsync(['run', '--runner', 'api', '--prompt', promptFile, '--out', file, '--model', model], env);
|
||||
expect(r.status).toBe(1);
|
||||
expect(r.stderr).toMatch(/api unavailable \(truncated\): .*output limit/);
|
||||
const meta = JSON.parse(fs.readFileSync(`${file}.meta.json`, 'utf8'));
|
||||
expect(meta).toMatchObject({ status: 'unavailable', reason: 'truncated' });
|
||||
expect(meta.detail).toContain(tokens);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user