Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions actions/setup/js/interpolate_prompt.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,11 @@ describe("interpolate_prompt", () => {
describe("main function integration", () => {
const { main } = require("./interpolate_prompt.cjs");
let tmpDir, promptPath, originalEnv;
const smokeStrategies = [
["single_agent_control", "Do not call any sub-agent."],
["delegated_sequential", "Execute and validate each sub-agent one at a time before moving to the next."],
["inline_strict", "Validate all three results and keep the current inline orchestration behavior."],
];
/**
* Apply the STEP 2.5 experiment condition substitution logic from main().
* Reads GH_AW_EXPERIMENTS_* from process.env and substitutes experiments.name
Expand Down Expand Up @@ -281,6 +286,7 @@ describe("interpolate_prompt", () => {
(tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "interpolate-test-"))),
(promptPath = path.join(tmpDir, "prompt.txt")),
(process.env.GH_AW_PROMPT = promptPath),
(process.env.GITHUB_WORKSPACE = tmpDir),
core.info.mockClear(),
core.setFailed.mockClear());
}),
Expand All @@ -291,6 +297,23 @@ describe("interpolate_prompt", () => {
}),
Object.assign(process.env, originalEnv));
}),
it.each(smokeStrategies)("renders only the assigned %s instructions from the sub-agent smoke", async (variant, instruction) => {
const workflow = fs.readFileSync(path.join(__dirname, "../../../.github/workflows/smoke-copilot-sub-agents.md"), "utf8");
const tasks = workflow.split("\n## Tasks\n")[1].split("\n## Output\n")[0];
process.env.GH_AW_EXPERIMENTS_SUB_AGENT_STRATEGY = variant;
fs.writeFileSync(promptPath, tasks);

await main();

expect(core.setFailed).not.toHaveBeenCalled();
const rendered = fs.readFileSync(promptPath, "utf8");
expect(rendered).toContain(instruction);
for (const [otherVariant, otherInstruction] of smokeStrategies) {
if (otherVariant !== variant) expect(rendered).not.toContain(otherInstruction);
}
expect(rendered.includes("Call `haiku-whoami`")).toBe(variant !== "single_agent_control");
expect(rendered).not.toContain("{{");
}),
it("should fail when GH_AW_PROMPT is not set", () => {
delete process.env.GH_AW_PROMPT;
const mainMatch = interpolatePromptScript.match(/async function main\(\)\s*{[\s\S]*?^}/m);
Expand Down
15 changes: 6 additions & 9 deletions actions/setup/js/is_truthy.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*
* Supports:
* - Simple falsy string check: "", "false", "no", "0", "null", "undefined"
* - GitHub Actions script style equality: lhs == "rhs" or lhs === "rhs"
* - GitHub Actions script style equality with single- or double-quoted literals
* After experiment substitution the condition looks like: concise == "concise"
*
* @param {string} expr - The expression to evaluate
Expand All @@ -13,19 +13,16 @@
function isTruthy(expr) {
const trimmed = expr.trim();

// Handle GitHub Actions script style equality expressions: lhs == "rhs" or lhs === "rhs"
// Handle GitHub Actions script style equality expressions with either quote style.
// Used by experiment conditionals after the experiment value has been substituted:
// {{#if experiments.prompt_style == "concise"}} becomes {{#if concise == "concise"}}
// Note: (.*?) allows an empty LHS — if the experiment variable was not set the substituted
// condition looks like ' == "concise"', which correctly returns false here rather than
// falling through to the generic truthy check and incorrectly returning true.
const eqMatch = trimmed.match(/^(.*?)\s*===?\s*"([^"]*)"\s*$/);
if (eqMatch) {
return eqMatch[1].trim() === eqMatch[2];
}
const neqMatch = trimmed.match(/^(.*?)\s*!==?\s*"([^"]*)"\s*$/);
if (neqMatch) {
return neqMatch[1].trim() !== neqMatch[2];
const comparison = trimmed.match(/^(.*?)\s*(===?|!==?)\s*(?:"([^"]*)"|'([^']*)')\s*$/);
if (comparison) {
const equal = comparison[1].trim() === (comparison[3] ?? comparison[4]);
return comparison[2].startsWith("!") ? !equal : equal;
}

const v = trimmed.toLowerCase();
Expand Down
23 changes: 23 additions & 0 deletions actions/setup/js/is_truthy.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,29 @@ describe("is_truthy.cjs", () => {
});

describe('GitHub Actions script style equality (v == "b")', () => {
it.each(["'", '"'])("compares literals delimited by %s for every supported operator", quote => {
for (const operator of ["==", "==="]) {
expect(isTruthy(` inline_strict ${operator} ${quote}inline_strict${quote} `)).toBe(true);
expect(isTruthy(`inline_strict ${operator} ${quote}single_agent_control${quote}`)).toBe(false);
expect(isTruthy(`${operator} ${quote}inline_strict${quote}`)).toBe(false);
expect(isTruthy(`${operator} ${quote}${quote}`)).toBe(true);
expect(isTruthy(`inline_strict ${operator} ${quote}${quote}`)).toBe(false);
}
for (const operator of ["!=", "!=="]) {
expect(isTruthy(`inline_strict ${operator} ${quote}inline_strict${quote}`)).toBe(false);
expect(isTruthy(`inline_strict ${operator} ${quote}single_agent_control${quote}`)).toBe(true);
expect(isTruthy(`${operator} ${quote}${quote}`)).toBe(false);
expect(isTruthy(`${operator} ${quote}inline_strict${quote}`)).toBe(true);
}
});

it("preserves the other quote style inside a literal", () => {
expect(isTruthy(`it's == "it's"`)).toBe(true);
expect(isTruthy(`say "yes" == 'say "yes"'`)).toBe(true);
expect(isTruthy(`other == "it's"`)).toBe(false);
expect(isTruthy(`other == 'say "yes"'`)).toBe(false);
});

it("should return true when value matches literal with ==", () => {
expect(isTruthy('concise == "concise"')).toBe(true);
expect(isTruthy('verbose == "verbose"')).toBe(true);
Expand Down
Loading