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
@@ -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" {
@@ -407,7 +408,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
407
408
  PromptData: harness.PromptData{
408
409
  TaskSystem: "", Ticket: start.Ticket.Key, Workflow: wf.Name, Repo: start.Repo,
409
410
  Node: current, NodeType: node.Type, Agent: node.Agent, NodeDescription: node.Description,
410
- NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key,
411
+ NextSteps: nextStepsText(nextSteps), Mailbox: mailbox.Key, PreviousFeedback: previousFeedback,
411
412
  },
412
413
  NextSteps: nextSteps,
413
414
  }
@@ -516,7 +517,9 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
516
517
  stepMessage := accepted.Report.Summary.Completed
517
518
  if accepted.Report.Status == domainworkflow.OutcomeFailure {
518
519
  stepStatus = run.StepFailed
519
- stepMessage = accepted.Report.Summary.IssuesDiscovered
520
+ if accepted.Report.Summary.IssuesDiscovered != domainworkflow.None {
521
+ stepMessage = accepted.Report.Summary.IssuesDiscovered
522
+ }
520
523
  }
521
524
  stepFinished := temporalworkflow.Now(ctx).UTC()
522
525
  step.Status, step.FinishedAt, step.Message, step.Route = stepStatus, &stepFinished, stepMessage, accepted.Report.NextStep
@@ -569,6 +572,7 @@ func runGraph(ctx temporalworkflow.Context, start run.Start, state *workflowStat
569
572
  return err
570
573
  }
571
574
  applyRuntimePolicy(state, current, work.Runtime)
575
+ previousFeedback = accepted.Report.Feedback.RequiredActions
572
576
  current = next
573
577
  }
574
578
 
@@ -859,11 +863,19 @@ func applyRuntimePolicy(state *workflowState, node string, policy run.RuntimePol
859
863
  }
860
864
 
861
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
+ }
862
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",
863
871
  r.Summary.Completed, r.Summary.Commits, r.Summary.NotCompleted, r.Summary.IssuesDiscovered, r.Summary.Verification, r.Summary.Notes)
864
872
  }
865
873
 
866
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
+ }
867
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",
868
880
  r.Summary.Commits, r.Feedback.ReasonForNextStep, r.Feedback.RequiredActions, r.Feedback.RelevantContext, r.Feedback.ExpectedResult)
869
881
  }
@@ -210,7 +210,7 @@ func (e *Engine) SubmitReport(ctx context.Context, req run.ReportRequest) (run.R
210
210
  return run.ReportAck{}, err
211
211
  }
212
212
  if err := wf.ValidateReport(req.Node, req.Report); err != nil {
213
- return run.ReportAck{Accepted: false}, err
213
+ return run.ReportAck{}, &run.InvalidReportError{Reason: err.Error()}
214
214
  }
215
215
  if err := c.SignalWorkflow(ctx, string(req.RunID), "", reportSignalName, reportSignal{
216
216
  ReportID: req.ReportID, Node: req.Node, NodeVisitID: state.CurrentNodeVisitID, Report: req.Report,
@@ -3,6 +3,7 @@ package temporal
3
3
  import (
4
4
  "context"
5
5
  "database/sql"
6
+ "errors"
6
7
  "testing"
7
8
  "time"
8
9
 
@@ -11,10 +12,84 @@ import (
11
12
  "github.com/rajpopat27/relay-flow/internal/task"
12
13
  "github.com/rajpopat27/relay-flow/internal/workflow"
13
14
  commonpb "go.temporal.io/api/common/v1"
15
+ enumspb "go.temporal.io/api/enums/v1"
16
+ historypb "go.temporal.io/api/history/v1"
14
17
  workflowpb "go.temporal.io/api/workflow/v1"
18
+ "go.temporal.io/sdk/client"
19
+ "go.temporal.io/sdk/converter"
15
20
  _ "modernc.org/sqlite"
16
21
  )
17
22
 
23
+ type reportValidationClient struct {
24
+ client.Client
25
+ start run.Start
26
+ signaled bool
27
+ }
28
+
29
+ func (c *reportValidationClient) QueryWorkflow(context.Context, string, string, string, ...interface{}) (converter.EncodedValue, error) {
30
+ return reportValidationValue{}, nil
31
+ }
32
+
33
+ type reportValidationValue struct{}
34
+
35
+ func (reportValidationValue) HasValue() bool { return true }
36
+ func (reportValidationValue) Get(dest interface{}) error {
37
+ *(dest.(*ReportStateSnapshot)) = ReportStateSnapshot{
38
+ State: run.StateWaiting, CurrentNode: "coding", CurrentNodeVisitID: "visit-1",
39
+ }
40
+ return nil
41
+ }
42
+
43
+ func (c *reportValidationClient) GetWorkflowHistory(context.Context, string, string, bool, enumspb.HistoryEventFilterType) client.HistoryEventIterator {
44
+ payload, _ := converter.GetDefaultDataConverter().ToPayloads(c.start)
45
+ return &reportValidationHistory{event: &historypb.HistoryEvent{
46
+ EventType: enumspb.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED,
47
+ Attributes: &historypb.HistoryEvent_WorkflowExecutionStartedEventAttributes{
48
+ WorkflowExecutionStartedEventAttributes: &historypb.WorkflowExecutionStartedEventAttributes{Input: payload},
49
+ },
50
+ }}
51
+ }
52
+
53
+ func (c *reportValidationClient) SignalWorkflow(context.Context, string, string, string, interface{}) error {
54
+ c.signaled = true
55
+ return nil
56
+ }
57
+
58
+ type reportValidationHistory struct {
59
+ event *historypb.HistoryEvent
60
+ read bool
61
+ }
62
+
63
+ func (h *reportValidationHistory) HasNext() bool { return !h.read }
64
+ func (h *reportValidationHistory) Next() (*historypb.HistoryEvent, error) {
65
+ h.read = true
66
+ return h.event, nil
67
+ }
68
+
69
+ func TestTemporalSubmitReportRejectsInvalidReportBeforeSignal(t *testing.T) {
70
+ client := &reportValidationClient{start: run.Start{Workflow: workflow.Workflow{
71
+ Name: "test", Nodes: map[string]workflow.Node{
72
+ "coding": {Type: workflow.NodeAgent, OnSuccess: []workflow.Route{{Target: "end"}}},
73
+ "end": {},
74
+ },
75
+ }}}
76
+ engine := &Engine{client: client}
77
+ report := workflow.Report{
78
+ Status: workflow.OutcomeSuccess, NextStep: "end",
79
+ Summary: workflow.Summary{Completed: "done", Commits: "None", NotCompleted: "None", IssuesDiscovered: "None", Verification: "None", Notes: "None"},
80
+ Feedback: workflow.Feedback{ReasonForNextStep: "None", RequiredActions: "needs work", RelevantContext: "None", ExpectedResult: "None"},
81
+ }
82
+ ack, err := engine.SubmitReport(context.Background(), run.ReportRequest{
83
+ RunID: "run-1", Node: "coding", ReportID: "message-1", Report: report,
84
+ })
85
+ if !errors.Is(err, run.ErrInvalidReport) || ack.Accepted || client.signaled {
86
+ t.Fatalf("invalid Temporal report: ack=%+v err=%v signaled=%v", ack, err, client.signaled)
87
+ }
88
+ if want := `report selects "end": every feedback field must be "None" because end has no mailbox`; err.Error() != want {
89
+ t.Fatalf("validation message = %q, want %q", err, want)
90
+ }
91
+ }
92
+
18
93
  func TestApplyTemporalProjectionStateRebuildsCanceledTimingIdempotently(t *testing.T) {
19
94
  started := time.Now().UTC().Add(-time.Minute)
20
95
  finished := started.Add(30 * time.Second)
@@ -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 {
@@ -137,23 +137,35 @@ func (h *Harness) FindSession(context.Context, string, string) (harness.Session,
137
137
  return harness.Session{}, false, nil
138
138
  }
139
139
 
140
- // RenderPrompt renders the selected session prompt, appends the harness-owned
141
- // HITL/TUI instructions for HITL nodes, then renders and appends the node's
142
- // 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.
143
143
  func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudgeTemplate string) (string, error) {
144
144
  var tmpl string
145
145
  switch kind {
146
146
  case harness.PromptInitial:
147
147
  tmpl = h.templates.Initial
148
+ if data.TaskSystem == "jira" && tmpl == defaultInitialPrompt {
149
+ tmpl = harness.JiraInitialPrompt
150
+ }
148
151
  case harness.PromptFeedback:
149
152
  tmpl = h.templates.Feedback
153
+ if data.TaskSystem == "jira" && tmpl == defaultFeedbackPrompt {
154
+ tmpl = harness.JiraFeedbackPrompt
155
+ }
150
156
  default:
151
157
  return "", fmt.Errorf("opencode: unknown prompt kind %q", kind)
152
158
  }
153
159
  prompt := renderTemplate(tmpl, data)
154
- 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) {
155
164
  prompt = appendPrompt(prompt, renderTemplate(h.templates.HITL, data))
156
165
  }
166
+ if kind == harness.PromptInitial && data.TaskSystem == "jira" {
167
+ return prompt, nil
168
+ }
157
169
  return appendPrompt(prompt, renderTemplate(nudgeTemplate, data)), nil
158
170
  }
159
171
 
@@ -204,6 +216,7 @@ func (h *Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error)
204
216
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
205
217
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
206
218
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
219
+ "RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
207
220
  } {
208
221
  env[key] = value
209
222
  }
@@ -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.10-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.3.12-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -105,6 +105,30 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
105
105
  }
106
106
  }
107
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
+
108
132
  func TestHarnessConfigRejectsUnknownPromptVariable(t *testing.T) {
109
133
  _, err := harness.New("opencode", config.RawValues{"initial": "{{unknown}}"})
110
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.10-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.3.12-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
  }
@@ -150,7 +150,7 @@ func (*Harness) FindSession(context.Context, string, string) (harness.Session, b
150
150
  }
151
151
 
152
152
  // RenderPrompt renders the selected initial or feedback template and the
153
- // 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
154
154
  // command syntax; feedback is sent to an existing session and must remain
155
155
  // raw so Pi does not expand the full prompt template again. HITL approval is
156
156
  // not encoded in the prompt; the Pi extension asks for approval through
@@ -160,8 +160,14 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
160
160
  switch kind {
161
161
  case harness.PromptInitial:
162
162
  tmpl = h.templates.Initial
163
+ if data.TaskSystem == "jira" && tmpl == defaultInitialPrompt {
164
+ tmpl = harness.JiraInitialPrompt
165
+ }
163
166
  case harness.PromptFeedback:
164
167
  tmpl = h.templates.Feedback
168
+ if data.TaskSystem == "jira" && tmpl == defaultFeedbackPrompt {
169
+ tmpl = harness.JiraFeedbackPrompt
170
+ }
165
171
  default:
166
172
  return "", fmt.Errorf("pi: unknown prompt kind %q", kind)
167
173
  }
@@ -170,7 +176,13 @@ func (h *Harness) RenderPrompt(kind harness.PromptKind, data harness.PromptData,
170
176
  return "", err
171
177
  }
172
178
  }
173
- 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
+ }
174
186
  if kind == harness.PromptInitial {
175
187
  return applyPromptTemplate(data.Agent, prompt), nil
176
188
  }
@@ -213,6 +225,7 @@ func (*Harness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
213
225
  "RELAY_FLOW_NODE_TYPE": string(spec.NodeType),
214
226
  "RELAY_FLOW_NUDGE_PROMPT": spec.NudgePrompt,
215
227
  "RELAY_FLOW_NEXT_STEPS_JSON": string(nextSteps),
228
+ "RELAY_FLOW_REPORT_FORMAT": workflow.ReportFormat,
216
229
  } {
217
230
  env[key] = value
218
231
  }
@@ -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
  }
@@ -61,7 +61,7 @@ func TestBuildCommandWithoutTaskEnvKeepsRelayVariablesOnly(t *testing.T) {
61
61
  if err != nil {
62
62
  t.Fatalf("BuildCommand: %v", err)
63
63
  }
64
- if len(cmd.Env) != 9 {
65
- 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)
66
66
  }
67
67
  }
@@ -120,6 +120,16 @@ type ReportAck struct {
120
120
  Duplicate bool `json:"duplicate"`
121
121
  }
122
122
 
123
+ // ErrInvalidReport identifies permanent semantic report-validation failures.
124
+ var ErrInvalidReport = errors.New("invalid report")
125
+
126
+ // InvalidReportError keeps the validation message intact while allowing
127
+ // callers to classify it with errors.Is(err, ErrInvalidReport).
128
+ type InvalidReportError struct{ Reason string }
129
+
130
+ func (e *InvalidReportError) Error() string { return e.Reason }
131
+ func (e *InvalidReportError) Is(target error) bool { return target == ErrInvalidReport }
132
+
123
133
  // ErrRestartConflict means the ticket cannot accept an explicit restart in
124
134
  // its current durable state. Server handlers map it to HTTP 409.
125
135
  var ErrRestartConflict = errors.New("restart conflict")
@@ -4,6 +4,7 @@ import (
4
4
  "bytes"
5
5
  "context"
6
6
  "encoding/json"
7
+ "errors"
7
8
  "fmt"
8
9
  "io"
9
10
  "net/http"
@@ -150,6 +151,36 @@ func TestReportEndpointAcceptsJSON(t *testing.T) {
150
151
  }
151
152
  }
152
153
 
154
+ func TestReportValidationErrorPreservesCodeAndMessageThroughClient(t *testing.T) {
155
+ const message = "NEXT STEP is end, so FEEDBACK must be exactly None."
156
+ fake := &fakeServices{reportErr: &run.InvalidReportError{Reason: message}}
157
+ c, cleanup := startHandler(t, fake)
158
+ defer cleanup()
159
+ body := []byte(`{"runId":"run-1","node":"coding","reportId":"message-1","report":{"status":"success","nextStep":"end"}}`)
160
+ status, env := do(t, c, http.MethodPost, "http://relay/reports", body)
161
+ if status != http.StatusBadRequest || env.Error == nil || env.Error.Code != "invalidReport" || env.Error.Message != message {
162
+ t.Fatalf("invalid report response: status=%d env=%+v", status, env)
163
+ }
164
+
165
+ dir := t.TempDir()
166
+ _, cleanupSocket := startHandlerOnSocket(t, dir, fake)
167
+ defer cleanupSocket()
168
+ client := server.NewClient(filepath.Join(dir, "server.sock"))
169
+ _, err := client.SubmitReport(context.Background(), run.ReportRequest{
170
+ RunID: "run-1", Node: "coding", ReportID: "message-1",
171
+ Report: workflow.Report{Status: workflow.OutcomeSuccess, NextStep: "end"},
172
+ })
173
+ var apiErr *server.APIError
174
+ if !errors.As(err, &apiErr) || apiErr.Code != "invalidReport" || apiErr.Message != message || apiErr.StatusCode != http.StatusBadRequest {
175
+ t.Fatalf("Go client lost structured validation error: %v", err)
176
+ }
177
+ fake.reportErr = errors.New("temporary backend failure")
178
+ status, env = do(t, c, http.MethodPost, "http://relay/reports", body)
179
+ if status != http.StatusInternalServerError || env.Error == nil || env.Error.Code != "internalError" {
180
+ t.Fatalf("temporary failure misclassified: status=%d env=%+v", status, env)
181
+ }
182
+ }
183
+
153
184
  func TestProcessedReportIDBypassesReportBodyValidation(t *testing.T) {
154
185
  runID := "payments/basicFlow/PAY-101"
155
186
  reportID := "session-1:message-1"
@@ -38,6 +38,16 @@ func (c *Client) httpClient() *http.Client {
38
38
  }
39
39
  }
40
40
 
41
+ // APIError retains the server's machine-readable code and agent-readable
42
+ // message separately so callers need not parse formatted error text.
43
+ type APIError struct {
44
+ Code string
45
+ Message string
46
+ StatusCode int
47
+ }
48
+
49
+ func (e *APIError) Error() string { return fmt.Sprintf("server %s: %s", e.Code, e.Message) }
50
+
41
51
  // call performs one HTTP request against the socket server and decodes
42
52
  // the standard envelope. Non-2xx responses return an error carrying the
43
53
  // server-provided code and message.
@@ -77,7 +87,7 @@ func (c *Client) call(ctx context.Context, method, path string, body []byte, out
77
87
  }
78
88
  if !env.OK {
79
89
  if env.Error != nil {
80
- return fmt.Errorf("server %s: %s", env.Error.Code, env.Error.Message)
90
+ return &APIError{Code: env.Error.Code, Message: env.Error.Message, StatusCode: resp.StatusCode}
81
91
  }
82
92
  return fmt.Errorf("server returned not-ok (HTTP %d)", resp.StatusCode)
83
93
  }
@@ -36,6 +36,7 @@ type fakeServices struct {
36
36
  runtimeAck run.NodeRuntimeRegistrationAck
37
37
  processedReports map[string]bool
38
38
  submittedReports int
39
+ reportErr error
39
40
  restartRun run.Run
40
41
  restartErr error
41
42
  restarts []string
@@ -125,6 +126,9 @@ func (f *fakeServices) SubmitReport(ctx context.Context, _ run.ReportRequest) (r
125
126
  case <-ctx.Done():
126
127
  }
127
128
  }
129
+ if f.reportErr != nil {
130
+ return run.ReportAck{}, f.reportErr
131
+ }
128
132
  return run.ReportAck{Accepted: true}, nil
129
133
  }
130
134
 
@@ -130,6 +130,8 @@ func writeEnv(w http.ResponseWriter, status int, env envelope) {
130
130
  // anything else is an unexpected 500.
131
131
  func mapErr(w http.ResponseWriter, err error) {
132
132
  switch {
133
+ case errors.Is(err, run.ErrInvalidReport):
134
+ writeErr(w, http.StatusBadRequest, "invalidReport", err.Error())
133
135
  case errors.Is(err, run.ErrRestartConflict):
134
136
  writeErr(w, http.StatusConflict, "conflict", err.Error())
135
137
  case errors.Is(err, run.ErrNotFound):