fix(hooks): option values are not the check verb, wrapper chains stay linear, agent stalls are timed, report re-checks deferred runs (GST-1088 follow-up review)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-11-03 07:12:44 +00:00
1 parent 365dd613d2
commit 7595189caf
6 files changed
+98 -39

No files matched your search

+17 -4
View File
@@ -7,9 +7,9 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { STATE_ERROR_DOC, stateErrorLine } from '../lib/skill-contract';
import { AGENT_STALL_MS, STATE_ERROR_DOC, stateErrorLine } from '../lib/skill-contract';
import {
CONTRACT_HOOK, DEBUG_REPORT, INTERACTIVE_TURNS, J1_TRANSCRIPT, ROOT, hookEnv, result, runHook, say, skillsDir, slash, tmpDir, tool, user,
CONTRACT_HOOK, DEBUG_REPORT, INTERACTIVE_TURNS, J1_TRANSCRIPT, ROOT, hookEnv, meta, result, runHook, say, skillsDir, slash, tmpDir, tool, user,
} from './helpers/skill-contract-fixture';
const PINNED = fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'skill-contract', 'block-message.txt'), 'utf8').trimEnd();
@@ -135,7 +135,7 @@ describe('never blocks the wrong turn', () => {
expect(JSON.parse(stop(file, { GSTACK_HEADLESS: '1' }).stdout).decision).toBe('block');
});
test('Agent readers still running when Stop fires: allowed once and recorded deferred; the same set still running next stop is checked', () => {
test('Agent readers still running when Stop fires: allowed and recorded deferred until the same set has run AGENT_STALL_MS, then checked', () => {
const base = [slash('review'), say('Dispatching 2 specialists: Testing, Security. Skipped: Performance (scope not detected).')];
const background = [...base, tool('a1', 'Agent', { description: 'Testing specialist', prompt: 'review', run_in_background: true }),
result('a1', 'Async agent launched in the background.'), say('Waiting for the specialists to finish.')];
@@ -143,7 +143,13 @@ describe('never blocks the wrong turn', () => {
expect(stop(file)).toEqual({ code: 0, stdout: '', stderr: '' });
expect(Object.values(record().runs)[0]).toMatchObject({ outcome: 'deferred', deferred_agents: ['a1'] });
expect(fs.readFileSync(path.join(state, 'hooks', 'skill-contract.log'), 'utf8')).toContain('event=agents-running');
fs.appendFileSync(file, `${say('Still waiting on the specialists.')}\n`);
fs.appendFileSync(file, `${user('how is it going?')}\n${meta('<task-notification>bash b9 completed</task-notification>')}\n${say('Still waiting on the specialists.')}\n`);
expect(stop(file)).toEqual({ code: 0, stdout: '', stderr: '' });
expect(Object.values(record().runs)[0]).toMatchObject({ outcome: 'deferred', deferred_agents: ['a1'] });
const rec = record();
const key = Object.keys(rec.runs)[0];
rec.runs[key].deferred_at = new Date(Date.now() - AGENT_STALL_MS - 1000).toISOString();
fs.writeFileSync(path.join(state, 'skill-contracts', 'sess-1.json'), JSON.stringify(rec));
expect(JSON.parse(stop(file).stdout).decision).toBe('block');
expect(Object.values(record().runs)[0]).toMatchObject({ outcome: 'blocked' });
expect(fs.readFileSync(path.join(state, 'hooks', 'skill-contract.log'), 'utf8')).toContain('event=agents-stalled');
@@ -151,6 +157,13 @@ describe('never blocks the wrong turn', () => {
expect(stop(transcript(unanswered, 'unanswered.jsonl'), {}, { session_id: 'sess-2' })).toEqual({ code: 0, stdout: '', stderr: '' });
});
test('a stop on a turn that ends on a question never starts the agent clock', () => {
const asking = [slash('review'), tool('a1', 'Agent', { description: 'Testing specialist', prompt: 'review', run_in_background: true }),
result('a1', 'Async agent launched in the background.'), say('Should I also review the migrations?')];
expect(stop(transcript(asking))).toEqual({ code: 0, stdout: '', stderr: '' });
expect(fs.existsSync(path.join(state, 'skill-contracts', 'sess-1.json'))).toBe(false);
});
test('no contract skill loaded: allowed', () => {
const r = stop(transcript([slash('qa'), say('Done. STATUS: DONE')]));
expect(r).toEqual({ code: 0, stdout: '', stderr: '' });
+22 -1
View File
@@ -8,7 +8,8 @@ import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
DEFAULT_MATCHERS, EXIT, STATUS_RE, blockMessage, checkContract, isCheckCommand, isPlaceholder, parseContract, parseTranscript, skillLoads, UnknownShape,
DEFAULT_MATCHERS, EXIT, STATUS_RE, blockMessage, checkContract, isCheckCommand, isMiss, isPlaceholder, parseContract, parseTranscript, reportRows, skillLoads, UnknownShape,
type CheckResult,
type Contract,
} from '../lib/skill-contract';
import { readFrontmatter } from '../lib/skill-route';
@@ -183,6 +184,26 @@ describe('checkContract is a pure function of the transcript', () => {
expect([cmd, isCheckCommand(cmd)]).toEqual([cmd, false]);
});
test('check_after_last_edit: an option value is never the verb, and a long chain of wrapper words stays fast', () => {
for (const cmd of ['make -C web test', 'npm --prefix web test', 'pnpm --filter app test', 'bun --cwd app test', 'yarn workspace app test',
'xcodebuild -scheme App test', 'cargo +nightly test', 'mvn clean test', 'bun run typecheck', 'node --test'])
expect([cmd, isCheckCommand(cmd)]).toEqual([cmd, true]);
for (const cmd of ['make -C web build', 'npm --prefix web install', 'pnpm --filter app build', 'xcodebuild -scheme App build'])
expect([cmd, isCheckCommand(cmd)]).toEqual([cmd, false]);
const started = performance.now();
expect(isCheckCommand(`${Array(40).fill('sh').join(' ')} echo`)).toBe(false);
expect(performance.now() - started).toBeLessThan(500);
});
test('report re-checks a deferred run: unmet is a miss, met is met', () => {
const run = { skill: 'review', load_line: 1, outcome: 'deferred' as const, missing: [], waived: [], deferred_agents: ['a1'], updated_at: '' };
const rec = { version: 1 as const, session_id: 's', transcript_path: 't', runs: { k: run } };
const unmet = reportRows([rec], () => ({ met: false, missing: ['specialists'], waived: [] }) as unknown as CheckResult);
expect(unmet.map(r => [r.outcome, isMiss(r)])).toEqual([['miss', true]]);
const met = reportRows([rec], () => ({ met: true, missing: [], waived: [] }) as unknown as CheckResult);
expect(met.map(r => [r.outcome, isMiss(r)])).toEqual([['met', false]]);
});
test('selected_specialists_dispatched: a selection line only counts from assistant text or a Bash result, with plain names', () => {
const red = [tool('c', 'Agent', { description: 'Red team', prompt: 'Think like an attacker and a chaos engineer.' }), result('c', '[]')];
const report = say('Pre-Landing Review: 0 blocking\nGate edits: none\nSTATUS: DONE');