relay-flow 0.2.3-alpha → 0.2.5-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 (85) hide show
  1. package/README.md +121 -10
  2. package/cmd/relay-flow/backend_selection_test.go +149 -0
  3. package/cmd/relay-flow/main.go +96 -13
  4. package/cmd/relay-flow/pi_wiring_test.go +64 -0
  5. package/cmd/relay-flow/scenario_test.go +19 -2
  6. package/cmd/relay-flow/serve.go +100 -19
  7. package/cmd/relay-flow/serve_recovery_test.go +100 -0
  8. package/cmd/relay-flow/temporal_init.go +170 -0
  9. package/cmd/relay-flow/temporal_init_test.go +217 -0
  10. package/cmd/relay-flow/temporal_report_test.go +733 -0
  11. package/examples/beads-workflow.yaml +3 -3
  12. package/examples/config-reference.yaml +144 -0
  13. package/examples/minimal-beads-task-workflow.yaml +35 -0
  14. package/examples/minimal-jira-task-workflow.yaml +68 -0
  15. package/examples/workflow-reference.yaml +112 -0
  16. package/go.mod +37 -16
  17. package/go.sum +129 -61
  18. package/internal/config/machine.go +33 -1
  19. package/internal/config/machine_test.go +76 -0
  20. package/internal/execution/goworkflows/engine.go +13 -38
  21. package/internal/execution/goworkflows/projection.go +47 -464
  22. package/internal/execution/projection/projection.go +867 -0
  23. package/internal/execution/projection/projection_test.go +347 -0
  24. package/internal/execution/temporal/activities.go +567 -0
  25. package/internal/execution/temporal/engine.go +384 -0
  26. package/internal/execution/temporal/engine_test.go +277 -0
  27. package/internal/execution/temporal/interpreter.go +736 -0
  28. package/internal/execution/temporal/operations.go +455 -0
  29. package/internal/execution/temporal/operations_test.go +101 -0
  30. package/internal/execution/temporal/recovery.go +194 -0
  31. package/internal/execution/temporal/recovery_runtime.go +41 -0
  32. package/internal/execution/temporal/recovery_test.go +102 -0
  33. package/internal/execution/temporal/snapshot_restart_test.go +72 -0
  34. package/internal/execution/temporal/spike_test.go +934 -0
  35. package/internal/execution/temporal/visibility_lag_test.go +415 -0
  36. package/internal/harness/opencode/opencode.go +3 -1
  37. package/internal/harness/opencode/opencode_test.go +1 -1
  38. package/internal/harness/opencode/repo_setup.go +1 -1
  39. package/internal/harness/pi/config_test.go +46 -0
  40. package/internal/harness/pi/lifecycle_test.go +69 -0
  41. package/internal/harness/pi/pi.go +264 -0
  42. package/internal/harness/pi/pi_test.go +338 -0
  43. package/internal/harness/pi/prompt_test.go +150 -0
  44. package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
  45. package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
  46. package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
  47. package/internal/harness/pi/validation_test.go +144 -0
  48. package/internal/runner/herdr/baseref.go +60 -0
  49. package/internal/runner/herdr/herdr.go +592 -0
  50. package/internal/runner/herdr/herdr_test.go +708 -0
  51. package/internal/runner/herdr/herdrcli/contract.go +128 -0
  52. package/internal/runner/herdr/herdrcli/exec.go +68 -0
  53. package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
  54. package/internal/runner/herdr/herdrcli/live_test.go +132 -0
  55. package/internal/runner/herdr/herdrcli/operations.go +281 -0
  56. package/internal/runner/herdr/herdrcli/response.go +116 -0
  57. package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
  58. package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
  59. package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
  60. package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
  61. package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
  62. package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
  63. package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
  64. package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
  65. package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
  66. package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
  67. package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
  68. package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
  69. package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
  70. package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
  71. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
  72. package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
  73. package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
  74. package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
  75. package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
  76. package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
  77. package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
  78. package/internal/runner/orca/orca.go +33 -0
  79. package/internal/runner/orca/orca_test.go +33 -4
  80. package/internal/runner/runner.go +8 -0
  81. package/internal/task/beads/beads.go +0 -16
  82. package/internal/task/beads/config_compatibility_test.go +7 -8
  83. package/internal/task/jira/filters_test.go +32 -6
  84. package/internal/task/jira/jira.go +27 -17
  85. package/package.json +1 -1
@@ -0,0 +1,264 @@
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 project-owned prompt templates under
4
+ // .pi/prompts. 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
+
29
+ Keep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.`
30
+ defaultFeedbackPrompt = `New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.`
31
+ )
32
+
33
+ var promptVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
34
+
35
+ var knownPromptVars = map[string]bool{
36
+ "taskSystem": true, "ticket": true, "workflow": true, "repo": true,
37
+ "node": true, "nodeType": true, "agent": true, "nodeDescription": true,
38
+ "nextSteps": true, "mailbox": true,
39
+ }
40
+
41
+ // Config is the adapter-owned root harnessConfig. Pi exposes no harness
42
+ // prompt for HITL approval because approval is performed through Pi's host
43
+ // UI by the runtime extension.
44
+ type Config struct {
45
+ Initial string `yaml:"initial"`
46
+ Feedback string `yaml:"feedback"`
47
+ }
48
+
49
+ // DefaultConfig is written by relay-flow init and fills omitted values when
50
+ // configuration is loaded through the harness factory.
51
+ func DefaultConfig() config.RawValues {
52
+ return config.RawValues{
53
+ "initial": defaultInitialPrompt,
54
+ "feedback": defaultFeedbackPrompt,
55
+ }
56
+ }
57
+
58
+ func init() {
59
+ harness.Register("pi", harness.Factory{
60
+ DefaultConfig: DefaultConfig,
61
+ New: func(raw config.RawValues) (harness.Harness, error) {
62
+ var cfg Config
63
+ if err := config.DecodeStrict(raw, &cfg); err != nil {
64
+ return nil, fmt.Errorf("pi harnessConfig: %w", err)
65
+ }
66
+ for name, tmpl := range map[string]string{
67
+ "initial": cfg.Initial,
68
+ "feedback": cfg.Feedback,
69
+ } {
70
+ if err := validateTemplate(tmpl); err != nil {
71
+ return nil, fmt.Errorf("pi harnessConfig templates.%s: %w", name, err)
72
+ }
73
+ }
74
+ return New(cfg), nil
75
+ },
76
+ })
77
+ }
78
+
79
+ // Harness implements harness.Harness for Pi.
80
+ type Harness struct {
81
+ templates Config
82
+ }
83
+
84
+ // New returns the production Pi harness. The no-argument form uses the
85
+ // adapter defaults; factory construction supplies an explicitly decoded
86
+ // configuration.
87
+ func New(cfg ...Config) *Harness {
88
+ if len(cfg) > 0 {
89
+ return &Harness{templates: cfg[0]}
90
+ }
91
+ return &Harness{templates: Config{
92
+ Initial: defaultInitialPrompt,
93
+ Feedback: defaultFeedbackPrompt,
94
+ }}
95
+ }
96
+
97
+ // SetupRepo is intentionally a no-op. The relay-flow Pi runtime extension is
98
+ // installed manually in Pi's global package settings rather than configured
99
+ // in each repository.
100
+ func (*Harness) SetupRepo(context.Context, string) error { return nil }
101
+
102
+ // ValidateAgent checks the Pi executable and the prompt-template command name.
103
+ // Pi owns prompt-template discovery; relay-flow does not inspect or register
104
+ // the user's project-owned .pi/prompts files.
105
+ func (*Harness) ValidateAgent(_ context.Context, _ string, agent string) error {
106
+ if err := validateAgentName(agent); err != nil {
107
+ return err
108
+ }
109
+ if _, err := exec.LookPath("pi"); err != nil {
110
+ return fmt.Errorf("pi: executable unavailable: %w", err)
111
+ }
112
+ return nil
113
+ }
114
+
115
+ func validateAgentName(agent string) error {
116
+ if agent == "default" {
117
+ return nil
118
+ }
119
+ if strings.TrimSpace(agent) == "" || strings.TrimSpace(agent) != agent || strings.ContainsAny(agent, `/\\`) || agent == "." || agent == ".." {
120
+ return fmt.Errorf("pi: invalid prompt template %q", agent)
121
+ }
122
+ return nil
123
+ }
124
+
125
+ func promptTemplatePath(agent string) string {
126
+ return filepath.Join(".pi", "prompts", agent+".md")
127
+ }
128
+
129
+ func applyPromptTemplate(agent, prompt string) string {
130
+ if agent == "" || agent == "default" {
131
+ return prompt
132
+ }
133
+ prefix := "/" + agent
134
+ if prompt == prefix || strings.HasPrefix(prompt, prefix+" ") ||
135
+ strings.HasPrefix(prompt, prefix+"\t") || strings.HasPrefix(prompt, prefix+"\n") {
136
+ return prompt
137
+ }
138
+ if prompt == "" {
139
+ return prefix
140
+ }
141
+ return prefix + " " + prompt
142
+ }
143
+
144
+ // FindSession is intentionally discovery-free. Normal execution resumes only
145
+ // the Pi session ID persisted by runtime registration.
146
+ func (*Harness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
147
+ return harness.Session{}, false, nil
148
+ }
149
+
150
+ // RenderPrompt renders the selected initial or feedback template and the
151
+ // node's nudge template. Named Pi agents are native prompt-template commands,
152
+ // so the complete rendered text is supplied as the command arguments. HITL
153
+ // approval is not encoded in the prompt; the Pi extension asks for approval
154
+ // through ctx.ui.select.
155
+ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
156
+ var tmpl string
157
+ switch kind {
158
+ case harness.PromptInitial:
159
+ tmpl = h.templates.Initial
160
+ case harness.PromptFeedback:
161
+ tmpl = h.templates.Feedback
162
+ default:
163
+ return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
164
+ }
165
+ if data.Agent != "" {
166
+ if err := validateAgentName(data.Agent); err != nil {
167
+ return "", err
168
+ }
169
+ }
170
+ prompt := appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data))
171
+ return applyPromptTemplate(data.Agent, prompt), nil
172
+ }
173
+
174
+ // BuildCommand returns the interactive Pi invocation. The runner supplies a
175
+ // PTY for Pi's stdin/stdout; the rendered prompt is the final positional argv
176
+ // value. A named workflow agent adds Pi's --prompt-template option for the
177
+ // project-owned .pi/prompts/<agent>.md file and invokes it with its native
178
+ // slash-command syntax. Pi 0.84.1 rejects a bare -- terminator, so none is
179
+ // included. A non-empty ResumeID selects Pi's exact session-id resume option.
180
+ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
181
+ if err := validateAgentName(spec.Agent); err != nil {
182
+ return runner.Command{}, err
183
+ }
184
+ nextSteps, err := json.Marshal(spec.NextSteps)
185
+ if err != nil {
186
+ return runner.Command{}, fmt.Errorf("pi: marshal next steps: %w", err)
187
+ }
188
+ root, err := relayFlowHome()
189
+ if err != nil {
190
+ return runner.Command{}, err
191
+ }
192
+ env := map[string]string{
193
+ "RELAY_FLOW_HOME": root,
194
+ "RELAY_FLOW_RUN_ID": string(spec.RunID),
195
+ "RELAY_FLOW_WORKFLOW": spec.Workflow,
196
+ "RELAY_FLOW_REPO": spec.RepoName,
197
+ "RELAY_FLOW_TICKET": spec.Ticket,
198
+ "RELAY_FLOW_NODE": spec.Node,
199
+ "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
200
+ "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
201
+ "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
202
+ }
203
+ args := []string{"--name", spec.Title}
204
+ if spec.Agent != "default" {
205
+ args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
206
+ }
207
+ if spec.ResumeID != "" {
208
+ args = append(args, "--session-id", spec.ResumeID)
209
+ }
210
+ args = append(args, applyPromptTemplate(spec.Agent, spec.Prompt))
211
+ return runner.Command{
212
+ Executable: "pi",
213
+ Args: args,
214
+ Env: env,
215
+ }, nil
216
+ }
217
+
218
+ func relayFlowHome() (string, error) {
219
+ if root := os.Getenv("RELAY_FLOW_HOME"); root != "" {
220
+ return root, nil
221
+ }
222
+ home, err := os.UserHomeDir()
223
+ if err != nil {
224
+ return "", fmt.Errorf("pi: resolve relay-flow home: %w", err)
225
+ }
226
+ return filepath.Join(home, ".relay-flow"), nil
227
+ }
228
+
229
+ func validateTemplate(tmpl string) error {
230
+ for _, match := range promptVarPattern.FindAllStringSubmatch(tmpl, -1) {
231
+ if !knownPromptVars[match[1]] {
232
+ return fmt.Errorf("unknown template variable {{%s}}", match[1])
233
+ }
234
+ }
235
+ return nil
236
+ }
237
+
238
+ func renderTemplate(tmpl string, data harness.PromptData) string {
239
+ values := map[string]string{
240
+ "taskSystem": data.TaskSystem,
241
+ "ticket": data.Ticket,
242
+ "workflow": data.Workflow,
243
+ "repo": data.Repo,
244
+ "node": data.Node,
245
+ "nodeType": string(data.NodeType),
246
+ "agent": data.Agent,
247
+ "nodeDescription": data.NodeDescription,
248
+ "nextSteps": data.NextSteps,
249
+ "mailbox": data.Mailbox,
250
+ }
251
+ return promptVarPattern.ReplaceAllStringFunc(tmpl, func(match string) string {
252
+ return values[promptVarPattern.FindStringSubmatch(match)[1]]
253
+ })
254
+ }
255
+
256
+ func appendPrompt(prompt, extra string) string {
257
+ if extra == "" {
258
+ return prompt
259
+ }
260
+ if prompt == "" {
261
+ return extra
262
+ }
263
+ return prompt + "\n\n" + extra
264
+ }
@@ -0,0 +1,338 @@
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 native prompt template.
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 TestBuildCommandUsesNativePromptTemplate(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
+ cmd, err := newPiHarness(t).BuildCommand(spec)
134
+ if err != nil {
135
+ t.Fatalf("BuildCommand: %v", err)
136
+ }
137
+ want := []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "/coder " + spec.Prompt}
138
+ if !reflect.DeepEqual(cmd.Args, want) {
139
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
140
+ }
141
+ capture := runStrictPi(t, cmd, t.TempDir(), capturePath)
142
+ if !reflect.DeepEqual(capture.args, want) {
143
+ t.Fatalf("fake Pi args = %#v, want %#v", capture.args, want)
144
+ }
145
+
146
+ spec.ResumeID = "session-123"
147
+ cmd, err = newPiHarness(t).BuildCommand(spec)
148
+ if err != nil {
149
+ t.Fatalf("BuildCommand(resume): %v", err)
150
+ }
151
+ want = []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/coder.md", "--session-id", spec.ResumeID, "/coder " + spec.Prompt}
152
+ if !reflect.DeepEqual(cmd.Args, want) {
153
+ t.Fatalf("resume Args = %#v, want %#v", cmd.Args, want)
154
+ }
155
+ }
156
+
157
+ func TestBuildCommandDoesNotValidatePromptTemplateFile(t *testing.T) {
158
+ spec := launchSpec(t)
159
+ spec.Agent = "reviewer"
160
+ cmd, err := newPiHarness(t).BuildCommand(spec)
161
+ if err != nil {
162
+ t.Fatalf("BuildCommand: %v", err)
163
+ }
164
+ want := []string{"--name", spec.Title, "--prompt-template", ".pi/prompts/reviewer.md", "/reviewer " + spec.Prompt}
165
+ if !reflect.DeepEqual(cmd.Args, want) {
166
+ t.Fatalf("Args = %#v, want %#v", cmd.Args, want)
167
+ }
168
+ }
169
+
170
+ func TestStrictPiCLIFakeRejectsUnsupportedLaunchFlags(t *testing.T) {
171
+ fakeDir, capturePath := strictPiCLI(t)
172
+ t.Setenv("PATH", fakeDir+string(os.PathListSeparator)+os.Getenv("PATH"))
173
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
174
+ base := launchSpec(t)
175
+ ticketEnvironment := t.TempDir()
176
+ cmd, err := newPiHarness(t).BuildCommand(base)
177
+ if err != nil {
178
+ t.Fatalf("BuildCommand: %v", err)
179
+ }
180
+
181
+ for _, args := range [][]string{
182
+ {"--", base.Prompt},
183
+ {"--agent", "default", base.Prompt},
184
+ {"--interactive", base.Prompt},
185
+ {"--report", base.Prompt},
186
+ {"--print", base.Prompt},
187
+ {"--mode", "json", base.Prompt},
188
+ {"--mode", "rpc", base.Prompt},
189
+ {"--extension", "relay-flow-plugin", base.Prompt},
190
+ {"install", "npm:relay-flow-plugin"},
191
+ } {
192
+ bad := exec.Command("pi", args...)
193
+ bad.Dir = ticketEnvironment
194
+ bad.Env = commandEnv(cmd.Env, capturePath)
195
+ if err := bad.Run(); err == nil {
196
+ t.Fatalf("strict fake accepted unsupported args %#v", args)
197
+ }
198
+ }
199
+ }
200
+
201
+ func newPiHarness(t *testing.T) harness.Harness {
202
+ t.Helper()
203
+ h, err := harness.New("pi", nil)
204
+ if err != nil {
205
+ t.Fatalf("harness.New(pi): %v", err)
206
+ }
207
+ return h
208
+ }
209
+
210
+ func launchSpec(t *testing.T) harness.LaunchSpec {
211
+ t.Helper()
212
+ return harness.LaunchSpec{
213
+ RunID: identity.NewRunID("payments", "basicFlow", "PAY-101"),
214
+ NodeVisitID: identity.NewNodeVisitID(),
215
+ RepoName: "payments",
216
+ RepoPath: t.TempDir(),
217
+ Workflow: "basicFlow",
218
+ Ticket: "PAY-101",
219
+ Node: "implement",
220
+ NodeType: workflow.NodeAgent,
221
+ Agent: "default",
222
+ Title: "PAY-101:implement",
223
+ Prompt: "first line\nsecond line",
224
+ NudgePrompt: "emit the complete report",
225
+ NextSteps: []workflow.Route{
226
+ {Target: "review", When: "implementation complete"},
227
+ {Target: "implement", When: "needs more work"},
228
+ },
229
+ }
230
+ }
231
+
232
+ type piCapture struct {
233
+ cwd string
234
+ args []string
235
+ env map[string]string
236
+ }
237
+
238
+ func runStrictPi(t *testing.T, command runner.Command, cwd, capturePath string) piCapture {
239
+ t.Helper()
240
+ process := exec.Command(command.Executable, command.Args...)
241
+ process.Dir = cwd
242
+ process.Env = commandEnv(command.Env, capturePath)
243
+ if output, err := process.CombinedOutput(); err != nil {
244
+ t.Fatalf("strict Pi fake: %v\n%s", err, output)
245
+ }
246
+ data, err := os.ReadFile(capturePath)
247
+ if err != nil {
248
+ t.Fatalf("read strict Pi capture: %v", err)
249
+ }
250
+ fields := strings.Split(string(data), "\x00")
251
+ if len(fields) < 12 || fields[len(fields)-1] != "" {
252
+ t.Fatalf("malformed strict Pi capture: %q", data)
253
+ }
254
+ fields = fields[:len(fields)-1]
255
+ capture := piCapture{
256
+ cwd: fields[0],
257
+ args: strings.Split(fields[10], "\x1f"),
258
+ env: map[string]string{
259
+ "RELAY_FLOW_HOME": fields[1],
260
+ "RELAY_FLOW_RUN_ID": fields[2],
261
+ "RELAY_FLOW_WORKFLOW": fields[3],
262
+ "RELAY_FLOW_REPO": fields[4],
263
+ "RELAY_FLOW_TICKET": fields[5],
264
+ "RELAY_FLOW_NODE": fields[6],
265
+ "RELAY_FLOW_NODE_TYPE": fields[7],
266
+ "RELAY_FLOW_NUDGE_PROMPT": fields[8],
267
+ "RELAY_FLOW_NEXT_STEPS_JSON": fields[9],
268
+ },
269
+ }
270
+ return capture
271
+ }
272
+
273
+ func commandEnv(values map[string]string, capturePath string) []string {
274
+ env := make([]string, 0, len(os.Environ())+len(values)+1)
275
+ for _, value := range os.Environ() {
276
+ key := strings.SplitN(value, "=", 2)[0]
277
+ if _, replaced := values[key]; replaced || key == "PI_FAKE_CAPTURE" {
278
+ continue
279
+ }
280
+ env = append(env, value)
281
+ }
282
+ env = append(env, "PI_FAKE_CAPTURE="+capturePath)
283
+ for key, value := range values {
284
+ env = append(env, key+"="+value)
285
+ }
286
+ return env
287
+ }
288
+
289
+ func strictPiCLI(t *testing.T) (string, string) {
290
+ t.Helper()
291
+ directory := t.TempDir()
292
+ executable := filepath.Join(directory, "pi")
293
+ capture := filepath.Join(directory, "capture")
294
+ const script = `#!/bin/sh
295
+ set -eu
296
+
297
+ capture=${PI_FAKE_CAPTURE:?}
298
+ original_args=
299
+ for arg in "$@"; do
300
+ if [ -n "$original_args" ]; then original_args="$original_args$(printf '\037')"; fi
301
+ original_args="$original_args$arg"
302
+ done
303
+ [ "${1:-}" = "--name" ] || exit 2
304
+ [ "$#" -ge 3 ] || exit 2
305
+ shift 2
306
+ if [ "${1:-}" = "--prompt-template" ]; then
307
+ [ "$#" -ge 3 ] || exit 2
308
+ case "$2" in
309
+ .pi/prompts/*.md) ;;
310
+ *) exit 2 ;;
311
+ esac
312
+ shift 2
313
+ fi
314
+ if [ "${1:-}" = "--session-id" ]; then
315
+ [ "$#" -ge 3 ] || exit 2
316
+ shift 2
317
+ fi
318
+ [ "$#" -eq 1 ] || exit 2
319
+
320
+ [ -n "${RELAY_FLOW_HOME:-}" ] || exit 3
321
+ [ -n "${RELAY_FLOW_RUN_ID:-}" ] || exit 3
322
+ [ -n "${RELAY_FLOW_WORKFLOW:-}" ] || exit 3
323
+ [ -n "${RELAY_FLOW_REPO:-}" ] || exit 3
324
+ [ -n "${RELAY_FLOW_TICKET:-}" ] || exit 3
325
+ [ -n "${RELAY_FLOW_NODE:-}" ] || exit 3
326
+ [ -n "${RELAY_FLOW_NODE_TYPE:-}" ] || exit 3
327
+ [ -n "${RELAY_FLOW_NEXT_STEPS_JSON:-}" ] || exit 3
328
+
329
+ printf '%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000' \
330
+ "$PWD" "$RELAY_FLOW_HOME" "$RELAY_FLOW_RUN_ID" "$RELAY_FLOW_WORKFLOW" \
331
+ "$RELAY_FLOW_REPO" "$RELAY_FLOW_TICKET" "$RELAY_FLOW_NODE" "$RELAY_FLOW_NODE_TYPE" \
332
+ "${RELAY_FLOW_NUDGE_PROMPT:-}" "$RELAY_FLOW_NEXT_STEPS_JSON" "$original_args" > "$capture"
333
+ `
334
+ if err := os.WriteFile(executable, []byte(script), 0o700); err != nil {
335
+ t.Fatal(err)
336
+ }
337
+ return directory, capture
338
+ }