clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

main
tinyrelay / internal / daemon / jobs_test.go
jobs_test.go 24.0K
1package daemon23import (4 "context"5 "encoding/json"6 "net/http"7 "net/http/httptest"8 "strconv"9 "strings"10 "testing"11 "time"1213 "github.com/FelineStateMachine/tinyrelay/internal/event"14 "github.com/FelineStateMachine/tinyrelay/internal/mcp"15)1617// jobTestRoom is the test tenant's own room, open to every member.18const jobTestRoom = "main"1920// jobTenant returns a tenant with a member who requests jobs and an agent21// granted to serve them in the tenant's room.22func jobTenant(t *testing.T) (*App, *Tenant, string, string) {23 t.Helper()24 app, tenant := testTenant(t)25 ctx := context.Background()26 owner := tenant.Policy().Owner27 member, _ := event.PublicKey(testMemberSecret)28 agent, _ := event.PublicKey(testAgentSecret)29 if _, err := tenant.Execute(ctx, owner, "setmember", []json.RawMessage{json.RawMessage(strconv.Quote(member)), json.RawMessage(`{"role":"member"}`)}); err != nil {30 t.Fatal(err)31 }32 now := time.Now().Unix()33 if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"room", jobTestRoom}, []string{"jobs", "serve"})); err != nil {34 t.Fatalf("grant rejected: %v", err)35 }36 return app, tenant, member, agent37}3839// jobRequestTags builds the tags of a request in the test room asking the40// given keys.41func jobRequestTags(asked []string, extra ...[]string) [][]string {42 tags := [][]string{{"h", jobTestRoom}}43 for _, key := range asked {44 tags = append(tags, []string{"p", key})45 }46 return append(tags, extra...)47}4849// jobAnswerTags builds the tags of an answer to a request in the test room.50func jobAnswerTags(request event.Event, extra ...[]string) [][]string {51 return append([][]string{{"h", jobTestRoom}, {"e", request.ID}, {"p", request.PubKey}}, extra...)52}5354func TestMCPJobToolsBuildValidateAndPublish(t *testing.T) {55 app, _, member, agent := jobTenant(t)56 now := time.Now().Unix()57 call := func(name string, arguments map[string]any, secret string) (map[string]any, bool) {58 t.Helper()59 w, response := mcpCall{method: "tools/call", name: name, arguments: arguments, sign: true, secret: secret}.do(t, app, "/mcp")60 if w.Code != http.StatusOK {61 t.Fatalf("%s: %d %s", name, w.Code, w.Body.String())62 }63 return mcpToolResult(t, response)64 }65 structured := func(result map[string]any) map[string]any {66 value, _ := result["structuredContent"].(map[string]any)67 return value68 }69 text := func(result map[string]any) string {70 content, _ := result["content"].([]any)71 if len(content) == 0 {72 return ""73 }74 return content[0].(map[string]any)["text"].(string)75 }76 tagsOf := func(result map[string]any) (map[string]any, string) {77 unsigned, _ := structured(result)["unsigned"].(map[string]any)78 tags, _ := json.Marshal(unsigned["tags"])79 return unsigned, string(tags)80 }81 _, response := mcpCall{method: "tools/list", sign: true}.do(t, app, "/mcp")82 names := map[string]bool{}83 for _, tool := range response.Result.(map[string]any)["tools"].([]any) {84 names[tool.(map[string]any)["name"].(string)] = true85 }86 for _, want := range []string{"list_jobs", "read_job", "request_job", "accept_job", "job_progress", "job_result", "job_error", "cancel_job"} {87 if !names[want] {88 t.Fatalf("missing tool %s", want)89 }90 }9192 // request_job: the template carries the room, the asked keys, the93 // subject, the thread root and the expiration; it refuses a request94 // without a room or an asked key, and the signed request publishes.95 root := strings.Repeat("d", 64)96 expires := strconv.FormatInt(now+3600, 10)97 result, isError := call("request_job", map[string]any{"room": jobTestRoom, "assignees": []any{agent}, "subject": "Summarize the notes", "content": "Summarize this week's notes.", "root": root, "expiration": now + 3600}, testMemberSecret)98 unsigned, tags := tagsOf(result)99 if isError || unsigned["kind"] != float64(event.KIND_JOB_REQUEST) || unsigned["content"] != "Summarize this week's notes." || tags != `[["h","main"],["p","`+agent+`"],["subject","Summarize the notes"],["e","`+root+`","","root"],["expiration","`+expires+`"]]` {100 t.Fatalf("request_job template: %v %s %s", isError, text(result), tags)101 }102 if result, isError = call("request_job", map[string]any{"assignees": []any{agent}, "content": "x"}, testMemberSecret); !isError || !strings.Contains(text(result), "room is required") {103 t.Fatalf("request_job without a room: %s", text(result))104 }105 if result, isError = call("request_job", map[string]any{"room": jobTestRoom, "content": "x"}, testMemberSecret); !isError || !strings.Contains(text(result), "at least one key") {106 t.Fatalf("request_job without assignees: %s", text(result))107 }108 if result, isError = call("request_job", map[string]any{"room": jobTestRoom, "assignees": []any{agent}}, testMemberSecret); !isError || !strings.Contains(text(result), "content or subject") {109 t.Fatalf("request_job without text: %s", text(result))110 }111 if result, isError = call("request_job", map[string]any{"event": mcpSigned(t, testMemberSecret, event.KIND_JOB_REQUEST, [][]string{{"h", jobTestRoom}}, "hello")}, testMemberSecret); !isError || !strings.Contains(text(result), "at least one asked key") || !strings.Contains(text(result), "Expected a signed kind 43001 event") {112 t.Fatalf("request_job malformed signed: %s", text(result))113 }114 request := mcpSigned(t, testMemberSecret, event.KIND_JOB_REQUEST, jobRequestTags([]string{agent}, []string{"subject", "Summarize the notes"}), "Summarize this week's notes.")115 result, isError = call("request_job", map[string]any{"event": request}, testMemberSecret)116 requestID, _ := structured(result)["event_id"].(string)117 if isError || structured(result)["accepted"] != true || requestID == "" {118 t.Fatalf("request_job publish: %s", text(result))119 }120121 // accept_job and job_progress: the template takes the request event or122 // its fields, and the signed answer publishes only from an asked key.123 result, isError = call("accept_job", map[string]any{"request": request}, testAgentSecret)124 unsigned, tags = tagsOf(result)125 if isError || unsigned["kind"] != float64(event.KIND_JOB_ACCEPTED) || unsigned["content"] != "" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+member+`"]]` {126 t.Fatalf("accept_job template: %v %s %s", isError, text(result), tags)127 }128 if result, isError = call("accept_job", map[string]any{"request": request, "room": "other"}, testAgentSecret); !isError || !strings.Contains(text(result), "must match the request") {129 t.Fatalf("accept_job mismatched room: %s", text(result))130 }131 if result, isError = call("job_progress", map[string]any{"e": requestID, "p": member, "room": jobTestRoom}, testAgentSecret); !isError || !strings.Contains(text(result), "content is required") {132 t.Fatalf("job_progress without content: %s", text(result))133 }134 result, isError = call("job_progress", map[string]any{"e": requestID, "p": member, "room": jobTestRoom, "content": "halfway"}, testAgentSecret)135 unsigned, tags = tagsOf(result)136 if isError || unsigned["kind"] != float64(event.KIND_JOB_PROGRESS) || unsigned["content"] != "halfway" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+member+`"]]` {137 t.Fatalf("job_progress template: %v %s %s", isError, text(result), tags)138 }139 if result, isError = call("job_progress", map[string]any{"event": mcpSigned(t, testAgentSecret, event.KIND_JOB_PROGRESS, [][]string{{"e", requestID}, {"p", member}}, "halfway")}, testAgentSecret); !isError || !strings.Contains(text(result), "room in an h tag") {140 t.Fatalf("job_progress malformed signed: %s", text(result))141 }142 requestEvent := event.Event{ID: requestID, PubKey: member}143 result, isError = call("job_progress", map[string]any{"event": mcpSigned(t, testAgentSecret, event.KIND_JOB_PROGRESS, jobAnswerTags(requestEvent), "halfway")}, testAgentSecret)144 if isError || structured(result)["accepted"] != true {145 t.Fatalf("job_progress publish: %s", text(result))146 }147 if result, isError = call("job_progress", map[string]any{"event": mcpSigned(t, testOwnerSecret, event.KIND_JOB_PROGRESS, jobAnswerTags(requestEvent), "not asked")}, testOwnerSecret); !isError || !strings.Contains(text(result), "key the request asked") {148 t.Fatalf("job_progress from a key not asked: %s", text(result))149 }150151 // job_result: the template carries the artifacts and refuses bad ones;152 // the relay checks the answer against the request it holds.153 artifacts := []any{map[string]any{"type": "e", "value": root}, map[string]any{"type": "a", "value": "30818:" + member + ":notes"}, map[string]any{"type": "r", "value": "https://example.com/report"}}154 result, isError = call("job_result", map[string]any{"request": request, "content": "the summary", "artifacts": artifacts}, testAgentSecret)155 unsigned, tags = tagsOf(result)156 if isError || unsigned["kind"] != float64(event.KIND_JOB_RESULT) || unsigned["content"] != "the summary" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+member+`"],["e","`+root+`"],["a","30818:`+member+`:notes"],["r","https://example.com/report"]]` {157 t.Fatalf("job_result template: %v %s %s", isError, text(result), tags)158 }159 if result, isError = call("job_result", map[string]any{"request": request, "content": "x", "artifacts": []any{map[string]any{"type": "r", "value": "ftp://example.com"}}}, testAgentSecret); !isError || !strings.Contains(text(result), "each artifact") {160 t.Fatalf("job_result bad artifact: %s", text(result))161 }162 if result, isError = call("job_result", map[string]any{"e": requestID, "p": member, "room": jobTestRoom}, testAgentSecret); !isError || !strings.Contains(text(result), "content is required") {163 t.Fatalf("job_result without content: %s", text(result))164 }165 if result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, event.KIND_JOB_RESULT, [][]string{{"h", jobTestRoom}, {"e", strings.Repeat("e", 64)}, {"p", member}}, "x")}, testAgentSecret); !isError || !strings.Contains(text(result), "request this relay holds") {166 t.Fatalf("job_result for an unknown request: %s", text(result))167 }168 result, isError = call("job_result", map[string]any{"event": mcpSigned(t, testAgentSecret, event.KIND_JOB_RESULT, jobAnswerTags(requestEvent, []string{"r", "https://example.com/report"}), "the summary")}, testAgentSecret)169 resultID, _ := structured(result)["event_id"].(string)170 if isError || structured(result)["accepted"] != true || resultID == "" {171 t.Fatalf("job_result publish: %s", text(result))172 }173174 // job_error and cancel_job build their events; only the requester175 // cancels.176 result, isError = call("job_error", map[string]any{"request": request, "content": "no such input"}, testAgentSecret)177 unsigned, tags = tagsOf(result)178 if isError || unsigned["kind"] != float64(event.KIND_JOB_ERROR) || unsigned["content"] != "no such input" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+member+`"]]` {179 t.Fatalf("job_error template: %v %s %s", isError, text(result), tags)180 }181 result, isError = call("cancel_job", map[string]any{"request": request, "p": agent, "content": "never mind"}, testMemberSecret)182 unsigned, tags = tagsOf(result)183 if isError || unsigned["kind"] != float64(event.KIND_JOB_CANCEL) || unsigned["content"] != "never mind" || tags != `[["h","main"],["e","`+requestID+`"],["p","`+agent+`"]]` {184 t.Fatalf("cancel_job template: %v %s %s", isError, text(result), tags)185 }186 if result, isError = call("cancel_job", map[string]any{"event": mcpSigned(t, testOwnerSecret, event.KIND_JOB_CANCEL, [][]string{{"h", jobTestRoom}, {"e", requestID}}, "")}, testOwnerSecret); !isError || !strings.Contains(text(result), "only the requester") {187 t.Fatalf("cancel_job from another key: %s", text(result))188 }189190 // The reads show the settled request.191 result, isError = call("list_jobs", map[string]any{"state": "done", "mine": true}, testMemberSecret)192 items, _ := structured(result)["items"].([]any)193 if isError || len(items) != 1 {194 t.Fatalf("list_jobs: %s", text(result))195 }196 item := items[0].(map[string]any)197 answer, _ := item["result"].(map[string]any)198 if item["id"] != requestID || item["status"] != "done" || item["subject"] != "Summarize the notes" || answer["id"] != resultID || answer["artifacts"].([]any)[0].(map[string]any)["value"] != "https://example.com/report" {199 t.Fatalf("list_jobs item: %v", item)200 }201 result, isError = call("read_job", map[string]any{"id": requestID}, testMemberSecret)202 if isError || len(structured(result)["answers"].([]any)) != 2 {203 t.Fatalf("read_job: %s", text(result))204 }205 r := httptest.NewRequest(http.MethodGet, "http://relay.test/llms.txt", nil)206 w := httptest.NewRecorder()207 app.ServeHTTP(w, r)208 if !strings.Contains(w.Body.String(), "request_job, accept_job, job_progress, job_result, job_error and cancel_job") || !strings.Contains(w.Body.String(), "list_jobs and read_job") || strings.Contains(w.Body.String(), "NIP-90") {209 t.Fatalf("llms.txt: %s", w.Body.String())210 }211}212213func TestJobAnswersWakeTheRequester(t *testing.T) {214 _, tenant, member, agent := jobTenant(t)215 ctx := context.Background()216 now := time.Now().Unix()217 request := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-100, "Summarize this week's notes.", jobRequestTags([]string{agent}, []string{"subject", "Summarize the notes"})...)218 untitled := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-90, "Build the preview.", jobRequestTags([]string{agent})...)219 result := event.Event{Kind: event.KIND_JOB_RESULT, PubKey: agent, CreatedAt: now, Tags: jobAnswerTags(request), Content: "the summary"}220 notices := tenant.pushNotices(ctx, result)221 if len(notices) != 1 || notices[0].category != pushMentions || notices[0].recipient != member || notices[0].body != "task Summarize the notes done" || !strings.HasSuffix(notices[0].url, "/e/"+result.ID) || len(notices[0].actions) != 0 {222 t.Fatalf("result notices %+v", notices)223 }224 failed := event.Event{Kind: event.KIND_JOB_ERROR, PubKey: agent, CreatedAt: now, Tags: jobAnswerTags(untitled), Content: "no such input"}225 if notices := tenant.pushNotices(ctx, failed); len(notices) != 1 || notices[0].category != pushMentions || notices[0].body != "task "+untitled.ID[:8]+" failed" {226 t.Fatalf("error notices %+v", notices)227 }228 for _, kind := range []int{event.KIND_JOB_ACCEPTED, event.KIND_JOB_PROGRESS} {229 quiet := event.Event{Kind: kind, PubKey: agent, CreatedAt: now, Tags: jobAnswerTags(request), Content: "halfway"}230 if notices := tenant.pushNotices(ctx, quiet); len(notices) != 0 {231 t.Fatalf("kind %d notices %+v", kind, notices)232 }233 }234 // The requester's own cancel wakes nobody, and neither does a result235 // the requester publishes.236 cancel := event.Event{Kind: event.KIND_JOB_CANCEL, PubKey: member, CreatedAt: now, Tags: [][]string{{"h", jobTestRoom}, {"e", request.ID}, {"p", agent}}}237 if notices := tenant.pushNotices(ctx, cancel); len(notices) != 0 {238 t.Fatalf("cancel notices %+v", notices)239 }240 own := event.Event{Kind: event.KIND_JOB_RESULT, PubKey: member, CreatedAt: now, Tags: jobAnswerTags(request), Content: "x"}241 if notices := tenant.pushNotices(ctx, own); len(notices) != 0 {242 t.Fatalf("own notices %+v", notices)243 }244}245246func TestBrowseJobsListsRequestsWithStateAndAnswers(t *testing.T) {247 _, tenant, member, agent := jobTenant(t)248 ctx := context.Background()249 now := time.Now().Unix()250 root := wikiPublish(t, tenant, testMemberSecret, event.KIND_CHAT, now-500, "Could you summarize the notes?", []string{"h", jobTestRoom})251252 open := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-400, "Summarize this week's notes.", jobRequestTags([]string{agent}, []string{"subject", "Summarize the notes"}, []string{"e", root.ID, "", "root"})...)253 wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_ACCEPTED, now-350, "", jobAnswerTags(open)...)254 working := wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_PROGRESS, now-300, "halfway", jobAnswerTags(open)...)255 done := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-390, "Build the preview.", jobRequestTags([]string{agent})...)256 wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_PROGRESS, now-380, "building", jobAnswerTags(done)...)257 result := wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_RESULT, now-200, "the preview", jobAnswerTags(done, []string{"e", root.ID}, []string{"a", "30818:" + member + ":notes"}, []string{"r", "https://example.com/preview"})...)258 failed := wikiPublish(t, tenant, testOwnerSecret, event.KIND_JOB_REQUEST, now-300, "Transcribe the call.", jobRequestTags([]string{agent})...)259 wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_ERROR, now-250, "no such input", jobAnswerTags(failed)...)260 cancelled := wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_REQUEST, now-280, "Never mind this one.", jobRequestTags([]string{agent})...)261 wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_ACCEPTED, now-270, "", jobAnswerTags(cancelled)...)262 wikiPublish(t, tenant, testMemberSecret, event.KIND_JOB_CANCEL, now-260, "", []string{"h", jobTestRoom}, []string{"e", cancelled.ID}, []string{"p", agent})263 // A late progress line does not reopen a cancelled task.264 wikiPublish(t, tenant, testAgentSecret, event.KIND_JOB_PROGRESS, now-255, "still going", jobAnswerTags(cancelled)...)265 // An expired request is saved directly, as it would have been before it266 // lapsed, and is no longer served.267 expired := event.Event{Kind: event.KIND_JOB_REQUEST, CreatedAt: now - 7200, Content: "old", Tags: jobRequestTags([]string{agent}, []string{"expiration", strconv.FormatInt(now-3600, 10)})}268 if err := event.Sign(&expired, testMemberSecret); err != nil {269 t.Fatal(err)270 }271 if _, err := tenant.store.Save(ctx, expired, storageSave(tenant)); err != nil {272 t.Fatal(err)273 }274 note := wikiPublish(t, tenant, testMemberSecret, 1, now-100, "not a job")275276 listed, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{})277 if err != nil {278 t.Fatal(err)279 }280 items := wikiItems(listed, "items")281 if len(items) != 4 || items[0]["id"] != cancelled.ID || items[1]["id"] != failed.ID || items[2]["id"] != done.ID || items[3]["id"] != open.ID || listed["next_cursor"] != "" {282 t.Fatalf("listed %v", items)283 }284 item := approvalByID(items, open.ID)285 progress, _ := item["progress"].(map[string]any)286 if item["state"] != "open" || item["status"] != "running" || item["room"] != jobTestRoom || item["requester"] != member || item["subject"] != "Summarize the notes" || item["content"] != "Summarize this week's notes." || item["root"] != root.ID || item["result"] != nil || item["error"] != nil || progress == nil || progress["id"] != working.ID || progress["content"] != "halfway" || progress["provider"] != agent {287 t.Fatalf("open item %v", item)288 }289 if assignees := item["assignees"].([]any); len(assignees) != 1 || assignees[0] != agent {290 t.Fatalf("assignees %v", item["assignees"])291 }292 item = approvalByID(items, done.ID)293 answer, _ := item["result"].(map[string]any)294 artifacts, _ := answer["artifacts"].([]any)295 if item["state"] != "done" || item["status"] != "done" || item["subject"] != nil || answer["id"] != result.ID || answer["kind"] != float64(event.KIND_JOB_RESULT) || answer["content"] != "the preview" || len(artifacts) != 3 {296 t.Fatalf("done item %v", item)297 }298 if first, last := artifacts[0].(map[string]any), artifacts[2].(map[string]any); first["type"] != "e" || first["value"] != root.ID || last["type"] != "r" || last["value"] != "https://example.com/preview" {299 t.Fatalf("artifacts %v", artifacts)300 }301 item = approvalByID(items, failed.ID)302 failure, _ := item["error"].(map[string]any)303 if item["state"] != "failed" || item["status"] != "failed" || item["result"] != nil || failure == nil || failure["content"] != "no such input" {304 t.Fatalf("failed item %v", item)305 }306 item = approvalByID(items, cancelled.ID)307 if item["state"] != "cancelled" || item["status"] != "cancelled" || item["cancelled_at"] != float64(now-260) || item["progress"].(map[string]any)["content"] != "still going" {308 t.Fatalf("cancelled item %v", item)309 }310311 for state, want := range map[string][]string{"open": {open.ID}, "done": {done.ID}, "failed": {failed.ID}, "cancelled": {cancelled.ID}} {312 filtered, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": state})313 if err != nil {314 t.Fatal(err)315 }316 got := []string{}317 for _, item := range wikiItems(filtered, "items") {318 got = append(got, item["id"].(string))319 }320 if strings.Join(got, ",") != strings.Join(want, ",") {321 t.Fatalf("state %s lists %v", state, got)322 }323 }324 mine, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"mine": true})325 if err != nil {326 t.Fatal(err)327 }328 if items := wikiItems(mine, "items"); len(items) != 3 || approvalByID(items, failed.ID) != nil {329 t.Fatalf("mine %v", items)330 }331 if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{"state": "bogus"}); err == nil || !strings.HasPrefix(err.Error(), "invalid:") {332 t.Fatalf("bogus state %v", err)333 }334 if _, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"mine": true}); err == nil || !strings.HasPrefix(err.Error(), "auth-required:") {335 t.Fatalf("guest mine %v", err)336 }337 // Guests see the requests on an open relay; pages follow the cursor.338 paged, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 3})339 if err != nil {340 t.Fatal(err)341 }342 if items := wikiItems(paged, "items"); len(items) != 3 || paged["next_cursor"] == "" {343 t.Fatalf("paged %v", paged)344 }345 rest, err := approvalCall(t, tenant, "", "browsejobs", map[string]any{"limit": 3, "cursor": paged["next_cursor"]})346 if err != nil {347 t.Fatal(err)348 }349 if items := wikiItems(rest, "items"); len(items) != 1 || items[0]["id"] != open.ID || rest["next_cursor"] != "" {350 t.Fatalf("second page %v", rest)351 }352353 detail, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": open.ID})354 if err != nil {355 t.Fatal(err)356 }357 timeline := wikiItems(detail, "answers")358 if item := detail["item"].(map[string]any); item["state"] != "open" || len(timeline) != 2 || timeline[0]["kind"] != float64(event.KIND_JOB_ACCEPTED) || timeline[1]["id"] != working.ID {359 t.Fatalf("open detail %v", detail)360 }361 detail, err = approvalCall(t, tenant, member, "browsejob", map[string]any{"id": cancelled.ID})362 if err != nil {363 t.Fatal(err)364 }365 if timeline := wikiItems(detail, "answers"); len(timeline) != 3 || timeline[1]["kind"] != float64(event.KIND_JOB_CANCEL) || timeline[1]["provider"] != member {366 t.Fatalf("cancelled detail %v", detail)367 }368 for _, id := range []string{note.ID, expired.ID, strings.Repeat("f", 64)} {369 if _, err := approvalCall(t, tenant, member, "browsejob", map[string]any{"id": id}); err == nil || !strings.HasPrefix(err.Error(), "not found:") {370 t.Fatalf("browsejob %s: %v", id, err)371 }372 }373 // Switching long tasks off closes the browse surface too.374 policy := tenant.Policy()375 policy.Features.Jobs = false376 if err := tenant.applyPolicy(ctx, policy); err != nil {377 t.Fatal(err)378 }379 if _, err := approvalCall(t, tenant, member, "browsejobs", map[string]any{}); err == nil || !strings.Contains(err.Error(), "switched off") {380 t.Fatalf("browse with jobs off %v", err)381 }382}383384func TestMCPJobBuildersKeepRoomScope(t *testing.T) {385 room := "private-work"386 agent := strings.Repeat("b", 64)387 request, err := mcpBuildJobRequest(mcp.Call{Arguments: map[string]any{"room": room, "assignees": []any{agent}, "content": "hello"}})388 if err != nil {389 t.Fatalf("request builder: %v", err)390 }391 if got := event.Tag(event.Event{Tags: request.Tags}, "h"); got != room {392 t.Fatalf("request room = %q, want %q", got, room)393 }394 requestEvent := map[string]any{"id": strings.Repeat("c", 64), "pubkey": strings.Repeat("d", 64), "kind": float64(event.KIND_JOB_REQUEST), "created_at": float64(1), "tags": request.Tags, "content": "hello"}395 for name, build := range map[string]func(mcp.Call) (mcpUnsigned, error){"accept": mcpBuildJobAnswer(event.KIND_JOB_ACCEPTED), "result": mcpBuildJobAnswer(event.KIND_JOB_RESULT), "cancel": mcpBuildJobCancel} {396 answer, err := build(mcp.Call{Arguments: map[string]any{"request": requestEvent, "content": "done"}})397 if err != nil {398 t.Fatalf("%s builder: %v", name, err)399 }400 if got := event.Tag(event.Event{Tags: answer.Tags}, "h"); got != room {401 t.Fatalf("%s room = %q, want %q", name, got, room)402 }403 if got := event.Tag(event.Event{Tags: answer.Tags}, "e"); got != strings.Repeat("c", 64) {404 t.Fatalf("%s request = %q", name, got)405 }406 }407 if _, err := mcpBuildJobAnswer(event.KIND_JOB_PROGRESS)(mcp.Call{Arguments: map[string]any{"e": strings.Repeat("c", 64), "p": strings.Repeat("d", 64), "content": "x"}}); err == nil {408 t.Fatal("progress builder accepted an answer without a room")409 }410}