- Notifications
You must be signed in to change notification settings - Fork906
feat: add cli command to report task status#18262
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
base:main
Are you sure you want to change the base?
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
package cli | ||
import ( | ||
"context" | ||
"errors" | ||
"net/url" | ||
"time" | ||
agentapi "github.com/coder/agentapi-sdk-go" | ||
"github.com/coder/coder/v2/cli/cliui" | ||
"github.com/coder/coder/v2/codersdk" | ||
"github.com/coder/coder/v2/codersdk/agentsdk" | ||
"github.com/coder/serpent" | ||
) | ||
func (r *RootCmd) taskCommand() *serpent.Command { | ||
cmd := &serpent.Command{ | ||
Use: "task", | ||
Short: "Interact with AI tasks.", | ||
Handler: func(i *serpent.Invocation) error { | ||
return i.Command.HelpHandler(i) | ||
}, | ||
Children: []*serpent.Command{ | ||
r.taskReportStatus(), | ||
}, | ||
} | ||
return cmd | ||
} | ||
func (r *RootCmd) taskReportStatus() *serpent.Command { | ||
var ( | ||
slug string | ||
interval time.Duration | ||
llmURL url.URL | ||
) | ||
cmd := &serpent.Command{ | ||
Use: "report-status", | ||
Short: "Report status of the currently running task to Coder.", | ||
Handler: func(inv *serpent.Invocation) error { | ||
ctx := inv.Context() | ||
// This is meant to run in a workspace, so instead of a regular client we | ||
// need a workspace agent client to update the status in coderd. | ||
agentClient, err := r.createAgentClient() | ||
if err != nil { | ||
return err | ||
} | ||
// We also need an agentapi client to get the LLM agent's current status. | ||
llmClient, err := agentapi.NewClient(llmURL.String()) | ||
if err != nil { | ||
return err | ||
} | ||
notifyCtx, notifyCancel := inv.SignalNotifyContext(ctx, StopSignals...) | ||
defer notifyCancel() | ||
outerLoop: | ||
for { | ||
res, err := llmClient.GetStatus(notifyCtx) | ||
if err != nil && !errors.Is(err, context.Canceled) { | ||
cliui.Warnf(inv.Stderr, "failed to fetch status: %s", err) | ||
} else { | ||
// Currently we only update the status, which leaves the last summary | ||
Contributor There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others.Learn more. AgentAPI reports that an agent is running whenever the underlying agent's terminal output changes. This also happens when a user uses the "control" mode to send raw keystrokes to the terminal. Once we implement web push notifications, they might start going off annoyingly in control mode. A more robust solution might also take into account:
If we reported that the status is stable for a given length and summary, we could stop sending updates until either length or summary changes. If these values don't change, it likely means the user is just fiddling in the terminal rather than the agent doing actual work. To avoid data races with the agent, the status update endpoint would:
In cases where the conversation length increases but the app status doesn't change, we could send a "Working..." summary update, which the agent would later replace with a richer description via MCP. MemberAuthor
| ||
// (if any) untouched. If we do want to update the summary here, we | ||
// will need to fetch the messages and generate one. | ||
status := codersdk.WorkspaceAppStatusStateWorking | ||
switch res.Status { | ||
case agentapi.StatusStable: // Stable == idle == done | ||
status = codersdk.WorkspaceAppStatusStateComplete | ||
case agentapi.StatusRunning: // Running == working | ||
} | ||
err = agentClient.PatchAppStatus(notifyCtx, agentsdk.PatchAppStatus{ | ||
AppSlug: slug, | ||
State: status, | ||
}) | ||
if err != nil && !errors.Is(err, context.Canceled) { | ||
cliui.Warnf(inv.Stderr, "failed to update status: %s", err) | ||
} | ||
} | ||
timer := time.NewTimer(interval) | ||
select { | ||
case <-notifyCtx.Done(): | ||
timer.Stop() | ||
break outerLoop | ||
case <-timer.C: | ||
} | ||
} | ||
return nil | ||
}, | ||
Options: []serpent.Option{ | ||
{ | ||
Flag: "app-slug", | ||
Description: "The app slug to use when reporting the status.", | ||
Env: "CODER_MCP_APP_STATUS_SLUG", | ||
Required: true, | ||
Value: serpent.StringOf(&slug), | ||
}, | ||
{ | ||
Flag: "agentapi-url", | ||
Description: "The URL of the LLM agent API.", | ||
Env: "CODER_AGENTAPI_URL", | ||
Required: true, | ||
Value: serpent.URLOf(&llmURL), | ||
}, | ||
{ | ||
Flag: "interval", | ||
Description: "The interval on which to poll for the status.", | ||
Env: "CODER_APP_STATUS_INTERVAL", | ||
Default: "30s", | ||
Value: serpent.DurationOf(&interval), | ||
}, | ||
}, | ||
} | ||
return cmd | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,134 @@ | ||
package cli_test | ||
import ( | ||
"context" | ||
"encoding/json" | ||
"io" | ||
"net/http" | ||
"net/http/httptest" | ||
"testing" | ||
"github.com/stretchr/testify/assert" | ||
"github.com/stretchr/testify/require" | ||
agentapi "github.com/coder/agentapi-sdk-go" | ||
"github.com/coder/coder/v2/cli/clitest" | ||
"github.com/coder/coder/v2/coderd/httpapi" | ||
"github.com/coder/coder/v2/codersdk" | ||
"github.com/coder/coder/v2/codersdk/agentsdk" | ||
"github.com/coder/coder/v2/pty/ptytest" | ||
"github.com/coder/coder/v2/testutil" | ||
) | ||
func TestExpTask(t *testing.T) { | ||
t.Parallel() | ||
tests := []struct { | ||
name string | ||
resp *codersdk.Response | ||
status *agentapi.GetStatusResponse | ||
expected codersdk.WorkspaceAppStatusState | ||
}{ | ||
{ | ||
name: "ReportWorking", | ||
resp: nil, | ||
status: &agentapi.GetStatusResponse{ | ||
Status: agentapi.StatusRunning, | ||
}, | ||
expected: codersdk.WorkspaceAppStatusStateWorking, | ||
}, | ||
{ | ||
name: "ReportComplete", | ||
resp: nil, | ||
status: &agentapi.GetStatusResponse{ | ||
Status: agentapi.StatusStable, | ||
}, | ||
expected: codersdk.WorkspaceAppStatusStateComplete, | ||
}, | ||
{ | ||
name: "ReportUpdateError", | ||
resp: &codersdk.Response{ | ||
Message: "Failed to get workspace app.", | ||
Detail: "This is a test failure.", | ||
}, | ||
status: &agentapi.GetStatusResponse{ | ||
Status: agentapi.StatusStable, | ||
}, | ||
expected: codersdk.WorkspaceAppStatusStateComplete, | ||
}, | ||
{ | ||
name: "ReportStatusError", | ||
resp: nil, | ||
status: nil, | ||
expected: codersdk.WorkspaceAppStatusStateComplete, | ||
}, | ||
} | ||
for _, test := range tests { | ||
test := test | ||
t.Run(test.name, func(t *testing.T) { | ||
t.Parallel() | ||
done := make(chan codersdk.WorkspaceAppStatusState) | ||
// A mock server for coderd. | ||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
body, err := io.ReadAll(r.Body) | ||
require.NoError(t, err) | ||
_ = r.Body.Close() | ||
var req agentsdk.PatchAppStatus | ||
err = json.Unmarshal(body, &req) | ||
require.NoError(t, err) | ||
if test.resp != nil { | ||
httpapi.Write(context.Background(), w, http.StatusBadRequest, test.resp) | ||
} else { | ||
httpapi.Write(context.Background(), w, http.StatusOK, nil) | ||
} | ||
done <- req.State | ||
})) | ||
t.Cleanup(srv.Close) | ||
agentURL := srv.URL | ||
// Another mock server for the LLM agent API. | ||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
if test.status != nil { | ||
httpapi.Write(context.Background(), w, http.StatusOK, test.status) | ||
} else { | ||
httpapi.Write(context.Background(), w, http.StatusBadRequest, nil) | ||
} | ||
})) | ||
t.Cleanup(srv.Close) | ||
agentapiURL := srv.URL | ||
inv, _ := clitest.New(t, "--agent-url", agentURL, "exp", "task", "report-status", | ||
"--app-slug", "claude-code", | ||
"--agentapi-url", agentapiURL) | ||
stdout := ptytest.New(t) | ||
inv.Stdout = stdout.Output() | ||
stderr := ptytest.New(t) | ||
inv.Stderr = stderr.Output() | ||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) | ||
t.Cleanup(cancel) | ||
go func() { | ||
err := inv.WithContext(ctx).Run() | ||
assert.NoError(t, err) | ||
}() | ||
// Should only try to update the status if we got one. | ||
if test.status == nil { | ||
stderr.ExpectMatch("failed to fetch status") | ||
} else { | ||
got := <-done | ||
require.Equal(t, got, test.expected) | ||
} | ||
// Non-nil for the update means there was an error. | ||
if test.resp != nil { | ||
stderr.ExpectMatch("failed to update status") | ||
} | ||
}) | ||
} | ||
} |
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Some generated files are not rendered by default. Learn more abouthow customized files appear on GitHub.
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.