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.
- package/README.md +10 -22
- package/cmd/relay-flow/commands_test.go +40 -2
- package/cmd/relay-flow/main.go +13 -1
- package/examples/config-reference.yaml +23 -19
- package/examples/workflow-reference.yaml +3 -0
- package/internal/execution/goworkflows/activities.go +13 -22
- package/internal/execution/goworkflows/engine.go +1 -1
- package/internal/execution/goworkflows/engine_test.go +52 -3
- package/internal/execution/goworkflows/interpreter.go +24 -11
- package/internal/execution/goworkflows/mailbox_test.go +28 -1
- package/internal/execution/temporal/activities.go +13 -22
- package/internal/execution/temporal/compact_mailbox_test.go +56 -0
- package/internal/execution/temporal/interpreter.go +14 -2
- package/internal/execution/temporal/operations.go +1 -1
- package/internal/execution/temporal/operations_test.go +75 -0
- package/internal/harness/harness.go +22 -10
- package/internal/harness/opencode/opencode.go +17 -4
- package/internal/harness/opencode/opencode_test.go +25 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/opencode/task_env_test.go +2 -2
- package/internal/harness/pi/pi.go +15 -2
- package/internal/harness/pi/pi_test.go +7 -4
- package/internal/harness/pi/prompt_test.go +13 -5
- package/internal/harness/pi/task_env_test.go +2 -2
- package/internal/run/run.go +10 -0
- package/internal/server/api_test.go +31 -0
- package/internal/server/client.go +11 -1
- package/internal/server/fixture_test.go +4 -0
- package/internal/server/server.go +2 -0
- package/internal/task/jira/jira.go +16 -11
- package/internal/task/jira/templates_test.go +38 -2
- package/internal/task/task.go +1 -0
- package/internal/workflow/report.go +7 -0
- 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
|
-
|
|
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{
|
|
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
|
|
32
|
-
Ticket
|
|
33
|
-
Workflow
|
|
34
|
-
Repo
|
|
35
|
-
Node
|
|
36
|
-
NodeType
|
|
37
|
-
Agent
|
|
38
|
-
NodeDescription
|
|
39
|
-
NextSteps
|
|
40
|
-
Mailbox
|
|
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
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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.
|
|
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.
|
|
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}}") {
|
|
@@ -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) !=
|
|
55
|
-
t.Fatalf("Env = %#v, want only the
|
|
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 :=
|
|
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) <
|
|
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[
|
|
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 := "
|
|
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 := "
|
|
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, "
|
|
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
|
|
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 := "
|
|
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) !=
|
|
65
|
-
t.Fatalf("Env = %#v, want only the
|
|
64
|
+
if len(cmd.Env) != 10 {
|
|
65
|
+
t.Fatalf("Env = %#v, want only the ten relay-flow variables", cmd.Env)
|
|
66
66
|
}
|
|
67
67
|
}
|
package/internal/run/run.go
CHANGED
|
@@ -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
|
|
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):
|