diff --git a/cmd/entire/cli/runner_setup.go b/cmd/entire/cli/runner_setup.go index f673af5b31..b5b204d862 100644 --- a/cmd/entire/cli/runner_setup.go +++ b/cmd/entire/cli/runner_setup.go @@ -87,6 +87,7 @@ type runnerSetupOptions struct { defaultsOnly bool printPrompt bool dryRun bool + agent string // --agent: text-generation agent for this run; skips the provider choice, saves nothing debugDir string // if set, dump prompt.txt (+ response.txt when a provider ran) here sources []string limit int @@ -114,11 +115,13 @@ or defaults tailored to this repo. Otherwise name the action up front: -y, --yes create the defaults if missing, then tailor them in place --defaults-only create the generic defaults and stop - --print-prompt print the tailoring prompt for your own agent to run + --print-prompt create the defaults if missing, then print the tailoring prompt for your own agent to run --dry-run show the tailoring as a diff and write nothing ---yes and --dry-run each call your configured summary provider once. Review a -tailoring with git diff .entire/runners. +--yes and --dry-run each call your configured summary provider once. With no +provider configured and several agents installed, you are asked which to use; +pass --agent to name one for this run instead (nothing is saved). +Review a tailoring with git diff .entire/runners. If is given (e.g. "risk" or "trail-risk"), only that runner is tuned.`, Args: cobra.MaximumNArgs(1), @@ -135,13 +138,15 @@ If is given (e.g. "risk" or "trail-risk"), only that runner is tuned.`, } cmd.Flags().BoolVarP(&opts.assumeYes, "yes", "y", false, - "Create the default runners if missing and tailor them to this repo, without asking") + "Create the default runners if missing and tailor them to this repo; add --agent to skip the provider choice too") cmd.Flags().BoolVar(&opts.defaultsOnly, "defaults-only", false, "Create the generic default runners and stop (no tailoring, no provider call)") cmd.Flags().BoolVar(&opts.printPrompt, "print-prompt", false, - "Print the tailoring prompt for your own agent instead of running a provider") + "Create the default runners if missing, then print the tailoring prompt for your own agent instead of running a provider") cmd.Flags().BoolVar(&opts.dryRun, "dry-run", false, "Show the tailoring as a diff and write nothing") + cmd.Flags().StringVar(&opts.agent, "agent", "", + "Text-generation agent to tailor with for this run (e.g. codex); skips the provider choice and saves nothing. Only for runs that tailor: --yes, --dry-run, or choosing to tailor at the prompt") cmd.Flags().StringSliceVar(&opts.sources, "sources", nil, "Comma-separated data sources to gather: repo, prs, checkpoints, trails, all (default: all)") cmd.Flags().IntVar(&opts.limit, "limit", 20, "How many recent PRs/issues/trails to sample") @@ -175,9 +180,17 @@ func runRunnerSetup(ctx context.Context, w, errW io.Writer, opts runnerSetupOpti return nil // picker cancelled; handleFormCancellation already said so } + // --agent names the agent that tailors, so it is a contradiction for a mode + // that never tailors — the same rule as dispatch's --agent without --local. + // Adding -y cannot rescue such a run: --defaults-only and --print-prompt + // outrank it in resolveRunnerSetupMode. Before the scaffold: a usage error + // must not leave files behind. + if opts.agent != "" && !mode.needsProvider() { + return errors.New("--agent names the agent that tailors, but this run does not tailor; drop --agent, or tailor with --yes or --dry-run instead of --defaults-only or --print-prompt") + } + // --sources and --limit only steer the gather, so they are validated for // the modes that gather and not for --defaults-only, which reads neither. - // Before the scaffold, though: a usage error must not leave files behind. var src tuneSources if mode.gathersSignal() { if src, err = parseTuneSources(opts.sources); err != nil { @@ -188,10 +201,17 @@ func runRunnerSetup(ctx context.Context, w, errW io.Writer, opts runnerSetupOpti } } - // Choosing a mode was the consent for creating the runner files. + // Choosing a mode was the consent for creating the runner files. In + // print-prompt mode stdout is the prompt itself — callers redirect it into + // their own agent — so the "created" lines are narration and belong on + // stderr with the rest of that mode's messages. var created []string if mode.writesRunnerFiles() && !haveRunners { - if created, err = createDefaultRunners(w, repoRoot); err != nil { + scaffoldW := w + if mode == setupModePrintPrompt { + scaffoldW = errW + } + if created, err = createDefaultRunners(scaffoldW, repoRoot); err != nil { return err } } @@ -206,10 +226,11 @@ func runRunnerSetup(ctx context.Context, w, errW io.Writer, opts runnerSetupOpti } // Resolved before the gather: resolution can fail outright, or stop to ask - // which provider to use, and neither belongs after seconds of waiting. + // which provider to use, and neither belongs after seconds of waiting. An + // --agent override takes dispatch's path: validated, used once, not saved. var provider *checkpointSummaryProvider if mode.needsProvider() { - if provider, err = resolveCheckpointSummaryProvider(ctx, errW); err != nil { + if provider, err = resolveDispatchSummaryProvider(ctx, errW, opts.agent); err != nil { return err } } diff --git a/cmd/entire/cli/runner_setup_test.go b/cmd/entire/cli/runner_setup_test.go index 23e6999450..16e8c049ee 100644 --- a/cmd/entire/cli/runner_setup_test.go +++ b/cmd/entire/cli/runner_setup_test.go @@ -163,6 +163,37 @@ func TestRunRunnerSetup_DefaultsOnlyIsANoopWhenConfigured(t *testing.T) { } } +// TestRunRunnerSetup_PrintPromptKeepsStdoutForThePrompt pins the stream split +// in this mode: stdout carries the prompt alone, because callers redirect it +// into their own agent, so the scaffold's "created" lines go to stderr with the +// rest of this mode's narration. +func TestRunRunnerSetup_PrintPromptKeepsStdoutForThePrompt(t *testing.T) { + repoRoot := newRunnerSetupRepo(t) + + var out, errOut bytes.Buffer + // No provider stub: this mode never resolves one. + if err := runRunnerSetup(context.Background(), &out, &errOut, runnerSetupOptions{ + printPrompt: true, + sources: []string{"repo"}, + limit: 1, + }); err != nil { + t.Fatalf("--print-prompt: %v", err) + } + + if strings.Contains(out.String(), "created .entire") { + t.Errorf("stdout must carry the prompt only, got a scaffold line:\n%s", out.String()) + } + if !strings.Contains(out.String(), "You are tuning Entire") { + t.Errorf("stdout missing the prompt:\n%s", out.String()) + } + if !strings.Contains(errOut.String(), "created .entire") { + t.Errorf("stderr missing the scaffold lines:\n%s", errOut.String()) + } + if written := runnerFiles(t, repoRoot); len(written) != wantDefaultCount(t) { + t.Errorf("--print-prompt wrote %d runner file(s), want the full default set (%d)", len(written), wantDefaultCount(t)) + } +} + func TestDefaultTuneRunners(t *testing.T) { t.Parallel() @@ -433,3 +464,122 @@ func TestRunRunnerSetup_BadGatherFlagsWriteNothing(t *testing.T) { }) } } + +// TestRunRunnerSetup_AgentFlagIsPromptless pins what --agent is for: with no +// provider configured, several agents installed, and a terminal to ask on, +// `-y --agent codex` still opens no picker. The named agent is used for this +// run only, so nothing is written to settings.local.json. +func TestRunRunnerSetup_AgentFlagIsPromptless(t *testing.T) { + repoRoot := newRunnerSetupRepo(t) + gen := stubNamedTuningAgent(t, agent.AgentNameCodex) + + var out, errOut bytes.Buffer + if err := runRunnerSetup(context.Background(), &out, &errOut, runnerSetupOptions{ + assumeYes: true, + agent: string(agent.AgentNameCodex), + sources: []string{"repo"}, // local signal only: no gh, no API + limit: 1, + }); err != nil { + t.Fatalf("runRunnerSetup: %v", err) + } + + if gen.calls != 1 { + t.Errorf("named agent was called %d time(s), want exactly 1", gen.calls) + } + if written := runnerFiles(t, repoRoot); len(written) != wantDefaultCount(t) { + t.Errorf("-y wrote %d runner file(s), want the full default set", len(written)) + } + if _, err := os.Stat(filepath.Join(repoRoot, settings.EntireSettingsLocalFile)); !os.IsNotExist(err) { + t.Errorf("--agent must not persist a provider choice (stat err = %v)", err) + } +} + +// TestRunRunnerSetup_AgentRequiresAProviderMode mirrors dispatch, where --agent +// without --local is an error: naming an agent for a mode that never tailors is +// a contradiction, and it is reported before anything is written. Adding -y does +// not rescue such a run, because the explicit mode flags outrank it. +func TestRunRunnerSetup_AgentRequiresAProviderMode(t *testing.T) { + repoRoot := newRunnerSetupRepo(t) + stubNamedTuningAgent(t, agent.AgentNameCodex) + + for _, tc := range []struct { + name string + opts runnerSetupOptions + }{ + {"defaults-only", runnerSetupOptions{defaultsOnly: true, agent: "codex"}}, + {"print-prompt", runnerSetupOptions{printPrompt: true, agent: "codex", limit: 1}}, + {"defaults-only even with -y", runnerSetupOptions{defaultsOnly: true, assumeYes: true, agent: "codex"}}, + } { + t.Run(tc.name, func(t *testing.T) { + err := runRunnerSetup(context.Background(), io.Discard, io.Discard, tc.opts) + if err == nil || !strings.Contains(err.Error(), "--agent") { + t.Fatalf("error = %v, want one naming --agent", err) + } + if strings.Contains(err.Error(), "pass it with") { + t.Errorf("error should not offer a flag that cannot rescue the run: %v", err) + } + if written := runnerFiles(t, repoRoot); len(written) != 0 { + t.Errorf("a usage error wrote %d runner file(s); it must write none", len(written)) + } + }) + } +} + +// tuningStubAgent answers every tailoring prompt with "no changes" and counts +// how often it was asked. +type tuningStubAgent struct { + *stubTextAgent + + calls int +} + +func (a *tuningStubAgent) GenerateText(context.Context, string, string) (string, error) { //nolint:unparam // agent.TextGenerator signature + a.calls++ + return "{}", nil +} + +// stubNamedTuningAgent registers one resolvable agent under name and arranges +// the resolver's worst case for a promptless run: no configured provider, two +// candidates, and a terminal. The picker and the settings writer both fail the +// test if reached. +func stubNamedTuningAgent(t *testing.T, name types.AgentName) *tuningStubAgent { + t.Helper() + stub := &tuningStubAgent{stubTextAgent: &stubTextAgent{name: name, kind: types.AgentType(name)}} + + originalLoad, originalSave := loadSummarySettings, saveLocalSummarySettings + originalGet, originalList := getSummaryAgent, listRegisteredAgents + originalCLI, originalCanPrompt := isSummaryCLIAvailable, canPromptForSummaryProvider + originalPrompt := promptSummaryProvider + originalDiscoverAlways, originalDiscoverNamed := discoverSummaryProvidersAlways, discoverNamedSummaryProvider + t.Cleanup(func() { + loadSummarySettings, saveLocalSummarySettings = originalLoad, originalSave + getSummaryAgent, listRegisteredAgents = originalGet, originalList + isSummaryCLIAvailable, canPromptForSummaryProvider = originalCLI, originalCanPrompt + promptSummaryProvider = originalPrompt + discoverSummaryProvidersAlways, discoverNamedSummaryProvider = originalDiscoverAlways, originalDiscoverNamed + }) + + loadSummarySettings = func(context.Context) (*settings.EntireSettings, error) { + return &settings.EntireSettings{}, nil // no summary_generation.provider + } + saveLocalSummarySettings = func(context.Context, *settings.EntireSettings) error { + t.Fatal("--agent must not persist a provider selection") + return nil + } + getSummaryAgent = func(n types.AgentName) (agent.Agent, error) { + if n != name { + return nil, fmt.Errorf("stub: no agent %s", n) + } + return stub, nil + } + listRegisteredAgents = func() []types.AgentName { return []types.AgentName{name, "other"} } + isSummaryCLIAvailable = func(n types.AgentName) bool { return n == name } + canPromptForSummaryProvider = func() bool { return true } + promptSummaryProvider = func([]checkpointSummaryProvider) (types.AgentName, error) { + t.Fatal("--agent must not open the provider picker") + return "", nil + } + discoverSummaryProvidersAlways = func(context.Context) {} + discoverNamedSummaryProvider = func(context.Context, types.AgentName) error { return nil } + return stub +}