relay-flow 0.2.1-alpha → 0.2.2-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 (72) hide show
  1. package/README.md +155 -22
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +74 -0
  4. package/cmd/relay-flow/main.go +25 -0
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +4 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +105 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine_test.go +33 -15
  16. package/internal/execution/goworkflows/fakes_test.go +50 -4
  17. package/internal/execution/goworkflows/interpreter.go +36 -29
  18. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  19. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  20. package/internal/execution/goworkflows/recovery_test.go +2 -2
  21. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  22. package/internal/harness/contract_test.go +10 -0
  23. package/internal/harness/factory.go +25 -3
  24. package/internal/harness/harness.go +30 -4
  25. package/internal/harness/opencode/opencode.go +125 -10
  26. package/internal/harness/opencode/opencode_test.go +183 -0
  27. package/internal/harness/opencode/repo_setup.go +361 -0
  28. package/internal/harness/plugin_selection_test.go +5 -5
  29. package/internal/recover/recover.go +11 -6
  30. package/internal/repo/service.go +10 -0
  31. package/internal/repo/service_test.go +51 -2
  32. package/internal/run/run.go +6 -4
  33. package/internal/task/beads/bdcli/bdcli.go +323 -0
  34. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  35. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  36. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  37. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  38. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  39. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  40. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  41. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  42. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  43. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  44. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  45. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  46. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  47. package/internal/task/beads/beads.go +840 -0
  48. package/internal/task/beads/beads_test.go +609 -0
  49. package/internal/task/beads/comments_test.go +242 -0
  50. package/internal/task/beads/config_compatibility_test.go +163 -0
  51. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  52. package/internal/task/beads/repo_composition_test.go +232 -0
  53. package/internal/task/beads/runtime_config_test.go +81 -0
  54. package/internal/task/beads/status_compatibility_test.go +233 -0
  55. package/internal/task/beads/status_test.go +257 -0
  56. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  57. package/internal/task/beads/validation_test.go +110 -0
  58. package/internal/task/contract_test.go +10 -0
  59. package/internal/task/factory.go +37 -4
  60. package/internal/task/jira/auth.go +27 -1
  61. package/internal/task/jira/auth_test.go +54 -1
  62. package/internal/task/jira/filters_test.go +60 -0
  63. package/internal/task/jira/jira.go +171 -29
  64. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  65. package/internal/task/jira/rest/adf.go +119 -82
  66. package/internal/task/jira/rest/adf_test.go +60 -0
  67. package/internal/task/jira/rest/client_test.go +1 -1
  68. package/internal/task/jira/templates_test.go +118 -0
  69. package/internal/task/jira/transition_defaults_test.go +4 -2
  70. package/internal/task/task.go +32 -0
  71. package/internal/workflow/report_test.go +45 -0
  72. package/package.json +1 -1
@@ -0,0 +1,124 @@
1
+ package goworkflows_test
2
+
3
+ import (
4
+ "context"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
10
+ "github.com/rajpopat27/relay-flow/internal/identity"
11
+ "github.com/rajpopat27/relay-flow/internal/run"
12
+ "github.com/rajpopat27/relay-flow/internal/task"
13
+ "github.com/rajpopat27/relay-flow/internal/workflow"
14
+ )
15
+
16
+ func TestEndSelectionSkipsFeedbackCommentActivity(t *testing.T) {
17
+ log := newEventLog()
18
+ sys := newFakeTaskSystem(log)
19
+ engine := newEndFeedbackTestEngine(t, sys)
20
+ wf := workflow.Workflow{
21
+ Name: "endFeedback",
22
+ Repos: []string{"payments"},
23
+ Nodes: map[string]workflow.Node{
24
+ "start": {OnSuccess: []workflow.Route{{Target: "implement"}}},
25
+ "implement": {
26
+ Type: workflow.NodeAgent, Agent: "build", Description: "implement",
27
+ OnSuccess: []workflow.Route{{Target: "end"}},
28
+ OnFailure: []workflow.Route{{Target: "implement"}},
29
+ },
30
+ "end": {},
31
+ },
32
+ }
33
+ rid := identity.NewRunID("payments", wf.Name, "PAY-101")
34
+ if _, err := engine.EnsureRun(context.Background(), run.Start{
35
+ ID: rid, Repo: "payments", RepoPath: "/srv/payments", Workflow: wf,
36
+ Ticket: task.TicketRef{ID: "demo-parent", Key: "demo-parent", Title: "parent"},
37
+ }); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ waitForEndFeedbackTest(t, engine, rid, func(r run.Run) bool {
41
+ return r.CurrentNode == "implement"
42
+ })
43
+
44
+ if _, err := engine.SubmitReport(context.Background(), run.ReportRequest{
45
+ RunID: rid, Node: "implement", ReportID: "end-feedback-test",
46
+ Report: endFeedbackSuccessReport(),
47
+ }); err != nil {
48
+ t.Fatal(err)
49
+ }
50
+ waitForEndFeedbackTest(t, engine, rid, func(r run.Run) bool {
51
+ return r.State == run.StateCompleted
52
+ })
53
+
54
+ events := log.all()
55
+ if !hasEvent(events, "comment:demo-parent-implement") {
56
+ t.Fatalf("current summary comment missing: %v", events)
57
+ }
58
+ if countEvent(events, "comment:demo-parent-implement") != 1 {
59
+ t.Fatalf("summary comment count = %d; events=%v", countEvent(events, "comment:demo-parent-implement"), events)
60
+ }
61
+ if hasEventPrefixExcept(events, "comment:", "comment:demo-parent-implement") {
62
+ t.Fatalf("end selection wrote feedback comment; events=%v", events)
63
+ }
64
+ }
65
+
66
+ func endFeedbackSuccessReport() workflow.Report {
67
+ none := "None"
68
+ return workflow.Report{
69
+ Status: workflow.OutcomeSuccess, NextStep: "end",
70
+ Summary: workflow.Summary{
71
+ Completed: "done", Commits: "abc123", NotCompleted: none,
72
+ IssuesDiscovered: none, Verification: "tested", Notes: none,
73
+ },
74
+ Feedback: workflow.Feedback{
75
+ ReasonForNextStep: none, RequiredActions: none,
76
+ RelevantContext: none, ExpectedResult: none,
77
+ },
78
+ }
79
+ }
80
+
81
+ func newEndFeedbackTestEngine(t *testing.T, sys task.System) *goworkflows.Engine {
82
+ t.Helper()
83
+ return newEngine(t, goworkflows.Dependencies{
84
+ Repos: repoRegistryWith("payments", sys),
85
+ Runner: newFakeRunner(newEventLog()),
86
+ Harness: newFakeHarness(newEventLog()),
87
+ })
88
+ }
89
+
90
+ func waitForEndFeedbackTest(t *testing.T, engine *goworkflows.Engine, id run.ID, predicate func(run.Run) bool) {
91
+ t.Helper()
92
+ waitFor(t, 10*time.Second, func() bool {
93
+ r, err := engine.GetRun(context.Background(), id)
94
+ return err == nil && predicate(r)
95
+ })
96
+ }
97
+
98
+ func hasEvent(events []string, want string) bool {
99
+ for _, event := range events {
100
+ if event == want {
101
+ return true
102
+ }
103
+ }
104
+ return false
105
+ }
106
+
107
+ func countEvent(events []string, want string) int {
108
+ count := 0
109
+ for _, event := range events {
110
+ if event == want {
111
+ count++
112
+ }
113
+ }
114
+ return count
115
+ }
116
+
117
+ func hasEventPrefixExcept(events []string, prefix, allowed string) bool {
118
+ for _, event := range events {
119
+ if strings.HasPrefix(event, prefix) && event != allowed {
120
+ return true
121
+ }
122
+ }
123
+ return false
124
+ }
@@ -9,6 +9,8 @@ import (
9
9
  "time"
10
10
 
11
11
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
12
+ "github.com/rajpopat27/relay-flow/internal/harness"
13
+ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
12
14
  "github.com/rajpopat27/relay-flow/internal/identity"
13
15
  "github.com/rajpopat27/relay-flow/internal/repo"
14
16
  "github.com/rajpopat27/relay-flow/internal/run"
@@ -44,33 +46,34 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
44
46
  wf := linearWorkflow(false)
45
47
  node := wf.Nodes["coding"]
46
48
  node.Type = workflow.NodeHITL
47
- description := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
49
+ spec := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node)
50
+ description := spec.Description
48
51
  for _, want := range []string{
49
- "Parent ticket: PAY-101",
50
- "Do not make code changes",
51
- "until the human is satisfied with the review",
52
+ "Required report format:",
53
+ "Node names identify workflow stages",
54
+ "SUMMARY is written to this current mailbox",
55
+ "requested changes in FEEDBACK",
52
56
  } {
53
57
  if !strings.Contains(description, want) {
54
58
  t.Fatalf("HITL mailbox description missing %q:\n%s", want, description)
55
59
  }
56
60
  }
57
- for _, unwanted := range []string{"Jira", "OpenCode", "Question tool"} {
61
+ for _, unwanted := range []string{"Jira", "OpenCode", "Question tool", "Approve and Reject", "Discuss the task with the human"} {
58
62
  if strings.Contains(description, unwanted) {
59
63
  t.Fatalf("generic mailbox description contains %q:\n%s", unwanted, description)
60
64
  }
61
65
  }
62
66
 
63
- node.Type = workflow.NodeAgent
64
- agentDescription := goworkflows.MailboxSpecForNode(&wf, "PAY-101", "coding", node).Description
65
- if strings.Contains(agentDescription, "Question tool") {
66
- t.Fatalf("agent mailbox description contains HITL Question instruction:\n%s", agentDescription)
67
- }
68
-
69
67
  for _, taskSystem := range []string{"jira", "linear"} {
70
- prompt := goworkflows.BuildLaunchSpecPrompt(taskSystem, "PAY-101", "PAY-234")
71
- want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback."
68
+ prompt, err := opencode.New().RenderPrompt(harness.PromptInitial, harness.PromptData{
69
+ TaskSystem: taskSystem, Ticket: "PAY-101", Mailbox: "PAY-234", NodeType: workflow.NodeHITL,
70
+ }, "")
71
+ if err != nil {
72
+ t.Fatal(err)
73
+ }
74
+ want := "Task system: " + taskSystem + "\nUse the " + taskSystem + " 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\nBefore submitting your report, present the complete proposed report through OpenCode's built-in Question tool with exactly two options: Approve and Reject. Submit it only after an explicit Approve answer."
72
75
  if prompt != want {
73
- t.Fatalf("BuildLaunchSpecPrompt(%s) = %q, want %q", taskSystem, prompt, want)
76
+ t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
74
77
  }
75
78
  if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
76
79
  t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
@@ -182,7 +185,11 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
182
185
  Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
183
186
  })
184
187
 
185
- rid, err := startRun(engine, linearWorkflow(false))
188
+ wf := linearWorkflow(false)
189
+ node := wf.Nodes["coding"]
190
+ node.NudgePrompt = "Continue {{ticket}} at {{node}}. Valid next steps: {{nextSteps}}."
191
+ wf.Nodes["coding"] = node
192
+ rid, err := startRun(engine, wf)
186
193
  if err != nil {
187
194
  t.Fatalf("EnsureRun failed: %v", err)
188
195
  }
@@ -206,6 +213,17 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
206
213
  if runtime.TerminalID == "" || runtime.NodeVisitID != r.CurrentNodeVisitID {
207
214
  t.Fatalf("terminal was not persisted for current visit: %+v", runtime)
208
215
  }
216
+ promptCalls := fh.promptCalls()
217
+ if len(promptCalls) != 1 {
218
+ t.Fatalf("RenderPrompt calls = %+v, want one initial prompt", promptCalls)
219
+ }
220
+ call := promptCalls[0]
221
+ if call.NudgeTemplate != node.NudgePrompt {
222
+ t.Fatalf("nudge passed to harness = %q, want raw template %q", call.NudgeTemplate, node.NudgePrompt)
223
+ }
224
+ if call.Data.Ticket != "PAY-101" || call.Data.Workflow != wf.Name || call.Data.Repo != "payments" || call.Data.Node != "coding" || call.Data.NextSteps == "" {
225
+ t.Fatalf("nudge prompt data = %+v, want current workflow values", call.Data)
226
+ }
209
227
 
210
228
  // Pre-edge gate: before following the start edge the run ensures the
211
229
  // runner environment AND validates every referenced agent, and applies
@@ -74,6 +74,7 @@ type fakeTaskSystem struct {
74
74
  specs []task.MailboxSpec
75
75
  comments []recordedComment
76
76
  resets []string
77
+ renderText func(task.TextKind, task.TextData) (string, error)
77
78
 
78
79
  // Failure/crash/slow injection — the fake IS the documented injection
79
80
  // seam (allowed seam a). These make the fake adapter fail/stall so the
@@ -122,6 +123,22 @@ func (s *fakeTaskSystem) ValidateConfig(context.Context, config.RawValues, map[s
122
123
  return nil
123
124
  }
124
125
 
126
+ func (s *fakeTaskSystem) RenderText(kind task.TextKind, data task.TextData) (string, error) {
127
+ if s.renderText != nil {
128
+ return s.renderText(kind, data)
129
+ }
130
+ switch kind {
131
+ case task.TextMailboxDescription:
132
+ return "Parent ticket: " + data.Ticket + "\nNode: " + data.Node + "\nType: " + data.NodeType + "\nAgent: " + data.Agent + "\nWork: " + data.NodeDescription + "\nMailbox: " + data.Mailbox, nil
133
+ case task.TextSummaryComment:
134
+ return "SUMMARY\n" + data.SummaryReport, nil
135
+ case task.TextFeedbackComment:
136
+ return "Feedback from " + data.SourceNode + " to " + data.TargetNode + " mailbox " + data.Mailbox + "\n" + data.FeedbackReport, nil
137
+ default:
138
+ return "", fmt.Errorf("unknown task text kind %q", kind)
139
+ }
140
+ }
141
+
125
142
  func (s *fakeTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, wf string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
126
143
  s.log.add("ensureMailboxes:" + parent.Key)
127
144
  s.mu.Lock()
@@ -455,16 +472,25 @@ func (f *fakeRunner) killTerminals() {
455
472
  type fakeHarness struct {
456
473
  log *eventLog
457
474
 
458
- mu sync.Mutex
459
- validated []string
460
- sessions map[string]harness.Session
461
- reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
475
+ mu sync.Mutex
476
+ validated []string
477
+ sessions map[string]harness.Session
478
+ renderedPrompts []renderedPromptCall
479
+ reconcileNudge int // nudges sent to idle live HITL sessions (must stay 0)
480
+ }
481
+
482
+ type renderedPromptCall struct {
483
+ Kind harness.PromptKind
484
+ Data harness.PromptData
485
+ NudgeTemplate string
462
486
  }
463
487
 
464
488
  func newFakeHarness(log *eventLog) *fakeHarness {
465
489
  return &fakeHarness{log: log, sessions: map[string]harness.Session{}}
466
490
  }
467
491
 
492
+ func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
493
+
468
494
  func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
469
495
  f.mu.Lock()
470
496
  f.validated = append(f.validated, agent)
@@ -481,6 +507,26 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
481
507
  return s, ok, nil
482
508
  }
483
509
 
510
+ func (f *fakeHarness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
511
+ f.mu.Lock()
512
+ f.renderedPrompts = append(f.renderedPrompts, renderedPromptCall{Kind: kind, Data: data, NudgeTemplate: nudge})
513
+ f.mu.Unlock()
514
+ prompt := string(kind) + ":" + data.TaskSystem + ":" + data.Mailbox
515
+ if data.NodeType == workflow.NodeHITL {
516
+ prompt += ":hitl"
517
+ }
518
+ if nudge != "" {
519
+ prompt += ":" + nudge
520
+ }
521
+ return prompt, nil
522
+ }
523
+
524
+ func (f *fakeHarness) promptCalls() []renderedPromptCall {
525
+ f.mu.Lock()
526
+ defer f.mu.Unlock()
527
+ return append([]renderedPromptCall(nil), f.renderedPrompts...)
528
+ }
529
+
484
530
  func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
485
531
  f.log.add("buildCommand:" + spec.Node + ":" + string(spec.NodeVisitID) + ":resume=" + spec.ResumeID)
486
532
  return runner.Command{Executable: "opencode"}, nil
@@ -188,22 +188,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
188
188
 
189
189
  title := start.Ticket.Key + ":" + current
190
190
 
191
- // Build the finished launch metadata from the workflow snapshot: the
192
- // prompt carries the node description, optional custom instructions,
193
- // complete report contract, and valid next steps. Custom instructions
194
- // are also retained separately for live-terminal revisits.
191
+ // Build task-system-neutral prompt data from the workflow snapshot. The
192
+ // selected harness owns rendering initial, feedback, and HITL text.
195
193
  nextSteps := append(append([]workflow.Route{}, node.OnSuccess...), node.OnFailure...)
196
- nudge, err := wf.RenderNudge(current, workflow.NudgeTemplateData{
197
- TaskSystem: a.TaskSystem,
198
- Ticket: start.Ticket.Key,
199
- Workflow: wf.Name,
200
- Repo: start.Repo,
201
- Node: current,
202
- NextSteps: nextStepsText(nextSteps),
203
- })
204
- if err != nil {
205
- return err
206
- }
207
194
  spec := harness.LaunchSpec{
208
195
  RunID: start.ID,
209
196
  NodeVisitID: visitID,
@@ -215,9 +202,20 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
215
202
  NodeType: node.Type,
216
203
  Agent: node.Agent,
217
204
  Title: title,
218
- Prompt: BuildLaunchSpecPrompt(a.TaskSystem, start.Ticket.Key, mb.Key),
219
- NudgePrompt: nudge,
220
- NextSteps: nextSteps,
205
+ NudgePrompt: node.NudgePrompt,
206
+ PromptData: harness.PromptData{
207
+ TaskSystem: a.TaskSystem,
208
+ Ticket: start.Ticket.Key,
209
+ Workflow: wf.Name,
210
+ Repo: start.Repo,
211
+ Node: current,
212
+ NodeType: node.Type,
213
+ Agent: node.Agent,
214
+ NodeDescription: node.Description,
215
+ NextSteps: nextStepsText(nextSteps),
216
+ Mailbox: mb.Key,
217
+ },
218
+ NextSteps: nextSteps,
221
219
  }
222
220
  if runtime.SessionID != "" {
223
221
  spec.ResumeID = runtime.SessionID
@@ -305,10 +303,14 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
305
303
  // complete current -> process next node.
306
304
  if _, err := retryLoop(ctx, start.ID, a, work, current,
307
305
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
306
+ summaryReport := renderSummaryReport(report)
308
307
  return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
309
- RunID: start.ID,
310
- Item: task.Target{Parent: work.Parent, Mailbox: &mb},
311
- Body: renderSummary(report),
308
+ RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &mb},
309
+ TextKind: task.TextSummaryComment,
310
+ TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
311
+ Workflow: wf.Name, Repo: start.Repo, Node: current, NodeType: string(node.Type),
312
+ Agent: node.Agent, NodeDescription: node.Description, Mailbox: mb.Key,
313
+ SourceNode: current, TargetNode: current, SummaryReport: summaryReport},
312
314
  Marker: string(visitID) + ":summary",
313
315
  })
314
316
  }); err != nil {
@@ -320,10 +322,15 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
320
322
  nextMb := mailboxes[next]
321
323
  if _, err := retryLoop(ctx, start.ID, a, work, current,
322
324
  func(ctx2 goworkflow.Context) goworkflow.Future[struct{}] {
325
+ feedbackReport := renderFeedbackReport(report)
323
326
  return goworkflow.ExecuteActivity[struct{}](ctx2, noNativeRetries, a.Comment, start.Repo, run.CommentWork{
324
- RunID: start.ID,
325
- Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
326
- Body: renderFeedback(current, report),
327
+ RunID: start.ID, Item: task.Target{Parent: work.Parent, Mailbox: &nextMb},
328
+ TextKind: task.TextFeedbackComment,
329
+ TextData: task.TextData{RunID: string(start.ID), Ticket: work.Parent.Key,
330
+ Workflow: wf.Name, Repo: start.Repo, Node: next, NodeType: string(wf.Nodes[next].Type),
331
+ Agent: wf.Nodes[next].Agent, NodeDescription: wf.Nodes[next].Description, Mailbox: nextMb.Key,
332
+ SourceNode: current, TargetNode: next, SummaryReport: renderSummaryReport(report),
333
+ FeedbackReport: feedbackReport},
327
334
  Marker: string(visitID) + ":feedback",
328
335
  })
329
336
  }); err != nil {
@@ -602,12 +609,12 @@ func nextStepsText(routes []workflow.Route) string {
602
609
  return b.String()
603
610
  }
604
611
 
605
- func renderSummary(r workflow.Report) string {
606
- return fmt.Sprintf("SUMMARY\nCOMPLETED:\n%s\n\nCOMMITS:\n%s\n\nNOT COMPLETED:\n%s\n\nISSUES DISCOVERED:\n%s\n\nVERIFICATION:\n%s\n\nNOTES:\n%s\n",
612
+ func renderSummaryReport(r workflow.Report) string {
613
+ 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",
607
614
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
608
615
  }
609
616
 
610
- func renderFeedback(source string, r workflow.Report) string {
611
- return fmt.Sprintf("Feedback from %s\nCOMMITS:\n%s\n\nREASON FOR NEXT STEP:\n%s\n\nREQUIRED ACTIONS:\n%s\n\nRELEVANT CONTEXT:\n%s\n\nEXPECTED RESULT:\n%s\n",
612
- source, r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
617
+ func renderFeedbackReport(r workflow.Report) string {
618
+ 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",
619
+ r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
613
620
  }
@@ -266,6 +266,91 @@ func TestSummaryCurrentFeedbackSelectedNextOnly(t *testing.T) {
266
266
  }
267
267
  }
268
268
 
269
+ func TestTaskSystemTemplatesRenderMailboxAndSplitOneReport(t *testing.T) {
270
+ log := newEventLog()
271
+ sys := newFakeTaskSystem(log)
272
+ seen := map[task.TextKind][]task.TextData{}
273
+ sys.renderText = func(kind task.TextKind, data task.TextData) (string, error) {
274
+ seen[kind] = append(seen[kind], data)
275
+ switch kind {
276
+ case task.TextMailboxDescription:
277
+ return "custom mailbox " + data.Node + " work=" + data.NodeDescription, nil
278
+ case task.TextSummaryComment:
279
+ return "custom summary node=" + data.Node + " mailbox=" + data.Mailbox + "\n" + data.SummaryReport, nil
280
+ case task.TextFeedbackComment:
281
+ return "custom feedback source=" + data.SourceNode + " target=" + data.TargetNode + " mailbox=" + data.Mailbox + "\n" + data.FeedbackReport, nil
282
+ default:
283
+ return "", nil
284
+ }
285
+ }
286
+ engine := newEngine(t, goworkflows.Dependencies{
287
+ Repos: repoRegistryWith("payments", sys), Runner: newFakeRunner(log), Harness: newFakeHarness(log), TaskSystem: "custom-task",
288
+ })
289
+ rid, _ := startRun(engine, threeNodeWorkflow())
290
+ waitFor(t, 10*time.Second, func() bool {
291
+ r, _ := engine.GetRun(context.Background(), rid)
292
+ return r.CurrentNode == "exploration"
293
+ })
294
+ if got := sys.specs[1].Description; !strings.Contains(got, "custom mailbox exploration work=explore the code") || !strings.Contains(got, "Required report format:") {
295
+ t.Fatalf("rendered mailbox description = %q", got)
296
+ }
297
+ var explorationData task.TextData
298
+ for _, data := range seen[task.TextMailboxDescription] {
299
+ if data.Node == "exploration" {
300
+ explorationData = data
301
+ }
302
+ }
303
+ for name, got := range map[string]string{
304
+ "runID": explorationData.RunID,
305
+ "ticket": explorationData.Ticket, "workflow": explorationData.Workflow,
306
+ "repo": explorationData.Repo, "node": explorationData.Node,
307
+ "nodeType": explorationData.NodeType, "agent": explorationData.Agent,
308
+ "nodeDescription": explorationData.NodeDescription, "mailbox": explorationData.Mailbox,
309
+ } {
310
+ if got == "" {
311
+ t.Fatalf("mailbox template value %s was empty: %+v", name, explorationData)
312
+ }
313
+ }
314
+ for name, got := range map[string]string{"nextSteps": explorationData.NextSteps, "successRoutes": explorationData.SuccessRoutes, "failureRoutes": explorationData.FailureRoutes} {
315
+ if !strings.Contains(got, "coding") && name != "failureRoutes" {
316
+ t.Fatalf("mailbox template %s = %q", name, got)
317
+ }
318
+ if name == "failureRoutes" && !strings.Contains(got, "exploration") {
319
+ t.Fatalf("mailbox template failureRoutes = %q", got)
320
+ }
321
+ }
322
+ report := successReport("coding")
323
+ report.Feedback = workflow.Feedback{ReasonForNextStep: "reviewed", RequiredActions: "implement", RelevantContext: "ctx", ExpectedResult: "done"}
324
+ if _, err := engine.SubmitReport(context.Background(), reportRequest(rid, "exploration", report)); err != nil {
325
+ t.Fatal(err)
326
+ }
327
+ waitFor(t, 10*time.Second, func() bool {
328
+ r, _ := engine.GetRun(context.Background(), rid)
329
+ return r.CurrentNode == "coding"
330
+ })
331
+ summary := sys.commentBodies("PAY-101-exploration")
332
+ feedback := sys.commentBodies("PAY-101-coding")
333
+ if len(summary) != 1 || !strings.Contains(summary[0].Body, "custom summary node=exploration mailbox=PAY-101-exploration") || !strings.Contains(summary[0].Body, "COMPLETED:") {
334
+ t.Fatalf("summary comments = %+v", summary)
335
+ }
336
+ if len(feedback) != 1 || !strings.Contains(feedback[0].Body, "source=exploration target=coding mailbox=PAY-101-coding") || !strings.Contains(feedback[0].Body, "REQUIRED ACTIONS:") {
337
+ t.Fatalf("feedback comments = %+v", feedback)
338
+ }
339
+ if len(seen[task.TextSummaryComment]) != 1 || seen[task.TextSummaryComment][0].SummaryReport == "" {
340
+ t.Fatalf("summary template data = %+v", seen[task.TextSummaryComment])
341
+ }
342
+ if len(seen[task.TextFeedbackComment]) != 1 {
343
+ t.Fatalf("feedback template data = %+v", seen[task.TextFeedbackComment])
344
+ }
345
+ feedbackData := seen[task.TextFeedbackComment][0]
346
+ if feedbackData.SourceNode != "exploration" || feedbackData.TargetNode != "coding" || feedbackData.Mailbox != "PAY-101-coding" || feedbackData.FeedbackReport == "" {
347
+ t.Fatalf("feedback template data = %+v", feedbackData)
348
+ }
349
+ if len(sys.commentBodies("PAY-101-review")) != 0 {
350
+ t.Fatal("one report sent feedback to an unselected mailbox")
351
+ }
352
+ }
353
+
269
354
  // 3.28: end/mailbox behavior, manual status not routing, HITL lifecycle.
270
355
 
271
356
  func TestManualMailboxStatusDoesNotRouteGraph(t *testing.T) {
@@ -5,6 +5,7 @@ import (
5
5
  "database/sql"
6
6
  "errors"
7
7
  "path/filepath"
8
+ "reflect"
8
9
  "testing"
9
10
  "time"
10
11
 
@@ -222,15 +223,18 @@ func TestEnsureNodeRuntimeUsesDirectIDsAndFallsBackFresh(t *testing.T) {
222
223
  if rt.TerminalID == "" || rt.TerminalID == "dead-term" || rt.SessionID != "dead-session" {
223
224
  t.Fatalf("failed direct IDs not replaced atomically: %+v", rt)
224
225
  }
225
- if fr.findCalls != 1 || fh.buildCalls != 1 || fr.createCalls != 1 {
226
+ if fr.findCalls != 2 || fh.buildCalls != 1 || fr.createCalls != 1 {
226
227
  t.Fatalf("stored-ID replacement calls: find=%d build=%d create=%d", fr.findCalls, fh.buildCalls, fr.createCalls)
227
228
  }
228
- if len(fr.findIDs) != 1 || fr.findIDs[0] != "dead-term" {
229
- t.Fatalf("FindTerminal IDs = %v, want [dead-term]", fr.findIDs)
229
+ if len(fr.findIDs) != 2 || fr.findIDs[0] != "dead-term" || fr.findIDs[1] != "dead-term" {
230
+ t.Fatalf("FindTerminal IDs = %v, want [dead-term dead-term]", fr.findIDs)
230
231
  }
231
232
  if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "dead-session" {
232
233
  t.Fatalf("BuildCommand ResumeIDs = %v, want [dead-session]", fh.resumeIDs)
233
234
  }
235
+ if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptInitial}) {
236
+ t.Fatalf("dead-terminal rendered prompts = %v, want initial", fh.rendered)
237
+ }
234
238
  for _, prompt := range fh.prompts {
235
239
  if prompt != "work" {
236
240
  t.Fatalf("same-visit relaunch prompt = %q, want standard prompt only", prompt)
@@ -261,7 +265,7 @@ func TestEnsureNodeRuntimeInitialLaunchAppendsCustomInstructions(t *testing.T) {
261
265
  if err := a.EnsureNodeRuntime(ctx, nw, "", spec, NodeRuntime{}); err != nil {
262
266
  t.Fatal(err)
263
267
  }
264
- if len(fh.prompts) != 1 || fh.prompts[0] != "standard prompt\n\ncustom instructions" {
268
+ if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\ncustom instructions" {
265
269
  t.Fatalf("initial prompt = %q", fh.prompts)
266
270
  }
267
271
  if len(fr.statuses) != 1 || fr.statuses[0] != runner.WorkspaceStatusInReview {
@@ -298,7 +302,7 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
298
302
  if fr.closeCalls != 1 || fr.closedIDs[0] != "live-old" {
299
303
  t.Fatalf("old live terminal not closed before replacement: %+v", fr.closedIDs)
300
304
  }
301
- if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nRead the latest review feedback." {
305
+ if len(fr.sentTexts) != 1 || fr.sentTexts[0] != "feedback\n\nRead the latest review feedback." {
302
306
  t.Fatalf("live revisit prompt = %q", fr.sentTexts)
303
307
  }
304
308
  if len(fh.prompts) != 1 || fh.prompts[0] != "work\n\nRead the latest review feedback." {
@@ -311,6 +315,9 @@ func TestEnsureNodeRuntimeSendFailureClosesLiveTerminal(t *testing.T) {
311
315
  if len(fh.resumeIDs) != 1 || fh.resumeIDs[0] != "session-old" {
312
316
  t.Fatalf("replacement ResumeIDs = %v, want [session-old]", fh.resumeIDs)
313
317
  }
318
+ if !reflect.DeepEqual(fh.rendered, []harness.PromptKind{harness.PromptFeedback, harness.PromptInitial}) {
319
+ t.Fatalf("rendered prompts = %v, want feedback then replacement initial", fh.rendered)
320
+ }
314
321
  }
315
322
 
316
323
  func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
@@ -338,6 +345,10 @@ func TestEnsureNodeRuntimeSameVisitSendsNothing(t *testing.T) {
338
345
  if len(fr.sentTexts) != 0 || fr.createCalls != 0 {
339
346
  t.Fatalf("same visit sent=%q creates=%d", fr.sentTexts, fr.createCalls)
340
347
  }
348
+ fh := a.Harness.(*runtimeTestHarness)
349
+ if len(fh.rendered) != 0 || fh.buildCalls != 0 {
350
+ t.Fatalf("same visit rendered=%v buildCalls=%d, want silence", fh.rendered, fh.buildCalls)
351
+ }
341
352
  }
342
353
 
343
354
  func TestEnsureNodeRuntimeRejectsStaleVisitWithoutLaunch(t *testing.T) {
@@ -487,8 +498,22 @@ type runtimeTestHarness struct {
487
498
  buildCalls int
488
499
  prompts []string
489
500
  resumeIDs []string
501
+ rendered []harness.PromptKind
502
+ }
503
+
504
+ func (h *runtimeTestHarness) RenderPrompt(kind harness.PromptKind, _ harness.PromptData, nudge string) (string, error) {
505
+ h.rendered = append(h.rendered, kind)
506
+ prompt := "work"
507
+ if kind == harness.PromptFeedback {
508
+ prompt = "feedback"
509
+ }
510
+ if nudge != "" {
511
+ prompt += "\n\n" + nudge
512
+ }
513
+ return prompt, nil
490
514
  }
491
515
 
516
+ func (*runtimeTestHarness) SetupRepo(context.Context, string) error { return nil }
492
517
  func (*runtimeTestHarness) ValidateAgent(context.Context, string, string) error { return nil }
493
518
  func (*runtimeTestHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
494
519
  return harness.Session{}, false, nil
@@ -759,8 +759,8 @@ func recoverTickets(ctx context.Context, engine *goworkflows.Engine, sys *fakeTa
759
759
  return err
760
760
  }
761
761
  rm := &run.RunManager{Executor: engine, Runs: engine}
762
- specsFor := func(w *workflow.Workflow, key string) []task.MailboxSpec {
763
- return goworkflows.MailboxSpecs(w, key)
762
+ specsFor := func(system task.System, work run.Work, w *workflow.Workflow) ([]task.MailboxSpec, error) {
763
+ return goworkflows.RenderMailboxSpecs(system, work, w)
764
764
  }
765
765
  return recoverpkg.FromTaskSystem(ctx, reg, fr, rm, specsFor)
766
766
  }
@@ -0,0 +1,31 @@
1
+ package goworkflows
2
+
3
+ import (
4
+ "encoding/json"
5
+ "os"
6
+ "testing"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/run"
9
+ )
10
+
11
+ func TestSharedReportContractFixtureRendersCommentValues(t *testing.T) {
12
+ b, err := os.ReadFile("../../../testdata/report-contract.json")
13
+ if err != nil {
14
+ t.Fatal(err)
15
+ }
16
+ var fixtures map[string]struct {
17
+ Envelope run.ReportRequest `json:"envelope"`
18
+ SummaryReport string `json:"summaryReport"`
19
+ FeedbackReport string `json:"feedbackReport"`
20
+ }
21
+ if err := json.Unmarshal(b, &fixtures); err != nil {
22
+ t.Fatal(err)
23
+ }
24
+ fixture := fixtures["work"]
25
+ if got := renderSummaryReport(fixture.Envelope.Report); got != fixture.SummaryReport {
26
+ t.Fatalf("summaryReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.SummaryReport)
27
+ }
28
+ if got := renderFeedbackReport(fixture.Envelope.Report); got != fixture.FeedbackReport {
29
+ t.Fatalf("feedbackReport mismatch\ngot:\n%s\nwant:\n%s", got, fixture.FeedbackReport)
30
+ }
31
+ }
@@ -32,6 +32,8 @@ func newFakeHarness() *fakeHarness {
32
32
  }
33
33
  }
34
34
 
35
+ func (f *fakeHarness) SetupRepo(context.Context, string) error { return nil }
36
+
35
37
  func (f *fakeHarness) ValidateAgent(_ context.Context, _, agent string) error {
36
38
  if !f.agents[agent] {
37
39
  return errUnknownAgent
@@ -44,6 +46,14 @@ func (f *fakeHarness) FindSession(_ context.Context, _, title string) (harness.S
44
46
  return s, ok, nil
45
47
  }
46
48
 
49
+ func (f *fakeHarness) RenderPrompt(_ harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
50
+ prompt := data.TaskSystem + ":" + data.Ticket + ":" + data.Mailbox
51
+ if nudge != "" {
52
+ prompt += "\n\n" + nudge
53
+ }
54
+ return prompt, nil
55
+ }
56
+
47
57
  func (f *fakeHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
48
58
  // The fake mirrors the required env contract; the real opencode harness
49
59
  // builds the executable/args. NEXT_STEPS_JSON carries the legal targets