relay-flow 0.2.3-alpha → 0.2.4-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +116 -10
  2. package/cmd/relay-flow/main.go +2 -0
  3. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  4. package/cmd/relay-flow/serve.go +2 -0
  5. package/examples/beads-workflow.yaml +3 -3
  6. package/examples/config-reference.yaml +144 -0
  7. package/examples/minimal-beads-task-workflow.yaml +34 -0
  8. package/examples/minimal-jira-task-workflow.yaml +68 -0
  9. package/examples/workflow-reference.yaml +111 -0
  10. package/internal/harness/opencode/opencode_test.go +1 -1
  11. package/internal/harness/opencode/repo_setup.go +1 -1
  12. package/internal/harness/pi/config_test.go +46 -0
  13. package/internal/harness/pi/lifecycle_test.go +69 -0
  14. package/internal/harness/pi/pi.go +261 -0
  15. package/internal/harness/pi/pi_test.go +322 -0
  16. package/internal/harness/pi/prompt_test.go +121 -0
  17. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  18. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  19. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  20. package/internal/harness/pi/validation_test.go +168 -0
  21. package/internal/runner/herdr/baseref.go +60 -0
  22. package/internal/runner/herdr/herdr.go +578 -0
  23. package/internal/runner/herdr/herdr_test.go +688 -0
  24. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  25. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  26. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  27. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  28. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  29. package/internal/runner/herdr/herdrcli/response.go +116 -0
  30. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  31. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  32. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  33. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  34. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  35. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  36. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  37. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  38. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  39. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  40. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  41. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  42. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  43. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  44. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  45. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  46. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  47. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  48. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  49. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  50. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  51. package/internal/task/beads/beads.go +0 -16
  52. package/internal/task/beads/config_compatibility_test.go +7 -8
  53. package/internal/task/jira/filters_test.go +32 -6
  54. package/internal/task/jira/jira.go +27 -17
  55. package/package.json +1 -1
@@ -0,0 +1,46 @@
1
+ package pi
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/config"
8
+ "github.com/rajpopat27/relay-flow/internal/harness"
9
+ )
10
+
11
+ func TestPiHarnessConfigUsesDocumentedDefaults(t *testing.T) {
12
+ defaults, err := harness.Defaults("pi")
13
+ if err != nil {
14
+ t.Fatalf("harness.Defaults(pi): %v", err)
15
+ }
16
+ if defaults["initial"] != defaultInitialPrompt {
17
+ t.Fatalf("initial default = %#v, want %#v", defaults["initial"], defaultInitialPrompt)
18
+ }
19
+ if defaults["feedback"] != defaultFeedbackPrompt {
20
+ t.Fatalf("feedback default = %#v, want %#v", defaults["feedback"], defaultFeedbackPrompt)
21
+ }
22
+ if _, ok := defaults["hitl"]; ok {
23
+ t.Fatalf("Pi defaults unexpectedly contain a HITL prompt: %#v", defaults["hitl"])
24
+ }
25
+ }
26
+
27
+ func TestPiHarnessConfigRejectsNonTemplateFields(t *testing.T) {
28
+ for _, field := range []string{"hitl", "agent", "model"} {
29
+ t.Run(field, func(t *testing.T) {
30
+ _, err := harness.New("pi", config.RawValues{field: "unsupported"})
31
+ if err == nil {
32
+ t.Fatalf("Pi harness accepted unsupported config field %q", field)
33
+ }
34
+ if !strings.Contains(err.Error(), field) {
35
+ t.Fatalf("error = %q, want field name %q", err, field)
36
+ }
37
+ })
38
+ }
39
+ }
40
+
41
+ func TestPiHarnessConfigRejectsExplicitNullTemplate(t *testing.T) {
42
+ _, err := harness.New("pi", config.RawValues{"initial": nil})
43
+ if err == nil || !strings.Contains(err.Error(), "explicit null") {
44
+ t.Fatalf("explicit null template error = %v", err)
45
+ }
46
+ }
@@ -0,0 +1,69 @@
1
+ package pi
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "reflect"
8
+ "testing"
9
+
10
+ "github.com/rajpopat27/relay-flow/internal/harness"
11
+ )
12
+
13
+ func TestPiSetupRepoIsSideEffectFree(t *testing.T) {
14
+ repoPath := t.TempDir()
15
+ sentinelPath := filepath.Join(repoPath, "existing.txt")
16
+ sentinel := []byte("leave this repository unchanged")
17
+ if err := os.WriteFile(sentinelPath, sentinel, 0o640); err != nil {
18
+ t.Fatal(err)
19
+ }
20
+
21
+ h := newPiHarness(t)
22
+ if err := h.SetupRepo(context.Background(), repoPath); err != nil {
23
+ t.Fatalf("SetupRepo: %v", err)
24
+ }
25
+
26
+ data, err := os.ReadFile(sentinelPath)
27
+ if err != nil {
28
+ t.Fatalf("read sentinel: %v", err)
29
+ }
30
+ if !reflect.DeepEqual(data, sentinel) {
31
+ t.Fatalf("SetupRepo changed existing repository content: %q", data)
32
+ }
33
+ for _, name := range []string{"opencode.json", "opencode.jsonc", ".pi"} {
34
+ if _, err := os.Stat(filepath.Join(repoPath, name)); !os.IsNotExist(err) {
35
+ t.Fatalf("SetupRepo created %q: %v", name, err)
36
+ }
37
+ }
38
+ }
39
+
40
+ func TestPiFindSessionDoesNotDiscoverByTitle(t *testing.T) {
41
+ missingRepo := filepath.Join(t.TempDir(), "not-a-repository")
42
+ h := newPiHarness(t)
43
+
44
+ session, ok, err := h.FindSession(context.Background(), missingRepo, "PAY-101:implement")
45
+ if err != nil {
46
+ t.Fatalf("FindSession: %v", err)
47
+ }
48
+ if ok {
49
+ t.Fatalf("FindSession found an unrequested session: %+v", session)
50
+ }
51
+ if session != (harness.Session{}) {
52
+ t.Fatalf("FindSession returned session data: %+v", session)
53
+ }
54
+ }
55
+
56
+ func TestPiResumeUsesOnlyPersistedLaunchSpecSessionID(t *testing.T) {
57
+ spec := launchSpec(t)
58
+ spec.RepoPath = filepath.Join(t.TempDir(), "missing-ticket-environment")
59
+ spec.ResumeID = "session-123"
60
+
61
+ cmd, err := newPiHarness(t).BuildCommand(spec)
62
+ if err != nil {
63
+ t.Fatalf("BuildCommand: %v", err)
64
+ }
65
+ want := []string{"--name", spec.Title, "--session-id", spec.ResumeID, spec.Prompt}
66
+ if !reflect.DeepEqual(cmd.Args, want) {
67
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
68
+ }
69
+ }
@@ -0,0 +1,261 @@
1
+ // Package pi is the built-in launch-time Harness adapter for the Pi coding
2
+ // agent. Pi supplies one built-in coding agent, represented by the logical
3
+ // relay-flow agent name "default", plus repository-owned role prompts under
4
+ // .pi/roles. The runtime extension is installed by the user and owns report
5
+ // parsing and delivery.
6
+ package pi
7
+
8
+ import (
9
+ "context"
10
+ "encoding/json"
11
+ "fmt"
12
+ "os"
13
+ "os/exec"
14
+ "path/filepath"
15
+ "regexp"
16
+ "strings"
17
+
18
+ "github.com/rajpopat27/relay-flow/internal/config"
19
+ "github.com/rajpopat27/relay-flow/internal/harness"
20
+ "github.com/rajpopat27/relay-flow/internal/runner"
21
+ )
22
+
23
+ const (
24
+ defaultInitialPrompt = `Task system: {{taskSystem}}
25
+ Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
26
+
27
+ Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.`
28
+ defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
29
+ )
30
+
31
+ var promptVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
32
+
33
+ var knownPromptVars = map[string]bool{
34
+ "taskSystem": true, "ticket": true, "workflow": true, "repo": true,
35
+ "node": true, "nodeType": true, "agent": true, "nodeDescription": true,
36
+ "nextSteps": true, "mailbox": true,
37
+ }
38
+
39
+ // Config is the adapter-owned root harnessConfig. Pi exposes no harness
40
+ // prompt for HITL approval because approval is performed through Pi's host
41
+ // UI by the runtime extension.
42
+ type Config struct {
43
+ Initial string `yaml:"initial"`
44
+ Feedback string `yaml:"feedback"`
45
+ }
46
+
47
+ // DefaultConfig is written by relay-flow init and fills omitted values when
48
+ // configuration is loaded through the harness factory.
49
+ func DefaultConfig() config.RawValues {
50
+ return config.RawValues{
51
+ "initial": defaultInitialPrompt,
52
+ "feedback": defaultFeedbackPrompt,
53
+ }
54
+ }
55
+
56
+ func init() {
57
+ harness.Register("pi", harness.Factory{
58
+ DefaultConfig: DefaultConfig,
59
+ New: func(raw config.RawValues) (harness.Harness, error) {
60
+ var cfg Config
61
+ if err := config.DecodeStrict(raw, &cfg); err != nil {
62
+ return nil, fmt.Errorf("pi harnessConfig: %w", err)
63
+ }
64
+ for name, tmpl := range map[string]string{
65
+ "initial": cfg.Initial,
66
+ "feedback": cfg.Feedback,
67
+ } {
68
+ if err := validateTemplate(tmpl); err != nil {
69
+ return nil, fmt.Errorf("pi harnessConfig templates.%s: %w", name, err)
70
+ }
71
+ }
72
+ return New(cfg), nil
73
+ },
74
+ })
75
+ }
76
+
77
+ // Harness implements harness.Harness for Pi.
78
+ type Harness struct {
79
+ templates Config
80
+ }
81
+
82
+ // New returns the production Pi harness. The no-argument form uses the
83
+ // adapter defaults; factory construction supplies an explicitly decoded
84
+ // configuration.
85
+ func New(cfg ...Config) *Harness {
86
+ if len(cfg) > 0 {
87
+ return &Harness{templates: cfg[0]}
88
+ }
89
+ return &Harness{templates: Config{
90
+ Initial: defaultInitialPrompt,
91
+ Feedback: defaultFeedbackPrompt,
92
+ }}
93
+ }
94
+
95
+ // SetupRepo is intentionally a no-op. The relay-flow Pi runtime extension is
96
+ // installed manually in Pi's global package settings rather than configured
97
+ // in each repository.
98
+ func (*Harness) SetupRepo(context.Context, string) error { return nil }
99
+
100
+ // ValidateAgent accepts Pi's built-in logical agent or a repository role
101
+ // prompt. Pi has no named-agent listing API, so custom roles are verified by
102
+ // checking for a readable, non-empty .pi/roles/<agent>.md file.
103
+ func (*Harness) ValidateAgent(_ context.Context, repoPath, agent string) error {
104
+ if _, err := resolveRolePrompt(repoPath, agent); err != nil {
105
+ return err
106
+ }
107
+ if _, err := exec.LookPath("pi"); err != nil {
108
+ return fmt.Errorf("pi: executable unavailable: %w", err)
109
+ }
110
+ return nil
111
+ }
112
+
113
+ func resolveRolePrompt(repoPath, agent string) (string, error) {
114
+ if agent == "default" {
115
+ return "", nil
116
+ }
117
+ if strings.TrimSpace(agent) == "" || strings.TrimSpace(agent) != agent || strings.ContainsAny(agent, `/\\`) || agent == "." || agent == ".." {
118
+ return "", fmt.Errorf("pi: invalid role %q", agent)
119
+ }
120
+ if repoPath == "" {
121
+ return "", fmt.Errorf("pi: role %q requires a repository path", agent)
122
+ }
123
+ root, err := filepath.Abs(repoPath)
124
+ if err != nil {
125
+ return "", fmt.Errorf("pi: resolve repository path for role %q: %w", agent, err)
126
+ }
127
+ path := filepath.Join(root, ".pi", "roles", agent+".md")
128
+ info, err := os.Stat(path)
129
+ if err != nil {
130
+ if os.IsNotExist(err) {
131
+ return "", fmt.Errorf("pi: role %q is unavailable; expected %s", agent, path)
132
+ }
133
+ return "", fmt.Errorf("pi: inspect role %q: %w", agent, err)
134
+ }
135
+ if !info.Mode().IsRegular() {
136
+ return "", fmt.Errorf("pi: role %q is not a regular file: %s", agent, path)
137
+ }
138
+ contents, err := os.ReadFile(path)
139
+ if err != nil {
140
+ return "", fmt.Errorf("pi: read role %q: %w", agent, err)
141
+ }
142
+ if strings.TrimSpace(string(contents)) == "" {
143
+ return "", fmt.Errorf("pi: role %q is empty: %s", agent, path)
144
+ }
145
+ return path, nil
146
+ }
147
+
148
+ // FindSession is intentionally discovery-free. Normal execution resumes only
149
+ // the Pi session ID persisted by runtime registration.
150
+ func (*Harness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
151
+ return harness.Session{}, false, nil
152
+ }
153
+
154
+ // RenderPrompt renders the selected initial or feedback template and the
155
+ // node's nudge template. HITL approval is not encoded in the prompt; the Pi
156
+ // extension asks for approval through ctx.ui.select.
157
+ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
158
+ var tmpl string
159
+ switch kind {
160
+ case harness.PromptInitial:
161
+ tmpl = h.templates.Initial
162
+ case harness.PromptFeedback:
163
+ tmpl = h.templates.Feedback
164
+ default:
165
+ return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
166
+ }
167
+ return appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data)), nil
168
+ }
169
+
170
+ // BuildCommand returns the interactive Pi invocation. The runner supplies a
171
+ // PTY for Pi's stdin/stdout; the rendered prompt is the final positional argv
172
+ // value. A custom role adds Pi's --append-system-prompt option with the role
173
+ // file from the registered repository. Pi 0.84.1 rejects a bare -- terminator,
174
+ // so none is included. A non-empty ResumeID selects Pi's exact session-id
175
+ // resume option.
176
+ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
177
+ rolePath, err := resolveRolePrompt(spec.RepoPath, spec.Agent)
178
+ if err != nil {
179
+ return runner.Command{}, err
180
+ }
181
+ nextSteps, err := json.Marshal(spec.NextSteps)
182
+ if err != nil {
183
+ return runner.Command{}, fmt.Errorf("pi: marshal next steps: %w", err)
184
+ }
185
+ root, err := relayFlowHome()
186
+ if err != nil {
187
+ return runner.Command{}, err
188
+ }
189
+ env := map[string]string{
190
+ "RELAY_FLOW_HOME": root,
191
+ "RELAY_FLOW_RUN_ID": string(spec.RunID),
192
+ "RELAY_FLOW_WORKFLOW": spec.Workflow,
193
+ "RELAY_FLOW_REPO": spec.RepoName,
194
+ "RELAY_FLOW_TICKET": spec.Ticket,
195
+ "RELAY_FLOW_NODE": spec.Node,
196
+ "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
197
+ "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
198
+ "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
199
+ }
200
+ args := []string{"--name", spec.Title}
201
+ if rolePath != "" {
202
+ args = append(args, "--append-system-prompt", rolePath)
203
+ }
204
+ if spec.ResumeID != "" {
205
+ args = append(args, "--session-id", spec.ResumeID)
206
+ }
207
+ args = append(args, spec.Prompt)
208
+ return runner.Command{
209
+ Executable: "pi",
210
+ Args: args,
211
+ Env: env,
212
+ }, nil
213
+ }
214
+
215
+ func relayFlowHome() (string, error) {
216
+ if root := os.Getenv("RELAY_FLOW_HOME"); root != "" {
217
+ return root, nil
218
+ }
219
+ home, err := os.UserHomeDir()
220
+ if err != nil {
221
+ return "", fmt.Errorf("pi: resolve relay-flow home: %w", err)
222
+ }
223
+ return filepath.Join(home, ".relay-flow"), nil
224
+ }
225
+
226
+ func validateTemplate(tmpl string) error {
227
+ for _, match := range promptVarPattern.FindAllStringSubmatch(tmpl, -1) {
228
+ if !knownPromptVars[match[1]] {
229
+ return fmt.Errorf("unknown template variable {{%s}}", match[1])
230
+ }
231
+ }
232
+ return nil
233
+ }
234
+
235
+ func renderTemplate(tmpl string, data harness.PromptData) string {
236
+ values := map[string]string{
237
+ "taskSystem": data.TaskSystem,
238
+ "ticket": data.Ticket,
239
+ "workflow": data.Workflow,
240
+ "repo": data.Repo,
241
+ "node": data.Node,
242
+ "nodeType": string(data.NodeType),
243
+ "agent": data.Agent,
244
+ "nodeDescription": data.NodeDescription,
245
+ "nextSteps": data.NextSteps,
246
+ "mailbox": data.Mailbox,
247
+ }
248
+ return promptVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
249
+ return values[promptVarPattern.FindStringSubmatch(match)[1]]
250
+ })
251
+ }
252
+
253
+ func appendPrompt(prompt, extra string) string {
254
+ if extra == "" {
255
+ return prompt
256
+ }
257
+ if prompt == "" {
258
+ return extra
259
+ }
260
+ return prompt + "\n\n" + extra
261
+ }
@@ -0,0 +1,322 @@
1
+ package pi
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "os/exec"
7
+ "path/filepath"
8
+ "reflect"
9
+ "strings"
10
+ "testing"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/harness"
13
+ "github.com/rajpopat27/relay-flow/internal/identity"
14
+ "github.com/rajpopat27/relay-flow/internal/runner"
15
+ "github.com/rajpopat27/relay-flow/internal/workflow"
16
+ )
17
+
18
+ // TestBuildCommandUsesStrictPiCLIContract executes the command returned by
19
+ // the adapter against a fake that accepts only the installed Pi 0.84.1 launch
20
+ // shape. The fake is intentionally an executable on PATH rather than a
21
+ // production lookup seam: Pi availability is covered separately, while this
22
+ // test verifies the opaque runner command and its handoff to the ticket
23
+ // environment, including an optional repository role prompt.
24
+ func TestBuildCommandUsesStrictPiCLIContract(t *testing.T) {
25
+ fakeDir, capturePath := strictPiCLI(t)
26
+ t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
27
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
28
+
29
+ base := launchSpec(t)
30
+ ticketEnvironment := t.TempDir()
31
+ nextSteps, err := json.Marshal(base.NextSteps)
32
+ if err != nil {
33
+ t.Fatal(err)
34
+ }
35
+ wantEnv := map[string]string{
36
+ "RELAY_FLOW_HOME": "/var/lib/relay-flow-test",
37
+ "RELAY_FLOW_RUN_ID": string(base.RunID),
38
+ "RELAY_FLOW_WORKFLOW": base.Workflow,
39
+ "RELAY_FLOW_REPO": base.RepoName,
40
+ "RELAY_FLOW_TICKET": base.Ticket,
41
+ "RELAY_FLOW_NODE": base.Node,
42
+ "RELAY_FLOW_NODE_TYPE": string(base.NodeType),
43
+ "RELAY_FLOW_NUDGE_PROMPT": base.NudgePrompt,
44
+ "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
45
+ }
46
+
47
+ tests := []struct {
48
+ name string
49
+ resumeID string
50
+ wantArgs []string
51
+ }{
52
+ {
53
+ name: "fresh",
54
+ wantArgs: []string{
55
+ "--name", "PAY-101:implement",
56
+ "first line\nsecond line",
57
+ },
58
+ },
59
+ {
60
+ name: "resumed",
61
+ resumeID: "session-123",
62
+ wantArgs: []string{
63
+ "--name", "PAY-101:implement",
64
+ "--session-id", "session-123",
65
+ "first line\nsecond line",
66
+ },
67
+ },
68
+ }
69
+
70
+ for _, tt := range tests {
71
+ t.Run(tt.name, func(t *testing.T) {
72
+ spec := base
73
+ spec.ResumeID = tt.resumeID
74
+ cmd, err := newPiHarness(t).BuildCommand(spec)
75
+ if err != nil {
76
+ t.Fatalf("BuildCommand: %v", err)
77
+ }
78
+ if cmd.Executable != "pi" {
79
+ t.Fatalf("Executable = %q, want pi", cmd.Executable)
80
+ }
81
+ if !reflect.DeepEqual(cmd.Args, tt.wantArgs) {
82
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, tt.wantArgs)
83
+ }
84
+ if !reflect.DeepEqual(cmd.Env, wantEnv) {
85
+ t.Fatalf("Env = %#v, want %#v", cmd.Env, wantEnv)
86
+ }
87
+ if _, ok := cmd.Env["RELAY_FLOW_NODE_VISIT_ID"]; ok {
88
+ t.Fatal("command leaked internal RELAY_FLOW_NODE_VISIT_ID")
89
+ }
90
+
91
+ capture := runStrictPi(t, cmd, ticketEnvironment, capturePath)
92
+ if capture.cwd != ticketEnvironment {
93
+ t.Fatalf("fake Pi cwd = %q, want ticket environment %q", capture.cwd, ticketEnvironment)
94
+ }
95
+ if !reflect.DeepEqual(capture.args, tt.wantArgs) {
96
+ t.Fatalf("fake Pi args = %#v, want %#v", capture.args, tt.wantArgs)
97
+ }
98
+ if !reflect.DeepEqual(capture.env, wantEnv) {
99
+ t.Fatalf("fake Pi relay-flow env = %#v, want %#v", capture.env, wantEnv)
100
+ }
101
+ })
102
+ }
103
+ }
104
+
105
+ func TestBuildCommandKeepsDashPrefixedPromptPositional(t *testing.T) {
106
+ fakeDir, capturePath := strictPiCLI(t)
107
+ t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
108
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
109
+
110
+ spec := launchSpec(t)
111
+ spec.Prompt = "- summarize the current changes"
112
+ cmd, err := newPiHarness(t).BuildCommand(spec)
113
+ if err != nil {
114
+ t.Fatalf("BuildCommand: %v", err)
115
+ }
116
+ want := []string{"--name", spec.Title, spec.Prompt}
117
+ if !reflect.DeepEqual(cmd.Args, want) {
118
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
119
+ }
120
+ capture := runStrictPi(t, cmd, t.TempDir(), capturePath)
121
+ if !reflect.DeepEqual(capture.args, want) {
122
+ t.Fatalf("fake Pi args = %#v, want %#v", capture.args, want)
123
+ }
124
+ }
125
+
126
+ func TestBuildCommandAddsExistingRolePrompt(t *testing.T) {
127
+ fakeDir, capturePath := strictPiCLI(t)
128
+ t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
129
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
130
+
131
+ spec := launchSpec(t)
132
+ spec.Agent = "coder"
133
+ rolePath := writePiRole(t, spec.RepoPath, spec.Agent, "You are the coder for relay-flow.\n")
134
+ cmd, err := newPiHarness(t).BuildCommand(spec)
135
+ if err != nil {
136
+ t.Fatalf("BuildCommand: %v", err)
137
+ }
138
+ want := []string{"--name", spec.Title, "--append-system-prompt", rolePath, spec.Prompt}
139
+ if !reflect.DeepEqual(cmd.Args, want) {
140
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
141
+ }
142
+ capture := runStrictPi(t, cmd, t.TempDir(), capturePath)
143
+ if !reflect.DeepEqual(capture.args, want) {
144
+ t.Fatalf("fake Pi args = %#v, want %#v", capture.args, want)
145
+ }
146
+ }
147
+
148
+ func TestBuildCommandRejectsMissingRolePrompt(t *testing.T) {
149
+ spec := launchSpec(t)
150
+ spec.Agent = "reviewer"
151
+ _, err := newPiHarness(t).BuildCommand(spec)
152
+ if err == nil || !strings.Contains(err.Error(), ".pi/roles/reviewer.md") {
153
+ t.Fatalf("missing role error = %v, want role path", err)
154
+ }
155
+ }
156
+
157
+ func TestStrictPiCLIFakeRejectsUnsupportedLaunchFlags(t *testing.T) {
158
+ fakeDir, capturePath := strictPiCLI(t)
159
+ t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
160
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
161
+ base := launchSpec(t)
162
+ ticketEnvironment := t.TempDir()
163
+ cmd, err := newPiHarness(t).BuildCommand(base)
164
+ if err != nil {
165
+ t.Fatalf("BuildCommand: %v", err)
166
+ }
167
+
168
+ for _, args := range [][]string{
169
+ {"--", base.Prompt},
170
+ {"--agent", "default", base.Prompt},
171
+ {"--interactive", base.Prompt},
172
+ {"--report", base.Prompt},
173
+ {"--print", base.Prompt},
174
+ {"--mode", "json", base.Prompt},
175
+ {"--mode", "rpc", base.Prompt},
176
+ {"--extension", "relay-flow-plugin", base.Prompt},
177
+ {"install", "npm:relay-flow-plugin"},
178
+ } {
179
+ bad := exec.Command("pi", args...)
180
+ bad.Dir = ticketEnvironment
181
+ bad.Env = commandEnv(cmd.Env, capturePath)
182
+ if err := bad.Run(); err == nil {
183
+ t.Fatalf("strict fake accepted unsupported args %#v", args)
184
+ }
185
+ }
186
+ }
187
+
188
+ func newPiHarness(t *testing.T) harness.Harness {
189
+ t.Helper()
190
+ h, err := harness.New("pi", nil)
191
+ if err != nil {
192
+ t.Fatalf("harness.New(pi): %v", err)
193
+ }
194
+ return h
195
+ }
196
+
197
+ func launchSpec(t *testing.T) harness.LaunchSpec {
198
+ t.Helper()
199
+ return harness.LaunchSpec{
200
+ RunID: identity.NewRunID("payments", "basicFlow", "PAY-101"),
201
+ NodeVisitID: identity.NewNodeVisitID(),
202
+ RepoName: "payments",
203
+ RepoPath: t.TempDir(),
204
+ Workflow: "basicFlow",
205
+ Ticket: "PAY-101",
206
+ Node: "implement",
207
+ NodeType: workflow.NodeAgent,
208
+ Agent: "default",
209
+ Title: "PAY-101:implement",
210
+ Prompt: "first line\nsecond line",
211
+ NudgePrompt: "emit the complete report",
212
+ NextSteps: []workflow.Route{
213
+ {Target: "review", When: "implementation complete"},
214
+ {Target: "implement", When: "needs more work"},
215
+ },
216
+ }
217
+ }
218
+
219
+ type piCapture struct {
220
+ cwd string
221
+ args []string
222
+ env map[string]string
223
+ }
224
+
225
+ func runStrictPi(t *testing.T, command runner.Command, cwd, capturePath string) piCapture {
226
+ t.Helper()
227
+ process := exec.Command(command.Executable, command.Args...)
228
+ process.Dir = cwd
229
+ process.Env = commandEnv(command.Env, capturePath)
230
+ if output, err := process.CombinedOutput(); err != nil {
231
+ t.Fatalf("strict Pi fake: %v\n%s", err, output)
232
+ }
233
+ data, err := os.ReadFile(capturePath)
234
+ if err != nil {
235
+ t.Fatalf("read strict Pi capture: %v", err)
236
+ }
237
+ fields := strings.Split(string(data), "\x00")
238
+ if len(fields) < 12 || fields[len(fields)-1] != "" {
239
+ t.Fatalf("malformed strict Pi capture: %q", data)
240
+ }
241
+ fields = fields[:len(fields)-1]
242
+ capture := piCapture{
243
+ cwd: fields[0],
244
+ args: strings.Split(fields[10], "\x1f"),
245
+ env: map[string]string{
246
+ "RELAY_FLOW_HOME": fields[1],
247
+ "RELAY_FLOW_RUN_ID": fields[2],
248
+ "RELAY_FLOW_WORKFLOW": fields[3],
249
+ "RELAY_FLOW_REPO": fields[4],
250
+ "RELAY_FLOW_TICKET": fields[5],
251
+ "RELAY_FLOW_NODE": fields[6],
252
+ "RELAY_FLOW_NODE_TYPE": fields[7],
253
+ "RELAY_FLOW_NUDGE_PROMPT": fields[8],
254
+ "RELAY_FLOW_NEXT_STEPS_JSON": fields[9],
255
+ },
256
+ }
257
+ return capture
258
+ }
259
+
260
+ func commandEnv(values map[string]string, capturePath string) []string {
261
+ env := make([]string, 0, len(os.Environ())+len(values)+1)
262
+ for _, value := range os.Environ() {
263
+ key := strings.SplitN(value, "=", 2)[0]
264
+ if _, replaced := values[key]; replaced || key == "PI_FAKE_CAPTURE" {
265
+ continue
266
+ }
267
+ env = append(env, value)
268
+ }
269
+ env = append(env, "PI_FAKE_CAPTURE="+capturePath)
270
+ for key, value := range values {
271
+ env = append(env, key+"="+value)
272
+ }
273
+ return env
274
+ }
275
+
276
+ func strictPiCLI(t *testing.T) (string, string) {
277
+ t.Helper()
278
+ directory := t.TempDir()
279
+ executable := filepath.Join(directory, "pi")
280
+ capture := filepath.Join(directory, "capture")
281
+ const script = `#!/bin/sh
282
+ set -eu
283
+
284
+ capture=${PI_FAKE_CAPTURE:?}
285
+ original_args=
286
+ for arg in "$@"; do
287
+ if [ -n "$original_args" ]; then original_args="$original_args$(printf '\037')"; fi
288
+ original_args="$original_args$arg"
289
+ done
290
+ [ "${1:-}" = "--name" ] || exit 2
291
+ [ "$#" -ge 3 ] || exit 2
292
+ shift 2
293
+ if [ "${1:-}" = "--append-system-prompt" ]; then
294
+ [ "$#" -ge 3 ] || exit 2
295
+ [ -s "$2" ] || exit 2
296
+ shift 2
297
+ fi
298
+ if [ "${1:-}" = "--session-id" ]; then
299
+ [ "$#" -ge 3 ] || exit 2
300
+ shift 2
301
+ fi
302
+ [ "$#" -eq 1 ] || exit 2
303
+
304
+ [ -n "${RELAY_FLOW_HOME:-}" ] || exit 3
305
+ [ -n "${RELAY_FLOW_RUN_ID:-}" ] || exit 3
306
+ [ -n "${RELAY_FLOW_WORKFLOW:-}" ] || exit 3
307
+ [ -n "${RELAY_FLOW_REPO:-}" ] || exit 3
308
+ [ -n "${RELAY_FLOW_TICKET:-}" ] || exit 3
309
+ [ -n "${RELAY_FLOW_NODE:-}" ] || exit 3
310
+ [ -n "${RELAY_FLOW_NODE_TYPE:-}" ] || exit 3
311
+ [ -n "${RELAY_FLOW_NEXT_STEPS_JSON:-}" ] || exit 3
312
+
313
+ printf '%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000' \
314
+ "$PWD" "$RELAY_FLOW_HOME" "$RELAY_FLOW_RUN_ID" "$RELAY_FLOW_WORKFLOW" \
315
+ "$RELAY_FLOW_REPO" "$RELAY_FLOW_TICKET" "$RELAY_FLOW_NODE" "$RELAY_FLOW_NODE_TYPE" \
316
+ "${RELAY_FLOW_NUDGE_PROMPT:-}" "$RELAY_FLOW_NEXT_STEPS_JSON" "$original_args" > "$capture"
317
+ `
318
+ if err := os.WriteFile(executable, []byte(script), 0o700); err != nil {
319
+ t.Fatal(err)
320
+ }
321
+ return directory, capture
322
+ }