relay-flow 0.3.9-alpha → 0.3.11-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 (57) hide show
  1. package/README.md +30 -34
  2. package/cmd/relay-flow/commands_test.go +449 -23
  3. package/cmd/relay-flow/main.go +207 -37
  4. package/cmd/relay-flow/observability_test.go +11 -0
  5. package/cmd/relay-flow/onboarding.go +1 -1
  6. package/cmd/relay-flow/onboarding_test.go +1 -1
  7. package/cmd/relay-flow/scenario_test.go +26 -10
  8. package/cmd/relay-flow/serve.go +27 -1
  9. package/examples/config-reference.yaml +23 -19
  10. package/examples/workflow-reference.yaml +3 -0
  11. package/internal/execution/goworkflows/activities.go +13 -22
  12. package/internal/execution/goworkflows/engine_test.go +17 -3
  13. package/internal/execution/goworkflows/fakes_test.go +20 -0
  14. package/internal/execution/goworkflows/interpreter.go +25 -11
  15. package/internal/execution/goworkflows/mailbox_test.go +28 -1
  16. package/internal/execution/goworkflows/ownership_test.go +259 -0
  17. package/internal/execution/goworkflows/recovery_test.go +81 -1
  18. package/internal/execution/temporal/activities.go +13 -22
  19. package/internal/execution/temporal/compact_mailbox_test.go +56 -0
  20. package/internal/execution/temporal/interpreter.go +16 -3
  21. package/internal/execution/temporal/recovery.go +68 -36
  22. package/internal/harness/harness.go +23 -10
  23. package/internal/harness/opencode/opencode.go +22 -4
  24. package/internal/harness/opencode/opencode_test.go +41 -1
  25. package/internal/harness/opencode/repo_setup.go +1 -1
  26. package/internal/harness/opencode/task_env_test.go +2 -2
  27. package/internal/harness/pi/pi.go +21 -2
  28. package/internal/harness/pi/pi_test.go +7 -4
  29. package/internal/harness/pi/prompt_test.go +13 -5
  30. package/internal/harness/pi/task_env_test.go +18 -2
  31. package/internal/recover/recover.go +30 -1
  32. package/internal/repo/binding_test.go +63 -0
  33. package/internal/repo/repo.go +47 -6
  34. package/internal/router/router.go +39 -5
  35. package/internal/router/router_test.go +67 -1
  36. package/internal/run/manager.go +90 -3
  37. package/internal/run/run_manager_test.go +74 -0
  38. package/internal/server/api_test.go +13 -0
  39. package/internal/server/client.go +7 -0
  40. package/internal/server/fixture_test.go +5 -0
  41. package/internal/server/server.go +32 -3
  42. package/internal/task/beads/beads.go +201 -3
  43. package/internal/task/beads/beads_test.go +172 -0
  44. package/internal/task/jira/effects_test.go +86 -0
  45. package/internal/task/jira/filters_test.go +103 -0
  46. package/internal/task/jira/helpers_test.go +11 -2
  47. package/internal/task/jira/jira.go +244 -15
  48. package/internal/task/jira/normalize.go +30 -15
  49. package/internal/task/jira/rest/client.go +17 -2
  50. package/internal/task/jira/templates_test.go +38 -2
  51. package/internal/task/jira/transition_defaults_test.go +3 -1
  52. package/internal/task/task.go +67 -0
  53. package/internal/workflow/report.go +10 -0
  54. package/internal/workflow/report_test.go +16 -0
  55. package/internal/workflow/workflow.go +24 -0
  56. package/internal/workflow/workflow_test.go +58 -1
  57. package/package.json +1 -1
@@ -0,0 +1,56 @@
1
+ package temporal
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/run"
8
+ "github.com/rajpopat27/relay-flow/internal/task"
9
+ "github.com/rajpopat27/relay-flow/internal/workflow"
10
+ )
11
+
12
+ type compactMailboxRenderer struct{ *lagTaskSystem }
13
+
14
+ func (*compactMailboxRenderer) RenderText(_ task.TextKind, data task.TextData) (string, error) {
15
+ return data.Ticket + " / " + data.Node + " — " + data.NodeType + " — " + data.Agent +
16
+ "\n" + data.NodeDescription + "\nSuccess routes:\n" + data.SuccessRoutes +
17
+ "\nFailure routes:\n" + data.FailureRoutes + "\n" + data.Report, nil
18
+ }
19
+
20
+ func TestCompactMailboxTemplateDoesNotAppendGenericInstructions(t *testing.T) {
21
+ wf := workflow.Workflow{Name: "test", Nodes: map[string]workflow.Node{
22
+ "coder": {Type: workflow.NodeAgent, Agent: "coder", Description: "Implement", OnSuccess: []workflow.Route{{Target: "end"}}},
23
+ }}
24
+ specs, err := RenderMailboxSpecs(&compactMailboxRenderer{&lagTaskSystem{}}, run.Work{
25
+ Parent: task.TicketRef{Key: "PAY-101"}, Repo: "payments", Workflow: wf.Name,
26
+ }, &wf)
27
+ if err != nil {
28
+ t.Fatal(err)
29
+ }
30
+ if len(specs) != 1 {
31
+ t.Fatalf("compact mailbox descriptions = %+v", specs)
32
+ }
33
+ for _, required := range []string{"PAY-101 / coder — agent — coder", "Implement", "Success routes:\nend", "Failure routes:", workflow.ReportFormat} {
34
+ if !strings.Contains(specs[0].Description, required) {
35
+ t.Fatalf("mailbox description lacks %q: %q", required, specs[0].Description)
36
+ }
37
+ }
38
+ if strings.Contains(specs[0].Description, "Required report format:") {
39
+ t.Fatalf("generic instructions appended to Jira template: %q", specs[0].Description)
40
+ }
41
+ }
42
+
43
+ func TestConciseReportRendersOnlySelectedSummaryAndFeedback(t *testing.T) {
44
+ r := workflow.Report{
45
+ Summary: workflow.Summary{Completed: "done", Commits: workflow.None, NotCompleted: workflow.None,
46
+ IssuesDiscovered: workflow.None, Verification: workflow.None, Notes: workflow.None},
47
+ Feedback: workflow.Feedback{ReasonForNextStep: workflow.None, RequiredActions: "review it",
48
+ RelevantContext: workflow.None, ExpectedResult: workflow.None},
49
+ }
50
+ if got := renderSummaryReport(r); got != "done" {
51
+ t.Fatalf("summary comment = %q", got)
52
+ }
53
+ if got := renderFeedbackReport(r); got != "review it" {
54
+ t.Fatalf("feedback comment = %q", got)
55
+ }
56
+ }
@@ -342,6 +342,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
342
342
  state.upsertStep(startStep)
343
343
 
344
344
  current := target
345
+ previousFeedback := ""
345
346
  lastStepByNode := map[string]int64{}
346
347
  lastDepthByNode := map[string]int{}
347
348
  for current != "end" {
@@ -402,11 +403,12 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
402
403
  spec := harness.LaunchSpec{
403
404
  RunID: start.ID, NodeVisitID: visit, RepoName: start.Repo, RepoPath: start.RepoPath,
404
405
  Workflow: wf.Name, Ticket: start.Ticket.Key, Node: current, NodeType: node.Type,
405
- Agent: node.Agent, Title: start.Ticket.Key + ":" + current, NudgePrompt: node.NudgePrompt,
406
+ AutoReject: node.AutoReject,
407
+ Agent: node.Agent, Title: start.Ticket.Key + ":" + current, NudgePrompt: node.NudgePrompt,
406
408
  PromptData: harness.PromptData{
407
409
  TaskSystem: "", Ticket: start.Ticket.Key, Workflow: wf.Name, Repo: start.Repo,
408
410
  Node: current, NodeType: node.Type, Agent: node.Agent, NodeDescription: node.Description,
409
- NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key,
411
+ NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key, PreviousFeedback: previousFeedback,
410
412
  },
411
413
  NextSteps: nextSteps,
412
414
  }
@@ -515,7 +517,9 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
515
517
  stepMessage := accepted.Report.Summary.Completed
516
518
  if accepted.Report.Status == domainworkflow.OutcomeFailure {
517
519
  stepStatus = run.StepFailed
518
- stepMessage = accepted.Report.Summary.IssuesDiscovered
520
+ if accepted.Report.Summary.IssuesDiscovered != domainworkflow.None {
521
+ stepMessage = accepted.Report.Summary.IssuesDiscovered
522
+ }
519
523
  }
520
524
  stepFinished := temporalworkflow.Now(ctx).UTC()
521
525
  step.Status, step.FinishedAt, step.Message, step.Route = stepStatus, &stepFinished, stepMessage, accepted.Report.NextStep
@@ -568,6 +572,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
568
572
  return err
569
573
  }
570
574
  applyRuntimePolicy(state, current, work.Runtime)
575
+ previousFeedback = accepted.Report.Feedback.RequiredActions
571
576
  current = next
572
577
  }
573
578
 
@@ -858,11 +863,19 @@ func applyRuntimePolicy(state *workflowState, node string, policy run.RuntimePol
858
863
  }
859
864
 
860
865
  func renderSummaryReport(r domainworkflow.Report) string {
866
+ if r.Summary.Commits == domainworkflow.None && r.Summary.NotCompleted == domainworkflow.None &&
867
+ r.Summary.IssuesDiscovered == domainworkflow.None && r.Summary.Verification == domainworkflow.None && r.Summary.Notes == domainworkflow.None {
868
+ return r.Summary.Completed
869
+ }
861
870
  return fmt.Sprintf("COMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s",
862
871
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
863
872
  }
864
873
 
865
874
  func renderFeedbackReport(r domainworkflow.Report) string {
875
+ if r.Summary.Commits == domainworkflow.None && r.Feedback.ReasonForNextStep == domainworkflow.None &&
876
+ r.Feedback.RelevantContext == domainworkflow.None && r.Feedback.ExpectedResult == domainworkflow.None {
877
+ return r.Feedback.RequiredActions
878
+ }
866
879
  return fmt.Sprintf("COMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s",
867
880
  r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
868
881
  }
@@ -6,10 +6,12 @@ import (
6
6
  "fmt"
7
7
  "log/slog"
8
8
  "sort"
9
- "strings"
10
9
  "time"
11
10
 
12
11
  "github.com/rajpopat27/relay-flow/internal/identity"
12
+ "github.com/rajpopat27/relay-flow/internal/repo"
13
+ "github.com/rajpopat27/relay-flow/internal/router"
14
+ "github.com/rajpopat27/relay-flow/internal/task"
13
15
  enumspb "go.temporal.io/api/enums/v1"
14
16
  "go.temporal.io/api/serviceerror"
15
17
  workflowpb "go.temporal.io/api/workflow/v1"
@@ -145,52 +147,82 @@ func (e *Engine) reconcileClaimedParents(ctx context.Context, visible map[string
145
147
  slog.Warn("Temporal recovery skipping repository with unavailable task system", "repo", rp.Name, "error", rp.TaskSystemError)
146
148
  continue
147
149
  }
150
+ capabilities, ok := rp.TaskSystem.(task.OwnershipCapabilities)
151
+ if !ok {
152
+ slog.Warn("Temporal recovery skipping repository without ownership capabilities", "repo", rp.Name,
153
+ "outcome", "ownership-capability-missing")
154
+ continue
155
+ }
148
156
  tickets, err := rp.TaskSystem.Poll(ctx)
149
157
  if err != nil {
150
158
  return fmt.Errorf("poll repo %q during Temporal recovery: %w", rp.Name, err)
151
159
  }
152
160
  for _, ticket := range tickets {
153
- for _, claim := range ticket.WorkflowClaims {
154
- if !strings.HasPrefix(claim, "wf:") {
155
- continue
156
- }
157
- workflowName := strings.TrimPrefix(claim, "wf:")
158
- var matched bool
159
- for _, binding := range rp.Bindings() {
160
- if binding.Workflow != nil && binding.Workflow.Name == workflowName {
161
- matched = true
162
- break
163
- }
164
- }
165
- if !matched {
166
- continue
167
- }
168
- expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
169
- if visible[string(expectedID)] {
170
- continue
161
+ // Projection reconciliation only considers claimed parents. Resolve
162
+ // through the same router as normal polling so ambiguous, unknown,
163
+ // and owner-mismatched claims cannot restore an execution.
164
+ if len(ticket.WorkflowClaims) != 1 {
165
+ continue
166
+ }
167
+ wf, err := router.ResolveWorkflow(rp, ticket)
168
+ if err != nil {
169
+ var ownerMismatch *router.ClaimOwnerMismatchError
170
+ if errors.As(err, &ownerMismatch) {
171
+ slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
172
+ "workflow", ownerMismatch.Workflow, "outcome", "claim-owner-mismatch")
173
+ } else {
174
+ slog.Warn("Temporal recovery skipped claimed ticket", "repo", rp.Name, "ticket", ticket.Key, "outcome", "invalid-claim", "error", err)
171
175
  }
172
- info, err := e.client.DescribeWorkflowExecution(ctx, string(expectedID), "")
173
- if err != nil {
174
- var notFound *serviceerror.NotFound
175
- if errors.As(err, &notFound) {
176
- // This is the documented claim-before-run gap. Record it
177
- // for operators, but leave creation to normal polling after
178
- // recovery; this path must never start a replacement.
179
- slog.Info("Temporal recovery missing claimed execution", "repo", rp.Name, "workflow", workflowName, "ticket", ticket.Key, "runID", string(expectedID))
180
- continue
181
- }
182
- return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
176
+ continue
177
+ }
178
+ workflowName := wf.Name
179
+ var matched *repo.WorkflowBinding
180
+ for _, binding := range rp.Bindings() {
181
+ if binding.Workflow != nil && binding.Workflow.Name == workflowName {
182
+ candidate := binding
183
+ matched = &candidate
184
+ break
183
185
  }
184
- if info == nil || info.WorkflowExecutionInfo == nil || info.WorkflowExecutionInfo.Execution == nil ||
185
- info.WorkflowExecutionInfo.Type == nil || info.WorkflowExecutionInfo.Type.Name != TicketWorkflowName || info.WorkflowExecutionInfo.TaskQueue != TaskQueue {
186
+ }
187
+ if matched == nil {
188
+ continue
189
+ }
190
+ // Existing Temporal executions are restored only after the same
191
+ // ownership gate used by normal claimed routing. Lifecycle
192
+ // filters remain intentionally bypassed.
193
+ if err := capabilities.ValidateOwnership(ctx, ticket.Ref(), workflowName, matched.Workflow.TaskConfig); err != nil {
194
+ if errors.Is(err, task.ErrOwnershipMismatch) {
195
+ slog.Info("Temporal recovery route outcome", "repo", rp.Name, "ticket", ticket.Key,
196
+ "workflow", workflowName, "outcome", "claim-owner-mismatch", "error", err)
186
197
  continue
187
198
  }
188
- if !shouldRestoreTemporalExecution(info.WorkflowExecutionInfo, time.Now().UTC(), e.retention) {
199
+ return fmt.Errorf("poll repo %q ticket %s validate ownership: %w", rp.Name, ticket.Key, err)
200
+ }
201
+ expectedID := identity.NewRunID(rp.Name, workflowName, ticket.Key)
202
+ if visible[string(expectedID)] {
203
+ continue
204
+ }
205
+ info, err := e.client.DescribeWorkflowExecution(ctx, string(expectedID), "")
206
+ if err != nil {
207
+ var notFound *serviceerror.NotFound
208
+ if errors.As(err, &notFound) {
209
+ // This is the documented claim-before-run gap. Record it
210
+ // for operators, but leave creation to normal polling after
211
+ // recovery; this path must never start a replacement.
212
+ slog.Info("Temporal recovery missing claimed execution", "repo", rp.Name, "workflow", workflowName, "ticket", ticket.Key, "runID", string(expectedID))
189
213
  continue
190
214
  }
191
- if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
192
- return fmt.Errorf("restore claimed Temporal workflow %s: %w", expectedID, err)
193
- }
215
+ return fmt.Errorf("describe claimed Temporal workflow %s: %w", expectedID, err)
216
+ }
217
+ if info == nil || info.WorkflowExecutionInfo == nil || info.WorkflowExecutionInfo.Execution == nil ||
218
+ info.WorkflowExecutionInfo.Type == nil || info.WorkflowExecutionInfo.Type.Name != TicketWorkflowName || info.WorkflowExecutionInfo.TaskQueue != TaskQueue {
219
+ continue
220
+ }
221
+ if !shouldRestoreTemporalExecution(info.WorkflowExecutionInfo, time.Now().UTC(), e.retention) {
222
+ continue
223
+ }
224
+ if err := e.restoreProjection(ctx, info.WorkflowExecutionInfo); err != nil {
225
+ return fmt.Errorf("restore claimed Temporal workflow %s: %w", expectedID, err)
194
226
  }
195
227
  }
196
228
  }
@@ -24,20 +24,32 @@ const (
24
24
  PromptFeedback PromptKind = "feedback"
25
25
  )
26
26
 
27
+ // Jira prompt defaults keep each agent read limited to the relevant mailbox.
28
+ // A node that needs its parent's details can request the optional parent-read
29
+ // instruction in its node description.
30
+ const JiraInitialPrompt = `Read the description of your assigned Jira task:
31
+ acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
32
+
33
+ Follow the work instructions, valid routes, and report format in that description.`
34
+
35
+ const JiraFeedbackPrompt = `Continue {{node}} from the latest update on your assigned Jira task:
36
+ acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json`
37
+
27
38
  // PromptData is the task-system-neutral data core supplies to the selected
28
39
  // harness. Harness templates, including HITL instructions, are rendered only
29
40
  // by the harness.
30
41
  type PromptData struct {
31
- TaskSystem string
32
- Ticket string
33
- Workflow string
34
- Repo string
35
- Node string
36
- NodeType workflow.NodeType
37
- Agent string
38
- NodeDescription string
39
- NextSteps string
40
- Mailbox string
42
+ TaskSystem string
43
+ Ticket string
44
+ Workflow string
45
+ Repo string
46
+ Node string
47
+ NodeType workflow.NodeType
48
+ Agent string
49
+ NodeDescription string
50
+ NextSteps string
51
+ Mailbox string
52
+ PreviousFeedback string
41
53
  }
42
54
 
43
55
  type LaunchSpec struct {
@@ -49,6 +61,7 @@ type LaunchSpec struct {
49
61
  Ticket string
50
62
  Node string
51
63
  NodeType workflow.NodeType
64
+ AutoReject bool
52
65
  Agent string
53
66
  Title string
54
67
  Prompt string
@@ -17,6 +17,7 @@ import (
17
17
  "os/exec"
18
18
  "path/filepath"
19
19
  "regexp"
20
+ "strconv"
20
21
  "strings"
21
22
 
22
23
  "github.com/rajpopat27/relay-flow/internal/config"
@@ -136,23 +137,35 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
136
137
  return harness.Session{}, false, nil
137
138
  }
138
139
 
139
- // RenderPrompt renders the selected session prompt, appends the harness-owned
140
- // HITL/TUI instructions for HITL nodes, then renders and appends the node's
141
- // nudge template.
140
+ // RenderPrompt renders the selected session prompt and HITL instructions.
141
+ // Jira first launches omit the node nudge so the initial read stays limited
142
+ // to the assigned mailbox description.
142
143
  func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
143
144
  var tmpl string
144
145
  switch kind {
145
146
  case harness.PromptInitial:
146
147
  tmpl = h.templates.Initial
148
+ if data.TaskSystem == "jira" && tmpl == defaultInitialPrompt {
149
+ tmpl = harness.JiraInitialPrompt
150
+ }
147
151
  case harness.PromptFeedback:
148
152
  tmpl = h.templates.Feedback
153
+ if data.TaskSystem == "jira" && tmpl == defaultFeedbackPrompt {
154
+ tmpl = harness.JiraFeedbackPrompt
155
+ }
149
156
  default:
150
157
  return "", fmt.Errorf("opencode: unknown prompt kind %q", kind)
151
158
  }
152
159
  prompt := renderTemplate(tmpl, data)
153
- if data.NodeType == workflow.NodeHITL {
160
+ if data.TaskSystem == "jira" && data.PreviousFeedback != "" {
161
+ prompt = appendPrompt(prompt, "Previous step feedback: "+data.PreviousFeedback)
162
+ }
163
+ if data.NodeType == workflow.NodeHITL && (data.TaskSystem != "jira" || h.templates.HITL != defaultHITLPrompt) {
154
164
  prompt = appendPrompt(prompt, renderTemplate(h.templates.HITL, data))
155
165
  }
166
+ if kind == harness.PromptInitial && data.TaskSystem == "jira" {
167
+ return prompt, nil
168
+ }
156
169
  return appendPrompt(prompt, renderTemplate(nudgeTemplate, data)), nil
157
170
  }
158
171
 
@@ -203,9 +216,14 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
203
216
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
204
217
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
205
218
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
219
+ "RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
206
220
  } {
207
221
  env[key] = value
208
222
  }
223
+ if spec.NodeType == workflow.NodeHITL {
224
+ // HITL policy is launch metadata, not part of the report wire object.
225
+ env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
226
+ }
209
227
  args := []string{}
210
228
  if spec.ResumeID != "" {
211
229
  args = append(args, "--session", spec.ResumeID)
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.3.9-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.11-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -57,6 +57,22 @@ func TestBuildCommandArgv(t *testing.T) {
57
57
  }
58
58
  }
59
59
 
60
+ func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
61
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
62
+ cmd, err := opencode.New().BuildCommand(harness.LaunchSpec{
63
+ NodeType: workflow.NodeHITL,
64
+ AutoReject: true,
65
+ Agent: "reviewer",
66
+ Prompt: "review the ticket",
67
+ })
68
+ if err != nil {
69
+ t.Fatalf("BuildCommand: %v", err)
70
+ }
71
+ if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
72
+ t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
73
+ }
74
+ }
75
+
60
76
  func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
61
77
  raw := config.RawValues{
62
78
  "initial": "initial {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nodeType}}|{{agent}}|{{nodeDescription}}|{{nextSteps}}|{{mailbox}}",
@@ -89,6 +105,30 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
89
105
  }
90
106
  }
91
107
 
108
+ func TestJiraDefaultPromptsUseOnlyMailboxReadsAndSelectedFeedback(t *testing.T) {
109
+ h := opencode.New()
110
+ data := harness.PromptData{
111
+ TaskSystem: "jira", Ticket: "PAY-101", Mailbox: "PAY-234", Node: "coder", NodeType: workflow.NodeHITL,
112
+ }
113
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, "Read the latest mailbox feedback.")
114
+ if err != nil {
115
+ t.Fatal(err)
116
+ }
117
+ wantInitial := "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
118
+ if initial != wantInitial {
119
+ t.Fatalf("Jira initial prompt = %q, want %q", initial, wantInitial)
120
+ }
121
+ data.PreviousFeedback = "Review found a missing test; add it."
122
+ continuation, err := h.RenderPrompt(harness.PromptFeedback, data, "")
123
+ if err != nil {
124
+ t.Fatal(err)
125
+ }
126
+ wantContinuation := "Continue coder from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nPrevious step feedback: Review found a missing test; add it."
127
+ if continuation != wantContinuation {
128
+ t.Fatalf("Jira continuation = %q, want %q", continuation, wantContinuation)
129
+ }
130
+ }
131
+
92
132
  func TestHarnessConfigRejectsUnknownPromptVariable(t *testing.T) {
93
133
  _, err := harness.New("opencode", config.RawValues{"initial": "{{unknown}}"})
94
134
  if err == nil || !strings.Contains(err.Error(), "unknown template variable {{unknown}}") {
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.3.9-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.11-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -51,7 +51,7 @@ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
51
51
  if err != nil {
52
52
  t.Fatalf("BuildCommand: %v", err)
53
53
  }
54
- if len(cmd.Env) != 9 {
55
- t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
54
+ if len(cmd.Env) != 10 {
55
+ t.Fatalf("Env = %#v, want only the ten relay-flow variables", cmd.Env)
56
56
  }
57
57
  }
@@ -13,11 +13,13 @@ import (
13
13
  "os/exec"
14
14
  "path/filepath"
15
15
  "regexp"
16
+ "strconv"
16
17
  "strings"
17
18
 
18
19
  "github.com/rajpopat27/relay-flow/internal/config"
19
20
  "github.com/rajpopat27/relay-flow/internal/harness"
20
21
  "github.com/rajpopat27/relay-flow/internal/runner"
22
+ "github.com/rajpopat27/relay-flow/internal/workflow"
21
23
  )
22
24
 
23
25
  const (
@@ -148,7 +150,7 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
148
150
  }
149
151
 
150
152
  // RenderPrompt renders the selected initial or feedback template and the
151
- // node's nudge template. Initial prompts use Pi's native prompt-template
153
+ // node's nudge template (except on a Jira first launch). Initial prompts use Pi's native prompt-template
152
154
  // command syntax; feedback is sent to an existing session and must remain
153
155
  // raw so Pi does not expand the full prompt template again. HITL approval is
154
156
  // not encoded in the prompt; the Pi extension asks for approval through
@@ -158,8 +160,14 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
158
160
  switch kind {
159
161
  case harness.PromptInitial:
160
162
  tmpl = h.templates.Initial
163
+ if data.TaskSystem == "jira" && tmpl == defaultInitialPrompt {
164
+ tmpl = harness.JiraInitialPrompt
165
+ }
161
166
  case harness.PromptFeedback:
162
167
  tmpl = h.templates.Feedback
168
+ if data.TaskSystem == "jira" && tmpl == defaultFeedbackPrompt {
169
+ tmpl = harness.JiraFeedbackPrompt
170
+ }
163
171
  default:
164
172
  return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
165
173
  }
@@ -168,7 +176,13 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
168
176
  return "", err
169
177
  }
170
178
  }
171
- prompt := appendPrompt(renderTemplate(tmpl, data), renderTemplate(nudgeTemplate, data))
179
+ prompt := renderTemplate(tmpl, data)
180
+ if data.TaskSystem == "jira" && data.PreviousFeedback != "" {
181
+ prompt = appendPrompt(prompt, "Previous step feedback: "+data.PreviousFeedback)
182
+ }
183
+ if kind != harness.PromptInitial || data.TaskSystem != "jira" {
184
+ prompt = appendPrompt(prompt, renderTemplate(nudgeTemplate, data))
185
+ }
172
186
  if kind == harness.PromptInitial {
173
187
  return applyPromptTemplate(data.Agent, prompt), nil
174
188
  }
@@ -211,9 +225,14 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
211
225
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
212
226
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
213
227
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
228
+ "RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
214
229
  } {
215
230
  env[key] = value
216
231
  }
232
+ if spec.NodeType == workflow.NodeHITL {
233
+ // HITL policy is launch metadata, not part of the report wire object.
234
+ env["RELAY_FLOW_AUTO_REJECT"] = strconv.FormatBool(spec.AutoReject)
235
+ }
217
236
  args := []string{"--name", spec.Title}
218
237
  if spec.Agent != "default" {
219
238
  args = append(args, "--prompt-template", promptTemplatePath(spec.Agent))
@@ -42,6 +42,7 @@ func TestBuildCommandUsesStrictPiCLIContract(t *testing.T) {
42
42
  "RELAY_FLOW_NODE_TYPE": string(base.NodeType),
43
43
  "RELAY_FLOW_NUDGE_PROMPT": base.NudgePrompt,
44
44
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
45
+ "RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
45
46
  }
46
47
 
47
48
  tests := []struct {
@@ -248,13 +249,13 @@ func runStrictPi(t *testing.T, command runner.Command, cwd, capturePath string)
248
249
  t.Fatalf("read strict Pi capture: %v", err)
249
250
  }
250
251
  fields := strings.Split(string(data), "\x00")
251
- if len(fields) < 12 || fields[len(fields)-1] != "" {
252
+ if len(fields) < 13 || fields[len(fields)-1] != "" {
252
253
  t.Fatalf("malformed strict Pi capture: %q", data)
253
254
  }
254
255
  fields = fields[:len(fields)-1]
255
256
  capture := piCapture{
256
257
  cwd: fields[0],
257
- args: strings.Split(fields[10], "\x1f"),
258
+ args: strings.Split(fields[11], "\x1f"),
258
259
  env: map[string]string{
259
260
  "RELAY_FLOW_HOME": fields[1],
260
261
  "RELAY_FLOW_RUN_ID": fields[2],
@@ -265,6 +266,7 @@ func runStrictPi(t *testing.T, command runner.Command, cwd, capturePath string)
265
266
  "RELAY_FLOW_NODE_TYPE": fields[7],
266
267
  "RELAY_FLOW_NUDGE_PROMPT": fields[8],
267
268
  "RELAY_FLOW_NEXT_STEPS_JSON": fields[9],
269
+ "RELAY_FLOW_REPORT_FORMAT": fields[10],
268
270
  },
269
271
  }
270
272
  return capture
@@ -325,11 +327,12 @@ fi
325
327
  [ -n "${RELAY_FLOW_NODE:-}" ] || exit 3
326
328
  [ -n "${RELAY_FLOW_NODE_TYPE:-}" ] || exit 3
327
329
  [ -n "${RELAY_FLOW_NEXT_STEPS_JSON:-}" ] || exit 3
330
+ [ -n "${RELAY_FLOW_REPORT_FORMAT:-}" ] || exit 3
328
331
 
329
- printf '%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000' \
332
+ printf '%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000%s\000' \
330
333
  "$PWD" "$RELAY_FLOW_HOME" "$RELAY_FLOW_RUN_ID" "$RELAY_FLOW_WORKFLOW" \
331
334
  "$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"
335
+ "${RELAY_FLOW_NUDGE_PROMPT:-}" "$RELAY_FLOW_NEXT_STEPS_JSON" "$RELAY_FLOW_REPORT_FORMAT" "$original_args" > "$capture"
333
336
  `
334
337
  if err := os.WriteFile(executable, []byte(script), 0o700); err != nil {
335
338
  t.Fatal(err)
@@ -29,7 +29,7 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
29
29
  if err != nil {
30
30
  t.Fatalf("RenderPrompt(initial): %v", err)
31
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\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
32
+ wantInitial := "Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
33
33
  if initial != wantInitial {
34
34
  t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
35
35
  }
@@ -38,10 +38,18 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
38
38
  if err != nil {
39
39
  t.Fatalf("RenderPrompt(feedback): %v", err)
40
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|PAY-234|review (when: ready)"
41
+ wantFeedback := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
42
42
  if feedback != wantFeedback {
43
43
  t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
44
44
  }
45
+ data.PreviousFeedback = "Add the missing test."
46
+ continued, err := h.RenderPrompt(harness.PromptFeedback, data, "")
47
+ if err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ if want := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nPrevious step feedback: Add the missing test."; continued != want {
51
+ t.Fatalf("Jira Pi continuation = %q, want %q", continued, want)
52
+ }
45
53
  }
46
54
 
47
55
  func TestPiRenderPromptHonorsNodeNudgeTimingInput(t *testing.T) {
@@ -107,11 +115,11 @@ func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.
107
115
  Mailbox: "PAY-234",
108
116
  }
109
117
 
110
- initial, err := h.RenderPrompt(harness.PromptInitial, data, "nudge {{node}}")
118
+ initial, err := h.RenderPrompt(harness.PromptInitial, data, "Read the latest mailbox feedback for {{node}}.")
111
119
  if err != nil {
112
120
  t.Fatalf("RenderPrompt(initial): %v", err)
113
121
  }
114
- wantInitial := "/coder 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\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge implement"
122
+ wantInitial := "/coder Read the description of your assigned Jira task:\nacli jira workitem view \"PAY-234\" --fields \"summary,description\" --json\n\nFollow the work instructions, valid routes, and report format in that description."
115
123
  if initial != wantInitial {
116
124
  t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
117
125
  }
@@ -120,7 +128,7 @@ func TestPiRenderPromptUsesNativeTemplateCommandForInitialNamedAgent(t *testing.
120
128
  if err != nil {
121
129
  t.Fatalf("RenderPrompt(feedback): %v", err)
122
130
  }
123
- wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nfeedback nudge"
131
+ wantFeedback := "Continue implement from the latest update on your assigned Jira task:\nacli jira workitem comment list --key \"PAY-234\" --limit 1 --order \"-created\" --json\n\nfeedback nudge"
124
132
  if feedback != wantFeedback {
125
133
  t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
126
134
  }
@@ -2,8 +2,24 @@ package pi
2
2
 
3
3
  import (
4
4
  "testing"
5
+
6
+ "github.com/rajpopat27/relay-flow/internal/workflow"
5
7
  )
6
8
 
9
+ func TestBuildCommandCarriesHITLAutoRejectPolicy(t *testing.T) {
10
+ t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
11
+ spec := launchSpec(t)
12
+ spec.NodeType = workflow.NodeHITL
13
+ spec.AutoReject = true
14
+ cmd, err := (&Harness{}).BuildCommand(spec)
15
+ if err != nil {
16
+ t.Fatalf("BuildCommand: %v", err)
17
+ }
18
+ if got := cmd.Env["RELAY_FLOW_AUTO_REJECT"]; got != "true" {
19
+ t.Fatalf("RELAY_FLOW_AUTO_REJECT = %q, want true", got)
20
+ }
21
+ }
22
+
7
23
  // TestBuildCommandCarriesTaskSystemEnvironment asserts the task-system
8
24
  // workspace environment reaches the runner command so agent task commands
9
25
  // address the same workspace as relay-flow, and that relay-flow's own
@@ -45,7 +61,7 @@ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
45
61
  if err != nil {
46
62
  t.Fatalf("BuildCommand: %v", err)
47
63
  }
48
- if len(cmd.Env) != 9 {
49
- t.Fatalf("Env = %#v, want only the nine relay-flow variables", cmd.Env)
64
+ if len(cmd.Env) != 10 {
65
+ t.Fatalf("Env = %#v, want only the ten relay-flow variables", cmd.Env)
50
66
  }
51
67
  }