From 134ae31feaf86f0f5bc3d674df2e031000bcb7ce Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 16 Sep 2025 13:07:01 +0100 Subject: [PATCH 1/4] refactor(agent-task create): extract session URL polling into a func Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 72 ++++++++++++++++------------- 1 file changed, 39 insertions(+), 33 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 08c1f8c02..51ae64e9b 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -151,40 +151,21 @@ func createRun(opts *CreateOptions) error { return err } - // Print this agent session URL and exit if we happen to get it. - // Right now, this never happens. - if job.PullRequest != nil && job.PullRequest.Number > 0 { - fmt.Fprintf(opts.IO.Out, "%s\n", agentSessionWebURL(repo, job)) - return nil - } - - // Otherwise, poll using exponential backoff until we either observe a PR or hit the overall timeout. - if opts.BackOff == nil { - opts.BackOff = backoff.NewExponentialBackOff( - backoff.WithMaxElapsedTime(10*time.Second), - backoff.WithInitialInterval(300*time.Millisecond), - backoff.WithMaxInterval(10*time.Second), - backoff.WithMultiplier(1.5), - ) - } - - jobWithPR, err := fetchJobWithBackoff(ctx, client, repo, job.ID, opts.BackOff) - if err != nil { - // If this does happen ever, we still want the user to get the - // fallback message and URL. So, we don't return with this error, - // but we do still want to print it. - fmt.Fprintf(opts.IO.ErrOut, "%v\n", err) - } - - if jobWithPR != nil { - opts.IO.StopProgressIndicator() - fmt.Fprintln(opts.IO.Out, agentSessionWebURL(repo, jobWithPR)) - return nil - } - - // Fallback if PR not yet ready + sessionURL, err := fetchJobSessionURL(ctx, client, repo, job, opts.BackOff) opts.IO.StopProgressIndicator() - fmt.Fprintf(opts.IO.Out, "job %s queued. View progress: https://github.com/copilot/agents\n", job.ID) + + if sessionURL != "" { + fmt.Fprintln(opts.IO.Out, sessionURL) + } else { + if err != nil { + // If this does happen ever, we still want the user to get the fallback + // message and URL. So, we don't return with this error, but we do still + // want to print it. + fmt.Fprintf(opts.IO.ErrOut, "%v\n", err) + } + fmt.Fprintf(opts.IO.Out, "job %s queued. View progress: %s\n", job.ID, capi.AgentsHomeURL) + } + return nil } @@ -198,6 +179,31 @@ func agentSessionWebURL(repo ghrepo.Interface, j *capi.Job) string { return fmt.Sprintf("https://github.com/%s/%s/pull/%d/agent-sessions/%s", url.PathEscape(repo.RepoOwner()), url.PathEscape(repo.RepoName()), j.PullRequest.Number, url.PathEscape(j.SessionID)) } +// fetchJobSessionURL tries to return the agent session URL for a job. If the pull +// request is not yet available, ("", nil) is returned. +func fetchJobSessionURL(ctx context.Context, client capi.CapiClient, repo ghrepo.Interface, job *capi.Job, bo backoff.BackOff) (string, error) { + if job.PullRequest != nil && job.PullRequest.Number > 0 { + // Return the agent session URL if we happen to get it. + // Right now, this never happens. + return agentSessionWebURL(repo, job), nil + } + + if bo == nil { + bo = backoff.NewExponentialBackOff( + backoff.WithMaxElapsedTime(10*time.Second), + backoff.WithInitialInterval(300*time.Millisecond), + backoff.WithMaxInterval(10*time.Second), + backoff.WithMultiplier(1.5), + ) + } + + jobWithPR, err := fetchJobWithBackoff(ctx, client, repo, job.ID, bo) + if jobWithPR != nil { + return agentSessionWebURL(repo, jobWithPR), nil + } + return "", err +} + // fetchJobWithBackoff polls the job resource until a PR number is present or the overall // timeout elapses. It returns the updated Job on success, (nil, nil) on timeout, // and (nil, error) only for non-retryable failures. From 4f7d577b9772cf42a488b69ef970fda31c17b01c Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 16 Sep 2025 13:18:09 +0100 Subject: [PATCH 2/4] feat(agent-task create): add `--follow` flag Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 55 ++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 51ae64e9b..7186f7d59 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -21,25 +21,38 @@ import ( "github.com/spf13/cobra" ) +const defaultLogPollInterval = 5 * time.Second + // CreateOptions holds options for create command type CreateOptions struct { - IO *iostreams.IOStreams - BaseRepo func() (ghrepo.Interface, error) - CapiClient func() (capi.CapiClient, error) - Config func() (gh.Config, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + CapiClient func() (capi.CapiClient, error) + Config func() (gh.Config, error) + + LogRenderer func() shared.LogRenderer + Sleep func(d time.Duration) + ProblemStatement string BackOff backoff.BackOff BaseBranch string Prompter prompter.Prompter ProblemStatementFile string + Follow bool +} + +func defaultLogRenderer() shared.LogRenderer { + return shared.NewLogRenderer() } func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { opts := &CreateOptions{ - IO: f.IOStreams, - CapiClient: shared.CapiClientFunc(f), - Config: f.Config, - Prompter: f.Prompter, + IO: f.IOStreams, + CapiClient: shared.CapiClientFunc(f), + Config: f.Config, + Prompter: f.Prompter, + LogRenderer: defaultLogRenderer, + Sleep: time.Sleep, } cmd := &cobra.Command{ @@ -91,6 +104,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.ProblemStatementFile, "from-file", "F", "", "Read task description from `file` (use \"-\" to read from standard input)") cmd.Flags().StringVarP(&opts.BaseBranch, "base", "b", "", "Base branch for the pull request (use default branch if not provided)") + cmd.Flags().BoolVar(&opts.Follow, "follow", false, "Follow agent session logs") return cmd } @@ -166,6 +180,9 @@ func createRun(opts *CreateOptions) error { fmt.Fprintf(opts.IO.Out, "job %s queued. View progress: %s\n", job.ID, capi.AgentsHomeURL) } + if opts.Follow { + return followLogs(opts, client, job.SessionID) + } return nil } @@ -234,3 +251,25 @@ func fetchJobWithBackoff(ctx context.Context, client capi.CapiClient, repo ghrep } return result, nil } + +func followLogs(opts *CreateOptions, capiClient capi.CapiClient, sessionID string) error { + ctx := context.Background() + + renderer := opts.LogRenderer() + + var called bool + fetcher := func() ([]byte, error) { + if called { + opts.Sleep(defaultLogPollInterval) + } + called = true + raw, err := capiClient.GetSessionLogs(ctx, sessionID) + if err != nil { + return nil, err + } + return raw, nil + } + + fmt.Fprintln(opts.IO.Out, "") + return renderer.Follow(fetcher, opts.IO.Out, opts.IO) +} From f5ed563a42d0c84c68303983144b0076431146e3 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 16 Sep 2025 13:18:26 +0100 Subject: [PATCH 3/4] docs(agent-task create): add example for `--follow` flag Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/cmd/agent-task/create/create.go b/pkg/cmd/agent-task/create/create.go index 7186f7d59..8b8084a1f 100644 --- a/pkg/cmd/agent-task/create/create.go +++ b/pkg/cmd/agent-task/create/create.go @@ -83,6 +83,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co # Create a task from an inline description $ gh agent-task create "build me a new app" + # Create a task from an inline description and follow logs + $ gh agent-task create "build me a new app" --follow + # Create a task from a file $ gh agent-task create -F task-desc.md From dab285c61a1e9c591a9f522c37039780e34b6877 Mon Sep 17 00:00:00 2001 From: "Babak K. Shandiz" Date: Tue, 16 Sep 2025 13:26:49 +0100 Subject: [PATCH 4/4] test(agent-task create): add test for `--follow` Signed-off-by: Babak K. Shandiz --- pkg/cmd/agent-task/create/create_test.go | 93 ++++++++++++++++++++++-- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/agent-task/create/create_test.go b/pkg/cmd/agent-task/create/create_test.go index b855ce687..d1298e606 100644 --- a/pkg/cmd/agent-task/create/create_test.go +++ b/pkg/cmd/agent-task/create/create_test.go @@ -9,13 +9,16 @@ import ( "testing" "time" + "github.com/MakeNowJust/heredoc" "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/agent-task/capi" + "github.com/cli/cli/v2/pkg/cmd/agent-task/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/google/shlex" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -57,6 +60,15 @@ func TestNewCmdCreate(t *testing.T) { BaseBranch: "feature", }, }, + { + name: "with --follow", + args: "'task description from args' --follow", + wantOpts: &CreateOptions{ + ProblemStatement: "task description from args", + ProblemStatementFile: "", + Follow: true, + }, + }, } for _, tt := range tests { @@ -135,14 +147,15 @@ func Test_createRun(t *testing.T) { } tests := []struct { - name string - isTTY bool - capiStubs func(*testing.T, *capi.CapiClientMock) - opts *CreateOptions // input options (IO & BackOff set later) - wantStdout string - wantStdErr string - wantErr string - wantErrIs error + name string + isTTY bool + opts *CreateOptions // input options (IO & BackOff set later) + capiStubs func(*testing.T, *capi.CapiClientMock) + logRendererStubs func(*testing.T, *shared.LogRendererMock) + wantStdout string + wantStdErr string + wantErr string + wantErrIs error }{ { name: "interactive with file prompts to edit with file contents", @@ -428,6 +441,62 @@ func Test_createRun(t *testing.T) { }, wantStdout: "job job123 queued. View progress: https://github.com/copilot/agents\n", }, + { + name: "success with follow logs and delayed PR after polling", + opts: &CreateOptions{ + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + ProblemStatement: "Do the thing", + Follow: true, + Sleep: func(d time.Duration) {}, + }, + capiStubs: func(t *testing.T, m *capi.CapiClientMock) { + m.CreateJobFunc = func(ctx context.Context, owner, repo, problemStatement, baseBranch string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "Do the thing", problemStatement) + require.Equal(t, "", baseBranch) + return &createdJobSuccess, nil + } + m.GetJobFunc = func(ctx context.Context, owner, repo, jobID string) (*capi.Job, error) { + require.Equal(t, "OWNER", owner) + require.Equal(t, "REPO", repo) + require.Equal(t, "job123", jobID) + return &createdJobSuccessWithPR, nil + } + + var count int + m.GetSessionLogsFunc = func(_ context.Context, id string) ([]byte, error) { + assert.Equal(t, "sess1", id) + + count++ + require.Less(t, count, 3, "too many calls to fetch logs") + if count == 1 { + return []byte(""), nil + } + return []byte(""), nil + } + }, + logRendererStubs: func(t *testing.T, m *shared.LogRendererMock) { + m.FollowFunc = func(fetcher func() ([]byte, error), w io.Writer, ios *iostreams.IOStreams) error { + raw, err := fetcher() + require.NoError(t, err) + w.Write([]byte("(rendered:) " + string(raw) + "\n")) + + raw, err = fetcher() + require.NoError(t, err) + w.Write([]byte("(rendered:) " + string(raw) + "\n")) + return nil + } + }, + wantStdout: heredoc.Doc(` + https://github.com/OWNER/REPO/pull/42/agent-sessions/sess1 + + (rendered:) + (rendered:) + `), + }, } for _, tt := range tests { @@ -452,6 +521,14 @@ func Test_createRun(t *testing.T) { // fast backoff tt.opts.BackOff = backoff.WithMaxRetries(&backoff.ZeroBackOff{}, 3) + logRenderer := &shared.LogRendererMock{} + if tt.logRendererStubs != nil { + tt.logRendererStubs(t, logRenderer) + } + tt.opts.LogRenderer = func() shared.LogRenderer { + return logRenderer + } + err := createRun(tt.opts) if tt.wantErrIs != nil { require.ErrorIs(t, err, tt.wantErrIs)