Skip to content

Commit cd6155e

Browse files
authored
Merge pull request #1749 from entireio/fix/review-interactive-codex-regressions
Fix review interactive setup and Codex defaults
2 parents 372bc14 + e2bd6e0 commit cd6155e

6 files changed

Lines changed: 284 additions & 33 deletions

File tree

cmd/entire/cli/interactive/interactive.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ func isAgentSubprocessEnv() bool {
8080
os.Getenv("GIT_TERMINAL_PROMPT") == "0"
8181
}
8282

83+
// IsTerminalReader reports whether r is an *os.File backed by a terminal.
84+
// It is useful when an explicitly interactive command needs to distinguish a
85+
// human at stdin from an agent process that merely inherited a controlling TTY.
86+
func IsTerminalReader(r io.Reader) bool {
87+
f, ok := r.(*os.File)
88+
if !ok {
89+
return false
90+
}
91+
return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd
92+
}
93+
8394
// IsTerminalWriter reports whether w is an *os.File backed by a terminal.
8495
// Use for deciding on color, pager, progress bars, or other writer-scoped
8596
// TTY formatting. For "can I prompt the user?" use CanPromptInteractively.

cmd/entire/cli/review/cmd.go

Lines changed: 51 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ To tag an already-finished session as a review, use
210210
}, deps)
211211
}
212212
if edit {
213-
if !interactive.IsTerminalWriter(cmd.OutOrStdout()) || !interactive.CanPromptInteractively() {
213+
if !reviewCommandIsInteractive(cmd) {
214214
err := errors.New("--edit requires an interactive terminal")
215215
cmd.SilenceUsage = true
216216
fmt.Fprintln(cmd.ErrOrStderr(), "--edit requires an interactive terminal.")
@@ -270,6 +270,41 @@ type reviewConfigureOptions struct {
270270
Slots []string // reviewer slots as "agent[=model]" entries (--set-slot)
271271
}
272272

273+
// reviewCommandIsInteractive requires the exact stdin consumed by huh and
274+
// Bubble Tea, plus stdout, to be terminals. CanPromptInteractively adds the
275+
// independent policy gate for tests, CI, and agent subprocess sentinels; a
276+
// controlling /dev/tty alone is insufficient because stdin may still be piped.
277+
func reviewCommandIsInteractive(cmd *cobra.Command) bool {
278+
hardDisabled := reviewInteractivityHardDisabled(
279+
os.Getenv(interactive.EnvTestTTY),
280+
os.Getenv("CI"),
281+
interactive.UnderTest(),
282+
)
283+
return reviewTTYIsInteractive(
284+
interactive.IsTerminalReader(cmd.InOrStdin()),
285+
interactive.IsTerminalWriter(cmd.OutOrStdout()),
286+
interactive.CanPromptInteractively(),
287+
hardDisabled,
288+
)
289+
}
290+
291+
func reviewInteractivityHardDisabled(testTTY, ci string, underTest bool) bool {
292+
// Match CanPromptInteractively's precedence: ENTIRE_TEST_TTY=1 may opt an
293+
// in-process test into interaction, while tests without that explicit
294+
// override must never read from a developer's real terminal.
295+
if testTTY != "" {
296+
return testTTY != "1"
297+
}
298+
return underTest || (ci != "" && ci != "false")
299+
}
300+
301+
func reviewTTYIsInteractive(stdinTTY, stdoutTTY, canPrompt, hardDisabled bool) bool {
302+
// Real stdio terminals are necessary but not sufficient: agent shells can
303+
// allocate a PTY while advertising that no human is available through the
304+
// sentinels enforced by CanPromptInteractively.
305+
return !hardDisabled && stdinTTY && stdoutTTY && canPrompt
306+
}
307+
273308
func (o reviewConfigureOptions) scripted() bool {
274309
// Local selects the destination only; by itself it must not force the
275310
// non-interactive/scripted path. `entire review --configure --local` should
@@ -329,7 +364,7 @@ func runReviewConfigure(ctx context.Context, cmd *cobra.Command, profileOverride
329364
// duplicate the catalog here. Pass the raw --profile value (empty when not
330365
// given) so the guided setup runs the "what kind of review?" type picker
331366
// instead of being silently defaulted to the general profile.
332-
if interactive.IsTerminalWriter(out) && interactive.CanPromptInteractively() {
367+
if reviewCommandIsInteractive(cmd) {
333368
name, profile, setupErr := RunReviewGuidedSetup(ctx, out, installed, deps.ReviewerFor, strings.TrimSpace(profileOverride), false, s)
334369
if setupErr != nil {
335370
return handlePickerError(cmd, silentErr, setupErr)
@@ -755,7 +790,7 @@ func runReview(ctx context.Context, cmd *cobra.Command, agentOverride, modelOver
755790
applyLegacyReviewProfileFallback(s)
756791

757792
profileOverride = strings.TrimSpace(profileOverride)
758-
interactiveTTY := interactive.IsTerminalWriter(out) && interactive.CanPromptInteractively()
793+
interactiveTTY := reviewCommandIsInteractive(cmd)
759794

760795
// Bare `entire review` never auto-runs a profile. Without a TTY we cannot
761796
// prompt, so list the profiles (or point at setup) and require an explicit
@@ -784,7 +819,7 @@ func runReview(ctx context.Context, cmd *cobra.Command, agentOverride, modelOver
784819
// Non-interactive first run writes the shared project settings; interactive
785820
// setup asks the user where to save below.
786821
saveScope := reviewScopeProject
787-
guidedSetup := interactive.IsTerminalWriter(out) && interactive.CanPromptInteractively()
822+
guidedSetup := interactiveTTY
788823
if guidedSetup {
789824
var setupErr error
790825
profileForSetup, profile, setupErr = RunReviewGuidedSetup(ctx, out, installed, deps.ReviewerFor, profileForSetup, true, s)
@@ -942,12 +977,12 @@ func nonLaunchableEligibleNames(profile settings.ReviewProfileConfig, eligible [
942977
// (true, nil). In a non-interactive context it cannot prompt, so it proceeds
943978
// (the user explicitly invoked `entire review`) after printing a note rather
944979
// than blocking on a confirm form that would error out.
945-
func confirmReReviewOrProceed(ctx context.Context, out io.Writer, deps Deps) (bool, error) {
980+
func confirmReReviewOrProceed(ctx context.Context, out io.Writer, deps Deps, canPrompt bool) (bool, error) {
946981
reviewed, meta := deps.HeadHasReviewCheckpoint(ctx)
947982
if !reviewed {
948983
return true, nil
949984
}
950-
if !interactive.CanPromptInteractively() {
985+
if !canPrompt {
951986
fmt.Fprintf(out, "Note: HEAD was already reviewed (%s); re-running.\n", meta)
952987
return true, nil
953988
}
@@ -1009,7 +1044,8 @@ func runSingleAgentPath(
10091044
}
10101045

10111046
// 4. Re-run guard: check if HEAD's checkpoint already has a review.
1012-
if proceed, guardErr := confirmReReviewOrProceed(ctx, out, deps); guardErr != nil {
1047+
canPrompt := reviewCommandIsInteractive(cmd)
1048+
if proceed, guardErr := confirmReReviewOrProceed(ctx, out, deps, canPrompt); guardErr != nil {
10131049
fmt.Fprintln(out, "prompt cancelled")
10141050
return silentErr(guardErr)
10151051
} else if !proceed {
@@ -1063,10 +1099,9 @@ func runSingleAgentPath(
10631099
defer cancelRun()
10641100

10651101
runCfg.EnrichSummary = reviewSummaryTokenEnricher(worktreeRoot, headSHA)
1066-
canPrompt := interactive.CanPromptInteractively()
10671102
sinks := composeSingleAgentSinks(singleAgentSinkInputs{
10681103
out: out,
1069-
isTTY: interactive.IsTerminalWriter(out) && canPrompt,
1104+
isTTY: canPrompt,
10701105
canPrompt: canPrompt,
10711106
agentName: displayName,
10721107
cancelRun: cancelRun,
@@ -1153,7 +1188,8 @@ func runMultiAgentPath(
11531188
return fmt.Errorf("resolve HEAD: %w", shaErr)
11541189
}
11551190

1156-
if proceed, guardErr := confirmReReviewOrProceed(ctx, out, deps); guardErr != nil {
1191+
canPrompt := reviewCommandIsInteractive(cmd)
1192+
if proceed, guardErr := confirmReReviewOrProceed(ctx, out, deps, canPrompt); guardErr != nil {
11571193
fmt.Fprintln(out, "prompt cancelled")
11581194
return deps.NewSilentError(guardErr)
11591195
} else if !proceed {
@@ -1235,7 +1271,7 @@ func runMultiAgentPath(
12351271
masterLabel := judgeLabel(judge)
12361272
sinks := composeMultiAgentSinks(multiAgentSinkInputs{
12371273
out: out,
1238-
isTTY: interactive.IsTerminalWriter(out) && interactive.CanPromptInteractively(),
1274+
isTTY: canPrompt,
12391275
agentNames: agentNames,
12401276
cancelRun: cancelRun,
12411277
runContext: runCtx,
@@ -1309,11 +1345,10 @@ func handlePickerError(cmd *cobra.Command, silentErr func(error) error, pickErr
13091345
// instead of monkey-patching interactive helpers at run time.
13101346
//
13111347
// isTTY here means "the TUI sink is safe to compose" — production callers
1312-
// AND IsTerminalWriter(out) with CanPromptInteractively() before passing
1313-
// it in, since the TUI both writes ANSI to stdout AND reads keypresses
1314-
// from stdin. A terminal-stdout-but-non-interactive-stdin scenario (an
1315-
// agent host like Claude Code invoking `entire review`) must NOT use the
1316-
// TUI — its dismissal loop would block forever.
1348+
// use reviewCommandIsInteractive before passing it in, since the TUI both
1349+
// writes ANSI to stdout and reads keypresses from stdin. A terminal stdout
1350+
// with non-interactive stdin must not use the TUI; its dismissal loop would
1351+
// block forever.
13171352
type multiAgentSinkInputs struct {
13181353
out io.Writer
13191354
isTTY bool

cmd/entire/cli/review/cmd_test.go

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1238,17 +1238,77 @@ func TestComposeMultiAgentSinks_TTYAutoSynthesisRunsBeforeTUIExit(t *testing.T)
12381238
}
12391239
}
12401240

1241+
// TestDispatchFork_LegacyGeneratedCodexSkillIsRepairedAndLaunched prevents
1242+
// guided setup's historical /review default from silently removing Codex from
1243+
// a multi-agent run. The compatibility repair must reach dispatch, not merely
1244+
// make the profile look valid in listing/configuration code.
1245+
func TestDispatchFork_LegacyGeneratedCodexSkillIsRepairedAndLaunched(t *testing.T) {
1246+
setupCmdTestRepo(t)
1247+
t.Setenv("HOME", t.TempDir())
1248+
1249+
if err := seedReviewConfig(context.Background(), map[string]settings.ReviewConfig{
1250+
testAgentName: {Skills: []string{"/review"}},
1251+
testCodexAgent: {
1252+
Skills: []string{"/review"},
1253+
},
1254+
}); err != nil {
1255+
t.Fatal(err)
1256+
}
1257+
1258+
claudeReviewer := &captureRunConfigReviewer{name: testAgentName}
1259+
codexReviewer := &captureRunConfigReviewer{name: testCodexAgent}
1260+
deps := review.Deps{
1261+
GetAgentsWithHooksInstalled: func(_ context.Context) []types.AgentName {
1262+
return []types.AgentName{testAgentName, testCodexAgent}
1263+
},
1264+
NewSilentError: func(err error) error { return err },
1265+
HeadHasReviewCheckpoint: func(_ context.Context) (bool, string) {
1266+
return false, ""
1267+
},
1268+
ReviewerFor: func(agentName string) reviewtypes.AgentReviewer {
1269+
switch agentName {
1270+
case testAgentName:
1271+
return claudeReviewer
1272+
case testCodexAgent:
1273+
return codexReviewer
1274+
default:
1275+
return nil
1276+
}
1277+
},
1278+
}
1279+
1280+
cmd := review.NewCommand(deps)
1281+
cmd.SetOut(&bytes.Buffer{})
1282+
errBuf := &bytes.Buffer{}
1283+
cmd.SetErr(errBuf)
1284+
cmd.SetArgs([]string{"general"})
1285+
1286+
if err := cmd.Execute(); err != nil {
1287+
t.Fatalf("run legacy generated profile: %v", err)
1288+
}
1289+
if !codexReviewer.called {
1290+
t.Fatalf("Codex was silently excluded; stderr:\n%s", errBuf.String())
1291+
}
1292+
if len(codexReviewer.got.Skills) != 0 {
1293+
t.Fatalf("Codex received obsolete generated skills %v, want none", codexReviewer.got.Skills)
1294+
}
1295+
if codexReviewer.got.AlwaysPrompt != "Review the change according to the profile task." {
1296+
t.Fatalf("Codex repaired prompt = %q", codexReviewer.got.AlwaysPrompt)
1297+
}
1298+
if strings.Contains(errBuf.String(), "skipping reviewer codex") {
1299+
t.Fatalf("Codex was reported as skipped:\n%s", errBuf.String())
1300+
}
1301+
}
1302+
12411303
// TestDispatchFork_InvalidSkillExcludesWorkerNotWholeCrew pins the blast
12421304
// radius of spawn-time skill validation in multi-agent runs: a worker whose
1243-
// configured skill no longer validates (e.g. codex's legacy auto-preselected
1244-
// "/review", orphaned when the curated builtin was removed) is excluded with
1245-
// a loud warning, and the remaining reviewers still run. Aborting the whole
1246-
// crew for one stale entry held every other agent hostage to a codex
1247-
// reconfigure.
1305+
// explicitly configured skill no longer validates is excluded with a loud
1306+
// warning, and the remaining reviewers still run. Aborting the whole crew for
1307+
// one stale entry would hold every other agent hostage to a reconfigure.
12481308
func TestDispatchFork_InvalidSkillExcludesWorkerNotWholeCrew(t *testing.T) {
12491309
setupCmdTestRepo(t)
1250-
// Controlled empty HOME: codex discovery finds nothing, so its "/review"
1251-
// (no longer a curated builtin) fails validation. Cannot t.Parallel —
1310+
// Controlled empty HOME: Codex discovery finds nothing, so the configured
1311+
// custom skill fails validation. Cannot t.Parallel —
12521312
// t.Setenv (setupCmdTestRepo already precludes it via t.Chdir).
12531313
t.Setenv("HOME", t.TempDir())
12541314

@@ -1257,7 +1317,7 @@ func TestDispatchFork_InvalidSkillExcludesWorkerNotWholeCrew(t *testing.T) {
12571317
Skills: []string{"/review"},
12581318
},
12591319
testCodexAgent: {
1260-
Skills: []string{"/review"}, // stale legacy entry
1320+
Skills: []string{"$missing-review"},
12611321
},
12621322
}); err != nil {
12631323
t.Fatal(err)
@@ -1301,7 +1361,7 @@ func TestDispatchFork_InvalidSkillExcludesWorkerNotWholeCrew(t *testing.T) {
13011361
t.Error("codex reviewer started despite failing skill validation")
13021362
}
13031363
stderr := errBuf.String()
1304-
if !strings.Contains(stderr, "/review") || !strings.Contains(stderr, "skipping") {
1364+
if !strings.Contains(stderr, "$missing-review") || !strings.Contains(stderr, "skipping") {
13051365
t.Errorf("stderr should warn about the excluded worker and its skill; got:\n%s", stderr)
13061366
}
13071367
}
@@ -1314,7 +1374,7 @@ func TestDispatchFork_AllWorkersInvalidStillFails(t *testing.T) {
13141374
t.Setenv("HOME", t.TempDir())
13151375

13161376
if err := seedReviewConfig(context.Background(), map[string]settings.ReviewConfig{
1317-
testCodexAgent: {Skills: []string{"/review"}},
1377+
testCodexAgent: {Skills: []string{"$missing-review"}},
13181378
"gemini": {Skills: []string{"$also-missing"}},
13191379
}); err != nil {
13201380
t.Fatal(err)

0 commit comments

Comments
 (0)