Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions cmd/entire/cli/runner_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <name> to name one for this run instead (nothing is saved).
Review a tailoring with git diff .entire/runners.

If <runner> is given (e.g. "risk" or "trail-risk"), only that runner is tuned.`,
Args: cobra.MaximumNArgs(1),
Expand All @@ -135,13 +138,15 @@ If <runner> 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")
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand Down
150 changes: 150 additions & 0 deletions cmd/entire/cli/runner_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
}
Loading