Files
gstack/test/proactive-skill-start.test.ts
Garry Tanandcapy-ai[bot] ed107918db v1.92.6.0 feat: proactive core routes only the four core skills, and they carry their trigger phrases (#3770)
* proactive core: the CLAUDE.md offer and descriptions follow core; core skills carry their trigger phrases (#3446, #2118)

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

* fix: pre-landing review fixes (core routing line keeps in-skill handoffs)

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

* fix: keep document-release's pre-merge cue and the ship catalog-mode pin after the description edits

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

* changelog: plain sentence break in the fragment

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 01:25:15 +00:00

208 lines
10 KiB
TypeScript

/**
* The PROACTIVE STATUS line (GSTA-93 fix 12, proactive core default): every
* skill reads `PROACTIVE:` from bin/gstack-skill-start, which asks
* bin/gstack-config and falls back to a literal when the config binary is
* missing. The default moved from `true` to `core`.
*
* Value: protects=the PROACTIVE STATUS line a skill reads is one of core | all
* | off: the stored value, core when unset, all/off for the legacy true/false
* (quotes, case and a trailing comment tolerated), core for a value gstack does
* not know, and the config default when gstack-config is missing beside the
* script; and the off-only surfaces (the first-run and first-loop tips, the
* CLAUDE.md routing offer) fire under core and not under off, by running the
* script, and the offered routing section is the core four under core and every
* rule under all; fails_when=gstack-skill-start stops consulting gstack-config, the
* default moves back to true, a legacy or junk value leaks into STATUS, the
* missing-config fallback literal drifts from lookup_default, or a tip or the
* routing offer fires under off; why_new=proactive-config.test.ts exercises
* gstack-config alone, gstack-skill-start.test.ts asserts only that the key is
* emitted, and no test ran skill-start without gstack-config beside it;
* seam=none (the fallback test copies the script next to an empty bin, the OV4
* sanitize test's pattern); no_claim=how the model applies the value (prose,
* not executed here)
* Regression proof — fails at HEAD: yes · passes at base: yes · passes after fix: yes
*/
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { execFileSync } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { runBin } from './helpers/run-bin';
import { readShippedSkillRouting } from './helpers/shipped-skill-routing';
const ROOT = path.resolve(import.meta.dir, '..');
const BIN = path.join(ROOT, 'bin');
let tmpHome: string;
beforeAll(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-proactive-ss-home-'));
});
afterAll(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
});
function freshState(config: string): string {
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-proactive-ss-state-'));
fs.writeFileSync(path.join(state, 'config.yaml'), config);
return state;
}
function runStart(script: string, state: string, cwd = tmpHome): string {
const r = runBin(script, ['--skill', 'testskill'], {
cwd,
timeoutMs: 30_000,
env: {
PATH: process.env.PATH,
// An interactive session: CI sets CI/GITHUB_ACTIONS, which gstack-session-kind reads
// as headless, and headless skips first-task detection, so the tips never fire there.
CLAUDE_CODE_ENTRYPOINT: 'cli',
HOME: tmpHome,
GSTACK_HOME: state,
GSTACK_STATE_DIR: state,
GSTACK_LEASE_DIR: path.join(state, 'leases'),
},
});
expect(r.status, r.stderr).toBe(0);
return r.stdout;
}
const proactiveLine = (out: string) => out.match(/^PROACTIVE: (.*)$/m)?.[1];
describe('gstack-skill-start PROACTIVE status line', () => {
test('carries core when nothing is stored, the stored value otherwise, the legacy true/false and the yes/no family as all/off, a quoted or upper-case hand edit normalized, and anything unknown as core', () => {
const cases: Array<[string, string]> = [
['update_check: false\n', 'core'],
['update_check: false\nproactive: all\n', 'all'],
['update_check: false\nproactive: off\n', 'off'],
['update_check: false\nproactive: true\n', 'all'],
['update_check: false\nproactive: false\n', 'off'],
['update_check: false\nproactive: "off"\n', 'off'],
['update_check: false\nproactive: OFF # typed by hand\n', 'off'],
['update_check: false\nproactive: no\n', 'off'],
['update_check: false\nproactive: 0\n', 'off'],
['update_check: false\nproactive: none\n', 'off'],
['update_check: false\nproactive: yes\n', 'all'],
['update_check: false\nproactive: on\n', 'all'],
['update_check: false\nproactive: maybe\n', 'core'],
];
for (const [config, expected] of cases) {
const state = freshState(config);
try {
expect(proactiveLine(runStart(path.join(BIN, 'gstack-skill-start'), state)), config).toBe(expected);
} finally {
fs.rmSync(state, { recursive: true, force: true });
}
}
}, 60_000);
test('the CLAUDE.md routing offer fires under core and not under off (the routing rules are the auto-invocation off turns off)', () => {
const cases: Array<[string, boolean]> = [
['update_check: false\n', true],
['update_check: false\nproactive: off\n', false],
['update_check: false\nproactive: false\n', false],
];
for (const [config, offered] of cases) {
const state = freshState(config);
fs.writeFileSync(path.join(state, '.proactive-prompted'), '');
try {
const out = runStart(path.join(BIN, 'gstack-skill-start'), state);
expect(out, config).toContain('HAS_ROUTING: no');
expect(out.includes('GSTACK_INSTRUCTION_BEGIN: routing-injection '), config).toBe(offered);
} finally {
fs.rmSync(state, { recursive: true, force: true });
}
}
});
test('the offered section follows the setting: core offers the four core rules and the scoping line, all offers every rule (#3446)', () => {
const offered = (config: string): string => {
const state = freshState(config);
fs.writeFileSync(path.join(state, '.proactive-prompted'), '');
try {
const out = runStart(path.join(BIN, 'gstack-skill-start'), state);
const block = out.slice(out.indexOf('GSTACK_INSTRUCTION_BEGIN: routing-injection '));
return block.slice(block.indexOf('## Skill routing\n'), block.indexOf('\nIf B:')).trim();
} finally {
fs.rmSync(state, { recursive: true, force: true });
}
};
const rulesOf = (section: string) => section.split('\n').filter(line => /^- .+→ invoke \//.test(line));
const shipped = readShippedSkillRouting();
const core = offered('update_check: false\n');
expect(rulesOf(core)).toEqual([
'- Product ideas/brainstorming → invoke /office-hours',
'- QA/testing site behavior → invoke /qa',
'- Code review/diff check → invoke /review',
'- Ship/deploy/PR → invoke /ship',
]);
expect(core).toContain('Do not suggest or auto-invoke other gstack skills: they run when the user types them, or when a running skill hands off to them (`gstack-config set proactive all` routes every skill).');
expect(core.split('\n').filter(line => line.includes('→'))).toEqual(rulesOf(core));
expect(core.startsWith(shipped.instruction)).toBe(true);
for (const config of ['update_check: false\nproactive: all\n', 'update_check: false\nproactive: true\n']) {
expect(offered(config), config).toBe(shipped.section);
}
expect(rulesOf(shipped.section).length).toBeGreaterThan(4);
}, 60_000);
test('a stored off answers the one-time proactive prompt: no prompt block, marker written; core still asks once telemetry was answered', () => {
const cases: Array<[string, boolean]> = [
['update_check: false\ntelemetry: off\n', true],
['update_check: false\ntelemetry: off\nproactive: off\n', false],
];
for (const [config, asked] of cases) {
const state = freshState(config);
fs.writeFileSync(path.join(state, '.telemetry-prompted'), '');
try {
const out = runStart(path.join(BIN, 'gstack-skill-start'), state);
expect(out.includes('GSTACK_INSTRUCTION_BEGIN: proactive-prompt'), config).toBe(asked);
expect(fs.existsSync(path.join(state, '.proactive-prompted')), config).toBe(!asked);
expect(out, config).toContain(`PROACTIVE_PROMPTED: ${asked ? 'no' : 'yes'}`);
} finally {
fs.rmSync(state, { recursive: true, force: true });
}
}
});
test('the first-run and first-loop tips are shown under core and suppressed under off/false (the marker is not written either)', () => {
const cases: Array<[string, boolean]> = [
['update_check: false\n', true],
['update_check: false\nproactive: off\n', false],
['update_check: false\nproactive: false\n', false],
];
for (const [config, shown] of cases) {
const state = freshState(config);
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-proactive-ss-repo-'));
execFileSync('git', ['init', '-q'], { cwd: repo, timeout: 30_000 });
try {
const first = runStart(path.join(BIN, 'gstack-skill-start'), state, repo);
expect(first, config).toContain('FIRST_TASK: greenfield');
expect(first.includes('GSTACK_INSTRUCTION_BEGIN: first-run-tip'), config).toBe(shown);
const second = runStart(path.join(BIN, 'gstack-skill-start'), state, repo);
expect(second.includes('GSTACK_INSTRUCTION_BEGIN: first-loop-tip'), config).toBe(shown);
expect(fs.existsSync(path.join(state, '.first-loop-tip-shown')), config).toBe(shown);
} finally {
fs.rmSync(state, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
}
}
}, 60_000);
test('regression: with no gstack-config beside it, the fallback reports the config default, not a stale literal', () => {
const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-proactive-ss-bin-'));
const state = freshState('update_check: false\n');
try {
for (const f of ['gstack-skill-start', 'gstack-state-root.sh']) fs.copyFileSync(path.join(BIN, f), path.join(fakeBin, f));
fs.chmodSync(path.join(fakeBin, 'gstack-skill-start'), 0o755);
const configDefault = runBin(path.join(BIN, 'gstack-config'), ['get', 'proactive'], { gstackHome: state, cwd: ROOT, trim: true }).stdout;
expect(configDefault).not.toBe('');
expect(proactiveLine(runStart(path.join(fakeBin, 'gstack-skill-start'), state))).toBe(configDefault);
} finally {
fs.rmSync(fakeBin, { recursive: true, force: true });
fs.rmSync(state, { recursive: true, force: true });
}
});
});