tinyrelayfile
tinyrelay/internal/daemon/jobs_test.go/

internal/daemon/jobs_test.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/FelineStateMachine/tinyrelay/internal/event"
14 "github.com/FelineStateMachine/tinyrelay/internal/mcp"
15)
16
17// jobTenant returns a tenant with a member who requests jobs and an agent
18// granted to serve them.
19func jobTenant(t *testing.T) (*App, *Tenant, string, string) {
20 t.Helper()
21 app, tenant := testTenant(t)
22 ctx := context.Background()
23 owner := tenant.Policy().Owner
24 member, _ := event.PublicKey(testMemberSecret)
25 agent, _ := event.PublicKey(testAgentSecret)
26 if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {
27 t.Fatal(err)
28 }
29 now := time.Now().Unix()
30 if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"jobs", "serve"})); err != nil {
31 t.Fatalf("grant rejected: %v", err)
32 }
33 return app, tenant, member, agent
34}
35
36func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {
37 app, _, member, _ := jobTenant(t)
38 now := time.Now().Unix()
39 call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {
40 t.Helper()
41 w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")
42 if w.Code != http.StatusOK {
43 t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())
44 }
45 return mcpToolResult(t, response)
46 }
47 structured := func(result map[string]any) map[string]any {
48 value, _ := result["structuredContent"].(map[string]any)
49 return value
50 }
51 text := func(result map[string]any) string {
52 content, _ := result["content"].([]any)
53 if len(content) == 0 {
54 return ""
55 }
56 return content[0].(map[string]any)["text"].(string)
57 }
58 _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")
59 names := map[string]bool{}
60 for _, tool := range response.Result.(map[string]any)["tools"].([]any) {
61 names[tool.(map[string]any)["name"].(string)] = true
62 }
63 for _, want := range []string{"list_jobs", "read_job", "request_job", "job_feedback", "job_result"} {
64 if !names[want] {
65 t.Fatalf("missing tool %s", want)
66 }
67 }
68
69 // request_job: the template carries the NIP-90 tags in order, refuses
70 // bad inputs and kinds, and the signed request publishes.
71 source := strings.Repeat("d", 64)
72 expires := strconv.FormatInt(now+3600, 10)
73 result, isError := call("request_job", map[string]any{"kind": 5001, "inputs": []any{map[string]any{"data": "hello", "type": "text"}, map[string]any{"data": source, "type": "event", "relay": "wss://relay.example", "marker": "context"}}, "output": "text/plain", "params": map[string]any{"model": "small", "lang": "es"}, "bid": 1000, "relays": []any{"wss://a.example", " "}, "expiration": now + 3600}, testMemberSecret)
74 unsigned, _ := structured(result)["unsigned"].(map[string]any)
75 tags, _ := json.Marshal(unsigned["tags"])
76 if isError || unsigned["kind"] != float64(5001) || unsigned["content"] != "" || string(tags) != `[["i","hello","text"],["i","`+source+`","event","wss://relay.example","context"],["output","text/plain"],["param","lang","es"],["param","model","small"],["bid","1000"],["relays","wss://a.example"],["expiration","`+expires+`"]]` {
77 t.Fatalf("request_job template: %v %s", isError, text(result))
78 }
79 if result, isError = call("request_job", map[string]any{"kind": 5001, "inputs": []any{map[string]any{"data": "hello", "type": "voice"}}}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job") {
80 t.Fatalf("request_job bad input: %s", text(result))
81 }
82 if result, isError = call("request_job", map[string]any{"kind": 1}, testMemberSecret); !isError {
83 t.Fatalf("request_job bad kind: %s", text(result))
84 }
85 if result, isError = call("request_job", map[string]any{"event": mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello"}}, "")}, testMemberSecret); !isError || !strings.Contains(text(result), "url, event, job or text") || !strings.Contains(text(result), "Expected a signed event of kind 5000 to 5127 or 5129 to 5999") {
86 t.Fatalf("request_job malformed signed: %s", text(result))
87 }
88 request := mcpSigned(t, testMemberSecret, 5001, [][]string{{"i", "hello", "text"}, {"output", "text/plain"}, {"bid", "1000"}}, "")
89 result, isError = call("request_job", map[string]any{"event": request}, testMemberSecret)
90 requestID, _ := structured(result)["event_id"].(string)
91 if isError || structured(result)["accepted"] != true || requestID == "" {
92 t.Fatalf("request_job publish: %s", text(result))
93 }
94
95 // job_feedback: the serving agent's template and signed publish, and
96 // the status vocabulary.
97 result, isError = call("job_feedback", map[string]any{"e": requestID, "p": member, "status": "processing", "info": "halfway", "amount": 500, "invoice": "lnbc1"}, testAgentSecret)
98 unsigned, _ = structured(result)["unsigned"].(map[string]any)
99 tags, _ = json.Marshal(unsigned["tags"])
100 if isError || unsigned["kind"] != float64(7000) || string(tags) != `[["status","processing","halfway"],["e","`+requestID+`"],["p","`+member+`"],["amount","500","lnbc1"]]` {
101 t.Fatalf("job_feedback template: %v %s", isError, text(result))
102 }
103 if result, isError = call("job_feedback", map[string]any{"e": requestID, "p": member, "status": "done"}, testAgentSecret); !isError || !strings.Contains(text(result), "status") {
104 t.Fatalf("job_feedback bad status: %s", text(result))
105 }
106 if result, isError = call("job_feedback", map[string]any{"event": mcpSigned(t, testAgentSecret, 7000, [][]string{{"e", requestID}, {"p", member}}, "")}, testAgentSecret); !isError || !strings.Contains(text(result), "payment-required, processing, error, success or partial") {
107 t.Fatalf("job_feedback malformed signed: %s", text(result))
108 }
109 result, isError = call("job_feedback", map[string]any{"event": mcpSigned(t, testAgentSecret, 7000, [][]string{{"status", "processing", "halfway"}, {"e", requestID}, {"p", member}}, "")}, testAgentSecret)
110 if isError || structured(result)["accepted"] != true {
111 t.Fatalf("job_feedback publish: %s", text(result))
112 }
113
114 // job_result: the template derives the kind, the request tag and the
115 // inputs from the request; the relay checks the answer against the
116 // request it holds.
117 result, isError = call("job_result", map[string]any{"request": request, "content": "hola", "amount": 700}, testAgentSecret)
118 unsigned, _ = structured(result)["unsigned"].(map[string]any)
119 resultTags, _ := unsigned["tags"].([]any)
120 if isError || unsigned["kind"] != float64(6001) || unsigned["content"] != "hola" || len(resultTags) != 5 {
121 t.Fatalf("job_result template: %v %s", isError, text(result))
122 }
123 requestTag := resultTags[0].([]any)
124 var embedded event.Event
125 if requestTag[0] != "request" || json.Unmarshal([]byte(requestTag[1].(string)), &embedded) != nil || embedded.ID != requestID {
126 t.Fatalf("job_result request tag %v", requestTag)
127 }
128 tags, _ = json.Marshal(resultTags[1:])
129 if string(tags) != `[["e","`+requestID+`"],["i","hello","text"],["p","`+member+`"],["amount","700"]]` {
130 t.Fatalf("job_result tags %s", tags)
131 }
132 if result, isError = call("job_result", map[string]any{"request": request, "kind": 6002, "content": "hola"}, testAgentSecret); !isError || !strings.Contains(text(result), "must match the request") {
133 t.Fatalf("job_result mismatched kind: %s", text(result))
134 }
135 if result, isError = call("job_result", map[string]any{"kind": 6001, "e": requestID, "p": member}, testAgentSecret); !isError || !strings.Contains(text(result), "content is required") {
136 t.Fatalf("job_result without content: %s", text(result))
137 }
138 if result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, 6002, [][]string{{"e", requestID}, {"p", member}}, "hola")}, testAgentSecret); !isError || !strings.Contains(text(result), "does not answer a kind 5001 request") {
139 t.Fatalf("job_result wrong kind rejected by relay: %s", text(result))
140 }
141 signedTags := [][]string{}
142 for _, tag := range resultTags {
143 values := []string{}
144 for _, value := range tag.([]any) {
145 values = append(values, value.(string))
146 }
147 signedTags = append(signedTags, values)
148 }
149 result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, 6001, signedTags, "hola")}, testAgentSecret)
150 resultID, _ := structured(result)["event_id"].(string)
151 if isError || structured(result)["accepted"] != true || resultID == "" {
152 t.Fatalf("job_result publish: %s", text(result))
153 }
154
155 // The reads show the settled request.
156 result, isError = call("list_jobs", map[string]any{"state": "done", "mine": true}, testMemberSecret)
157 items, _ := structured(result)["items"].([]any)
158 if isError || len(items) != 1 || items[0].(map[string]any)["id"] != requestID || items[0].(map[string]any)["status"] != "processing" || items[0].(map[string]any)["result"].(map[string]any)["id"] != resultID {
159 t.Fatalf("list_jobs: %s", text(result))
160 }
161 result, isError = call("read_job", map[string]any{"id": requestID}, testMemberSecret)
162 if isError || len(structured(result)["feedback"].([]any)) != 1 || len(structured(result)["results"].([]any)) != 1 {
163 t.Fatalf("read_job: %s", text(result))
164 }
165 r := httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil)
166 w := httptest.NewRecorder()
167 app.ServeHTTP(w, r)
168 if !strings.Contains(w.Body.String(), "request_job, job_feedback and job_result") || !strings.Contains(w.Body.String(), "list_jobs and read_job") {
169 t.Fatalf("llms.txt: %s", w.Body.String())
170 }
171}
172
173func TestMCPJobBuildersKeepRoomScope(t *testing.T) {
174 room := "private-work"
175 request, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{
176 "kind": float64(5001), "room": room,
177 "inputs": []any{map[string]any{"data": "hello", "type": "text"}},
178 }})
179 if err != nil {
180 t.Fatalf("request builder: %v", err)
181 }
182 if got := event.Tag(event.Event{Tags: request.Tags}, "h"); got != room {
183 t.Fatalf("request room = %q, want %q", got, room)
184 }
185
186 feedback, err := mcpBuildJobFeedback(mcp.Call{Arguments: map[string]any{
187 "e": strings.Repeat("a", 64), "p": strings.Repeat("b", 64),
188 "room": room, "status": "processing",
189 }})
190 if err != nil {
191 t.Fatalf("feedback builder: %v", err)
192 }
193 if got := event.Tag(event.Event{Tags: feedback.Tags}, "h"); got != room {
194 t.Fatalf("feedback room = %q, want %q", got, room)
195 }
196
197 requestEvent := event.Event{ID: strings.Repeat("c", 64), PubKey: strings.Repeat("d", 64), Kind: 5001, Tags: request.Tags}
198 result, err := mcpBuildJobResult(mcp.Call{Arguments: map[string]any{
199 "request": map[string]any{"id": requestEvent.ID, "pubkey": requestEvent.PubKey, "kind": float64(requestEvent.Kind), "created_at": float64(1), "tags": requestEvent.Tags, "content": ""},
200 "content": "done",
201 }})
202 if err != nil {
203 t.Fatalf("result builder: %v", err)
204 }
205 if got := event.Tag(event.Event{Tags: result.Tags}, "h"); got != room {
206 t.Fatalf("result room = %q, want %q", got, room)
207 }
208}
209
210func TestJobAnswersWakeTheRequesterAsMentions(t *testing.T) {
211 _, tenant, member, agent := jobTenant(t)
212 ctx := context.Background()
213 now := time.Now().Unix()
214 request := wikiPublish(t, tenant, testMemberSecret, 5001, now-100, "", []string{"i", "hello", "text"})
215 result := event.Event{Kind: 6001, PubKey: agent, CreatedAt: now, Tags: [][]string{{"e", request.ID}, {"p", member}}, Content: "hola"}
216 notices := tenant.pushNotices(ctx, result)
217 if len(notices) != 1 || notices[0].category != pushMentions || notices[0].recipient != member || notices[0].body != "job 5001 result" || !strings.HasSuffix(notices[0].url, "/e/"+result.ID) || len(notices[0].actions) != 0 {
218 t.Fatalf("result notices %+v", notices)
219 }
220 for status, want := range map[string]int{"error": 1, "payment-required": 1, "processing": 0, "success": 0, "partial": 0} {
221 feedback := event.Event{Kind: 7000, PubKey: agent, CreatedAt: now, Tags: [][]string{{"status", status}, {"e", request.ID}, {"p", member}}}
222 notices := tenant.pushNotices(ctx, feedback)
223 if len(notices) != want {
224 t.Fatalf("%s feedback notices %+v", status, notices)
225 }
226 if want == 1 && (notices[0].category != pushMentions || notices[0].body != "job 5001 "+status) {
227 t.Fatalf("%s feedback notice %+v", status, notices[0])
228 }
229 }
230 // Feedback for a request the relay does not hold names its own kind.
231 elsewhere := event.Event{Kind: 7000, PubKey: agent, CreatedAt: now, Tags: [][]string{{"status", "error"}, {"e", strings.Repeat("e", 64)}, {"p", member}}}
232 if notices := tenant.pushNotices(ctx, elsewhere); len(notices) != 1 || notices[0].body != "job 7000 error" {
233 t.Fatalf("elsewhere notices %+v", notices)
234 }
235 // The requester's own feedback wakes nobody.
236 own := event.Event{Kind: 7000, PubKey: member, CreatedAt: now, Tags: [][]string{{"status", "error"}, {"e", request.ID}, {"p", member}}}
237 if notices := tenant.pushNotices(ctx, own); len(notices) != 0 {
238 t.Fatalf("own notices %+v", notices)
239 }
240}
241
242func TestBrowseJobsListsRequestsWithStatusAndResults(t *testing.T) {
243 _, tenant, member, agent := jobTenant(t)
244 ctx := context.Background()
245 owner := tenant.Policy().Owner
246 now := time.Now().Unix()
247
248 open := wikiPublish(t, tenant, testMemberSecret, 5001, now-400, "", []string{"i", "hello", "text"}, []string{"i", strings.Repeat("d", 64), "event", "wss://relay.example", "context"}, []string{"output", "text/plain"}, []string{"bid", "1000"}, []string{"param", "lang", "es"}, []string{"relays", "wss://a.example", "wss://b.example"}, []string{"p", agent})
249 wikiPublish(t, tenant, testAgentSecret, 7000, now-350, "", []string{"e", open.ID}, []string{"p", member}, []string{"status", "payment-required"}, []string{"amount", "500", "lnbc1"})
250 working := wikiPublish(t, tenant, testAgentSecret, 7000, now-300, "", []string{"e", open.ID}, []string{"p", member}, []string{"status", "processing", "halfway"})
251 done := wikiPublish(t, tenant, testMemberSecret, 5002, now-390, "", []string{"i", "summarize", "text"})
252 wikiPublish(t, tenant, testAgentSecret, 7000, now-380, "", []string{"e", done.ID}, []string{"p", member}, []string{"status", "processing"})
253 result := wikiPublish(t, tenant, testAgentSecret, 6002, now-200, "the summary", []string{"e", done.ID}, []string{"p", member}, []string{"amount", "700"}, []string{"request", "{}"})
254 failed := wikiPublish(t, tenant, testOwnerSecret, 5001, now-300, "", []string{"i", "broken", "text"})
255 wikiPublish(t, tenant, testAgentSecret, 7000, now-250, "", []string{"e", failed.ID}, []string{"p", owner}, []string{"status", "error", "no such input"})
256 // An expired request is saved directly, as it would have been before it
257 // lapsed, and is no longer served.
258 expired := event.Event{Kind: 5001, CreatedAt: now - 7200, Content: "", Tags: [][]string{{"expiration", strconv.FormatInt(now-3600, 10)}}}
259 if err := event.Sign(&expired, testMemberSecret); err != nil {
260 t.Fatal(err)
261 }
262 if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil {
263 t.Fatal(err)
264 }
265 note := wikiPublish(t, tenant, testMemberSecret, 1, now-100, "not a job")
266
267 listed, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{})
268 if err != nil {
269 t.Fatal(err)
270 }
271 items := wikiItems(listed, "items")
272 if len(items) != 3 || items[0]["id"] != failed.ID || items[1]["id"] != done.ID || items[2]["id"] != open.ID || listed["next_cursor"] != "" {
273 t.Fatalf("listed %v", items)
274 }
275 item := approvalByID(items, open.ID)
276 feedback, _ := item["feedback"].(map[string]any)
277 if item["state"] != "open" || item["status"] != "processing" || item["kind"] != 5001.0 || item["requester"] != member || item["output"] != "text/plain" || item["bid"] != "1000" || item["result"] != nil || feedback == nil || feedback["id"] != working.ID || feedback["info"] != "halfway" || feedback["provider"] != agent {
278 t.Fatalf("open item %v", item)
279 }
280 if inputs := item["inputs"].([]any); len(inputs) != 2 || inputs[0].(map[string]any)["type"] != "text" || inputs[1].(map[string]any)["relay"] != "wss://relay.example" || inputs[1].(map[string]any)["marker"] != "context" {
281 t.Fatalf("inputs %v", item["inputs"])
282 }
283 if params := item["params"].([]any); len(params) != 1 || params[0].(map[string]any)["key"] != "lang" || params[0].(map[string]any)["value"] != "es" {
284 t.Fatalf("params %v", item["params"])
285 }
286 if relays := item["relays"].([]any); len(relays) != 2 || relays[1] != "wss://b.example" {
287 t.Fatalf("relays %v", item["relays"])
288 }
289 if providers := item["providers"].([]any); len(providers) != 1 || providers[0] != agent {
290 t.Fatalf("providers %v", item["providers"])
291 }
292 item = approvalByID(items, done.ID)
293 answer, _ := item["result"].(map[string]any)
294 if item["state"] != "done" || item["status"] != "processing" || answer == nil || answer["id"] != result.ID || answer["kind"] != 6002.0 || answer["amount"] != "700" || answer["content"] != "the summary" {
295 t.Fatalf("done item %v", item)
296 }
297 item = approvalByID(items, failed.ID)
298 if item["state"] != "done" || item["status"] != "error" || item["result"] != nil {
299 t.Fatalf("failed item %v", item)
300 }
301
302 for state, want := range map[string][]string{"open": {open.ID}, "done": {failed.ID, done.ID}} {
303 filtered, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": state})
304 if err != nil {
305 t.Fatal(err)
306 }
307 got := []string{}
308 for _, item := range wikiItems(filtered, "items") {
309 got = append(got, item["id"].(string))
310 }
311 if strings.Join(got, ",") != strings.Join(want, ",") {
312 t.Fatalf("state %s lists %v", state, got)
313 }
314 }
315 mine, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"mine": true})
316 if err != nil {
317 t.Fatal(err)
318 }
319 if items := wikiItems(mine, "items"); len(items) != 2 || approvalByID(items, failed.ID) != nil {
320 t.Fatalf("mine %v", items)
321 }
322 if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {
323 t.Fatalf("bogus state %v", err)
324 }
325 if _, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"mine": true}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {
326 t.Fatalf("guest mine %v", err)
327 }
328 // Guests see the requests on an open relay; pages follow the cursor.
329 paged, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 2})
330 if err != nil {
331 t.Fatal(err)
332 }
333 if items := wikiItems(paged, "items"); len(items) != 2 || paged["next_cursor"] == "" {
334 t.Fatalf("paged %v", paged)
335 }
336 rest, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 2, "cursor": paged["next_cursor"]})
337 if err != nil {
338 t.Fatal(err)
339 }
340 if items := wikiItems(rest, "items"); len(items) != 1 || items[0]["id"] != open.ID || rest["next_cursor"] != "" {
341 t.Fatalf("second page %v", rest)
342 }
343
344 detail, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": open.ID})
345 if err != nil {
346 t.Fatal(err)
347 }
348 timeline := wikiItems(detail, "feedback")
349 if item := detail["item"].(map[string]any); item["state"] != "open" || len(timeline) != 2 || timeline[0]["status"] != "payment-required" || timeline[0]["invoice"] != "lnbc1" || timeline[1]["id"] != working.ID || len(detail["results"].([]any)) != 0 {
350 t.Fatalf("open detail %v", detail)
351 }
352 detail, err = approvalCall(t, tenant, member, "browsejob", map[string]any{"id": done.ID})
353 if err != nil {
354 t.Fatal(err)
355 }
356 if results := wikiItems(detail, "results"); len(results) != 1 || results[0]["id"] != result.ID || len(wikiItems(detail, "feedback")) != 1 {
357 t.Fatalf("done detail %v", detail)
358 }
359 for _, id := range []string{note.ID, expired.ID, strings.Repeat("f", 64)} {
360 if _, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": id}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {
361 t.Fatalf("browsejob %s: %v", id, err)
362 }
363 }
364 // Switching long tasks off closes the browse surface too.
365 policy := tenant.Policy()
366 policy.Features.Jobs = false
367 if err := tenant.applyPolicy(ctx, policy); err != nil {
368 t.Fatal(err)
369 }
370 if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{}); err == nil || !strings.Contains(err.Error(), "switched off") {
371 t.Fatalf("browse with jobs off %v", err)
372 }
373}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs