Skip to content

Commit 67c1d92

Browse files
committed
Add cli command to report task status
1 parent c339066 commit 67c1d92

File tree

5 files changed

+261
-0
lines changed

5 files changed

+261
-0
lines changed

cli/exp.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func (r *RootCmd) expCmd() *serpent.Command {
1616
r.mcpCommand(),
1717
r.promptExample(),
1818
r.rptyCommand(),
19+
r.taskCommand(),
1920
},
2021
}
2122
return cmd

cli/exp_task.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/url"
7+
"time"
8+
9+
agentapi "github.com/coder/agentapi-sdk-go"
10+
agentapigen "github.com/coder/agentapi-sdk-go/gen"
11+
"github.com/coder/coder/v2/cli/cliui"
12+
"github.com/coder/coder/v2/codersdk"
13+
"github.com/coder/coder/v2/codersdk/agentsdk"
14+
"github.com/coder/serpent"
15+
)
16+
17+
func (r *RootCmd) taskCommand() *serpent.Command {
18+
cmd := &serpent.Command{
19+
Use: "task",
20+
Short: "Interact with AI tasks.",
21+
Handler: func(i *serpent.Invocation) error {
22+
return i.Command.HelpHandler(i)
23+
},
24+
Children: []*serpent.Command{
25+
r.taskReportStatus(),
26+
},
27+
}
28+
return cmd
29+
}
30+
31+
func (r *RootCmd) taskReportStatus() *serpent.Command {
32+
var (
33+
slug string
34+
interval time.Duration
35+
llmURL url.URL
36+
)
37+
cmd := &serpent.Command{
38+
Use: "report-status",
39+
Short: "Report status of the currently running task to Coder.",
40+
Handler: func(inv *serpent.Invocation) error {
41+
ctx := inv.Context()
42+
43+
// This is meant to run in a workspace, so instead of a regular client we
44+
// need a workspace agent client to update the status in coderd.
45+
agentClient, err := r.createAgentClient()
46+
if err != nil {
47+
return err
48+
}
49+
50+
// We also need an agentapi client to get the LLM agent's current status.
51+
llmClient, err := agentapi.NewClient(llmURL.String())
52+
if err != nil {
53+
return err
54+
}
55+
56+
notifyCtx, notifyCancel := inv.SignalNotifyContext(ctx, StopSignals...)
57+
defer notifyCancel()
58+
59+
outerLoop:
60+
for {
61+
res, err := llmClient.GetStatus(notifyCtx)
62+
if err != nil && !errors.Is(err, context.Canceled) {
63+
cliui.Warnf(inv.Stderr, "failed to fetch status: %s", err)
64+
} else {
65+
// Currently we only update the status, which leaves the last summary
66+
// (if any) untouched. If we do want to update the summary here, we
67+
// will need to fetch the messages and generate one.
68+
status := codersdk.WorkspaceAppStatusStateWorking
69+
switch res.Status {
70+
case agentapigen.Stable: // Stable == idle == done
71+
status = codersdk.WorkspaceAppStatusStateComplete
72+
case agentapigen.Running: // Running == working
73+
}
74+
err = agentClient.PatchAppStatus(notifyCtx, agentsdk.PatchAppStatus{
75+
AppSlug: slug,
76+
State: status,
77+
})
78+
if err != nil && !errors.Is(err, context.Canceled) {
79+
cliui.Warnf(inv.Stderr, "failed to update status: %s", err)
80+
}
81+
}
82+
83+
timer := time.NewTimer(interval)
84+
select {
85+
case <-notifyCtx.Done():
86+
timer.Stop()
87+
break outerLoop
88+
case <-timer.C:
89+
}
90+
}
91+
92+
return nil
93+
},
94+
Options: []serpent.Option{
95+
{
96+
Flag: "app-slug",
97+
Description: "The app slug to use when reporting the status.",
98+
Env: "CODER_MCP_APP_STATUS_SLUG",
99+
Required: true,
100+
Value: serpent.StringOf(&slug),
101+
},
102+
{
103+
Flag: "agentapi-url",
104+
Description: "The URL of the LLM agent API.",
105+
Env: "CODER_AGENTAPI_URL",
106+
Required: true,
107+
Value: serpent.URLOf(&llmURL),
108+
},
109+
{
110+
Flag: "interval",
111+
Description: "The interval on which to poll for the status.",
112+
Env: "CODER_APP_STATUS_INTERVAL",
113+
Default: "30s",
114+
Value: serpent.DurationOf(&interval),
115+
},
116+
},
117+
}
118+
return cmd
119+
}

cli/exp_task_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package cli_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
agentapi "github.com/coder/agentapi-sdk-go"
15+
agentapigen "github.com/coder/agentapi-sdk-go/gen"
16+
"github.com/coder/coder/v2/cli/clitest"
17+
"github.com/coder/coder/v2/coderd/httpapi"
18+
"github.com/coder/coder/v2/codersdk"
19+
"github.com/coder/coder/v2/codersdk/agentsdk"
20+
"github.com/coder/coder/v2/pty/ptytest"
21+
"github.com/coder/coder/v2/testutil"
22+
)
23+
24+
func TestExpTask(t *testing.T) {
25+
t.Parallel()
26+
27+
tests := []struct {
28+
name string
29+
resp *codersdk.Response
30+
status *agentapi.GetStatusResponse
31+
expected codersdk.WorkspaceAppStatusState
32+
}{
33+
{
34+
name: "ReportWorking",
35+
resp: nil,
36+
status: &agentapi.GetStatusResponse{
37+
Status: agentapigen.Running,
38+
},
39+
expected: codersdk.WorkspaceAppStatusStateWorking,
40+
},
41+
{
42+
name: "ReportComplete",
43+
resp: nil,
44+
status: &agentapi.GetStatusResponse{
45+
Status: agentapigen.Stable,
46+
},
47+
expected: codersdk.WorkspaceAppStatusStateComplete,
48+
},
49+
{
50+
name: "ReportUpdateError",
51+
resp: &codersdk.Response{
52+
Message: "Failed to get workspace app.",
53+
Detail: "This is a test failure.",
54+
},
55+
status: &agentapi.GetStatusResponse{
56+
Status: agentapigen.Stable,
57+
},
58+
expected: codersdk.WorkspaceAppStatusStateComplete,
59+
},
60+
{
61+
name: "ReportStatusError",
62+
resp: nil,
63+
status: nil,
64+
expected: codersdk.WorkspaceAppStatusStateComplete,
65+
},
66+
}
67+
68+
for _, test := range tests {
69+
test := test
70+
t.Run(test.name, func(t *testing.T) {
71+
t.Parallel()
72+
done := make(chan codersdk.WorkspaceAppStatusState)
73+
74+
// A mock server for coderd.
75+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
76+
body, err := io.ReadAll(r.Body)
77+
require.NoError(t, err)
78+
_ = r.Body.Close()
79+
80+
var req agentsdk.PatchAppStatus
81+
err = json.Unmarshal(body, &req)
82+
require.NoError(t, err)
83+
84+
if test.resp != nil {
85+
httpapi.Write(context.Background(), w, http.StatusBadRequest, test.resp)
86+
} else {
87+
httpapi.Write(context.Background(), w, http.StatusOK, nil)
88+
}
89+
done <- req.State
90+
}))
91+
t.Cleanup(srv.Close)
92+
agentURL := srv.URL
93+
94+
// Another mock server for the LLM agent API.
95+
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
96+
if test.status != nil {
97+
httpapi.Write(context.Background(), w, http.StatusOK, test.status)
98+
} else {
99+
httpapi.Write(context.Background(), w, http.StatusBadRequest, nil)
100+
}
101+
}))
102+
t.Cleanup(srv.Close)
103+
agentapiURL := srv.URL
104+
105+
inv, _ := clitest.New(t, "--agent-url", agentURL, "exp", "task", "report-status",
106+
"--app-slug", "claude-code",
107+
"--agentapi-url", agentapiURL)
108+
stdout := ptytest.New(t)
109+
inv.Stdout = stdout.Output()
110+
stderr := ptytest.New(t)
111+
inv.Stderr = stderr.Output()
112+
113+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
114+
t.Cleanup(cancel)
115+
116+
go func() {
117+
err := inv.WithContext(ctx).Run()
118+
assert.NoError(t, err)
119+
}()
120+
121+
// Should only try to update the status if we got one.
122+
if test.status == nil {
123+
stderr.ExpectMatch("failed to fetch status")
124+
} else {
125+
got := <-done
126+
require.Equal(t, got, test.expected)
127+
}
128+
129+
// Non-nil for the update means there was an error.
130+
if test.resp != nil {
131+
stderr.ExpectMatch("failed to update status")
132+
}
133+
})
134+
}
135+
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,7 @@ require (
510510
github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect
511511
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
512512
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 // indirect
513+
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 // indirect
513514
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da // indirect
514515
github.com/envoyproxy/go-control-plane/envoy v1.32.4 // indirect
515516
github.com/envoyproxy/protoc-gen-validate v1.2.1 // indirect
@@ -523,6 +524,7 @@ require (
523524
github.com/samber/lo v1.50.0 // indirect
524525
github.com/spiffe/go-spiffe/v2 v2.5.0 // indirect
525526
github.com/tidwall/sjson v1.2.5 // indirect
527+
github.com/tmaxmax/go-sse v0.10.0 // indirect
526528
github.com/ulikunitz/xz v0.5.12 // indirect
527529
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
528530
github.com/zeebo/xxh3 v1.0.2 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -894,6 +894,8 @@ github.com/cncf/xds/go v0.0.0-20230105202645-06c439db220b/go.mod h1:eXthEFrGJvWH
894894
github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
895895
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42 h1:Om6kYQYDUk5wWbT0t0q6pvyM49i9XZAv9dDrkDA7gjk=
896896
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
897+
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8=
898+
github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4=
897899
github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41 h1:SBN/DA63+ZHwuWwPHPYoCZ/KLAjHv5g4h2MS4f2/MTI=
898900
github.com/coder/bubbletea v1.2.2-0.20241212190825-007a1cdb2c41/go.mod h1:I9ULxr64UaOSUv7hcb3nX4kowodJCVS7vt7VVJk/kW4=
899901
github.com/coder/clistat v1.0.0 h1:MjiS7qQ1IobuSSgDnxcCSyBPESs44hExnh2TEqMcGnA=
@@ -1827,6 +1829,8 @@ github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZ
18271829
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
18281830
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
18291831
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
1832+
github.com/tmaxmax/go-sse v0.10.0 h1:j9F93WB4Hxt8wUf6oGffMm4dutALvUPoDDxfuDQOSqA=
1833+
github.com/tmaxmax/go-sse v0.10.0/go.mod h1:u/2kZQR1tyngo1lKaNCj1mJmhXGZWS1Zs5yiSOD+Eg8=
18301834
github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a h1:eg5FkNoQp76ZsswyGZ+TjYqA/rhKefxK8BW7XOlQsxo=
18311835
github.com/u-root/gobusybox/src v0.0.0-20240225013946-a274a8d5d83a/go.mod h1:e/8TmrdreH0sZOw2DFKBaUV7bvDWRq6SeM9PzkuVM68=
18321836
github.com/u-root/u-root v0.14.0 h1:Ka4T10EEML7dQ5XDvO9c3MBN8z4nuSnGjcd1jmU2ivg=

0 commit comments

Comments
 (0)