relay-flow 0.2.2-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.
- package/README.md +154 -13
- package/cmd/relay-flow/commands_test.go +5 -1
- package/cmd/relay-flow/main.go +27 -1
- package/cmd/relay-flow/pi_wiring_test.go +64 -0
- package/cmd/relay-flow/serve.go +14 -0
- package/examples/beads-workflow.yaml +3 -3
- package/examples/config-reference.yaml +144 -0
- package/examples/minimal-beads-task-workflow.yaml +34 -0
- package/examples/minimal-jira-task-workflow.yaml +68 -0
- package/examples/workflow-reference.yaml +111 -0
- package/internal/execution/goworkflows/activities.go +19 -0
- package/internal/execution/goworkflows/engine.go +20 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/goworkflows/fakes_test.go +34 -6
- package/internal/execution/goworkflows/interpreter.go +48 -1
- package/internal/execution/goworkflows/projection.go +52 -11
- package/internal/execution/goworkflows/recovery_test.go +129 -0
- package/internal/harness/opencode/opencode.go +4 -4
- package/internal/harness/opencode/opencode_test.go +59 -4
- package/internal/harness/opencode/repo_setup.go +42 -2
- package/internal/harness/pi/config_test.go +46 -0
- package/internal/harness/pi/lifecycle_test.go +69 -0
- package/internal/harness/pi/pi.go +261 -0
- package/internal/harness/pi/pi_test.go +322 -0
- package/internal/harness/pi/prompt_test.go +121 -0
- package/internal/harness/pi/testdata/pi-0.84.1/capture.json +126 -0
- package/internal/harness/pi/testdata/pi-0.84.1/noninteractive-output.txt +11 -0
- package/internal/harness/pi/testdata/pi-0.84.1/tui-output-sanitized.txt +17 -0
- package/internal/harness/pi/validation_test.go +168 -0
- package/internal/identity/identity.go +28 -1
- package/internal/identity/identity_test.go +40 -0
- package/internal/run/manager.go +193 -25
- package/internal/run/run.go +24 -6
- package/internal/run/run_manager_test.go +100 -0
- package/internal/runner/herdr/baseref.go +60 -0
- package/internal/runner/herdr/herdr.go +578 -0
- package/internal/runner/herdr/herdr_test.go +688 -0
- package/internal/runner/herdr/herdrcli/contract.go +128 -0
- package/internal/runner/herdr/herdrcli/exec.go +68 -0
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +274 -0
- package/internal/runner/herdr/herdrcli/live_test.go +132 -0
- package/internal/runner/herdr/herdrcli/operations.go +281 -0
- package/internal/runner/herdr/herdrcli/response.go +116 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-panes.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/empty-tabs.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-not-git-worktree.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-pane-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-workspace-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/error-worktree-not-found.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/malformed.json +1 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-get.json +25 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-list.json +45 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info-shell.json +22 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-process-info.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/pane-rename.json +23 -0
- package/internal/runner/herdr/herdrcli/testdata/snapshot.json +213 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +175 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-create.json +31 -0
- package/internal/runner/herdr/herdrcli/testdata/tab-list.json +26 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-close.json +6 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-create.json +58 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-list.json +35 -0
- package/internal/runner/herdr/herdrcli/testdata/worktree-open.json +59 -0
- package/internal/server/api_test.go +54 -0
- package/internal/server/client.go +11 -0
- package/internal/server/fixture_test.go +18 -0
- package/internal/server/server.go +16 -0
- package/internal/task/beads/beads.go +16 -16
- package/internal/task/beads/config_compatibility_test.go +7 -8
- package/internal/task/beads/status_compatibility_test.go +43 -0
- package/internal/task/jira/filters_test.go +32 -6
- package/internal/task/jira/helpers_test.go +3 -0
- package/internal/task/jira/jira.go +43 -17
- package/internal/task/jira/transition_defaults_test.go +43 -0
- package/internal/task/task.go +8 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
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
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
|
|
13
|
+
h := newPiHarness(t)
|
|
14
|
+
data := harness.PromptData{
|
|
15
|
+
TaskSystem: "jira",
|
|
16
|
+
Ticket: "PAY-101",
|
|
17
|
+
Workflow: "basicFlow",
|
|
18
|
+
Repo: "payments",
|
|
19
|
+
Node: "implement",
|
|
20
|
+
NodeType: workflow.NodeAgent,
|
|
21
|
+
Agent: "default",
|
|
22
|
+
NodeDescription: "Implement the requested change.",
|
|
23
|
+
NextSteps: "review (when: ready)",
|
|
24
|
+
Mailbox: "PAY-234",
|
|
25
|
+
}
|
|
26
|
+
nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
|
|
27
|
+
|
|
28
|
+
initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
|
|
29
|
+
if err != nil {
|
|
30
|
+
t.Fatalf("RenderPrompt(initial): %v", err)
|
|
31
|
+
}
|
|
32
|
+
wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
|
|
33
|
+
if initial != wantInitial {
|
|
34
|
+
t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
feedback, err := h.RenderPrompt(harness.PromptFeedback, data, nudge)
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("RenderPrompt(feedback): %v", err)
|
|
40
|
+
}
|
|
41
|
+
wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
|
|
42
|
+
if feedback != wantFeedback {
|
|
43
|
+
t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func TestPiRenderPromptHonorsNodeNudgeTimingInput(t *testing.T) {
|
|
48
|
+
h, err := harness.New("pi", config.RawValues{
|
|
49
|
+
"initial": "initial {{node}}",
|
|
50
|
+
"feedback": "feedback {{node}}",
|
|
51
|
+
})
|
|
52
|
+
if err != nil {
|
|
53
|
+
t.Fatalf("harness.New(pi): %v", err)
|
|
54
|
+
}
|
|
55
|
+
data := harness.PromptData{Node: "implement", Agent: "default"}
|
|
56
|
+
|
|
57
|
+
for _, tt := range []struct {
|
|
58
|
+
name string
|
|
59
|
+
kind harness.PromptKind
|
|
60
|
+
want string
|
|
61
|
+
nudge string
|
|
62
|
+
}{
|
|
63
|
+
{name: "initial without nudge", kind: harness.PromptInitial, want: "initial implement"},
|
|
64
|
+
{name: "initial with nudge", kind: harness.PromptInitial, want: "initial implement\n\nnudge implement", nudge: "nudge {{node}}"},
|
|
65
|
+
{name: "feedback without nudge", kind: harness.PromptFeedback, want: "feedback implement"},
|
|
66
|
+
{name: "feedback with nudge", kind: harness.PromptFeedback, want: "feedback implement\n\nnudge implement", nudge: "nudge {{node}}"},
|
|
67
|
+
} {
|
|
68
|
+
t.Run(tt.name, func(t *testing.T) {
|
|
69
|
+
got, err := h.RenderPrompt(tt.kind, data, tt.nudge)
|
|
70
|
+
if err != nil {
|
|
71
|
+
t.Fatalf("RenderPrompt: %v", err)
|
|
72
|
+
}
|
|
73
|
+
if got != tt.want {
|
|
74
|
+
t.Fatalf("prompt = %q, want %q", got, tt.want)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func TestPiPromptRetainsDefaultAgentLabel(t *testing.T) {
|
|
81
|
+
h, err := harness.New("pi", config.RawValues{
|
|
82
|
+
"initial": "agent={{agent}} node={{node}}",
|
|
83
|
+
"feedback": "feedback {{agent}}",
|
|
84
|
+
})
|
|
85
|
+
if err != nil {
|
|
86
|
+
t.Fatalf("harness.New(pi): %v", err)
|
|
87
|
+
}
|
|
88
|
+
got, err := h.RenderPrompt(harness.PromptInitial, harness.PromptData{
|
|
89
|
+
Agent: "default",
|
|
90
|
+
Node: "implement",
|
|
91
|
+
}, "")
|
|
92
|
+
if err != nil {
|
|
93
|
+
t.Fatalf("RenderPrompt: %v", err)
|
|
94
|
+
}
|
|
95
|
+
if got != "agent=default node=implement" {
|
|
96
|
+
t.Fatalf("prompt = %q, want default agent label", got)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func TestPiPromptsDoNotIncludeOpenCodeQuestionInstructions(t *testing.T) {
|
|
101
|
+
h := newPiHarness(t)
|
|
102
|
+
data := harness.PromptData{
|
|
103
|
+
TaskSystem: "jira",
|
|
104
|
+
Ticket: "PAY-101",
|
|
105
|
+
Node: "review",
|
|
106
|
+
NodeType: workflow.NodeHITL,
|
|
107
|
+
Agent: "default",
|
|
108
|
+
Mailbox: "PAY-235",
|
|
109
|
+
}
|
|
110
|
+
for _, kind := range []harness.PromptKind{harness.PromptInitial, harness.PromptFeedback} {
|
|
111
|
+
got, err := h.RenderPrompt(kind, data, "")
|
|
112
|
+
if err != nil {
|
|
113
|
+
t.Fatalf("RenderPrompt(%q): %v", kind, err)
|
|
114
|
+
}
|
|
115
|
+
for _, forbidden := range []string{"OpenCode", "Question tool", "Approve", "Reject"} {
|
|
116
|
+
if strings.Contains(got, forbidden) {
|
|
117
|
+
t.Fatalf("%q prompt contains OpenCode HITL instruction %q: %q", kind, forbidden, got)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
{
|
|
2
|
+
"capturedAt": "2026-08-31T18:34Z",
|
|
3
|
+
"version": "0.84.1",
|
|
4
|
+
"command": "/home/linuxbrew/.linuxbrew/Cellar/pi-coding-agent/0.84.1/bin/pi",
|
|
5
|
+
"cwd": "/tmp/pi-live-contract/project",
|
|
6
|
+
"note": "Executed live against installed Pi 0.84.1 with a real provider (github-copilot). Interactive runs used script(1) to supply a PTY. The smoke runs added --session-dir, --model, --thinking, -nt and -e for isolation/observability; the production launch shape itself is argv[0..] below and was validated by the rejection cases in errors.",
|
|
7
|
+
"argv": {
|
|
8
|
+
"production": {
|
|
9
|
+
"fresh": [
|
|
10
|
+
"pi",
|
|
11
|
+
"--name",
|
|
12
|
+
"PAY-101:implement",
|
|
13
|
+
"<rendered prompt as one positional argument>"
|
|
14
|
+
],
|
|
15
|
+
"resume": [
|
|
16
|
+
"pi",
|
|
17
|
+
"--name",
|
|
18
|
+
"PAY-101:implement",
|
|
19
|
+
"--session-id",
|
|
20
|
+
"<persisted session id>",
|
|
21
|
+
"<rendered prompt as one positional argument>"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"smokeObserved": [
|
|
25
|
+
"/home/linuxbrew/.linuxbrew/Cellar/node/26.7.0/bin/node",
|
|
26
|
+
"/home/linuxbrew/.linuxbrew/Cellar/pi-coding-agent/0.84.1/libexec/bin/pi",
|
|
27
|
+
"--name",
|
|
28
|
+
"PAY-101:implement",
|
|
29
|
+
"--session-dir",
|
|
30
|
+
"/tmp/pi-live-contract/sessions",
|
|
31
|
+
"--model",
|
|
32
|
+
"github-copilot/claude-haiku-4.5",
|
|
33
|
+
"--thinking",
|
|
34
|
+
"off",
|
|
35
|
+
"-nt",
|
|
36
|
+
"-e",
|
|
37
|
+
"<extension path>",
|
|
38
|
+
"<prompt>"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"interactive": {
|
|
42
|
+
"stdinIsTTY": true,
|
|
43
|
+
"stdoutIsTTY": true,
|
|
44
|
+
"mode": "tui",
|
|
45
|
+
"hasUI": true,
|
|
46
|
+
"processAliveAfterSettled": true,
|
|
47
|
+
"observedAliveSecondsAfterSettle": 65,
|
|
48
|
+
"terminalTitle": "\u03c0 - PAY-101:review - project",
|
|
49
|
+
"footer": "/tmp/pi-live-contract/project \u2022 PAY-101:review"
|
|
50
|
+
},
|
|
51
|
+
"nonInteractive": {
|
|
52
|
+
"stdinIsTTY": false,
|
|
53
|
+
"stdoutIsTTY": false,
|
|
54
|
+
"mode": "print",
|
|
55
|
+
"hasUI": false,
|
|
56
|
+
"exitCode": 0,
|
|
57
|
+
"processExitsAfterResponse": true
|
|
58
|
+
},
|
|
59
|
+
"environment": {
|
|
60
|
+
"childCwd": "/tmp/pi-live-contract/project",
|
|
61
|
+
"relayFlowVariablesVisibleToExtension": [
|
|
62
|
+
"RELAY_FLOW_HOME",
|
|
63
|
+
"RELAY_FLOW_RUN_ID",
|
|
64
|
+
"RELAY_FLOW_WORKFLOW",
|
|
65
|
+
"RELAY_FLOW_REPO",
|
|
66
|
+
"RELAY_FLOW_TICKET",
|
|
67
|
+
"RELAY_FLOW_NODE",
|
|
68
|
+
"RELAY_FLOW_NODE_TYPE",
|
|
69
|
+
"RELAY_FLOW_NUDGE_PROMPT",
|
|
70
|
+
"RELAY_FLOW_NEXT_STEPS_JSON"
|
|
71
|
+
]
|
|
72
|
+
},
|
|
73
|
+
"session": {
|
|
74
|
+
"fresh": {
|
|
75
|
+
"id": "01a0591c-3747-7075-9bb7-cc89a0f65dcd",
|
|
76
|
+
"file": "/tmp/pi-live-contract/sessions/2026-08-31T18-36-56-519Z_01a0591c-3747-7075-9bb7-cc89a0f65dcd.jsonl",
|
|
77
|
+
"branchLengthAtSessionStart": 3
|
|
78
|
+
},
|
|
79
|
+
"resumed": {
|
|
80
|
+
"requestedSessionId": "01a0591b-51a6-7cf0-8a6d-e9b31594cca4",
|
|
81
|
+
"observedSessionId": "01a0591b-51a6-7cf0-8a6d-e9b31594cca4",
|
|
82
|
+
"file": "/tmp/pi-live-contract/sessions/2026-08-31T18-35-57-734Z_01a0591b-51a6-7cf0-8a6d-e9b31594cca4.jsonl",
|
|
83
|
+
"branchLengthAtSessionStart": 6,
|
|
84
|
+
"priorEntryIdsPreserved": true,
|
|
85
|
+
"modelRecalledPriorTurn": "I output STATUS: success."
|
|
86
|
+
},
|
|
87
|
+
"nameSource": "--name; pi.getSessionName() returns it at session_start, so the extension's rename is a no-op",
|
|
88
|
+
"entryShape": "SessionEntry { type, id, parentId, timestamp, message }; branch also carries non-message entries session_info, model_change, thinking_level_change; assistant message has role/content[{type:text}]/stopReason=stop"
|
|
89
|
+
},
|
|
90
|
+
"extension": {
|
|
91
|
+
"events": [
|
|
92
|
+
"session_start",
|
|
93
|
+
"agent_settled"
|
|
94
|
+
],
|
|
95
|
+
"sessionStartEventShape": [
|
|
96
|
+
"type",
|
|
97
|
+
"reason"
|
|
98
|
+
],
|
|
99
|
+
"agentSettledEventShape": [
|
|
100
|
+
"type"
|
|
101
|
+
],
|
|
102
|
+
"sessionStartContext": {
|
|
103
|
+
"mode": "tui",
|
|
104
|
+
"hasUI": true,
|
|
105
|
+
"cwd": "/tmp/pi-live-contract/project",
|
|
106
|
+
"uiSelectType": "function"
|
|
107
|
+
},
|
|
108
|
+
"actionsExercisedLive": [
|
|
109
|
+
"pi.getSessionName",
|
|
110
|
+
"pi.sendUserMessage",
|
|
111
|
+
"ctx.sessionManager.getSessionId",
|
|
112
|
+
"ctx.sessionManager.getSessionFile",
|
|
113
|
+
"ctx.sessionManager.getBranch",
|
|
114
|
+
"ctx.ui.select"
|
|
115
|
+
],
|
|
116
|
+
"ui": "ctx.ui.select(title, options) -> Promise<string | undefined>; live selector rendered 'Approve relay-flow report for PAY-101:review' with Approve/Reject",
|
|
117
|
+
"hasUIIsTheRealGuard": "ctx.ui.select remains typeof 'function' in print mode while ctx.hasUI is false"
|
|
118
|
+
},
|
|
119
|
+
"errors": {
|
|
120
|
+
"bareDoubleDash": "Error: Unknown option: --",
|
|
121
|
+
"agentFlag": "Error: Unknown option: --agent",
|
|
122
|
+
"interactiveFlag": "Error: Unknown option: --interactive",
|
|
123
|
+
"reportFlag": "Error: Unknown option: --report",
|
|
124
|
+
"agentList": "No named-agent listing command; pi --list-models lists models only"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Live non-TTY run of installed Pi 0.84.1 (stdin and stdout redirected, real provider):
|
|
2
|
+
|
|
3
|
+
$ pi --name "PAY-101:implement" --session-dir <dir> --model github-copilot/claude-haiku-4.5 \
|
|
4
|
+
--thinking off -nt -e <capture extension> "Reply with exactly: PRINT MODE OK" < /dev/null > out.txt
|
|
5
|
+
exit=0
|
|
6
|
+
stdout: PRINT MODE OK
|
|
7
|
+
|
|
8
|
+
Extension context observed in the same run: mode=print, hasUI=false, stdinIsTTY=false,
|
|
9
|
+
stdoutIsTTY=false, and the process exited after the single response. This is why the
|
|
10
|
+
relay-flow launch path requires the runner's PTY: without both TTY streams Pi selects
|
|
11
|
+
print mode and cannot host an interactive HITL session.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Sanitized excerpts from the live Pi 0.84.1 TUI run (PTY supplied by script(1)); ANSI
|
|
2
|
+
sequences stripped, repeated spinner frames removed.
|
|
3
|
+
|
|
4
|
+
π - PAY-101:review - project <- terminal title from the session name
|
|
5
|
+
/tmp/pi-live-contract/project • PAY-101:review <- footer: cwd • session name (--name)
|
|
6
|
+
↑641 ↓107 $0.001 (sub) 0.4%/200k (auto) claude-haiku-4.5 • thinking off
|
|
7
|
+
|
|
8
|
+
STATUS: success
|
|
9
|
+
...
|
|
10
|
+
VERIFICATION: Ran the live Pi smoke test
|
|
11
|
+
|
|
12
|
+
Approve relay-flow report for PAY-101:review <- ctx.ui.select title from plugin/pi.ts
|
|
13
|
+
Approve
|
|
14
|
+
Reject
|
|
15
|
+
|
|
16
|
+
The process stayed alive after the response settled and after the selector was dismissed
|
|
17
|
+
with Escape; it was terminated by the smoke test, not by Pi.
|