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
82 changes: 82 additions & 0 deletions pkg/cmd/coop/coop_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/spf13/cobra"

"github.com/stripe/stripe-cli/pkg/coop"
"github.com/stripe/stripe-cli/pkg/coop/followups"
"github.com/stripe/stripe-cli/pkg/coop/helpers"
"github.com/stripe/stripe-cli/pkg/coop/workflow"
)
Expand All @@ -28,6 +29,8 @@ type coopAgentActionCmd struct {
passed bool

completed string
action string
target string
}

func newCoopAgentCmd() *coopAgentCmd {
Expand All @@ -43,6 +46,7 @@ func newCoopAgentCmd() *coopAgentCmd {
ac.cmd.AddCommand(newCoopAgentSkipCmd().cmd)
ac.cmd.AddCommand(newCoopAgentAwaitReviewCmd().cmd)
ac.cmd.AddCommand(newCoopAgentNextActionCmd().cmd)
ac.cmd.AddCommand(newCoopAgentStartFollowupCmd().cmd)
return ac
}

Expand Down Expand Up @@ -164,6 +168,23 @@ func newCoopAgentNextActionCmd() *coopAgentActionCmd {
return c
}

func newCoopAgentStartFollowupCmd() *coopAgentActionCmd {
c := &coopAgentActionCmd{}
c.cmd = &cobra.Command{
Use: "start-followup",
Short: "Start an internal guided follow-up session",
RunE: func(cmd *cobra.Command, args []string) error {
return runCoopStartFollowup(c.session, c.action, c.target)
},
}
c.cmd.Flags().StringVar(&c.session, "session", "", "Parent session ID")
c.cmd.Flags().StringVar(&c.action, "action", "", "Follow-up action ID")
c.cmd.Flags().StringVar(&c.target, "target", "", "Detected deployment target")
mustMarkFlagRequired(c.cmd, "session")
mustMarkFlagRequired(c.cmd, "action")
return c
}

func (c *coopAgentActionCmd) addSessionStepFlags() {
c.cmd.Flags().StringVar(&c.session, "session", "", "Session ID")
c.cmd.Flags().IntVar(&c.step, "step", 0, "1-based node number")
Expand Down Expand Up @@ -205,6 +226,67 @@ func nextActionHint(sessionID string) string {
return fmt.Sprintf("stripe coop agent next-action --session=%s", sessionID)
}

func runCoopStartFollowup(parentSessionID, actionID, target string) error {
store, err := coop.NewStore(coopConfigFolder())
if err != nil {
return fmt.Errorf("creating store: %w", err)
}

parent, err := store.Read(parentSessionID)
Comment thread
tomer-stripe marked this conversation as resolved.
if err != nil {
return outputCoopError(fmt.Sprintf("Parent session %q not found.", parentSessionID), "stripe coop agent next-action --session=<session>")
}

action, err := followups.GuidedActionByID(actionID, target)
if err != nil {
return outputCoopError(err.Error(), "stripe coop agent start-followup --session=<session> --action=deploy")
}
if err := validateFollowupParent(parent, action.ID); err != nil {
return outputCoopError(err.Error(), "stripe coop agent next-action --session="+parent.ID)
}

settings := make(map[string]string, len(parent.Settings)+1)
for key, value := range parent.Settings {
settings[key] = value
}
if target != "" {
settings["deploy_target"] = target
}

sessionID := "coop_" + generateShortID()
session := coop.NewSessionFromGuidedAction(action, sessionID, coop.GuidedActionSessionOptions{
ParentSessionID: parent.ID,
ParentStepID: action.ID,
Settings: settings,
UsedSandbox: parent.UsedSandbox || coopSandboxClaimURL() != "",
})
if err := store.Write(session); err != nil {
return fmt.Errorf("writing guided follow-up session: %w", err)
}

return outputJSON(newCoopAgentGuidedActionResponse(action, session))
}

func validateFollowupParent(parent *coop.Session, actionID string) error {
if parent.Status != coop.SessionCompleted {
return fmt.Errorf("parent session %q is not completed", parent.ID)
}
if parent.NextSteps == nil {
return fmt.Errorf("parent session %q has no next-step suggestions", parent.ID)
}
for _, completed := range parent.NextSteps.Completed {
if completed == actionID {
return fmt.Errorf("follow-up action %q is already completed for parent session %q", actionID, parent.ID)
}
}
for _, suggestion := range parent.NextSteps.Suggestions {
if suggestion.ID == actionID {
return nil
}
}
return fmt.Errorf("follow-up action %q is not available for parent session %q", actionID, parent.ID)
}

func outputAgentResponse(resp coop.CommandResponse, err error) error {
if err != nil {
return err
Expand Down
186 changes: 186 additions & 0 deletions pkg/cmd/coop/coop_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,192 @@ func TestCoopAgentNextActionReturnsStructuredErrorForHelperFailure(t *testing.T)
assert.Equal(t, "stripe coop agent next-action --session=agent_test_session", resp.Hint)
}

func TestCoopAgentStartFollowupCreatesGuidedSession(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
store, err := coop.NewStore(coopConfigFolder())
require.NoError(t, err)
parent := &coop.Session{
SchemaVersion: coop.CurrentSessionSchemaVersion,
ID: "parent_session",
Blueprint: "one-time-payment",
Status: coop.SessionCompleted,
Settings: map[string]string{"language": "node"},
NextSteps: &coop.NextStepsState{
Suggestions: []coop.NextStepSuggestion{
{ID: "deploy-update", Title: "Deploy your changes"},
},
},
}
require.NoError(t, store.Write(parent))

cmd := newCoopAgentStartFollowupCmd().cmd
cmd.SetArgs([]string{"--session", parent.ID, "--action", "deploy-update", "--target", "Vercel"})

output := captureStdout(t, func() {
require.NoError(t, cmd.Execute())
})

var resp coopAgentRunResponse
require.NoError(t, json.Unmarshal([]byte(output), &resp))
require.True(t, resp.OK)
assert.Contains(t, resp.Message, "Deploy your changes")
assert.Contains(t, resp.Next, "stripe coop agent start-work")
assert.Contains(t, resp.AgentInstructions, "guided co-op follow-up")
assert.Contains(t, resp.AgentInstructions, "Vercel")
require.Len(t, resp.Nodes, 3)
assert.Equal(t, "Inspect existing deploy config", resp.Nodes[0].Title)

ids, err := store.List()
require.NoError(t, err)
require.Len(t, ids, 2)

var child *coop.Session
for _, id := range ids {
if id == parent.ID {
continue
}
child, err = store.Read(id)
require.NoError(t, err)
}
require.NotNil(t, child)
assert.Equal(t, "Deploy your changes", child.Blueprint)
assert.Equal(t, parent.ID, child.ParentSessionID)
assert.Equal(t, "deploy-update", child.ParentStepID)
assert.Equal(t, "node", child.Settings["language"])
assert.Equal(t, "Vercel", child.Settings["deploy_target"])
assert.Equal(t, "deploy-update", child.Settings["guided_action"])
assert.Len(t, child.Steps, 3)
}

func TestCoopAgentStartFollowupRequiresCompletedParent(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
store, err := coop.NewStore(coopConfigFolder())
require.NoError(t, err)
parent := &coop.Session{
SchemaVersion: coop.CurrentSessionSchemaVersion,
ID: "parent_session",
Status: coop.SessionActive,
NextSteps: &coop.NextStepsState{
Suggestions: []coop.NextStepSuggestion{
{ID: "deploy", Title: "Deploy with Stripe Projects"},
},
},
}
require.NoError(t, store.Write(parent))

cmd := newCoopAgentStartFollowupCmd().cmd
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--session", parent.ID, "--action", "deploy"})

stderr := captureStderr(t, func() {
err := cmd.Execute()
require.Error(t, err)
assert.IsType(t, RenderedError{}, err)
})

var resp coop.CommandResponse
require.NoError(t, json.Unmarshal([]byte(stderr), &resp))
assert.False(t, resp.OK)
assert.Contains(t, resp.Error, `parent session "parent_session" is not completed`)
}

func TestCoopAgentStartFollowupRequiresSuggestedAction(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
store, err := coop.NewStore(coopConfigFolder())
require.NoError(t, err)
parent := &coop.Session{
SchemaVersion: coop.CurrentSessionSchemaVersion,
ID: "parent_session",
Status: coop.SessionCompleted,
NextSteps: &coop.NextStepsState{
Suggestions: []coop.NextStepSuggestion{
{ID: "summarize", Title: "Write a STRIPE.md summary"},
},
},
}
require.NoError(t, store.Write(parent))

cmd := newCoopAgentStartFollowupCmd().cmd
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--session", parent.ID, "--action", "deploy"})

stderr := captureStderr(t, func() {
err := cmd.Execute()
require.Error(t, err)
assert.IsType(t, RenderedError{}, err)
})

var resp coop.CommandResponse
require.NoError(t, json.Unmarshal([]byte(stderr), &resp))
assert.False(t, resp.OK)
assert.Contains(t, resp.Error, `follow-up action "deploy" is not available for parent session "parent_session"`)
}

func TestCoopAgentStartFollowupRejectsCompletedAction(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
store, err := coop.NewStore(coopConfigFolder())
require.NoError(t, err)
parent := &coop.Session{
SchemaVersion: coop.CurrentSessionSchemaVersion,
ID: "parent_session",
Status: coop.SessionCompleted,
NextSteps: &coop.NextStepsState{
Suggestions: []coop.NextStepSuggestion{
{ID: "deploy", Title: "Deploy with Stripe Projects"},
},
Completed: []string{"deploy"},
},
}
require.NoError(t, store.Write(parent))

cmd := newCoopAgentStartFollowupCmd().cmd
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--session", parent.ID, "--action", "deploy"})

stderr := captureStderr(t, func() {
err := cmd.Execute()
require.Error(t, err)
assert.IsType(t, RenderedError{}, err)
})

var resp coop.CommandResponse
require.NoError(t, json.Unmarshal([]byte(stderr), &resp))
assert.False(t, resp.OK)
assert.Contains(t, resp.Error, `follow-up action "deploy" is already completed for parent session "parent_session"`)
}

func TestCoopAgentStartFollowupRejectsUnknownAction(t *testing.T) {
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
store, err := coop.NewStore(coopConfigFolder())
require.NoError(t, err)
parent := &coop.Session{
SchemaVersion: coop.CurrentSessionSchemaVersion,
ID: "parent_session",
Status: coop.SessionCompleted,
}
require.NoError(t, store.Write(parent))

cmd := newCoopAgentStartFollowupCmd().cmd
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs([]string{"--session", parent.ID, "--action", "unknown"})

stderr := captureStderr(t, func() {
err := cmd.Execute()
require.Error(t, err)
assert.IsType(t, RenderedError{}, err)
})

var resp coop.CommandResponse
require.NoError(t, json.Unmarshal([]byte(stderr), &resp))
assert.False(t, resp.OK)
assert.Contains(t, resp.Error, `guided action "unknown" not found`)
assert.Equal(t, "stripe coop agent start-followup --session=<session> --action=deploy", resp.Hint)
}

type nextActionErrorStore struct {
session *coop.Session
}
Expand Down
26 changes: 21 additions & 5 deletions pkg/cmd/coop/coop_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ func (rc *coopAgentRunCmd) runCmd(cmd *cobra.Command, args []string) error {
}

func newCoopAgentRunResponse(bp *coop.Blueprint, session *coop.Session) coopAgentRunResponse {
return newCoopAgentSessionResponse(bp.Title, session, agentInstructions(bp, session))
}

func newCoopAgentGuidedActionResponse(action *coop.GuidedAction, session *coop.Session) coopAgentRunResponse {
return newCoopAgentSessionResponse(action.Title, session, guidedActionAgentInstructions(action, session))
}

func newCoopAgentSessionResponse(title string, session *coop.Session, instructions string) coopAgentRunResponse {
var nodes []nodeBrief
nodeNumber := 0
for _, step := range session.Steps {
Expand All @@ -101,10 +109,10 @@ func newCoopAgentRunResponse(bp *coop.Blueprint, session *coop.Session) coopAgen
SessionID: session.ID,
Node: 1,
State: "created",
Message: fmt.Sprintf("Session started: %s (%d nodes)", bp.Title, session.TotalNodes()),
Message: fmt.Sprintf("Session started: %s (%d nodes)", title, session.TotalNodes()),
Next: fmt.Sprintf("stripe coop agent start-work --session=%s --step=1 --note=%s", session.ID, quoteArg("Beginning: "+session.Steps[0].Nodes[0].Title)),
},
AgentInstructions: agentInstructions(bp, session),
AgentInstructions: instructions,
Nodes: nodes,
}
return resp
Expand Down Expand Up @@ -165,7 +173,15 @@ type nodeBrief struct {

func agentInstructions(bp *coop.Blueprint, session *coop.Session) string {
preamble := fmt.Sprintf("You are building a working Stripe integration: %q", bp.Title)
return sessionLifecycleInstructions(preamble, session)
}

func guidedActionAgentInstructions(action *coop.GuidedAction, session *coop.Session) string {
preamble := fmt.Sprintf("You are completing a guided co-op follow-up: %q.\n\n%s", action.Title, action.AgentContext)
return sessionLifecycleInstructions(preamble, session)
}

func sessionLifecycleInstructions(preamble string, session *coop.Session) string {
return fmt.Sprintf(`%s

BEFORE YOU START — ensure you have API access:
Expand All @@ -184,7 +200,7 @@ Each node has a description that tells you what to do. Follow the description

If a node includes review_prompt, that is the baseline acceptance check shown to the human. If it includes review_command, run that exact command when verifying or explain why it does not apply. Make your implementation note and verifications directly answer these fields. When you add verification checks, write them as useful confirmation guidance for the human too: include concrete actions and expected results, such as "Visit http://localhost:3000/checkout, click Pay, and confirm the browser redirects to Stripe Checkout" rather than vague labels like "manual test passed".

Node 1 is always "Understand the project" — scan files, identify the tech stack, and summarize what you found. This helps you adapt the remaining nodes to the developer's actual setup. Don't ask the developer questions you can answer by reading the code.
If a node asks you to understand the project, scan files, identify the tech stack, and summarize what you found. This helps you adapt the remaining nodes to the developer's actual setup. Don't ask the developer questions you can answer by reading the code.

Agent lifecycle commands (use this session id: %s):
1. stripe coop agent start-work --session=%s --step=<n> --note="<what you're about to do>"
Expand All @@ -194,7 +210,7 @@ Agent lifecycle commands (use this session id: %s):
5. Follow the JSON response's next command. Most nodes continue to the next node in the same step.
6. Only run stripe coop agent await-review --session=%s --step=<n> when the response says the step is ready for review. Await blocks until the human confirms the step or requests changes.
7. If confirmed: move to next node. If rejected: redo the affected node (check the message for feedback).
8. When the final node is confirmed: IMMEDIATELY run "stripe coop agent next-action --session=%s". Do not stop or ask — just run it. It shows the developer their options in the TUI and blocks until they choose.
8. When the final node is confirmed: IMMEDIATELY run the JSON response's next command. Do not stop or ask. It will return to the parent session for follow-up work or show the developer their options in the TUI.

Steps are the default human-review unit. Build and verify each node one at a time, but do not interrupt the developer for every node. At the end of each step, before running await, help the developer verify the step: run relevant review_command values, start any needed app/server, keep useful processes running, share the local URL or command to open it, create or identify test data, and explain exactly what observable result they should confirm. Add these concrete user-facing checks with stripe coop agent report-check --session=%s --step=<n> --check="..." --passed so the review card has useful evidence.

Expand All @@ -209,7 +225,7 @@ Important:
- If a node doesn't apply to the user's setup, skip it: stripe coop agent skip --session=%s --step=<n> --note="<reason>"
- Always install the LATEST version of the Stripe SDK for the language in use. Do not pin to old versions.
Examples: "npm install stripe@latest", "pip install --upgrade stripe", "gem install stripe"
Check https://docs.stripe.com/libraries for current versions if unsure.`, preamble, session.ID, session.ID, session.ID, session.ID, session.ID, session.ID, session.ID, session.ID)
Check https://docs.stripe.com/libraries for current versions if unsure.`, preamble, session.ID, session.ID, session.ID, session.ID, session.ID, session.ID, session.ID)
}

func outputJSON(v interface{}) error {
Expand Down
Loading
Loading