diff --git a/actions/setup/js/interpolate_prompt.test.cjs b/actions/setup/js/interpolate_prompt.test.cjs index 5443d7497f7..0a8b25b91b4 100644 --- a/actions/setup/js/interpolate_prompt.test.cjs +++ b/actions/setup/js/interpolate_prompt.test.cjs @@ -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 @@ -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()); }), @@ -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); diff --git a/actions/setup/js/is_truthy.cjs b/actions/setup/js/is_truthy.cjs index 42b6e9354f7..7dc9dab5501 100644 --- a/actions/setup/js/is_truthy.cjs +++ b/actions/setup/js/is_truthy.cjs @@ -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 @@ -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(); diff --git a/actions/setup/js/is_truthy.test.cjs b/actions/setup/js/is_truthy.test.cjs index e052d0d0981..b7eb0384fd5 100644 --- a/actions/setup/js/is_truthy.test.cjs +++ b/actions/setup/js/is_truthy.test.cjs @@ -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);