From 2763d5093943b296666388e25b770c7846046bc0 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 22 May 2026 11:23:58 -0400 Subject: [PATCH] fix(3808): codex adapter activates TEXT_MODE when request_user_input is unavailable (#84) When Codex reports `request_user_input` as unavailable (Default mode), the Codex skill adapter's Execute-mode-fallback section now explicitly instructs the agent to append `--text` to `{{GSD_ARGS}}` to activate the workflow's built-in TEXT_MODE branching. This ensures every AskUserQuestion gate is handled consistently through the workflow's own text-mode mechanism rather than ad-hoc plain-text fallback, and eliminates any path to silent-default selection (#3018 / #3808). Adds regression test bug-3808-codex-adapter-text-mode-fallback.test.cjs with typed semantic-flag assertions covering gsd-plan-phase, gsd-discuss-phase, gsd-execute-phase, and gsd-verify-work. Co-authored-by: Claude Sonnet 4.6 --- .../3808-codex-adapter-text-mode-fallback.md | 5 + bin/install.js | 2 +- ...-codex-adapter-text-mode-fallback.test.cjs | 155 ++++++++++++++++++ 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 .changeset/3808-codex-adapter-text-mode-fallback.md create mode 100644 tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs diff --git a/.changeset/3808-codex-adapter-text-mode-fallback.md b/.changeset/3808-codex-adapter-text-mode-fallback.md new file mode 100644 index 000000000..c2f34653e --- /dev/null +++ b/.changeset/3808-codex-adapter-text-mode-fallback.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3808 +--- +**Codex adapter now activates TEXT_MODE when `request_user_input` is unavailable** — `bin/install.js:getCodexSkillAdapterHeader` previously told the agent only to "stop and present plain-text"; it now explicitly instructs the agent to append `--text` to `{{GSD_ARGS}}` so the workflow's built-in `TEXT_MODE` branching handles all `AskUserQuestion` gates consistently, eliminating silent-default selection in Codex Default mode (#3808). diff --git a/bin/install.js b/bin/install.js index 5cd7bca04..60ee5e0de 100755 --- a/bin/install.js +++ b/bin/install.js @@ -2579,7 +2579,7 @@ Multi-select workaround: - Codex has no \`multiSelect\`. Use sequential single-selects, or present a numbered freeform list asking the user to enter comma-separated numbers. Execute mode fallback: -- When \`request_user_input\` is rejected or unavailable, you MUST stop and present the questions as a plain-text numbered list, then wait for the user's reply. Do NOT pick a default and continue (#3018). +- When \`request_user_input\` is rejected or unavailable, activate TEXT_MODE: append \`--text\` to \`{{GSD_ARGS}}\` so the workflow's built-in text-mode branching takes over. Present every \`AskUserQuestion\` call as a plain-text numbered list, then stop and wait for the user's reply. Do NOT pick a default and continue (#3018 / #3808). - You may only proceed without a user answer when one of these is true: (a) the invocation included an explicit non-interactive flag (\`--auto\` or \`--all\`), (b) the user has explicitly approved a specific default for this question, or diff --git a/tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs b/tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs new file mode 100644 index 000000000..1ad63e281 --- /dev/null +++ b/tests/bug-3808-codex-adapter-text-mode-fallback.test.cjs @@ -0,0 +1,155 @@ +/** + * Regression test for bug #3808. + * + * When Codex runs in Default mode, `request_user_input` is reported as + * unavailable. The Codex skill adapter must tell the agent to activate the + * workflow's built-in TEXT_MODE mechanism (`--text` flag) rather than either: + * (a) silently picking a default value — the #3018 failure mode, or + * (b) ad-hoc plain-text fallback that bypasses the workflow's own branching. + * + * Workflows (e.g. plan-phase.md) already have TEXT_MODE logic: + * "Set TEXT_MODE=true if `--text` is present in $ARGUMENTS OR text_mode + * from init JSON is true." + * The adapter must tell the agent to USE that mechanism when + * `request_user_input` is unavailable instead of inventing its own fallback + * or silently continuing with defaults. + * + * Test design: mirrors the typed-semantic-flag pattern from bug #3018 so that + * prose rewording doesn't break tests as long as the semantics stay correct. + */ + +'use strict'; + +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const INSTALL = require(path.join(__dirname, '..', 'bin', 'install.js')); +const { getCodexSkillAdapterHeader } = INSTALL; + +/** + * Extract the "Execute mode fallback" section text from the adapter header. + * Returns null if the section is missing. Section runs from the + * "Execute mode fallback:" label up to the next heading or tag. + */ +function extractExecuteModeFallback(header) { + const m = header.match(/Execute mode fallback:\s*\n([\s\S]*?)(?=\n##\s|\n<\/codex_skill_adapter>)/); + return m ? m[1].trim() : null; +} + +/** + * Parse the Execute-mode-fallback section into a typed semantic-flag record. + * + * Flags for bug #3808 (TEXT_MODE activation): + * activatesTextMode — does the prose tell the agent to activate TEXT_MODE / use --text? + * instructsStop — does the prose tell the agent to stop/halt/wait? + * presentsPlainText — does the prose mention plain-text / numbered-list presentation? + * silentlyPicksDefaults — (anti-pattern) does the prose instruct silent-default picking? + */ +function parseExecuteModeFallbackFor3808(section) { + if (!section || typeof section !== 'string') { + return { + ok: false, + sectionLength: 0, + activatesTextMode: false, + instructsStop: false, + presentsPlainText: false, + silentlyPicksDefaults: false, + }; + } + + const lower = section.toLowerCase(); + + // (a) TEXT_MODE activation — adapter must tell the agent to use the workflow's + // built-in text mode mechanism when request_user_input is unavailable. + // Accept either: explicit "--text" flag mention OR "text_mode" / "text mode" + // paired with context showing it is being SET/ACTIVATED (not just referenced). + const mentionsTextFlag = section.includes('--text'); + const mentionsTextModeOn = /text_mode\s*=\s*true|set\s+text_mode|activate\s+text.?mode|enable\s+text.?mode|text.?mode.*active|text.?mode.*on\b/i.test(section); + const activatesTextMode = mentionsTextFlag || mentionsTextModeOn; + + // (b) STOP/WAIT directive — the agent must halt instead of proceeding silently. + const instructsStop = /\b(stop|halt|wait)\b/.test(lower); + + // (c) Plain-text fallback presentation. + const presentsPlainText = /plain.?text|numbered list/.test(lower); + + // Anti-pattern guard — the prose that caused #3018 and resurfaces in #3808. + const silentlyPicksDefaults = /pick (a |the )?(reasonable|sensible|sane) default/i.test(section); + + return { + ok: true, + sectionLength: section.length, + activatesTextMode, + instructsStop, + presentsPlainText, + silentlyPicksDefaults, + }; +} + +describe('bug #3808: codex skill adapter activates TEXT_MODE when request_user_input is unavailable', () => { + const SKILL_NAMES = ['gsd-plan-phase', 'gsd-discuss-phase', 'gsd-execute-phase', 'gsd-verify-work']; + + test('getCodexSkillAdapterHeader is exported', () => { + assert.equal(typeof getCodexSkillAdapterHeader, 'function'); + }); + + test('Execute mode fallback section exists for all key skills', () => { + for (const skillName of SKILL_NAMES) { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + assert.ok(section !== null && section.length > 0, + `${skillName}: Execute mode fallback section must exist and have content`); + } + }); + + for (const skillName of SKILL_NAMES) { + test(`${skillName}: fallback activates TEXT_MODE (--text flag or text_mode=true) when request_user_input is unavailable`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.activatesTextMode, true, + `${skillName}: fallback must instruct the agent to activate TEXT_MODE (mention --text flag or text_mode=true/active) when request_user_input is unavailable (#3808). Section was:\n${section}`); + }); + + test(`${skillName}: fallback instructs STOP/WAIT (not silent continuation)`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.instructsStop, true, + `${skillName}: fallback must include stop/halt/wait instruction. Section was:\n${section}`); + }); + + test(`${skillName}: fallback does NOT contain silent-default anti-pattern`, () => { + const header = getCodexSkillAdapterHeader(skillName); + const section = extractExecuteModeFallback(header); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.equal(parsed.silentlyPicksDefaults, false, + `${skillName}: regression — fallback must NOT instruct the agent to pick defaults autonomously (#3018 / #3808). Section was:\n${section}`); + }); + } + + test('typed semantic-record snapshot for gsd-plan-phase — full contract', () => { + const section = extractExecuteModeFallback(getCodexSkillAdapterHeader('gsd-plan-phase')); + const parsed = parseExecuteModeFallbackFor3808(section); + assert.deepStrictEqual( + { + ok: parsed.ok, + activatesTextMode: parsed.activatesTextMode, + instructsStop: parsed.instructsStop, + presentsPlainText: parsed.presentsPlainText, + silentlyPicksDefaults: parsed.silentlyPicksDefaults, + }, + { + ok: true, + activatesTextMode: true, + instructsStop: true, + presentsPlainText: true, + silentlyPicksDefaults: false, + }, + `gsd-plan-phase: full TEXT_MODE fallback contract violated (#3808). Section was:\n${section}`, + ); + }); +});