relay-flow 0.3.10-alpha → 0.3.12-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 (34) hide show
  1. package/README.md +10 -22
  2. package/cmd/relay-flow/commands_test.go +40 -2
  3. package/cmd/relay-flow/main.go +13 -1
  4. package/examples/config-reference.yaml +23 -19
  5. package/examples/workflow-reference.yaml +3 -0
  6. package/internal/execution/goworkflows/activities.go +13 -22
  7. package/internal/execution/goworkflows/engine.go +1 -1
  8. package/internal/execution/goworkflows/engine_test.go +52 -3
  9. package/internal/execution/goworkflows/interpreter.go +24 -11
  10. package/internal/execution/goworkflows/mailbox_test.go +28 -1
  11. package/internal/execution/temporal/activities.go +13 -22
  12. package/internal/execution/temporal/compact_mailbox_test.go +56 -0
  13. package/internal/execution/temporal/interpreter.go +14 -2
  14. package/internal/execution/temporal/operations.go +1 -1
  15. package/internal/execution/temporal/operations_test.go +75 -0
  16. package/internal/harness/harness.go +22 -10
  17. package/internal/harness/opencode/opencode.go +17 -4
  18. package/internal/harness/opencode/opencode_test.go +25 -1
  19. package/internal/harness/opencode/repo_setup.go +1 -1
  20. package/internal/harness/opencode/task_env_test.go +2 -2
  21. package/internal/harness/pi/pi.go +15 -2
  22. package/internal/harness/pi/pi_test.go +7 -4
  23. package/internal/harness/pi/prompt_test.go +13 -5
  24. package/internal/harness/pi/task_env_test.go +2 -2
  25. package/internal/run/run.go +10 -0
  26. package/internal/server/api_test.go +31 -0
  27. package/internal/server/client.go +11 -1
  28. package/internal/server/fixture_test.go +4 -0
  29. package/internal/server/server.go +2 -0
  30. package/internal/task/jira/jira.go +16 -11
  31. package/internal/task/jira/templates_test.go +38 -2
  32. package/internal/task/task.go +1 -0
  33. package/internal/workflow/report.go +7 -0
  34. package/package.json +1 -1
package/README.md CHANGED
@@ -28,13 +28,13 @@ same `relay-flow-plugin` package with host-specific entrypoints.
28
28
  **OpenCode**
29
29
 
30
30
  ```sh
31
- opencode plugin relay-flow-plugin@0.3.10-alpha
31
+ opencode plugin relay-flow-plugin@0.3.12-alpha
32
32
  ```
33
33
 
34
34
  **Pi**
35
35
 
36
36
  ```sh
37
- pi install npm:relay-flow-plugin@0.3.10-alpha
37
+ pi install npm:relay-flow-plugin@0.3.12-alpha
38
38
  ```
39
39
 
40
40
  Pi loads the package's `pi.ts` extension from its manifest. Do not add
@@ -164,7 +164,7 @@ OpenCode plugin configuration uses both entrypoints. The server entrypoint is li
164
164
  ```json
165
165
  {
166
166
  "$schema": "https://opencode.ai/config.json",
167
- "plugin": ["relay-flow-plugin@0.3.10-alpha"]
167
+ "plugin": ["relay-flow-plugin@0.3.12-alpha"]
168
168
  }
169
169
  ```
170
170
 
@@ -173,7 +173,7 @@ The native HITL approval entrypoint is listed in `.opencode/tui.json`:
173
173
  ```json
174
174
  {
175
175
  "$schema": "https://opencode.ai/tui.json",
176
- "plugin": ["relay-flow-plugin@0.3.10-alpha"]
176
+ "plugin": ["relay-flow-plugin@0.3.12-alpha"]
177
177
  }
178
178
  ```
179
179
 
@@ -189,7 +189,7 @@ Pi plugin: install the same published package manually in Pi's global package
189
189
  settings before starting a Pi harness session:
190
190
 
191
191
  ```sh
192
- pi install npm:relay-flow-plugin@0.3.10-alpha
192
+ pi install npm:relay-flow-plugin@0.3.12-alpha
193
193
  ```
194
194
 
195
195
  Relay-flow does not install or configure the package automatically. Pi resolves
@@ -614,28 +614,16 @@ durable run waiting; Pi does not require an LLM Question tool for this step.
614
614
 
615
615
  ## Structured node report
616
616
 
617
- Every visit (agent or HITL) submits one report with the same fixed labels:
617
+ Every visit (agent or HITL) submits one report with four fixed fields:
618
618
 
619
619
  ```
620
620
  STATUS: success | failure
621
- NEXT STEP: <one configured route for that status>
622
-
623
- SUMMARY:
624
- COMPLETED: ...
625
- COMMITS: <commit IDs or None>
626
- NOT COMPLETED: ... | None
627
- ISSUES DISCOVERED: ... | None
628
- VERIFICATION: ...
629
- NOTES: ... | None
630
-
631
- FEEDBACK:
632
- REASON FOR NEXT STEP: ...
633
- REQUIRED ACTIONS: ...
634
- RELEVANT CONTEXT: ...
635
- EXPECTED RESULT: ...
621
+ NEXT STEP: <one valid route>
622
+ SUMMARY: <concise result>
623
+ FEEDBACK: <concise handoff, or None when NEXT STEP is end>
636
624
  ```
637
625
 
638
- The labels above are fixed; configurable templates do not change the parsed report contract. The plugin submits one `report` object containing both lower-camel `summary` and `feedback` objects. Relay-flow validates that complete shape once, renders `summaryReport` through the task system's summary-comment template on the current mailbox, and renders `feedbackReport` through its feedback-comment template on only the selected next mailbox. `None` is the literal marker for an intentionally empty section. When `NEXT STEP` is `end`, every FEEDBACK field must be `None` and no feedback comment is written.
626
+ The report format is hardcoded, exposed as `{{report}}` in Jira mailbox templates and passed to the runtime plugin through `RELAY_FLOW_REPORT_FORMAT`; configurable templates do not change it. The plugin maps `SUMMARY` to `report.summary.completed` and `FEEDBACK` to `report.feedback.requiredActions`, filling the remaining internal JSON fields with `None`. Relay-flow validates the structured report and writes the current summary to its mailbox and only the selected feedback to the next mailbox. When `NEXT STEP` is `end`, `FEEDBACK` must be exactly `None` and no feedback comment is written.
639
627
 
640
628
  The plugin delivers `{runId, node, reportId, report}` as one JSON object via `relay-flow report` stdin with the shared backoff (initial 2s, factor 2, jitter 0.2, max 5m) until acknowledged. It derives `reportId` from the harness session/message identity. Duplicate/stale reports are acked safely with no repeated graph effects. Invalid agent output is nudged; ordinary, missing, or empty HITL output stays silent, while partial report-shaped HITL output is corrected and a valid HITL report opens the native TUI approval dialog. Relay-flow HITL approval does not use OpenCode's Question tool.
641
629
 
@@ -4,6 +4,7 @@ import (
4
4
  "bytes"
5
5
  "context"
6
6
  "database/sql"
7
+ "encoding/json"
7
8
  "errors"
8
9
  "fmt"
9
10
  "io"
@@ -23,6 +24,7 @@ import (
23
24
 
24
25
  "github.com/charmbracelet/huh"
25
26
  "github.com/rajpopat27/relay-flow/internal/config"
27
+ "github.com/rajpopat27/relay-flow/internal/harness"
26
28
  "github.com/rajpopat27/relay-flow/internal/repo"
27
29
  runsvc "github.com/rajpopat27/relay-flow/internal/run"
28
30
  "github.com/rajpopat27/relay-flow/internal/runner"
@@ -276,6 +278,36 @@ func TestReportAckMatrix(t *testing.T) {
276
278
  }
277
279
  }
278
280
 
281
+ func TestReportValidationErrorIsStructuredOnStderr(t *testing.T) {
282
+ const message = "NEXT STEP is end, so FEEDBACK must be exactly None."
283
+ const valid = `{"runId":"run-1","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"done","commits":"None","notCompleted":"None","issuesDiscovered":"None","verification":"None","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
284
+ home := t.TempDir()
285
+ serveAck(t, home, runsvc.ReportAck{}, &runsvc.InvalidReportError{Reason: message})
286
+ reader, writer, err := os.Pipe()
287
+ if err != nil {
288
+ t.Fatal(err)
289
+ }
290
+ original := os.Stderr
291
+ os.Stderr = writer
292
+ code := cli(t, home, valid, "report")
293
+ os.Stderr = original
294
+ _ = writer.Close()
295
+ defer reader.Close()
296
+ output, err := io.ReadAll(reader)
297
+ if err != nil {
298
+ t.Fatal(err)
299
+ }
300
+ var response struct {
301
+ Error struct {
302
+ Code string `json:"code"`
303
+ Message string `json:"message"`
304
+ } `json:"error"`
305
+ }
306
+ if err := json.Unmarshal(output, &response); err != nil || code != exitFail || response.Error.Code != "invalidReport" || response.Error.Message != message {
307
+ t.Fatalf("CLI validation response: exit=%d stderr=%q parsed=%+v err=%v", code, output, response, err)
308
+ }
309
+ }
310
+
279
311
  func TestReportUnreachableServerExits1(t *testing.T) {
280
312
  valid := `{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"s:m","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
281
313
  if code := cli(t, t.TempDir(), valid, "report"); code != 1 {
@@ -386,6 +418,9 @@ func TestInitWritesHarnessPromptDefaults(t *testing.T) {
386
418
  t.Fatalf("harnessConfig.%s = %#v", key, cfg.HarnessConfig[key])
387
419
  }
388
420
  }
421
+ if cfg.HarnessConfig["initial"] != harness.JiraInitialPrompt || cfg.HarnessConfig["feedback"] != harness.JiraFeedbackPrompt {
422
+ t.Fatalf("Jira prompt defaults = %#v", cfg.HarnessConfig)
423
+ }
389
424
  }
390
425
 
391
426
  func TestInitWritesTaskTextDefaults(t *testing.T) {
@@ -406,6 +441,9 @@ func TestInitWritesTaskTextDefaults(t *testing.T) {
406
441
  t.Fatalf("taskConfig.templates.%s = %#v", key, templates[key])
407
442
  }
408
443
  }
444
+ if !strings.Contains(templates["mailboxDescription"].(string), "{{report}}") {
445
+ t.Fatal("Jira mailbox description must expose the canonical report")
446
+ }
409
447
  }
410
448
 
411
449
  func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
@@ -480,7 +518,7 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
480
518
  if !ok {
481
519
  t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
482
520
  }
483
- taskTemplates["mailboxDescription"] = "custom {{nodeDescription}}"
521
+ taskTemplates["mailboxDescription"] = "custom {{ticket}} / {{node}} — {{nodeType}} — {{agent}}\n{{nodeDescription}}\n{{successRoutes}}\n{{failureRoutes}}\n{{report}}"
484
522
  if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
485
523
  t.Fatal(err)
486
524
  }
@@ -528,7 +566,7 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
528
566
  if !ok {
529
567
  t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
530
568
  }
531
- if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{nodeDescription}}" {
569
+ if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{ticket}} / {{node}} — {{nodeType}} — {{agent}}\n{{nodeDescription}}\n{{successRoutes}}\n{{failureRoutes}}\n{{report}}" {
532
570
  t.Fatalf("forced init changed task text override: %#v", got)
533
571
  }
534
572
  for _, key := range []string{"summaryComment", "feedbackComment"} {
@@ -9,6 +9,7 @@ import (
9
9
  "bufio"
10
10
  "context"
11
11
  "encoding/json"
12
+ "errors"
12
13
  "flag"
13
14
  "fmt"
14
15
  "io"
@@ -664,6 +665,10 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
664
665
  fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
665
666
  return exitFail
666
667
  }
668
+ if names[0] == "jira" {
669
+ harnessDefaults["initial"] = harness.JiraInitialPrompt
670
+ harnessDefaults["feedback"] = harness.JiraFeedbackPrompt
671
+ }
667
672
  if !*force || !configExists {
668
673
  cfg.HarnessConfig = harnessDefaults
669
674
  } else {
@@ -1142,7 +1147,14 @@ func cmdReport(c *server.Client, stdin io.Reader) int {
1142
1147
  ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
1143
1148
  defer cancel()
1144
1149
  if _, err := c.SubmitReport(ctx, req); err != nil {
1145
- fmt.Fprintln(os.Stderr, err)
1150
+ var apiErr *server.APIError
1151
+ if errors.As(err, &apiErr) && apiErr.Code == "invalidReport" {
1152
+ _ = json.NewEncoder(os.Stderr).Encode(map[string]any{
1153
+ "error": map[string]string{"code": apiErr.Code, "message": apiErr.Message},
1154
+ })
1155
+ } else {
1156
+ fmt.Fprintln(os.Stderr, err)
1157
+ }
1146
1158
  return exitFail
1147
1159
  }
1148
1160
  return exitOK
@@ -42,21 +42,19 @@ taskConfig:
42
42
  # parentStatus: In Progress
43
43
  # taskStatus: In Progress
44
44
 
45
- # Task-system text templates. The fixed report contract is appended by
46
- # relay-flow and is not changed by these templates.
45
+ # Jira mailbox descriptions include the canonical report through {{report}}.
47
46
  templates:
48
47
  mailboxDescription: |-
49
- Parent ticket: {{ticket}}
50
- Workflow: {{workflow}}
51
- Node: {{node}}
52
- Node type: {{nodeType}}
53
- Agent: {{agent}}
54
- Mailbox: {{mailbox}}
55
-
56
- Node work:
48
+ {{ticket}} / {{node}} — {{nodeType}} — {{agent}}
57
49
  {{nodeDescription}}
58
50
 
59
- Read this mailbox's comments for feedback from previous nodes.
51
+ Success routes:
52
+ {{successRoutes}}
53
+
54
+ Failure routes:
55
+ {{failureRoutes}}
56
+
57
+ {{report}}
60
58
 
61
59
  summaryComment: |-
62
60
  Summary for {{node}}
@@ -80,14 +78,19 @@ harnessPlugin: opencode
80
78
  harnessConfig:
81
79
  # OpenCode supports initial, feedback, and hitl templates.
82
80
  initial: |-
83
- Task system: {{taskSystem}}
84
- Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
81
+ Read the description of your assigned Jira task:
82
+ acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
85
83
 
86
- Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
84
+ Follow the work instructions, valid routes, and report format in that description.
87
85
 
88
86
  feedback: |-
89
- New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.
87
+ Continue {{node}} from the latest update on your assigned Jira task:
88
+ acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
90
89
 
90
+ # A node needing parent details can request this additional instruction
91
+ # in its node description (it is not included by default):
92
+ # Read the parent Jira ticket for this step:
93
+ # acli jira workitem view "{{ticket}}" --fields "summary,description" --json
91
94
  hitl: |-
92
95
  Return the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.
93
96
 
@@ -135,12 +138,13 @@ repos:
135
138
  # harnessPlugin: pi
136
139
  # harnessConfig:
137
140
  # initial: |
138
- # Task system: {{taskSystem}}
139
- # Use the {{taskSystem}} tools to read the parent ticket {{ticket}}.
141
+ # Read the description of your assigned Jira task:
142
+ # acli jira workitem view "{{mailbox}}" --fields "summary,description" --json
140
143
  #
141
- # Your mailbox is {{mailbox}}. Read its description and comments for node instructions and feedback.
144
+ # Follow the work instructions, valid routes, and report format in that description.
142
145
  # feedback: |
143
- # New feedback was added to the comments section of your mailbox subtask {{mailbox}}. Read it.
146
+ # Continue {{node}} from the latest update on your assigned Jira task:
147
+ # acli jira workitem comment list --key "{{mailbox}}" --limit 1 --order "-created" --json
144
148
  #
145
149
  # Pi accepts only initial and feedback harness templates. Pi workflow nodes
146
150
  # may use agent: default or a native project prompt template in
@@ -56,6 +56,9 @@ nodes:
56
56
  type: agent
57
57
  agent: build
58
58
  description: |
59
+ Read the parent Jira ticket for this step:
60
+ acli jira workitem view "{{ticket}}" --fields "summary,description" --json
61
+
59
62
  Implement the parent ticket in the ticket worktree.
60
63
  Run the relevant tests and verification commands.
61
64
  nudgePrompt: |
@@ -80,7 +80,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
80
80
  if err != nil {
81
81
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
82
82
  }
83
- specs[i].Description = appendText(custom, specs[i].Description)
83
+ if data.Report != "" && strings.Contains(custom, data.Report) {
84
+ specs[i].Description = custom
85
+ } else {
86
+ specs[i].Description = appendText(custom, specs[i].Description)
87
+ }
84
88
  }
85
89
  return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
86
90
  }
@@ -488,26 +492,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
488
492
  var b strings.Builder
489
493
  b.WriteString(`Required report format:
490
494
 
491
- STATUS: success | failure
492
- NEXT STEP: <one valid node name>
493
-
494
- SUMMARY:
495
- COMPLETED:
496
- COMMITS:
497
- NOT COMPLETED:
498
- ISSUES DISCOVERED:
499
- VERIFICATION:
500
- NOTES:
501
-
502
- FEEDBACK:
503
- REASON FOR NEXT STEP:
504
- REQUIRED ACTIONS:
505
- RELEVANT CONTEXT:
506
- EXPECTED RESULT:
495
+ ` + workflow.ReportFormat + `
507
496
 
508
- Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
509
-
510
- Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
497
+ Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
511
498
  writeRoutes := func(label string, routes []workflow.Route) {
512
499
  if len(routes) == 0 {
513
500
  return
@@ -535,7 +522,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
535
522
  Agent: n.Agent, NodeDescription: n.Description,
536
523
  NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
537
524
  SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
538
- Mailbox: ticketKey + ":" + name,
525
+ Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
539
526
  },
540
527
  }
541
528
  }
@@ -569,7 +556,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
569
556
  if err != nil {
570
557
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
571
558
  }
572
- specs[i].Description = appendText(custom, specs[i].Description)
559
+ if data.Report != "" && strings.Contains(custom, data.Report) {
560
+ specs[i].Description = custom
561
+ } else {
562
+ specs[i].Description = appendText(custom, specs[i].Description)
563
+ }
573
564
  }
574
565
  return specs, nil
575
566
  }
@@ -394,7 +394,7 @@ func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.R
394
394
  }
395
395
  if err := wf.ValidateReport(req.Node, req.Report); err != nil {
396
396
  slog.Info("report validation failed", append(attrs, "reason", err.Error())...)
397
- return run.ReportAck{Accepted: false}, err
397
+ return run.ReportAck{}, &run.InvalidReportError{Reason: err.Error()}
398
398
  }
399
399
  signal := reportSignal{
400
400
  ReportID: req.ReportID, Node: req.Node,
@@ -3,6 +3,7 @@ package goworkflows_test
3
3
  import (
4
4
  "context"
5
5
  "database/sql"
6
+ "errors"
6
7
  "path/filepath"
7
8
  "strings"
8
9
  "testing"
@@ -72,13 +73,16 @@ func TestMailboxDescriptionAndLaunchPromptAreTaskSystemNeutral(t *testing.T) {
72
73
  t.Fatal(err)
73
74
  }
74
75
  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\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval."
76
+ if taskSystem == "jira" {
77
+ want = "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."
78
+ }
75
79
  if prompt != want {
76
80
  t.Fatalf("RenderPrompt(%s) = %q, want %q", taskSystem, prompt, want)
77
81
  }
78
- if strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask") {
82
+ if taskSystem != "jira" && (strings.Contains(prompt, "Jira") || strings.Contains(prompt, "subtask")) {
79
83
  t.Fatalf("launch prompt contains task-system-specific mailbox wording: %q", prompt)
80
84
  }
81
- if strings.Contains(prompt, "STATUS:") || strings.Contains(prompt, node.Description) {
85
+ if strings.Contains(prompt, "STATUS:") || (taskSystem != "jira" && strings.Contains(prompt, node.Description)) {
82
86
  t.Fatalf("launch prompt duplicates mailbox instructions: %q", prompt)
83
87
  }
84
88
  }
@@ -291,6 +295,40 @@ func TestSerialGraphOneNodeAtATime(t *testing.T) {
291
295
  })
292
296
  }
293
297
 
298
+ func TestInvalidReportIsPermanentAndDoesNotSignal(t *testing.T) {
299
+ log := newEventLog()
300
+ engine := newEngine(t, goworkflows.Dependencies{
301
+ Repos: repoRegistryWith("payments", newFakeTaskSystem(log)),
302
+ Runner: newFakeRunner(log), Harness: newFakeHarness(log),
303
+ })
304
+ rid, err := startRun(engine, linearWorkflow(false))
305
+ if err != nil {
306
+ t.Fatal(err)
307
+ }
308
+ waitFor(t, 10*time.Second, func() bool {
309
+ r, err := engine.GetRun(context.Background(), rid)
310
+ return err == nil && r.CurrentNode == "coding" && r.CurrentNodeVisitID != ""
311
+ })
312
+ before, _ := engine.GetRun(context.Background(), rid)
313
+ invalid := successReport("end")
314
+ invalid.Feedback.RequiredActions = "needs work"
315
+ ack, err := engine.SubmitReport(context.Background(), reportRequest(rid, "coding", invalid))
316
+ if !errors.Is(err, run.ErrInvalidReport) || ack.Accepted {
317
+ t.Fatalf("invalid report: ack=%+v err=%v", ack, err)
318
+ }
319
+ if want := `report selects "end": every feedback field must be "None" because end has no mailbox`; err.Error() != want {
320
+ t.Fatalf("validation message = %q, want %q", err, want)
321
+ }
322
+ after, err := engine.GetRun(context.Background(), rid)
323
+ if err != nil || after.CurrentNodeVisitID != before.CurrentNodeVisitID {
324
+ t.Fatalf("invalid report advanced visit: before=%+v after=%+v err=%v", before, after, err)
325
+ }
326
+ ack, err = engine.SubmitReport(context.Background(), reportRequest(rid, "coding", successReport("end")))
327
+ if err != nil || !ack.Accepted {
328
+ t.Fatalf("corrected report: ack=%+v err=%v", ack, err)
329
+ }
330
+ }
331
+
294
332
  func TestRevisitCreatesNewVisit(t *testing.T) {
295
333
  log := newEventLog()
296
334
  sys := newFakeTaskSystem(log)
@@ -467,6 +505,7 @@ func TestTransitionOrdering(t *testing.T) {
467
505
  log := newEventLog()
468
506
  sys := newFakeTaskSystem(log)
469
507
  fr := newFakeRunner(log)
508
+ fh := newFakeHarness(log)
470
509
  wf := workflow.Workflow{
471
510
  Name: "reviewFlow", Repos: []string{"payments"},
472
511
  Nodes: map[string]workflow.Node{
@@ -485,7 +524,7 @@ func TestTransitionOrdering(t *testing.T) {
485
524
  },
486
525
  }
487
526
  engine := newEngine(t, goworkflows.Dependencies{
488
- Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: newFakeHarness(log),
527
+ Repos: repoRegistryWith("payments", sys), Runner: fr, Harness: fh,
489
528
  })
490
529
  rid, _ := startRun(engine, wf)
491
530
  waitFor(t, 10*time.Second, func() bool {
@@ -510,6 +549,16 @@ func TestTransitionOrdering(t *testing.T) {
510
549
  t.Fatalf("HITL status was not set before terminal start; events=%v", events)
511
550
  }
512
551
 
552
+ var sawReviewFeedback bool
553
+ for _, call := range fh.promptCalls() {
554
+ if call.Data.Node == "review" && call.Data.PreviousFeedback == "review it" {
555
+ sawReviewFeedback = true
556
+ }
557
+ }
558
+ if !sawReviewFeedback {
559
+ t.Fatalf("next node prompt did not receive selected feedback: %+v", fh.promptCalls())
560
+ }
561
+
513
562
  // Exact cross-primitive order, observed through the fake-adapter and fake-
514
563
  // runner call logs (the settled observation seam):
515
564
  // summary(current) -> feedback(selected next) -> CompleteMailbox(current)
@@ -205,6 +205,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
205
205
  }
206
206
 
207
207
  current := target
208
+ previousFeedback := ""
208
209
  seenReportIDs := map[string]bool{}
209
210
  lastStepByNode := map[string]int64{}
210
211
  lastDepthByNode := map[string]int{}
@@ -297,16 +298,17 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
297
298
  Title: title,
298
299
  NudgePrompt: node.NudgePrompt,
299
300
  PromptData: harness.PromptData{
300
- TaskSystem: a.TaskSystem,
301
- Ticket: start.Ticket.Key,
302
- Workflow: wf.Name,
303
- Repo: start.Repo,
304
- Node: current,
305
- NodeType: node.Type,
306
- Agent: node.Agent,
307
- NodeDescription: node.Description,
308
- NextSteps: nextStepsText(nextSteps),
309
- Mailbox: mb.Key,
301
+ TaskSystem: a.TaskSystem,
302
+ Ticket: start.Ticket.Key,
303
+ Workflow: wf.Name,
304
+ Repo: start.Repo,
305
+ Node: current,
306
+ NodeType: node.Type,
307
+ Agent: node.Agent,
308
+ NodeDescription: node.Description,
309
+ NextSteps: nextStepsText(nextSteps),
310
+ Mailbox: mb.Key,
311
+ PreviousFeedback: previousFeedback,
310
312
  },
311
313
  NextSteps: nextSteps,
312
314
  }
@@ -395,7 +397,9 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
395
397
  stepMessage := report.Summary.Completed
396
398
  if report.Status == workflow.OutcomeFailure {
397
399
  stepStatus = run.StepFailed
398
- stepMessage = report.Summary.IssuesDiscovered
400
+ if report.Summary.IssuesDiscovered != workflow.None {
401
+ stepMessage = report.Summary.IssuesDiscovered
402
+ }
399
403
  }
400
404
  stepFinished := goworkflow.Now(ctx).UTC()
401
405
  if _, err := retryLoop(ctx, start.ID, a, work, current,
@@ -465,6 +469,7 @@ func (a *Activities) runGraph(ctx goworkflow.Context, start run.Start) error {
465
469
  return err
466
470
  }
467
471
 
472
+ previousFeedback = report.Feedback.RequiredActions
468
473
  current = next
469
474
  }
470
475
 
@@ -761,11 +766,19 @@ func nextStepsText(routes []workflow.Route) string {
761
766
  }
762
767
 
763
768
  func renderSummaryReport(r workflow.Report) string {
769
+ if r.Summary.Commits == workflow.None && r.Summary.NotCompleted == workflow.None &&
770
+ r.Summary.IssuesDiscovered == workflow.None && r.Summary.Verification == workflow.None && r.Summary.Notes == workflow.None {
771
+ return r.Summary.Completed
772
+ }
764
773
  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",
765
774
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
766
775
  }
767
776
 
768
777
  func renderFeedbackReport(r workflow.Report) string {
778
+ if r.Summary.Commits == workflow.None && r.Feedback.ReasonForNextStep == workflow.None &&
779
+ r.Feedback.RelevantContext == workflow.None && r.Feedback.ExpectedResult == workflow.None {
780
+ return r.Feedback.RequiredActions
781
+ }
769
782
  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",
770
783
  r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
771
784
  }
@@ -41,6 +41,33 @@ func threeNodeWorkflow() workflow.Workflow {
41
41
  }
42
42
  }
43
43
 
44
+ func TestCompactMailboxTemplateDoesNotAppendGenericInstructions(t *testing.T) {
45
+ wf := threeNodeWorkflow()
46
+ sys := newFakeTaskSystem(newEventLog())
47
+ sys.renderText = func(_ task.TextKind, data task.TextData) (string, error) {
48
+ return data.Ticket + " / " + data.Node + " — " + data.NodeType + " — " + data.Agent +
49
+ "\n" + data.NodeDescription + "\nSuccess routes:\n" + data.SuccessRoutes +
50
+ "\nFailure routes:\n" + data.FailureRoutes + "\n" + data.Report, nil
51
+ }
52
+ specs, err := goworkflows.RenderMailboxSpecs(sys, run.Work{
53
+ Parent: task.TicketRef{Key: "PAY-101"}, Repo: "payments", Workflow: wf.Name,
54
+ }, &wf)
55
+ if err != nil {
56
+ t.Fatal(err)
57
+ }
58
+ for _, spec := range specs {
59
+ for _, required := range []string{"PAY-101 / " + spec.Node, wf.Nodes[spec.Node].Description,
60
+ "Success routes:", "Failure routes:", " — when: ", workflow.ReportFormat} {
61
+ if !strings.Contains(spec.Description, required) {
62
+ t.Fatalf("mailbox %s description lacks %q: %q", spec.Node, required, spec.Description)
63
+ }
64
+ }
65
+ if strings.Contains(spec.Description, "Required report format:") {
66
+ t.Fatalf("mailbox %s appended generic instructions: %q", spec.Node, spec.Description)
67
+ }
68
+ }
69
+ }
70
+
44
71
  func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
45
72
  log := newEventLog()
46
73
  sys := newFakeTaskSystem(log)
@@ -107,7 +134,7 @@ func TestMailboxesEnsuredForWorkNodesOnly(t *testing.T) {
107
134
  t.Fatalf("%s description lacks route explanation %q: %q", node, when, d)
108
135
  }
109
136
  }
110
- for _, required := range []string{"Required report format:", "STATUS:", "COMMITS:", "FEEDBACK:"} {
137
+ for _, required := range []string{"Required report format:", "STATUS:", "NEXT STEP:", "SUMMARY:", "FEEDBACK:"} {
111
138
  if !strings.Contains(d, required) {
112
139
  t.Fatalf("%s description lacks %q: %q", node, required, d)
113
140
  }
@@ -85,7 +85,11 @@ func (a *Activities) EnsureMailboxes(ctx context.Context, w run.Work, specs []ta
85
85
  if err != nil {
86
86
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
87
87
  }
88
- specs[i].Description = appendText(custom, specs[i].Description)
88
+ if data.Report != "" && strings.Contains(custom, data.Report) {
89
+ specs[i].Description = custom
90
+ } else {
91
+ specs[i].Description = appendText(custom, specs[i].Description)
92
+ }
89
93
  }
90
94
  return sys.EnsureMailboxes(ctx, w.Parent, w.Workflow, specs)
91
95
  }
@@ -486,26 +490,9 @@ func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflo
486
490
  var b strings.Builder
487
491
  b.WriteString(`Required report format:
488
492
 
489
- STATUS: success | failure
490
- NEXT STEP: <one valid node name>
491
-
492
- SUMMARY:
493
- COMPLETED:
494
- COMMITS:
495
- NOT COMPLETED:
496
- ISSUES DISCOVERED:
497
- VERIFICATION:
498
- NOTES:
499
-
500
- FEEDBACK:
501
- REASON FOR NEXT STEP:
502
- REQUIRED ACTIONS:
503
- RELEVANT CONTEXT:
504
- EXPECTED RESULT:
493
+ ` + workflow.ReportFormat + `
505
494
 
506
- Every field is required; use None for an intentionally empty section. COMMITS must contain the relevant commit IDs or None.
507
-
508
- Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, every FEEDBACK field must be None.`)
495
+ Node names identify workflow stages; they are not task-system statuses. STATUS describes whether the work at this node succeeded or failed, not the status of the parent or mailbox. NEXT STEP must name exactly one target listed below for that STATUS. Submit one report only: its SUMMARY is written to this current mailbox, while its FEEDBACK is written only to the selected next node's mailbox. For review nodes, put requested changes in FEEDBACK and select the node responsible for acting on them. Relay-flow and the task system own parent and mailbox status changes. When NEXT STEP is end, FEEDBACK must be None.`)
509
496
  writeRoutes := func(label string, routes []workflow.Route) {
510
497
  if len(routes) == 0 {
511
498
  return
@@ -533,7 +520,7 @@ Node names identify workflow stages; they are not task-system statuses. STATUS d
533
520
  Agent: n.Agent, NodeDescription: n.Description,
534
521
  NextSteps: nextStepsText(append(append([]workflow.Route{}, n.OnSuccess...), n.OnFailure...)),
535
522
  SuccessRoutes: successRoutes, FailureRoutes: failureRoutes,
536
- Mailbox: ticketKey + ":" + name,
523
+ Mailbox: ticketKey + ":" + name, Report: workflow.ReportFormat,
537
524
  },
538
525
  }
539
526
  }
@@ -567,7 +554,11 @@ func RenderMailboxSpecs(sys task.System, w run.Work, wf *workflow.Workflow) ([]t
567
554
  if err != nil {
568
555
  return nil, fmt.Errorf("render mailbox %q description: %w", specs[i].Node, err)
569
556
  }
570
- specs[i].Description = appendText(custom, specs[i].Description)
557
+ if data.Report != "" && strings.Contains(custom, data.Report) {
558
+ specs[i].Description = custom
559
+ } else {
560
+ specs[i].Description = appendText(custom, specs[i].Description)
561
+ }
571
562
  }
572
563
  return specs, nil
573
564
  }