From 894b37cb16c3833f1c92450c2e0fec5105596976 Mon Sep 17 00:00:00 2001 From: Som Date: Sun, 2 Aug 2026 07:14:47 +0000 Subject: [PATCH] fix: deliver ruleset on mid-session /ponytail switch (#663) The mid-session mode-switch write in ponytail-mode-tracker.js only emitted the confirmation string for non-Qoder platforms (native Claude Code, Codex); ruleset injection was gated behind isQoder, so the main thread stayed rule-less until the next SessionStart even though subagents spawned meanwhile already saw it. Fold the ruleset into the same write, mirroring the pattern Qoder already used. Strengthens the existing native-Claude and Codex mid-session-switch tests, which exercised this exact branch but only asserted the flag file / systemMessage, not the delivered ruleset content. --- hooks/ponytail-mode-tracker.js | 11 +++++++---- tests/hooks.test.js | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/hooks/ponytail-mode-tracker.js b/hooks/ponytail-mode-tracker.js index f644f00a..7fdce5d8 100644 --- a/hooks/ponytail-mode-tracker.js +++ b/hooks/ponytail-mode-tracker.js @@ -64,14 +64,17 @@ function finish() { } else if (mode && mode !== 'off') { setMode(mode); modeSwitched = true; - // ponytail: Qoder needs the full ruleset every turn, so when a mode - // switch happens we fold the confirmation into the ruleset output - // below (one JSON on stdout) instead of emitting two separate writes. + // ponytail: a mid-session switch must deliver the ruleset immediately, + // not just the confirmation (#663) — otherwise the main thread stays + // rule-less until the next SessionStart, even though subagents spawned + // meanwhile already see it (ponytail-subagent.js reads the flag + // independently). Qoder folds this same way below since it has no + // SessionStart to fall back on. if (!isQoder) { writeHookOutput( 'UserPromptSubmit', mode, - 'PONYTAIL MODE CHANGED — level: ' + mode, + 'PONYTAIL MODE CHANGED — level: ' + mode + '\n\n' + getPonytailInstructions(mode), ); } } else if (mode === 'off') { diff --git a/tests/hooks.test.js b/tests/hooks.test.js index bf4a8eb3..bcc29f28 100644 --- a/tests/hooks.test.js +++ b/tests/hooks.test.js @@ -76,6 +76,19 @@ assert.equal(result.status, 0, result.stderr); assert.equal(fs.readFileSync(codexState, 'utf8'), 'lite'); output = JSON.parse(result.stdout); assert.equal(output.systemMessage, 'PONYTAIL:LITE'); +// #663: Codex shares the same !isQoder mode-switch write path as native +// Claude, so a mid-session switch must also deliver the ruleset here, not +// just the systemMessage badge. +assert.equal(output.hookSpecificOutput.hookEventName, 'UserPromptSubmit'); +assert.match( + output.hookSpecificOutput.additionalContext, + /PONYTAIL MODE CHANGED — level: lite/, +); +assert.match( + output.hookSpecificOutput.additionalContext, + /You are a lazy senior developer/, + 'Codex mid-session mode switch must deliver the ruleset immediately, not just the badge (#663)', +); // Querying bare @ponytail should report the active level ('lite') without resetting it to default ('ultra') result = run( @@ -428,6 +441,21 @@ result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/pony assert.equal(result.status, 0, result.stderr); assert.equal(fs.readFileSync(defFlag, 'utf8'), 'ultra', 'plain switch must set the session mode'); assert.equal(JSON.parse(fs.readFileSync(defConfig, 'utf8')).defaultMode, 'lite', 'plain switch must not persist the default'); +// #663: a mid-session switch on native Claude/Codex (non-Qoder) must deliver +// the ruleset immediately, not just the confirmation — otherwise the main +// thread stays rule-less until the next SessionStart, while subagents +// spawned meanwhile (ponytail-subagent.js reads the flag independently) +// already see it. +assert.match( + result.stdout, + /PONYTAIL MODE CHANGED — level: ultra/, + 'plain switch must still emit the confirmation', +); +assert.match( + result.stdout, + /You are a lazy senior developer/, + 'mid-session mode switch must deliver the ruleset immediately, not just the confirmation (#663)', +); // review is not a valid default (#377) — the command is ignored, config unchanged. result = run('ponytail-mode-tracker.js', defEnv, JSON.stringify({ prompt: '/ponytail default review' }));