Files
gstack/test/ship-calls.test.ts
Garry Tanandcapy-ai[bot] cd0c912a0e v1.92.27.0 ship: each phase program returns the next phase's sections once, in code (GSTA-167 calls PR 3) (#3670)
* ship: each phase program returns the next phase's sections once, in code (GSTA-167 calls PR 3)

Rebased onto main 2d1b55d76 (GST-482) as one commit. Drops the pass program's
cwd STOP and its test: GST-482's per-reader REVIEWED_TREE check replaces it.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): a scoped pass keeps the merge base for its pending Step 11 voices (GST-619 model; #3670 merge fix-check)

The driver passed diffBase: RANGE_BASE on scoped passes too, and --diff-base won in pass(), so the voices read only the fix range. pass() now ignores --diff-base on a scoped step, and the driver and Capy skill name diffBase for full repeat passes only.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(ship): keep GST-619's explicit --diff-base; the driver alone stops passing it on a scoped pass

The fix-check on 7e9d544 noted that ignoring --diff-base on a scoped step contradicts GST-619's released contract (an explicit --diff-base still wins). Revert that guard; the driver and Capy skill already pass diffBase on full repeat passes only. A driver test (fails on 857c4b674's texts) pins that, the program test runs a scoped pass as the driver says, and review-policy names diffBase for full repeat passes.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 13:42:07 +00:00

67 lines
4.2 KiB
TypeScript

/**
* gstack-ship-budget calls (GSTA-167 calls plan, C9): count a driven /ship's parent calls from the
* host's own transcript events, not from the agent's say-so, and split keep-alive polls from
* decision calls; flag every gap over the 300 s prompt-cache lifetime.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { countCalls } from '../lib/ship-calls';
const BIN = path.resolve(import.meta.dir, '..', 'bin', 'gstack-ship-budget');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ship-calls-'));
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
const at = (s: string) => `2026-10-30T${s}.000Z`;
const exec = (t: string, calls: string[]) => ({ id: `x${t}`, source: 'tool', tool: 'execute', calls: calls.map(tool => ({ tool, durationMs: 1 })), text: '', createdAt: at(t) });
// Shapes copied from GSTA-189's transcript (tools.thread.get transcript items), times shortened.
const ITEMS = [
{ id: 'u', source: 'user', text: 'run /ship', createdAt: at('18:19:00') },
exec('18:19:35', ['bash', 'thread_get', 'bash']),
exec('18:23:17', ['bash']),
{ id: 'w1', source: 'tool', tool: 'wait', text: 'Waiting for preflight lanes to finish', createdAt: at('18:23:23') },
exec('18:31:23', ['bash', 'read', 'subagent_create', 'subagent_create']),
{ id: 'w2', source: 'tool', tool: 'wait', text: 'Waiting for specialist readers to finish', createdAt: at('18:31:27') },
exec('18:31:38', ['subagent_get', 'subagent_get']),
exec('18:33:36', ['subagent_list', 'subagent_get', 'bash']),
{ id: 'a', source: 'assistant', text: 'Phase 3 running', createdAt: at('18:33:40') },
{ id: 'm', source: 'tool', tool: 'message_agent', text: 'GSTA-167', createdAt: at('18:40:00') },
];
describe('countCalls', () => {
// Value: protects=acceptance counts calls from host events: each tool item is one call, a wait or a poll-only program (status reads plus at most one bash for the note) is a keep-alive, a lone bash is a decision, everything else is a decision call, and every gap over 300 s between calls is an idle gap; fails_when=user or assistant text is counted as a parent call, a poll with a note is counted as a decision, or a 6-minute gap is missed;
// why_new=calls were counted by hand from transcripts; seam=none; no_claim=the host's billing of a gap
test('splits decision calls from keep-alives and flags gaps over 300 s', () => {
const c = countCalls(ITEMS);
expect(c).toMatchObject({ total: 8, decision: 4, keepalive: 4, idleGaps: 2, maxGapS: 480 });
expect(c.first).toBe(at('18:19:35'));
expect(c.last).toBe(at('18:40:00'));
});
test('--since and --until bound the window', () => {
const c = countCalls(ITEMS, { since: at('18:31:00'), until: at('18:34:00') });
expect(c).toMatchObject({ total: 4, decision: 1, keepalive: 3, idleGaps: 0 });
});
});
describe('gstack-ship-budget calls', () => {
test('prints one CALLS line from an events file; a missing file is a usage error', () => {
const f = path.join(tmp, 'events.json');
fs.writeFileSync(f, JSON.stringify({ items: ITEMS, cursor: '01M6719TXDC28B5ZS6712P5AB7' }));
const r = spawnSync(BIN, ['calls', '--events', f], { encoding: 'utf8', timeout: 30_000 });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout.trim()).toBe(`CALLS: total=8 decision=4 keepalive=4 idle_gaps_over_300s=2 max_gap_s=480 first=${at('18:19:35')} last=${at('18:40:00')} source=01M6719TXDC28B5ZS6712P5AB7`);
const missing = spawnSync(BIN, ['calls', '--events', path.join(tmp, 'nope.json')], { encoding: 'utf8', timeout: 30_000 });
expect(missing.status).toBe(2);
});
test('a literal ~ in --events is the home directory, as a shell would expand it (GST-438)', () => {
const home = fs.mkdtempSync(path.join(tmp, 'home-'));
fs.writeFileSync(path.join(home, 'events.json'), JSON.stringify({ items: ITEMS, cursor: 'c1' }));
const r = spawnSync(BIN, ['calls', '--events', '~/events.json'], { encoding: 'utf8', timeout: 30_000, env: { ...process.env, HOME: home } });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/^CALLS: total=8 decision=4 keepalive=4 .* source=c1$/m);
});
});