diff --git a/adapters/linear/doc.go b/adapters/linear/doc.go index 307daa3..9a795c9 100644 --- a/adapters/linear/doc.go +++ b/adapters/linear/doc.go @@ -34,6 +34,14 @@ // - UpdateSession: set/replace externalUrls, add/remove URLs, and replace the // whole plan array. Setting externalUrls can keep a new session from being // marked unresponsive. +// - CreateSessionOnIssue / CreateSessionOnComment (and their ForTenant +// variants for multi-tenant mode): typed proactive agent session creation +// (agentSessionCreateOnIssue / agentSessionCreateOnComment). The returned +// CreatedAgentSession carries an opaque ThreadID that works everywhere a +// webhook-minted agent-session Thread ID does. +// - SuggestRepositories: typed issueRepositorySuggestions ranking of +// candidate repositories with confidence scores; a low-confidence result +// pairs naturally with a "select" elicitation. // - GraphQL: a deliberate low-level escape hatch for preview Linear APIs. It // reuses the client-credentials token-refresh path, surfaces GraphQL errors, // and never exposes or returns the access token. @@ -136,8 +144,7 @@ // // Multi-tenant OAuth installs (ADR 0006) reuse the per-org credential resolution // above; the OAuth installation *web flow*, token streaming (ADR 0011), Markdown -// conversion, reactions, edit/delete, files, repository-suggestion ranking, and -// issue-workflow automation remain deferred; the latter are reachable through the -// GraphQL escape hatch. This is an app-owned actor, not a personal-API-key or -// user-OAuth user bot. +// conversion, reactions, edit/delete, files, and issue-workflow automation +// remain deferred; the latter is reachable through the GraphQL escape hatch. +// This is an app-owned actor, not a personal-API-key or user-OAuth user bot. package linear diff --git a/adapters/linear/linear_test.go b/adapters/linear/linear_test.go index 416b3bf..0dde811 100644 --- a/adapters/linear/linear_test.go +++ b/adapters/linear/linear_test.go @@ -479,6 +479,17 @@ type linearAPIServer struct { // 429 with Retry-After before succeeding. rateLimit int rateLimitSeen int + // throttleNext, when > 0, makes the next N GraphQL calls (any operation) + // return a 429 with Retry-After before succeeding. + throttleNext int + // sessionCreates records AgentSessionCreateOnIssue/OnComment inputs; + // sessionCreateOverride, when non-nil, is returned verbatim instead of the + // canned success payload. suggestionVars / suggestionsOverride are the same + // pair for IssueRepositorySuggestions. + sessionCreates []map[string]any + sessionCreateOverride map[string]any + suggestionVars []map[string]any + suggestionsOverride map[string]any // History read fixtures (see history_test.go): mocked GraphQL responses keyed // by operation name, recorded history requests, and knobs to throttle or block // history reads. @@ -530,6 +541,48 @@ func newLinearAPIServer(t *testing.T, expires int64) *linearAPIServer { writeJSON(t, w, map[string]any{"data": map[string]any{"viewer": map[string]any{"id": "APP1", "name": "Linear Bot", "displayName": "Linear Bot", "organization": map[string]any{"id": "ORG1"}}}}) return } + api.mu.Lock() + if api.throttleNext > 0 { + api.throttleNext-- + api.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + writeJSON(t, w, map[string]any{"retryAfter": "0.01"}) + return + } + api.mu.Unlock() + if strings.Contains(req.Query, "AgentSessionCreateOn") { + input, _ := req.Variables["input"].(map[string]any) + api.mu.Lock() + api.sessionCreates = append(api.sessionCreates, input) + override := api.sessionCreateOverride + api.mu.Unlock() + if override != nil { + writeJSON(t, w, override) + return + } + if strings.Contains(req.Query, "AgentSessionCreateOnComment") { + writeJSON(t, w, map[string]any{"data": map[string]any{"agentSessionCreateOnComment": map[string]any{"success": true, "agentSession": map[string]any{"id": "SNEW2", "issue": map[string]any{"id": "ISSUE7"}, "comment": map[string]any{"id": "CROOT1"}}}}}) + return + } + writeJSON(t, w, map[string]any{"data": map[string]any{"agentSessionCreateOnIssue": map[string]any{"success": true, "agentSession": map[string]any{"id": "SNEW1", "issue": map[string]any{"id": "ISSUE7"}, "comment": nil}}}}) + return + } + if strings.Contains(req.Query, "IssueRepositorySuggestions") { + api.mu.Lock() + api.suggestionVars = append(api.suggestionVars, req.Variables) + override := api.suggestionsOverride + api.mu.Unlock() + if override != nil { + writeJSON(t, w, override) + return + } + writeJSON(t, w, map[string]any{"data": map[string]any{"issueRepositorySuggestions": map[string]any{"suggestions": []map[string]any{ + {"hostname": "github.com", "repositoryFullName": "acme/backend", "confidence": 0.92}, + {"hostname": "github.com", "repositoryFullName": "acme/frontend", "confidence": 0.35}, + }}}}) + return + } if strings.Contains(req.Query, "AgentActivityCreate") { input, ok := req.Variables["input"].(map[string]any) if !ok { @@ -682,6 +735,38 @@ func (a *linearAPIServer) lastComment(t *testing.T) map[string]any { return a.comments[len(a.comments)-1] } +func (a *linearAPIServer) sessionCreateCount() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.sessionCreates) +} + +func (a *linearAPIServer) lastSessionCreate(t *testing.T) map[string]any { + t.Helper() + a.mu.Lock() + defer a.mu.Unlock() + if len(a.sessionCreates) == 0 { + t.Fatal("no session create recorded") + } + return a.sessionCreates[len(a.sessionCreates)-1] +} + +func (a *linearAPIServer) suggestionCount() int { + a.mu.Lock() + defer a.mu.Unlock() + return len(a.suggestionVars) +} + +func (a *linearAPIServer) lastSuggestionVars(t *testing.T) map[string]any { + t.Helper() + a.mu.Lock() + defer a.mu.Unlock() + if len(a.suggestionVars) == 0 { + t.Fatal("no repository suggestion request recorded") + } + return a.suggestionVars[len(a.suggestionVars)-1] +} + func (a *linearAPIServer) commentCount() int { a.mu.Lock() defer a.mu.Unlock() diff --git a/adapters/linear/multitenant_test.go b/adapters/linear/multitenant_test.go index 01d6515..e607c91 100644 --- a/adapters/linear/multitenant_test.go +++ b/adapters/linear/multitenant_test.go @@ -100,6 +100,11 @@ type linearMTServer struct { // historyAuth records the bearer token of each AgentSessionHistory read, so // history tests can prove per-tenant token resolution (see history_test.go). historyAuth []string + // sessionCreateAuth / suggestAuth record the bearer token of each + // AgentSessionCreateOn* mutation and IssueRepositorySuggestions query, so + // proactive-capability tests can prove per-tenant token resolution. + sessionCreateAuth []string + suggestAuth []string } func newLinearMTServer(t *testing.T) *linearMTServer { @@ -135,6 +140,20 @@ func newLinearMTServer(t *testing.T) *linearMTServer { writeJSON(t, w, map[string]any{"data": map[string]any{"agentActivityCreate": map[string]any{"success": true, "agentActivity": map[string]any{"id": id}}}}) return } + if strings.Contains(req.Query, "AgentSessionCreateOn") { + api.mu.Lock() + api.sessionCreateAuth = append(api.sessionCreateAuth, auth) + api.mu.Unlock() + writeJSON(t, w, map[string]any{"data": map[string]any{"agentSessionCreateOnIssue": map[string]any{"success": true, "agentSession": map[string]any{"id": "SMT1", "issue": map[string]any{"id": "ISSUEMT"}, "comment": nil}}}}) + return + } + if strings.Contains(req.Query, "IssueRepositorySuggestions") { + api.mu.Lock() + api.suggestAuth = append(api.suggestAuth, auth) + api.mu.Unlock() + writeJSON(t, w, map[string]any{"data": map[string]any{"issueRepositorySuggestions": map[string]any{"suggestions": []map[string]any{{"hostname": "github.com", "repositoryFullName": "acme/backend", "confidence": 0.5}}}}}) + return + } if strings.Contains(req.Query, "AgentSessionHistory") { api.mu.Lock() api.historyAuth = append(api.historyAuth, auth) diff --git a/adapters/linear/proactive_test.go b/adapters/linear/proactive_test.go new file mode 100644 index 0000000..04a5a8f --- /dev/null +++ b/adapters/linear/proactive_test.go @@ -0,0 +1,351 @@ +package linear_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/coder/chat" + "github.com/coder/chat/adapters/linear" +) + +// TestCreateSessionOnIssueMintsPostableThread covers the #47 acceptance: the +// typed helper returns a session convertible into the adapter's opaque Thread +// ID, and the created session can be posted to with Thread.Post and PostThought. +func TestCreateSessionOnIssueMintsPostableThread(t *testing.T) { + t.Parallel() + + api := newLinearAPIServer(t, 3600) + now := time.UnixMilli(1_700_000_000_000) + bot, adapter := newLinearRuntime(t, api, linear.Options{WebhookSecret: "whsec", Now: func() time.Time { return now }}) + + created, err := adapter.CreateSessionOnIssue(context.Background(), linear.CreateSessionOnIssueInput{ + IssueID: "ENG-123", + ExternalURLs: []linear.ExternalURL{{URL: "https://example.com/run/1", Label: "Dashboard"}}, + }) + if err != nil { + t.Fatalf("create session on issue: %v", err) + } + if created.SessionID != "SNEW1" || created.IssueID != "ISSUE7" || created.CommentID != "" { + t.Fatalf("created = %#v", created) + } + + input := api.lastSessionCreate(t) + if input["issueId"] != "ENG-123" { + t.Fatalf("issueId = %v", input["issueId"]) + } + urls, ok := input["externalUrls"].([]any) + if !ok || len(urls) != 1 { + t.Fatalf("externalUrls = %#v", input["externalUrls"]) + } + url, _ := urls[0].(map[string]any) + if url["url"] != "https://example.com/run/1" || url["label"] != "Dashboard" { + t.Fatalf("externalUrls[0] = %#v", urls[0]) + } + + // The Thread ID is minted from the canonical identifiers Linear returned and + // validates like any webhook-minted agent-session thread. + ref, err := adapter.ValidateThreadID(created.ThreadID) + if err != nil { + t.Fatalf("validate thread id: %v", err) + } + if ref.Tenant != "ORG1" || ref.Channel != "ISSUE7" || ref.Root != "SNEW1" { + t.Fatalf("thread ref = %#v", ref) + } + + // Thread Handle reconstruction + Thread.Post creates the response activity on + // the new session. + thread, err := bot.Thread(context.Background(), created.ThreadID) + if err != nil { + t.Fatalf("thread: %v", err) + } + if _, err := thread.Post(context.Background(), chat.Markdown("**update**")); err != nil { + t.Fatalf("post: %v", err) + } + api.assertActivity(t, 0, linearActivity{ + AgentSessionID: "SNEW1", + Content: activityContent{Type: "response", Body: "**update**"}, + }) + + // PostThought targets the same created session. + if _, err := adapter.PostThought(context.Background(), created.ThreadID, "Working..."); err != nil { + t.Fatalf("post thought: %v", err) + } + api.assertActivity(t, 1, linearActivity{ + AgentSessionID: "SNEW1", + Ephemeral: true, + Content: activityContent{Type: "thought", Body: "Working..."}, + }) +} + +func TestCreateSessionOnCommentMintsPostableThread(t *testing.T) { + t.Parallel() + + api := newLinearAPIServer(t, 3600) + now := time.UnixMilli(1_700_000_000_000) + bot, adapter := newLinearRuntime(t, api, linear.Options{WebhookSecret: "whsec", Now: func() time.Time { return now }}) + + created, err := adapter.CreateSessionOnComment(context.Background(), linear.CreateSessionOnCommentInput{CommentID: "CROOT1"}) + if err != nil { + t.Fatalf("create session on comment: %v", err) + } + if created.SessionID != "SNEW2" || created.IssueID != "ISSUE7" || created.CommentID != "CROOT1" { + t.Fatalf("created = %#v", created) + } + if input := api.lastSessionCreate(t); input["commentId"] != "CROOT1" { + t.Fatalf("commentId = %v", input["commentId"]) + } + + thread, err := bot.Thread(context.Background(), created.ThreadID) + if err != nil { + t.Fatalf("thread: %v", err) + } + if _, err := thread.Post(context.Background(), chat.Text("hello")); err != nil { + t.Fatalf("post: %v", err) + } + api.assertActivity(t, 0, linearActivity{ + AgentSessionID: "SNEW2", + Content: activityContent{Type: "response", Body: "hello"}, + }) +} + +func TestCreateSessionValidationAndErrorPaths(t *testing.T) { + t.Parallel() + + api := newLinearAPIServer(t, 3600) + now := time.UnixMilli(1_700_000_000_000) + _, adapter := newLinearRuntime(t, api, linear.Options{WebhookSecret: "whsec", Now: func() time.Time { return now }}) + ctx := context.Background() + + // Missing identifiers are rejected before any API call. + if _, err := adapter.CreateSessionOnIssue(ctx, linear.CreateSessionOnIssueInput{}); err == nil { + t.Fatal("expected missing issue id to fail") + } + if _, err := adapter.CreateSessionOnComment(ctx, linear.CreateSessionOnCommentInput{}); err == nil { + t.Fatal("expected missing comment id to fail") + } + // Single-install ForTenant guards the minted Thread ID's organization. + if _, err := adapter.CreateSessionOnIssueForTenant(ctx, "OTHER_ORG", linear.CreateSessionOnIssueInput{IssueID: "I1"}); err == nil || !strings.Contains(err.Error(), "does not match initialized organization") { + t.Fatalf("mismatched tenant err = %v", err) + } + if _, err := adapter.CreateSessionOnIssueForTenant(ctx, "", linear.CreateSessionOnIssueInput{IssueID: "I1"}); err == nil { + t.Fatal("expected empty tenant to fail") + } + if got := api.sessionCreateCount(); got != 0 { + t.Fatalf("invalid inputs reached the API: %d", got) + } + // The matching tenant is accepted in single-install mode. + if _, err := adapter.CreateSessionOnIssueForTenant(ctx, "ORG1", linear.CreateSessionOnIssueInput{IssueID: "I1"}); err != nil { + t.Fatalf("matching tenant: %v", err) + } + + // success=false and a missing issue in the payload are explicit errors. + api.mu.Lock() + api.sessionCreateOverride = map[string]any{"data": map[string]any{"agentSessionCreateOnIssue": map[string]any{"success": false}}} + api.mu.Unlock() + if _, err := adapter.CreateSessionOnIssue(ctx, linear.CreateSessionOnIssueInput{IssueID: "I1"}); err == nil || !strings.Contains(err.Error(), "failed to create agent session") { + t.Fatalf("success=false err = %v", err) + } + api.mu.Lock() + api.sessionCreateOverride = map[string]any{"data": map[string]any{"agentSessionCreateOnIssue": map[string]any{"success": true, "agentSession": map[string]any{"id": "SX", "issue": nil, "comment": nil}}}} + api.mu.Unlock() + if _, err := adapter.CreateSessionOnIssue(ctx, linear.CreateSessionOnIssueInput{IssueID: "I1"}); err == nil || !strings.Contains(err.Error(), "did not return an issue") { + t.Fatalf("missing issue err = %v", err) + } + + // A GraphQL errors array surfaces as a returned error. + errAPI := newGraphQLErrorServer(t) + _, errAdapter := newLinearRuntime(t, errAPI, linear.Options{WebhookSecret: "whsec", Now: func() time.Time { return now }}) + if _, err := errAdapter.CreateSessionOnIssue(ctx, linear.CreateSessionOnIssueInput{IssueID: "I1"}); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("graphql error = %v", err) + } + if _, err := errAdapter.CreateSessionOnComment(ctx, linear.CreateSessionOnCommentInput{CommentID: "C1"}); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("graphql error = %v", err) + } +} + +// TestCreateSessionRetriesOnRateLimit proves the proactive helpers ride the +// adapter's bounded rate-limit retry (ADR 0005): one 429 is retried and the +// mutation still succeeds. +func TestCreateSessionRetriesOnRateLimit(t *testing.T) { + t.Parallel() + + api := newLinearAPIServer(t, 3600) + now := time.UnixMilli(1_700_000_000_000) + _, adapter := newLinearRuntime(t, api, linear.Options{WebhookSecret: "whsec", Now: func() time.Time { return now }}) + + api.mu.Lock() + api.throttleNext = 1 + api.mu.Unlock() + created, err := adapter.CreateSessionOnIssue(context.Background(), linear.CreateSessionOnIssueInput{IssueID: "I1"}) + if err != nil { + t.Fatalf("create after throttle: %v", err) + } + if created.SessionID != "SNEW1" { + t.Fatalf("created = %#v", created) + } +} + +func TestSuggestRepositories(t *testing.T) { + t.Parallel() + + api := newLinearAPIServer(t, 3600) + now := time.UnixMilli(1_700_000_000_000) + _, adapter := newLinearRuntime(t, api, linear.Options{WebhookSecret: "whsec", Now: func() time.Time { return now }}) + ctx := context.Background() + candidates := []linear.CandidateRepository{ + {Hostname: "github.com", RepositoryFullName: "acme/backend"}, + {Hostname: "github.com", RepositoryFullName: "acme/frontend"}, + } + + // Agent-session thread: the session id sharpens the ranking. + sessionThread := linear.EncodeAgentSessionThreadIDForTest("ORG1", "ISSUE1", "S1") + suggestions, err := adapter.SuggestRepositories(ctx, sessionThread, candidates) + if err != nil { + t.Fatalf("suggest repositories: %v", err) + } + want := []linear.RepositorySuggestion{ + {Hostname: "github.com", RepositoryFullName: "acme/backend", Confidence: 0.92}, + {Hostname: "github.com", RepositoryFullName: "acme/frontend", Confidence: 0.35}, + } + if len(suggestions) != len(want) { + t.Fatalf("suggestions = %#v", suggestions) + } + for i := range want { + if suggestions[i] != want[i] { + t.Fatalf("suggestions[%d] = %#v, want %#v", i, suggestions[i], want[i]) + } + } + vars := api.lastSuggestionVars(t) + if vars["issueId"] != "ISSUE1" || vars["agentSessionId"] != "S1" { + t.Fatalf("variables = %#v", vars) + } + sent, ok := vars["candidateRepositories"].([]any) + if !ok || len(sent) != 2 { + t.Fatalf("candidateRepositories = %#v", vars["candidateRepositories"]) + } + first, _ := sent[0].(map[string]any) + if first["hostname"] != "github.com" || first["repositoryFullName"] != "acme/backend" { + t.Fatalf("candidateRepositories[0] = %#v", sent[0]) + } + + // Issue-comment thread: no session, so agentSessionId is omitted. + commentThread := linear.EncodeCommentThreadIDForTest("ORG1", "ISSUE1", "C9") + if _, err := adapter.SuggestRepositories(ctx, commentThread, candidates); err != nil { + t.Fatalf("suggest on comment thread: %v", err) + } + vars = api.lastSuggestionVars(t) + if _, present := vars["agentSessionId"]; present { + t.Fatalf("agentSessionId sent for a comment thread: %#v", vars) + } + + // Validation happens before any API call. + before := api.suggestionCount() + if _, err := adapter.SuggestRepositories(ctx, sessionThread, nil); err == nil { + t.Fatal("expected empty candidates to fail") + } + if _, err := adapter.SuggestRepositories(ctx, sessionThread, []linear.CandidateRepository{{Hostname: "github.com"}}); err == nil { + t.Fatal("expected candidate without repository full name to fail") + } + if _, err := adapter.SuggestRepositories(ctx, sessionThread, []linear.CandidateRepository{{RepositoryFullName: "acme/backend"}}); err == nil { + t.Fatal("expected candidate without hostname to fail") + } + if _, err := adapter.SuggestRepositories(ctx, chat.ThreadID("linear:v1:garbage"), candidates); err == nil { + t.Fatal("expected malformed thread id to fail") + } + if got := api.suggestionCount(); got != before { + t.Fatalf("invalid inputs reached the API: %d calls", got-before) + } + + // A throttled query is retried within the bounded RetryPolicy (ADR 0005). + api.mu.Lock() + api.throttleNext = 1 + api.mu.Unlock() + if _, err := adapter.SuggestRepositories(ctx, sessionThread, candidates); err != nil { + t.Fatalf("suggest after throttle: %v", err) + } + + // A GraphQL errors array surfaces as a returned error. + errAPI := newGraphQLErrorServer(t) + _, errAdapter := newLinearRuntime(t, errAPI, linear.Options{WebhookSecret: "whsec", Now: func() time.Time { return now }}) + if _, err := errAdapter.SuggestRepositories(ctx, sessionThread, candidates); err == nil || !strings.Contains(err.Error(), "boom") { + t.Fatalf("graphql error = %v", err) + } +} + +// TestProactiveCapabilitiesMultiTenant proves per-tenant credential resolution +// (ADR 0006) for the new helpers: each org's call carries that org's derived +// token, the single-install entry points are rejected in multi-tenant mode, and +// an uninstalled org fails cleanly at install lookup. +func TestProactiveCapabilitiesMultiTenant(t *testing.T) { + t.Parallel() + + api := newLinearMTServer(t) + now := time.UnixMilli(1_700_000_000_000) + api.setOrgToken("clientA", "token-A") + api.setOrgToken("clientB", "token-B") + store := newFakeInstallStore() + store.set("ORG_A", chat.Install{ + Tenant: "ORG_A", + BotActorID: "APP_A", + Credential: linear.LinearInstall{WebhookSecret: "secretA", ClientCredentials: linear.ClientCredentials{ClientID: "clientA", ClientSecret: "csA"}}, + }) + store.set("ORG_B", chat.Install{ + Tenant: "ORG_B", + BotActorID: "APP_B", + Credential: linear.LinearInstall{WebhookSecret: "secretB", ClientCredentials: linear.ClientCredentials{ClientID: "clientB", ClientSecret: "csB"}}, + }) + _, adapter := newMultiTenantLinearRuntime(t, api, store, now) + ctx := context.Background() + + // Single-install entry points require the ForTenant variants here. + if _, err := adapter.CreateSessionOnIssue(ctx, linear.CreateSessionOnIssueInput{IssueID: "I1"}); err == nil || !strings.Contains(err.Error(), "CreateSessionOnIssueForTenant") { + t.Fatalf("single-install entry in multi-tenant mode err = %v", err) + } + if _, err := adapter.CreateSessionOnComment(ctx, linear.CreateSessionOnCommentInput{CommentID: "C1"}); err == nil || !strings.Contains(err.Error(), "CreateSessionOnCommentForTenant") { + t.Fatalf("single-install entry in multi-tenant mode err = %v", err) + } + if _, err := adapter.CreateSessionOnIssueForTenant(ctx, "", linear.CreateSessionOnIssueInput{IssueID: "I1"}); err == nil { + t.Fatal("expected empty tenant to fail") + } + + created, err := adapter.CreateSessionOnIssueForTenant(ctx, "ORG_A", linear.CreateSessionOnIssueInput{IssueID: "ENG-1"}) + if err != nil { + t.Fatalf("create for ORG_A: %v", err) + } + ref, err := adapter.ValidateThreadID(created.ThreadID) + if err != nil { + t.Fatalf("validate created thread: %v", err) + } + if ref.Tenant != "ORG_A" || ref.Root != "SMT1" { + t.Fatalf("thread ref = %#v", ref) + } + + suggestions, err := adapter.SuggestRepositories(ctx, linear.EncodeAgentSessionThreadIDForTest("ORG_B", "ISSUE_B", "SB"), []linear.CandidateRepository{{Hostname: "github.com", RepositoryFullName: "acme/backend"}}) + if err != nil { + t.Fatalf("suggest for ORG_B: %v", err) + } + if len(suggestions) != 1 || suggestions[0].RepositoryFullName != "acme/backend" { + t.Fatalf("suggestions = %#v", suggestions) + } + + api.mu.Lock() + sessionAuth := append([]string(nil), api.sessionCreateAuth...) + suggestAuth := append([]string(nil), api.suggestAuth...) + api.mu.Unlock() + if len(sessionAuth) != 1 || sessionAuth[0] != "Bearer token-A" { + t.Fatalf("session create auth = %v, want token-A", sessionAuth) + } + if len(suggestAuth) != 1 || suggestAuth[0] != "Bearer token-B" { + t.Fatalf("suggest auth = %v, want token-B", suggestAuth) + } + + // An uninstalled org fails cleanly at install lookup. + if _, err := adapter.CreateSessionOnIssueForTenant(ctx, "ORG_X", linear.CreateSessionOnIssueInput{IssueID: "I1"}); err == nil || !strings.Contains(err.Error(), "install lookup") { + t.Fatalf("uninstalled org err = %v", err) + } + if _, err := adapter.SuggestRepositories(ctx, linear.EncodeAgentSessionThreadIDForTest("ORG_X", "I1", "SX"), []linear.CandidateRepository{{Hostname: "github.com", RepositoryFullName: "a/b"}}); err == nil || !strings.Contains(err.Error(), "install lookup") { + t.Fatalf("uninstalled org err = %v", err) + } +} diff --git a/adapters/linear/session.go b/adapters/linear/session.go index d0596d3..4db2d0d 100644 --- a/adapters/linear/session.go +++ b/adapters/linear/session.go @@ -144,3 +144,192 @@ type agentSessionUpdateData struct { Success bool `json:"success"` } `json:"agentSessionUpdate"` } + +// CreateSessionOnIssueInput drives the agentSessionCreateOnIssue mutation: +// proactively creating an agent session on an issue the agent was not mentioned +// on or delegated. Linear's Agent API is in developer preview upstream and this +// shape may change with it. +type CreateSessionOnIssueInput struct { + // IssueID is the target issue: a UUID or an issue identifier such as + // "ENG-123". + IssueID string + // ExternalURLs optionally seeds the session's external links at creation, + // which also keeps the new session from being marked unresponsive within the + // Agent Session Timing Contract window (ADR 0008). + ExternalURLs []ExternalURL +} + +// CreateSessionOnCommentInput drives the agentSessionCreateOnComment mutation: +// proactively creating an agent session rooted on an existing issue comment. +type CreateSessionOnCommentInput struct { + // CommentID is the root comment the session will be associated with. + CommentID string + // ExternalURLs optionally seeds the session's external links at creation. + ExternalURLs []ExternalURL +} + +// CreatedAgentSession identifies a proactively created Linear agent session. +type CreatedAgentSession struct { + // ThreadID is the adapter's opaque agent-session Thread ID for the new + // session. It works everywhere a webhook-minted Thread ID does: Thread Handle + // reconstruction via chat.Chat.Thread, Thread.Post, PostThought, the other + // activity helpers, and UpdateSession. + ThreadID chat.ThreadID + // SessionID, IssueID, and CommentID are the raw Linear identifiers of the + // created session, its issue, and its root comment (empty when the session + // was created on an issue without a root comment). + SessionID string + IssueID string + CommentID string +} + +// CreateSessionOnIssue proactively creates an agent session on an issue when the +// agent was not mentioned or delegated. It is reached through Adapter Access and +// requires the single-install identity discovered at Init; multi-tenant callers +// use CreateSessionOnIssueForTenant. +func (a *Adapter) CreateSessionOnIssue(ctx context.Context, in CreateSessionOnIssueInput) (*CreatedAgentSession, error) { + assertAdapter(a) + if a.multiTenant() { + return nil, errors.New("linear: CreateSessionOnIssue requires a tenant; use CreateSessionOnIssueForTenant in multi-tenant mode") + } + return a.createSessionOnIssue(ctx, a.BotActor().Tenant, in) +} + +// CreateSessionOnIssueForTenant is the multi-tenant form of CreateSessionOnIssue: +// it resolves the per-org access token for the given Platform Tenant (the Linear +// organizationId) and mints the returned Thread ID for that tenant. +func (a *Adapter) CreateSessionOnIssueForTenant(ctx context.Context, tenant string, in CreateSessionOnIssueInput) (*CreatedAgentSession, error) { + assertAdapter(a) + if err := a.validateProactiveTenant(tenant); err != nil { + return nil, err + } + return a.createSessionOnIssue(ctx, tenant, in) +} + +// CreateSessionOnComment proactively creates an agent session rooted on an +// existing issue comment. It is reached through Adapter Access and requires the +// single-install identity discovered at Init; multi-tenant callers use +// CreateSessionOnCommentForTenant. +func (a *Adapter) CreateSessionOnComment(ctx context.Context, in CreateSessionOnCommentInput) (*CreatedAgentSession, error) { + assertAdapter(a) + if a.multiTenant() { + return nil, errors.New("linear: CreateSessionOnComment requires a tenant; use CreateSessionOnCommentForTenant in multi-tenant mode") + } + return a.createSessionOnComment(ctx, a.BotActor().Tenant, in) +} + +// CreateSessionOnCommentForTenant is the multi-tenant form of +// CreateSessionOnComment. +func (a *Adapter) CreateSessionOnCommentForTenant(ctx context.Context, tenant string, in CreateSessionOnCommentInput) (*CreatedAgentSession, error) { + assertAdapter(a) + if err := a.validateProactiveTenant(tenant); err != nil { + return nil, err + } + return a.createSessionOnComment(ctx, tenant, in) +} + +// validateProactiveTenant guards proactive session creation, which mints Thread +// IDs for a tenant instead of validating an existing one: the tenant is required +// (it becomes the Thread ID's organization), and in single-install mode it must +// match the initialized organization so the minted Thread ID stays usable. +func (a *Adapter) validateProactiveTenant(tenant string) error { + if tenant == "" { + return errors.New("linear: tenant is required") + } + if !a.multiTenant() { + bot := a.BotActor() + if bot.Tenant != "" && tenant != bot.Tenant { + return fmt.Errorf("linear: tenant %q does not match initialized organization", tenant) + } + } + return nil +} + +func (a *Adapter) createSessionOnIssue(ctx context.Context, tenant string, in CreateSessionOnIssueInput) (*CreatedAgentSession, error) { + if in.IssueID == "" { + return nil, errors.New("linear: issue id is required") + } + input := map[string]any{"issueId": in.IssueID} + if len(in.ExternalURLs) > 0 { + input["externalUrls"] = in.ExternalURLs + } + var resp graphQLResponse[agentSessionCreateOnIssueData] + if err := a.callGraphQL(ctx, tenant, `mutation AgentSessionCreateOnIssue($input: AgentSessionCreateOnIssue!) { agentSessionCreateOnIssue(input: $input) { success agentSession { id issue { id } comment { id } } } }`, map[string]any{"input": input}, &resp); err != nil { + return nil, err + } + if err := resp.firstError(); err != nil { + return nil, err + } + return createdSessionFromPayload(tenant, resp.Data.AgentSessionCreateOnIssue) +} + +func (a *Adapter) createSessionOnComment(ctx context.Context, tenant string, in CreateSessionOnCommentInput) (*CreatedAgentSession, error) { + if in.CommentID == "" { + return nil, errors.New("linear: comment id is required") + } + input := map[string]any{"commentId": in.CommentID} + if len(in.ExternalURLs) > 0 { + input["externalUrls"] = in.ExternalURLs + } + var resp graphQLResponse[agentSessionCreateOnCommentData] + if err := a.callGraphQL(ctx, tenant, `mutation AgentSessionCreateOnComment($input: AgentSessionCreateOnComment!) { agentSessionCreateOnComment(input: $input) { success agentSession { id issue { id } comment { id } } } }`, map[string]any{"input": input}, &resp); err != nil { + return nil, err + } + if err := resp.firstError(); err != nil { + return nil, err + } + return createdSessionFromPayload(tenant, resp.Data.AgentSessionCreateOnComment) +} + +// createdSessionFromPayload converts an agentSessionCreateOn* payload into a +// CreatedAgentSession, minting the opaque agent-session Thread ID from the +// canonical identifiers Linear returned (not the caller's input, which may be an +// issue identifier alias such as "ENG-123"). +func createdSessionFromPayload(tenant string, payload createdSessionPayload) (*CreatedAgentSession, error) { + session := payload.AgentSession + if !payload.Success || session.ID == "" { + return nil, errors.New("linear: failed to create agent session") + } + if session.Issue.ID == "" { + return nil, errors.New("linear: created agent session did not return an issue") + } + threadID, err := encodeThreadID(threadPayload{ + Organization: tenant, + Issue: session.Issue.ID, + Comment: session.Comment.ID, + Session: session.ID, + Kind: threadKindAgentSession, + }) + if err != nil { + return nil, err + } + return &CreatedAgentSession{ + ThreadID: threadID, + SessionID: session.ID, + IssueID: session.Issue.ID, + CommentID: session.Comment.ID, + }, nil +} + +type agentSessionCreateOnIssueData struct { + AgentSessionCreateOnIssue createdSessionPayload `json:"agentSessionCreateOnIssue"` +} + +type agentSessionCreateOnCommentData struct { + AgentSessionCreateOnComment createdSessionPayload `json:"agentSessionCreateOnComment"` +} + +type createdSessionPayload struct { + Success bool `json:"success"` + AgentSession struct { + ID string `json:"id"` + Issue nodeRef `json:"issue"` + Comment nodeRef `json:"comment"` + } `json:"agentSession"` +} + +// nodeRef is a minimal Supported Platform Shape for a referenced Linear node: a +// nullable object read only for its id (JSON null decodes to the zero value). +type nodeRef struct { + ID string `json:"id"` +} diff --git a/adapters/linear/suggestions.go b/adapters/linear/suggestions.go new file mode 100644 index 0000000..28b301a --- /dev/null +++ b/adapters/linear/suggestions.go @@ -0,0 +1,77 @@ +package linear + +import ( + "context" + "errors" + + "github.com/coder/chat" +) + +// CandidateRepository is one repository the agent already has access to, offered +// to Linear's issueRepositorySuggestions ranking. Hostname is the Git service +// host (e.g. "github.com"); RepositoryFullName is the owner/name form (e.g. +// "acme/backend"). +type CandidateRepository struct { + Hostname string `json:"hostname"` + RepositoryFullName string `json:"repositoryFullName"` +} + +// RepositorySuggestion is one ranked repository returned by Linear. Confidence +// is Linear's score from 0.0 to 1.0; Hostname may be empty when Linear does not +// resolve one. +type RepositorySuggestion struct { + Hostname string `json:"hostname"` + RepositoryFullName string `json:"repositoryFullName"` + Confidence float64 `json:"confidence"` +} + +// SuggestRepositories asks Linear to rank the candidate repositories most likely +// to be relevant for the issue behind the given thread, using issue, session, +// guidance, and Linear-internal signals (issueRepositorySuggestions). It accepts +// both Linear thread kinds: on an agent-session thread the session id is passed +// along to sharpen the ranking; on an issue-comment thread only the issue is +// used. A low-confidence result pairs naturally with a "select"-signal +// elicitation offering the user the shortlist. +// +// It is reached through Adapter Access, inherits the adapter's bounded +// rate-limit retry (ADR 0005) and per-tenant token resolution (ADR 0006), and — +// like the rest of the agent surface — wraps a preview Linear API that may +// change upstream. +func (a *Adapter) SuggestRepositories(ctx context.Context, id chat.ThreadID, candidates []CandidateRepository) ([]RepositorySuggestion, error) { + assertAdapter(a) + if len(candidates) == 0 { + return nil, errors.New("linear: at least one candidate repository is required") + } + for _, candidate := range candidates { + if candidate.Hostname == "" || candidate.RepositoryFullName == "" { + return nil, errors.New("linear: candidate repository hostname and repository full name are required") + } + } + payload, err := a.validateThreadPayload(id) + if err != nil { + return nil, err + } + variables := map[string]any{ + "issueId": payload.Issue, + "candidateRepositories": candidates, + } + // agentSessionId is a nullable variable: sent for agent-session threads, + // omitted for issue-comment threads. + if payload.Session != "" { + variables["agentSessionId"] = payload.Session + } + var resp graphQLResponse[repositorySuggestionsData] + if err := a.callGraphQL(ctx, payload.Organization, `query IssueRepositorySuggestions($issueId: String!, $agentSessionId: String, $candidateRepositories: [CandidateRepository!]!) { issueRepositorySuggestions(issueId: $issueId, agentSessionId: $agentSessionId, candidateRepositories: $candidateRepositories) { suggestions { hostname repositoryFullName confidence } } }`, variables, &resp); err != nil { + return nil, err + } + if err := resp.firstError(); err != nil { + return nil, err + } + return resp.Data.IssueRepositorySuggestions.Suggestions, nil +} + +type repositorySuggestionsData struct { + IssueRepositorySuggestions struct { + Suggestions []RepositorySuggestion `json:"suggestions"` + } `json:"issueRepositorySuggestions"` +} diff --git a/docs/how-to/linear-agent-sessions.md b/docs/how-to/linear-agent-sessions.md index 79a731c..412f89a 100644 --- a/docs/how-to/linear-agent-sessions.md +++ b/docs/how-to/linear-agent-sessions.md @@ -126,15 +126,24 @@ _, err := ev.Thread.Post(ctx, chat.Markdown(answer)) // final response return err ``` -Users can press **Stop** on a session. Check for it through the raw message -escape hatch: +Users can press **Stop** on a session. It arrives as a prompt carrying the +human-to-agent `stop` signal; Linear expects the agent to halt immediately +and then confirm with one final `response` (or `error`) activity. The worked +example detects and confirms it in one helper: + ```go -if raw, ok := linear.RawMessageFrom(ev.Message); ok && raw.StopRequested() { - return nil // wind down gracefully +func confirmStop(ctx context.Context, ev *chat.MessageEvent) (bool, error) { + raw, ok := linear.RawMessageFrom(ev.Message) + if !ok || !raw.StopRequested() { + return false, nil + } + _, err := ev.Thread.Post(ctx, chat.Text("Stopping as requested — no further changes will be made.")) + return true, err } ``` +Call it first in every message handler and return when it reports stopped. This check only runs when the stop event reaches your handler, and events on one thread are serialized by the thread lock — a stop arriving while a handler is still running cannot preempt it (`ConcurrencyDrop` discards it on @@ -142,10 +151,249 @@ conflict; `ConcurrencyQueue` delivers it only after the in-flight handler returns). There is no pre-lock hook, so **Linear's Stop control cannot cancel in-flight work through this adapter today**. What you can do: structure long sessions as short handler turns (each turn checks `StopRequested` on the -event that started it before doing more work), or receive the stop signal -out-of-band through your own channel (for example, your own Linear webhook -endpoint or admin API that sets a cancellation flag your handlers poll — -the flag must be set by something outside the runtime's serialized dispatch). +event that started it before doing more work — `confirmStop` above is exactly +that turn-boundary check), or receive the stop signal out-of-band through +your own channel (for example, your own Linear webhook endpoint or admin API +that sets a cancellation flag your handlers poll — the flag must be set by +something outside the runtime's serialized dispatch). + +## Worked Capability Loops + +The rest of this page walks the full interaction loops. Every code block is +extracted from the buildable, tested example +([`examples/linear-agent-hello-world/capabilities.go`](../../examples/linear-agent-hello-world/capabilities.go)); +a documentation test keeps the snippets and the source in sync. The +`linearAgentAccess` parameter is the example's small interface over +`*linear.Adapter` — obtained via `chat.AdapterAs` as shown above — so the +handlers stay testable against a fake. + +### Start A Session Proactively + +When work starts from an external trigger (a failing build, a cron job) and +the agent was neither mentioned nor delegated, create the session yourself. +`CreateSessionOnIssue` wraps Linear's `agentSessionCreateOnIssue` mutation and +returns a `CreatedAgentSession` whose `ThreadID` behaves exactly like a +webhook-minted one: + + +```go +func startProactiveSession(ctx context.Context, la linearAgentAccess, issueID, dashboardURL string) (chat.ThreadID, error) { + created, err := la.CreateSessionOnIssue(ctx, linear.CreateSessionOnIssueInput{ + IssueID: issueID, // a UUID or an identifier such as "ENG-123" + // Seeding externalUrls also keeps the fresh session from being marked + // unresponsive before the first activity arrives. + ExternalURLs: []linear.ExternalURL{{URL: dashboardURL, Label: "Run dashboard"}}, + }) + if err != nil { + return "", err + } + if _, err := la.PostThought(ctx, created.ThreadID, "Investigating this issue."); err != nil { + // The session already exists on Linear's side (and the seeded external + // URL keeps it responsive), so return its handle with the error: the + // caller can retry the activity or end the session cleanly instead of + // leaking it — re-running the whole helper would create a duplicate. + return created.ThreadID, err + } + return created.ThreadID, nil +} +``` + +`CreateSessionOnComment` roots the session on an existing issue comment +instead. In multi-tenant mode (an `InstallStore` configured), use +`CreateSessionOnIssueForTenant` / `CreateSessionOnCommentForTenant` with the +target organization id — proactive creation has no inbound webhook to resolve +the tenant from. + +### Pick A Repository With Suggestions + +`SuggestRepositories` wraps Linear's `issueRepositorySuggestions` query: pass +the candidate repositories the agent already has access to, and Linear ranks +them with confidence scores using issue, session, guidance, and internal +signals. Proceed when confident; otherwise pair the shortlist with a `select` +elicitation: + + +```go +func chooseRepository(ctx context.Context, la linearAgentAccess, ev *chat.MessageEvent, pending *pendingSelections, candidates []linear.CandidateRepository) error { + suggestions, err := la.SuggestRepositories(ctx, ev.Thread.ID(), candidates) + if err != nil { + return err + } + if best := bestSuggestion(suggestions); best != nil && best.Confidence >= 0.8 { + _, err := la.PostThought(ctx, ev.Thread.ID(), "Working in "+best.RepositoryFullName+".") + return err + } + return offerRepositoryChoice(ctx, la, ev.Thread.ID(), pending, suggestions) +} +``` + +(`pendingSelections` is the example's small take-once per-thread registry of +offered option values — see the select section below.) + +### Offer Choices With A Select Elicitation + +A `select`-signal elicitation renders the options natively in Linear. It is a +completion signal: the session waits for the user after you post it. + + +```go +func offerRepositoryChoice(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, pending *pendingSelections, suggestions []linear.RepositorySuggestion) error { + if len(suggestions) == 0 { + _, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{ + Body: "I couldn't match a repository — which one should I work in?", + }) + return err + } + options := make([]linear.SelectOption, 0, len(suggestions)) + values := make([]string, 0, len(suggestions)) + for _, s := range suggestions { + option := repositoryOptionValue(s) + options = append(options, linear.SelectOption{Value: option, Label: option}) + values = append(values, option) + } + _, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{ + Body: "Which repository should I work in?", + Signal: "select", + SignalMetadata: linear.SelectSignalMetadata{Options: options}, + }) + if err != nil { + return err + } + // Record what was offered — and by which workflow — so the next follow-up + // on this thread is interpreted as the answer (see handleSelection). + pending.set(threadID, pendingSelection{Kind: selectionKindRepository, Values: values}) + return nil +} +``` + +Qualify option identities with everything needed to disambiguate them — here +the Git host, so `github.com/acme/backend` and `gitlab.example.com/acme/backend` +stay distinct choices. + +The user's answer arrives as a **regular follow-up prompt** (routed to +`OnSubscribedMessage` on a subscribed thread): a picked option delivers the +option's `value` as the message text, but users may instead reply in free +text, which dismisses the elicitation. Match known values and let everything +else fall through to your normal prompt handling: + + +```go +func handleSelection(ctx context.Context, ev *chat.MessageEvent, sel pendingSelection) (bool, error) { + answer := strings.TrimSpace(ev.Message.Text) + for _, value := range sel.Values { + if answer != value { + continue + } + var ack string + switch sel.Kind { + case selectionKindDeploy: + ack = "Deploying to **" + value + "** — I'll report back here." + case selectionKindRepository: + ack = "Working in **" + value + "** — I'll open a pull request here when I'm done." + default: + ack = "Got it: **" + value + "**." + } + _, err := ev.Thread.Post(ctx, chat.Markdown(ack)) + return true, err + } + return false, nil +} +``` + +The registry stores which workflow asked (`pendingSelection.Kind`) alongside +the offered values, so a repository choice continues the repository workflow +instead of being misread as, say, a deployment target. + +Since free-text replies are natural language, a production agent should +involve its LLM when interpreting an unmatched answer rather than failing. + +Only interpret a follow-up as an answer while a choice is actually pending on +that thread — otherwise a later message that happens to equal an option value +would be misread as a selection. The example keeps a small per-thread registry +(`pendingSelections` in `capabilities.go`) and records the offered values when +it posts the elicitation. An unmatched free-text reply consumes the pending +state for good (Linear dismisses the elicitation UI), but a matched choice +whose acknowledgement fails to post is re-registered — under `DispatchDeferred` +a handler error is only observed, never redelivered, so the user's retry must +still be interpreted as an answer: + + +```go + if sel, ok := pending.take(ev.Thread.ID()); ok { + if handled, err := handleSelection(ctx, ev, sel); handled { + if err != nil { + pending.set(ev.Thread.ID(), sel) + } + return err + } + } +``` + +A confirmed stop also abandons any pending selection (`newFollowUpHandler` in +the example): a stopped session will not answer its elicitation, so a later +message must not be misread as a choice. + +### Ask The User To Link An Account (Auth Elicitation) + +An `auth`-signal elicitation makes Linear render an ephemeral "Link account" +button pointing at your auth flow. `signalMetadata.url` is your account +linking URL; the optional `userId` restricts the prompt to one Linear user: + + +```go +func requireAccountLink(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, authURL, linearUserID string) error { + _, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{ + Body: "Please link your account to continue.", + Signal: "auth", + SignalMetadata: linear.AuthSignalMetadata{ + URL: authURL, + UserID: linearUserID, // optional: restricts the prompt to one user + ProviderName: "Example CI", + }, + }) + return err +} +``` + +The follow-up is the part that differs from `select`: **Linear sends no +webhook when the user completes the auth flow.** Your own auth callback is +the trigger. Store the session's `ThreadID` (for example, keyed by the OAuth +`state` parameter) before posting the elicitation, then resume from the +callback — a `thought` both resumes the session and dismisses the ephemeral +auth UI: + + +```go +func resumeAfterAccountLink(ctx context.Context, bot *chat.Chat, la linearAgentAccess, threadID chat.ThreadID) error { + if _, err := la.PostThought(ctx, threadID, "Account linked — resuming."); err != nil { + return err + } + thread, err := bot.Thread(ctx, threadID) + if err != nil { + return err + } + _, err = thread.Post(ctx, chat.Markdown("All set — the account is linked and the task is done.")) + return err +} +``` + +If the user replies in the session instead of completing the link, the reply +arrives as a normal follow-up prompt — handle it like any other message. + +### Publish Progress Links With externalUrls + +Surface a pull request or dashboard on the session as work progresses. +`AddExternalURLs` appends without replacing existing links (`ExternalURLs` +replaces the whole list); Linear also treats the update as session activity: + + +```go +func publishPullRequest(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, prURL string) error { + return la.UpdateSession(ctx, threadID, linear.AgentSessionUpdateInput{ + AddExternalURLs: []linear.ExternalURL{{URL: prURL, Label: "Pull request"}}, + }) +} +``` ## Generic Issue Comments @@ -162,8 +410,7 @@ comment threads — they only make sense inside agent sessions. ## Known Gaps The Linear adapter is experimental. The Linear agent API surface it wraps is -itself in developer preview upstream, and some operations (proactive session -creation, repository suggestions, issue workflow automation) currently -require the `GraphQL` escape hatch rather than typed helpers. The tracked -list lives in +itself in developer preview upstream, and some operations (for example issue +workflow automation) still require the `GraphQL` escape hatch rather than +typed helpers. The tracked list lives in [`docs/linear-agent-capabilities.md`](../linear-agent-capabilities.md). diff --git a/docs/linear-agent-capabilities.md b/docs/linear-agent-capabilities.md index 20cd9c7..93487eb 100644 --- a/docs/linear-agent-capabilities.md +++ b/docs/linear-agent-capabilities.md @@ -35,6 +35,9 @@ hatch rather than typed helpers. | Human-to-agent stop signal | Supported | `RawMessageFrom(ev.Message)` exposes `Signal` / `StopRequested()`; see the routing caveat below. | | Session updates | Supported | `UpdateSession` sets `externalUrls` and replaces the session plan array. | | GraphQL escape hatch | Supported | `GraphQL` (single-install) and `GraphQLForTenant` (multi-tenant) reuse adapter auth and token refresh, surface GraphQL errors, and never expose tokens. | +| Proactive agent session creation | Supported | `CreateSessionOnIssue` / `CreateSessionOnComment` (plus `ForTenant` variants) wrap `agentSessionCreateOnIssue` / `agentSessionCreateOnComment`; the returned `CreatedAgentSession` carries the adapter's opaque `ThreadID` ([#47](https://github.com/coder/chat/issues/47)). | +| Repository suggestions | Supported | `SuggestRepositories` wraps `issueRepositorySuggestions` with typed candidates and confidence-scored results ([#48](https://github.com/coder/chat/issues/48)). | +| Worked UX examples | Supported | Auth/select elicitation loops, `externalUrls` updates, stop handling, proactive sessions, and repository suggestions are worked through in [`docs/how-to/linear-agent-sessions.md`](how-to/linear-agent-sessions.md), extracted from the tested runnable example ([#49](https://github.com/coder/chat/issues/49)). | | Rate-limit handling | Supported | Bounded retry on HTTP 429 and GraphQL `RATELIMITED` with a typed `*linear.RateLimited` error (ADR 0005). | | Message history read-through | Supported | `chat.HistoryReader` reads agent-session activities and issue-comment threads, newest-first with `Before` paging (ADR 0009). | | Thread reconstruction | Supported | Stored Linear `ThreadID`s (agent-session and comment kinds) reconstruct a `Thread` for later posting. | @@ -43,28 +46,7 @@ hatch rather than typed helpers. ## Missing Capabilities To Track -### 1. Proactive Agent Session Creation - -**Status:** Missing typed helpers; possible via `GraphQL`. Tracked in -[#47](https://github.com/coder/chat/issues/47). - -Linear supports creating sessions when the agent was not mentioned or -delegated (`agentSessionCreateOnIssue`, `agentSessionCreateOnComment`). -A typed helper should return a session convertible into this adapter's opaque -`ThreadID`, with tests proving the created session can be posted to with -`Thread.Post` and `PostThought`. - -### 2. Repository Suggestions - -**Status:** Missing typed helpers; possible via `GraphQL`. Tracked in -[#48](https://github.com/coder/chat/issues/48). - -Linear exposes `issueRepositorySuggestions` for ranking candidate -repositories. A helper should cover the candidate input shape, returned -suggestions (hostname, repository full name, confidence), and pairing low -confidence with a `select` elicitation. - -### 3. Issue Workflow Best Practices +### 1. Issue Workflow Best Practices **Status:** Missing typed helpers; possible via `GraphQL`. @@ -73,7 +55,7 @@ workflow state when work begins and setting the agent as `Issue.delegate`. This likely belongs in a higher-level helper package or example workflow, not the core adapter. -### 4. Stop Handling Versus Thread Serialization +### 2. Stop Handling Versus Thread Serialization **Status:** Inherent limitation; needs an application-owned pattern. @@ -86,10 +68,11 @@ cannot drive active cancellation through this adapter today. Workable patterns: split sessions into short handler turns that check `StopRequested` at each turn boundary, or deliver the stop out-of-band (an application-owned webhook/endpoint outside the runtime's serialized dispatch that sets a -cancellation flag handlers poll). A documented example is still to be -written. +cancellation flag handlers poll). The turn-boundary pattern is worked through +in [`docs/how-to/linear-agent-sessions.md`](how-to/linear-agent-sessions.md) +(`confirmStop`); the serialization limitation itself remains. -### 5. Best-Practice Webhook Categories +### 3. Best-Practice Webhook Categories **Status:** Partial. @@ -109,19 +92,11 @@ adapter registers handlers for `OAuthApp` revocation, `Comment`, Inbox Notification or Permission Change payloads. This adapter follows that model. Reaction webhooks are not normalized here either. -### 6. UX Example Coverage - -**Status:** Docs gap. Tracked in [#49](https://github.com/coder/chat/issues/49). - -The mechanics for auth elicitation (`signalMetadata.url`, optional `userId`), -select elicitation, and PR/dashboard links via `externalUrls` are all -implemented, but worked examples (including the follow-up behavior after a -user completes auth or makes a selection) are still to be written. - ## Planned Work -Future work is sequenced on the public issue tracker, not in this document: -[#47](https://github.com/coder/chat/issues/47) (proactive session creation), -[#48](https://github.com/coder/chat/issues/48) (repository suggestions), and -[#49](https://github.com/coder/chat/issues/49) (worked UX examples). This page -tracks current capability status only. +Future work is sequenced on the public issue tracker, not in this document; +this page tracks current capability status only. The former tracked gaps for +proactive session creation ([#47](https://github.com/coder/chat/issues/47)), +repository suggestions ([#48](https://github.com/coder/chat/issues/48)), and +worked UX examples ([#49](https://github.com/coder/chat/issues/49)) shipped as +typed helpers and documented loops; see the Current Support table above. diff --git a/documentation_test.go b/documentation_test.go index 6d2f27d..c2f0e07 100644 --- a/documentation_test.go +++ b/documentation_test.go @@ -2,6 +2,7 @@ package chat_test import ( "os" + "regexp" "strings" "testing" ) @@ -145,3 +146,42 @@ func TestDocumentationCoversMessageHistoryCapability(t *testing.T) { } } } + +// TestLinearHowToSnippetsAreExtractedFromBuildableSource keeps the worked +// examples in the Linear how-to honest: every fenced Go block annotated with a +// `` marker must appear verbatim (modulo whitespace) in +// the referenced buildable, tested source file. +func TestLinearHowToSnippetsAreExtractedFromBuildableSource(t *testing.T) { + t.Parallel() + + doc, err := os.ReadFile("docs/how-to/linear-agent-sessions.md") + if err != nil { + t.Fatalf("read how-to: %v", err) + } + pattern := regexp.MustCompile("(?s)\\s*```go\\n(.*?)```") + matches := pattern.FindAllStringSubmatch(string(doc), -1) + if len(matches) < 8 { + t.Fatalf("marked snippets = %d, want at least 8", len(matches)) + } + normalizedSources := map[string]string{} + for _, match := range matches { + path := strings.TrimSpace(match[1]) + snippet := match[2] + normalizedSource, ok := normalizedSources[path] + if !ok { + source, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read snippet source %s: %v", path, err) + } + normalizedSource = strings.Join(strings.Fields(string(source)), " ") + normalizedSources[path] = normalizedSource + } + normalizedSnippet := strings.Join(strings.Fields(snippet), " ") + if normalizedSnippet == "" { + t.Fatalf("empty marked snippet for %s", path) + } + if !strings.Contains(normalizedSource, normalizedSnippet) { + t.Fatalf("doc snippet drifted from %s:\n%s", path, snippet) + } + } +} diff --git a/examples/linear-agent-hello-world/README.md b/examples/linear-agent-hello-world/README.md index 032997f..01afa20 100644 --- a/examples/linear-agent-hello-world/README.md +++ b/examples/linear-agent-hello-world/README.md @@ -15,6 +15,15 @@ the Linear `Comment` webhook scope, a comment that mentions the app actor routes to `OnNewMention` and `Thread.Post` replies with an ordinary issue comment rather than an agent activity. +[`capabilities.go`](capabilities.go) holds the worked capability loops behind +[docs/how-to/linear-agent-sessions.md](../../docs/how-to/linear-agent-sessions.md): +proactive session creation (`CreateSessionOnIssue`), repository suggestions +paired with a `select` elicitation (`SuggestRepositories`), auth elicitation +with the resume-after-linking follow-up, select-answer handling, `externalUrls` +updates, and stop confirmation. Each helper is exercised by +[`capabilities_test.go`](capabilities_test.go); the stop and select-answer +helpers are wired into the running bot's `OnSubscribedMessage` handler. + This is a Linear app-actor example (ADR 0008, ADR 0013), not a personal API key user bot. diff --git a/examples/linear-agent-hello-world/capabilities.go b/examples/linear-agent-hello-world/capabilities.go new file mode 100644 index 0000000..4c6a160 --- /dev/null +++ b/examples/linear-agent-hello-world/capabilities.go @@ -0,0 +1,238 @@ +package main + +// Worked examples for the Linear capability loops: proactive session creation, +// repository suggestions, auth/select elicitations, externalUrls updates, and +// stop handling. The code snippets in docs/how-to/linear-agent-sessions.md are +// extracted from this file; documentation_test.go keeps them in sync. + +import ( + "context" + "strings" + "sync" + + "github.com/coder/chat" + "github.com/coder/chat/adapters/linear" +) + +// Selection kinds distinguish which elicitation a pending choice answers, so +// the answer continues the right workflow instead of a generic acknowledgement. +const ( + selectionKindDeploy = "deploy" + selectionKindRepository = "repository" +) + +// pendingSelection is one outstanding select elicitation: which workflow asked +// (Kind) and the option values that were offered. +type pendingSelection struct { + Kind string + Values []string +} + +// pendingSelections tracks threads with an outstanding select elicitation, so +// a follow-up is interpreted as an answer only while a choice is actually +// pending. take consumes the entry; the follow-up handler re-registers it only +// when a matched choice fails to post, and lets an unmatched free-text reply +// consume it for good, matching Linear dismissing the elicitation UI. Durable +// Thread Application State is app-owned; a real bot would persist this +// alongside its other state. +type pendingSelections struct { + mu sync.Mutex + selection map[chat.ThreadID]pendingSelection +} + +func newPendingSelections() *pendingSelections { + return &pendingSelections{selection: map[chat.ThreadID]pendingSelection{}} +} + +// set records the thread's latest outstanding elicitation. +func (p *pendingSelections) set(id chat.ThreadID, sel pendingSelection) { + p.mu.Lock() + defer p.mu.Unlock() + p.selection[id] = sel +} + +// take returns and clears the pending selection for the thread. +func (p *pendingSelections) take(id chat.ThreadID) (pendingSelection, bool) { + p.mu.Lock() + defer p.mu.Unlock() + sel, ok := p.selection[id] + delete(p.selection, id) + return sel, ok +} + +// startProactiveSession creates an agent session on an issue the agent was +// neither mentioned on nor delegated (agentSessionCreateOnIssue) — for example +// when work starts from an external trigger such as a failing build — and posts +// a first thought inside the ~10s first-thought window. The returned Thread ID +// works everywhere a webhook-minted one does. CreateSessionOnComment roots the +// session on an existing comment instead; in multi-tenant mode use the +// ForTenant variants with the target organization id. +func startProactiveSession(ctx context.Context, la linearAgentAccess, issueID, dashboardURL string) (chat.ThreadID, error) { + created, err := la.CreateSessionOnIssue(ctx, linear.CreateSessionOnIssueInput{ + IssueID: issueID, // a UUID or an identifier such as "ENG-123" + // Seeding externalUrls also keeps the fresh session from being marked + // unresponsive before the first activity arrives. + ExternalURLs: []linear.ExternalURL{{URL: dashboardURL, Label: "Run dashboard"}}, + }) + if err != nil { + return "", err + } + if _, err := la.PostThought(ctx, created.ThreadID, "Investigating this issue."); err != nil { + // The session already exists on Linear's side (and the seeded external + // URL keeps it responsive), so return its handle with the error: the + // caller can retry the activity or end the session cleanly instead of + // leaking it — re-running the whole helper would create a duplicate. + return created.ThreadID, err + } + return created.ThreadID, nil +} + +// chooseRepository asks Linear to rank candidate repositories the agent already +// has access to (issueRepositorySuggestions), then either proceeds confidently +// or pairs the low-confidence shortlist with a select elicitation. +func chooseRepository(ctx context.Context, la linearAgentAccess, ev *chat.MessageEvent, pending *pendingSelections, candidates []linear.CandidateRepository) error { + suggestions, err := la.SuggestRepositories(ctx, ev.Thread.ID(), candidates) + if err != nil { + return err + } + if best := bestSuggestion(suggestions); best != nil && best.Confidence >= 0.8 { + _, err := la.PostThought(ctx, ev.Thread.ID(), "Working in "+best.RepositoryFullName+".") + return err + } + return offerRepositoryChoice(ctx, la, ev.Thread.ID(), pending, suggestions) +} + +// bestSuggestion picks the highest-confidence suggestion, or nil when Linear +// returned none. +func bestSuggestion(suggestions []linear.RepositorySuggestion) *linear.RepositorySuggestion { + var best *linear.RepositorySuggestion + for i := range suggestions { + if best == nil || suggestions[i].Confidence > best.Confidence { + best = &suggestions[i] + } + } + return best +} + +// offerRepositoryChoice turns a suggestion shortlist into a select elicitation. +// The elicitation is a completion signal: the session waits for the user, and +// their choice comes back as a follow-up prompt (see handleSelection), so the +// offered values are recorded as this thread's pending selection. +func offerRepositoryChoice(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, pending *pendingSelections, suggestions []linear.RepositorySuggestion) error { + if len(suggestions) == 0 { + _, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{ + Body: "I couldn't match a repository — which one should I work in?", + }) + return err + } + options := make([]linear.SelectOption, 0, len(suggestions)) + values := make([]string, 0, len(suggestions)) + for _, s := range suggestions { + option := repositoryOptionValue(s) + options = append(options, linear.SelectOption{Value: option, Label: option}) + values = append(values, option) + } + _, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{ + Body: "Which repository should I work in?", + Signal: "select", + SignalMetadata: linear.SelectSignalMetadata{Options: options}, + }) + if err != nil { + return err + } + // Record what was offered — and by which workflow — so the next follow-up + // on this thread is interpreted as the answer (see handleSelection). + pending.set(threadID, pendingSelection{Kind: selectionKindRepository, Values: values}) + return nil +} + +// repositoryOptionValue qualifies the repository with its Git host so two +// same-named repositories on different hosts stay distinguishable options. +// Hostname may be empty when Linear does not resolve one. +func repositoryOptionValue(s linear.RepositorySuggestion) string { + if s.Hostname == "" { + return s.RepositoryFullName + } + return s.Hostname + "/" + s.RepositoryFullName +} + +// handleSelection handles the follow-up prompt that answers a select +// elicitation: a chosen option arrives as a regular prompt whose text is the +// option's value. The pending selection's Kind routes the choice to the right +// workflow (this example acknowledges; a real bot would continue that +// workflow). Users may instead reply in free text, so unmatched answers return +// handled=false and fall through to normal prompt handling (ideally an LLM +// interpreting the reply). +func handleSelection(ctx context.Context, ev *chat.MessageEvent, sel pendingSelection) (bool, error) { + answer := strings.TrimSpace(ev.Message.Text) + for _, value := range sel.Values { + if answer != value { + continue + } + var ack string + switch sel.Kind { + case selectionKindDeploy: + ack = "Deploying to **" + value + "** — I'll report back here." + case selectionKindRepository: + ack = "Working in **" + value + "** — I'll open a pull request here when I'm done." + default: + ack = "Got it: **" + value + "**." + } + _, err := ev.Thread.Post(ctx, chat.Markdown(ack)) + return true, err + } + return false, nil +} + +// requireAccountLink asks the user to link an external account before the agent +// continues. Linear renders an ephemeral "Link account" button from the auth +// signal; the elicitation completes the session pending the user's action. +func requireAccountLink(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, authURL, linearUserID string) error { + _, err := la.PostElicitation(ctx, threadID, linear.ElicitationInput{ + Body: "Please link your account to continue.", + Signal: "auth", + SignalMetadata: linear.AuthSignalMetadata{ + URL: authURL, + UserID: linearUserID, // optional: restricts the prompt to one user + ProviderName: "Example CI", + }, + }) + return err +} + +// resumeAfterAccountLink runs from the application's own auth callback once the +// user finishes linking: Linear sends no webhook for auth completion, so the +// application's stored Thread ID reconstructs the session and a thought resumes +// it (which also dismisses the ephemeral auth UI). +func resumeAfterAccountLink(ctx context.Context, bot *chat.Chat, la linearAgentAccess, threadID chat.ThreadID) error { + if _, err := la.PostThought(ctx, threadID, "Account linked — resuming."); err != nil { + return err + } + thread, err := bot.Thread(ctx, threadID) + if err != nil { + return err + } + _, err = thread.Post(ctx, chat.Markdown("All set — the account is linked and the task is done.")) + return err +} + +// publishPullRequest surfaces a pull request on the session without replacing +// links that are already there. Linear also treats externalUrls updates as +// session activity, so setting one keeps a fresh session responsive. +func publishPullRequest(ctx context.Context, la linearAgentAccess, threadID chat.ThreadID, prURL string) error { + return la.UpdateSession(ctx, threadID, linear.AgentSessionUpdateInput{ + AddExternalURLs: []linear.ExternalURL{{URL: prURL, Label: "Pull request"}}, + }) +} + +// confirmStop detects the human-to-agent stop signal and confirms the halt: +// after disengaging, Linear expects one final response (or error) activity +// confirming the agent's state. +func confirmStop(ctx context.Context, ev *chat.MessageEvent) (bool, error) { + raw, ok := linear.RawMessageFrom(ev.Message) + if !ok || !raw.StopRequested() { + return false, nil + } + _, err := ev.Thread.Post(ctx, chat.Text("Stopping as requested — no further changes will be made.")) + return true, err +} diff --git a/examples/linear-agent-hello-world/capabilities_test.go b/examples/linear-agent-hello-world/capabilities_test.go new file mode 100644 index 0000000..4f08468 --- /dev/null +++ b/examples/linear-agent-hello-world/capabilities_test.go @@ -0,0 +1,340 @@ +package main + +import ( + "context" + "errors" + "testing" + + "github.com/coder/chat" + "github.com/coder/chat/adapters/linear" + "github.com/coder/chat/state/memory" +) + +// newTestEvent builds a MessageEvent backed by the recording fake adapter so +// the worked examples that post through ev.Thread are exercised end to end. +func newTestEvent(t *testing.T, adapter *testLinearAdapter, msg *chat.Message) *chat.MessageEvent { + t.Helper() + ctx := context.Background() + bot, err := chat.New(ctx, chat.WithState(memory.New()), chat.WithAdapter(adapter)) + if err != nil { + t.Fatalf("new chat: %v", err) + } + t.Cleanup(func() { + if err := bot.Shutdown(context.Background()); err != nil { + t.Fatalf("shutdown chat: %v", err) + } + }) + thread, err := bot.Thread(ctx, chat.ThreadID("linear:v1:thread-1")) + if err != nil { + t.Fatalf("thread: %v", err) + } + return &chat.MessageEvent{Thread: thread, Message: msg} +} + +func TestStartProactiveSessionPostsFirstThought(t *testing.T) { + adapter := &testLinearAdapter{} + + threadID, err := startProactiveSession(context.Background(), adapter, "ENG-123", "https://example.com/run/1") + if err != nil { + t.Fatalf("start proactive session: %v", err) + } + if threadID != chat.ThreadID("linear:v1:new-session") { + t.Fatalf("thread id = %q", threadID) + } + if len(adapter.sessionCreates) != 1 || adapter.sessionCreates[0].IssueID != "ENG-123" { + t.Fatalf("session creates = %#v", adapter.sessionCreates) + } + if urls := adapter.sessionCreates[0].ExternalURLs; len(urls) != 1 || urls[0].URL != "https://example.com/run/1" { + t.Fatalf("external urls = %#v", urls) + } + if len(adapter.thoughts) != 1 || adapter.thoughts[0] != "Investigating this issue." { + t.Fatalf("thoughts = %#v", adapter.thoughts) + } +} + +func TestChooseRepositoryProceedsOnHighConfidence(t *testing.T) { + adapter := &testLinearAdapter{suggestions: []linear.RepositorySuggestion{ + {Hostname: "github.com", RepositoryFullName: "acme/frontend", Confidence: 0.4}, + {Hostname: "github.com", RepositoryFullName: "acme/backend", Confidence: 0.9}, + }} + ev := newTestEvent(t, adapter, &chat.Message{Text: "fix the bug"}) + + if err := chooseRepository(context.Background(), adapter, ev, newPendingSelections(), []linear.CandidateRepository{{Hostname: "github.com", RepositoryFullName: "acme/backend"}}); err != nil { + t.Fatalf("choose repository: %v", err) + } + if len(adapter.thoughts) != 1 || adapter.thoughts[0] != "Working in acme/backend." { + t.Fatalf("thoughts = %#v", adapter.thoughts) + } + if len(adapter.elicitations) != 0 { + t.Fatalf("unexpected elicitations = %#v", adapter.elicitations) + } +} + +func TestChooseRepositoryElicitsSelectOnLowConfidence(t *testing.T) { + adapter := &testLinearAdapter{suggestions: []linear.RepositorySuggestion{ + {Hostname: "github.com", RepositoryFullName: "acme/backend", Confidence: 0.42}, + {Hostname: "github.com", RepositoryFullName: "acme/frontend", Confidence: 0.35}, + }} + ev := newTestEvent(t, adapter, &chat.Message{Text: "fix the bug"}) + pending := newPendingSelections() + + if err := chooseRepository(context.Background(), adapter, ev, pending, []linear.CandidateRepository{{Hostname: "github.com", RepositoryFullName: "acme/backend"}}); err != nil { + t.Fatalf("choose repository: %v", err) + } + if len(adapter.elicitations) != 1 { + t.Fatalf("elicitations = %#v", adapter.elicitations) + } + elicitation := adapter.elicitations[0] + if elicitation.Signal != "select" { + t.Fatalf("signal = %q", elicitation.Signal) + } + metadata, ok := elicitation.SignalMetadata.(linear.SelectSignalMetadata) + if !ok || len(metadata.Options) != 2 { + t.Fatalf("signal metadata = %#v", elicitation.SignalMetadata) + } + if metadata.Options[0].Value != "github.com/acme/backend" || metadata.Options[1].Value != "github.com/acme/frontend" { + t.Fatalf("options = %#v", metadata.Options) + } + // The offered values become this thread's pending selection so the next + // follow-up is interpreted as the answer. + sel, ok := pending.take(ev.Thread.ID()) + if !ok || sel.Kind != selectionKindRepository || len(sel.Values) != 2 || sel.Values[0] != "github.com/acme/backend" { + t.Fatalf("pending = %#v, %v", sel, ok) + } +} + +func TestOfferRepositoryChoiceKeepsSameNameReposOnDifferentHostsDistinct(t *testing.T) { + adapter := &testLinearAdapter{} + pending := newPendingSelections() + threadID := chat.ThreadID("linear:v1:thread-1") + + err := offerRepositoryChoice(context.Background(), adapter, threadID, pending, []linear.RepositorySuggestion{ + {Hostname: "github.com", RepositoryFullName: "acme/backend", Confidence: 0.4}, + {Hostname: "gitlab.example.com", RepositoryFullName: "acme/backend", Confidence: 0.4}, + {RepositoryFullName: "acme/orphan", Confidence: 0.1}, // hostname unresolved + }) + if err != nil { + t.Fatalf("offer repository choice: %v", err) + } + metadata, ok := adapter.elicitations[0].SignalMetadata.(linear.SelectSignalMetadata) + if !ok || len(metadata.Options) != 3 { + t.Fatalf("signal metadata = %#v", adapter.elicitations[0].SignalMetadata) + } + if metadata.Options[0].Value != "github.com/acme/backend" || + metadata.Options[1].Value != "gitlab.example.com/acme/backend" || + metadata.Options[2].Value != "acme/orphan" { + t.Fatalf("options = %#v", metadata.Options) + } + sel, ok := pending.take(threadID) + if !ok || sel.Kind != selectionKindRepository || len(sel.Values) != 3 || sel.Values[1] != "gitlab.example.com/acme/backend" { + t.Fatalf("pending = %#v, %v", sel, ok) + } +} + +func TestFollowUpHandlerClearsPendingSelectionOnStop(t *testing.T) { + adapter := &testLinearAdapter{} + pending := newPendingSelections() + ev := newTestEvent(t, adapter, &chat.Message{Text: "stop", Raw: &linear.RawMessage{Signal: "stop"}}) + pending.set(ev.Thread.ID(), pendingSelection{Kind: selectionKindDeploy, Values: []string{"staging", "prod"}}) + + if err := newFollowUpHandler(adapter, pending)(context.Background(), ev); err != nil { + t.Fatalf("follow-up handler: %v", err) + } + if len(adapter.posted) != 1 { + t.Fatalf("posted = %#v", adapter.posted) + } + // The stopped session will not answer its elicitation: a later "staging" + // message must not be misread as a choice. + if _, ok := pending.take(ev.Thread.ID()); ok { + t.Fatal("pending selection survived the stop") + } +} + +func TestPendingSelectionsAreTakeOnce(t *testing.T) { + pending := newPendingSelections() + threadID := chat.ThreadID("linear:v1:thread-1") + + if _, ok := pending.take(threadID); ok { + t.Fatal("take on empty registry reported a pending selection") + } + pending.set(threadID, pendingSelection{Kind: selectionKindDeploy, Values: []string{"staging", "prod"}}) + sel, ok := pending.take(threadID) + if !ok || sel.Kind != selectionKindDeploy || len(sel.Values) != 2 || sel.Values[0] != "staging" { + t.Fatalf("take = %#v, %v", sel, ok) + } + // The next follow-up no longer sees a pending selection: a bare "staging" + // message must not be consumed as a choice. + if _, ok := pending.take(threadID); ok { + t.Fatal("pending selection survived take") + } +} + +func TestMentionHandlerRegistersPendingSelectionOnDeployElicitation(t *testing.T) { + adapter := &testLinearAdapter{} + pending := newPendingSelections() + ev := newTestEvent(t, adapter, &chat.Message{Text: "deploy"}) + + if err := newMentionHandler(adapter, pending)(context.Background(), ev); err != nil { + t.Fatalf("mention handler: %v", err) + } + if len(adapter.elicitations) != 1 || adapter.elicitations[0].Signal != "select" { + t.Fatalf("elicitations = %#v", adapter.elicitations) + } + sel, ok := pending.take(ev.Thread.ID()) + if !ok || sel.Kind != selectionKindDeploy || len(sel.Values) != 2 { + t.Fatalf("pending = %#v, %v", sel, ok) + } +} + +func TestChooseRepositoryAsksFreeFormWithoutSuggestions(t *testing.T) { + adapter := &testLinearAdapter{} + ev := newTestEvent(t, adapter, &chat.Message{Text: "fix the bug"}) + pending := newPendingSelections() + + if err := chooseRepository(context.Background(), adapter, ev, pending, []linear.CandidateRepository{{Hostname: "github.com", RepositoryFullName: "acme/backend"}}); err != nil { + t.Fatalf("choose repository: %v", err) + } + if len(adapter.elicitations) != 1 || adapter.elicitations[0].Signal != "" { + t.Fatalf("elicitations = %#v", adapter.elicitations) + } + // A free-form question offers no option values, so nothing is pending. + if _, ok := pending.take(ev.Thread.ID()); ok { + t.Fatal("free-form elicitation registered a pending selection") + } +} + +func TestHandleSelectionMatchesOptionValueOrFallsThrough(t *testing.T) { + adapter := &testLinearAdapter{} + deploy := pendingSelection{Kind: selectionKindDeploy, Values: []string{"staging", "prod"}} + ev := newTestEvent(t, adapter, &chat.Message{Text: "staging"}) + + handled, err := handleSelection(context.Background(), ev, deploy) + if err != nil || !handled { + t.Fatalf("handled = %v, err = %v", handled, err) + } + if len(adapter.posted) != 1 || adapter.posted[0] != "Deploying to **staging** — I'll report back here." { + t.Fatalf("posted = %#v", adapter.posted) + } + + // Free text is not consumed: it falls through to normal prompt handling. + free := newTestEvent(t, adapter, &chat.Message{Text: "actually, roll back instead"}) + handled, err = handleSelection(context.Background(), free, deploy) + if err != nil || handled { + t.Fatalf("handled = %v, err = %v", handled, err) + } + if len(adapter.posted) != 1 { + t.Fatalf("free text posted = %#v", adapter.posted) + } +} + +func TestHandleSelectionRoutesRepositoryChoicesToRepositoryHandling(t *testing.T) { + adapter := &testLinearAdapter{} + ev := newTestEvent(t, adapter, &chat.Message{Text: "github.com/acme/backend"}) + + handled, err := handleSelection(context.Background(), ev, pendingSelection{ + Kind: selectionKindRepository, + Values: []string{"github.com/acme/backend", "github.com/acme/frontend"}, + }) + if err != nil || !handled { + t.Fatalf("handled = %v, err = %v", handled, err) + } + if len(adapter.posted) != 1 || adapter.posted[0] != "Working in **github.com/acme/backend** — I'll open a pull request here when I'm done." { + t.Fatalf("posted = %#v", adapter.posted) + } +} + +func TestFollowUpHandlerRetainsPendingChoiceWhenPostFails(t *testing.T) { + postErr := errors.New("post failed") + adapter := &testLinearAdapter{postErr: postErr} + pending := newPendingSelections() + ev := newTestEvent(t, adapter, &chat.Message{Text: "staging"}) + deploy := pendingSelection{Kind: selectionKindDeploy, Values: []string{"staging", "prod"}} + pending.set(ev.Thread.ID(), deploy) + + err := newFollowUpHandler(adapter, pending)(context.Background(), ev) + if !errors.Is(err, postErr) { + t.Fatalf("handler error = %v, want %v", err, postErr) + } + // The acknowledgement never posted, so the user's retry must still be + // interpreted as an answer: the choice stays pending. + sel, ok := pending.take(ev.Thread.ID()) + if !ok || sel.Kind != selectionKindDeploy { + t.Fatalf("pending after failed post = %#v, %v", sel, ok) + } +} + +func TestRequireAccountLinkSendsAuthSignal(t *testing.T) { + adapter := &testLinearAdapter{} + + if err := requireAccountLink(context.Background(), adapter, chat.ThreadID("linear:v1:thread-1"), "https://auth.example.com/oauth", "USER1"); err != nil { + t.Fatalf("require account link: %v", err) + } + if len(adapter.elicitations) != 1 || adapter.elicitations[0].Signal != "auth" { + t.Fatalf("elicitations = %#v", adapter.elicitations) + } + metadata, ok := adapter.elicitations[0].SignalMetadata.(linear.AuthSignalMetadata) + if !ok || metadata.URL != "https://auth.example.com/oauth" || metadata.UserID != "USER1" { + t.Fatalf("signal metadata = %#v", adapter.elicitations[0].SignalMetadata) + } +} + +func TestResumeAfterAccountLinkPostsThoughtThenResponse(t *testing.T) { + ctx := context.Background() + adapter := &testLinearAdapter{} + bot, err := chat.New(ctx, chat.WithState(memory.New()), chat.WithAdapter(adapter)) + if err != nil { + t.Fatalf("new chat: %v", err) + } + defer func() { + if err := bot.Shutdown(context.Background()); err != nil { + t.Fatalf("shutdown chat: %v", err) + } + }() + + if err := resumeAfterAccountLink(ctx, bot, adapter, chat.ThreadID("linear:v1:thread-1")); err != nil { + t.Fatalf("resume after account link: %v", err) + } + if len(adapter.thoughts) != 1 || adapter.thoughts[0] != "Account linked — resuming." { + t.Fatalf("thoughts = %#v", adapter.thoughts) + } + if len(adapter.posted) != 1 { + t.Fatalf("posted = %#v", adapter.posted) + } +} + +func TestPublishPullRequestAddsExternalURL(t *testing.T) { + adapter := &testLinearAdapter{} + + if err := publishPullRequest(context.Background(), adapter, chat.ThreadID("linear:v1:thread-1"), "https://github.com/acme/backend/pull/7"); err != nil { + t.Fatalf("publish pull request: %v", err) + } + if len(adapter.sessionUpdates) != 1 { + t.Fatalf("session updates = %#v", adapter.sessionUpdates) + } + added := adapter.sessionUpdates[0].AddExternalURLs + if len(added) != 1 || added[0].URL != "https://github.com/acme/backend/pull/7" || added[0].Label != "Pull request" { + t.Fatalf("added external urls = %#v", added) + } +} + +func TestConfirmStopConfirmsOnlyOnStopSignal(t *testing.T) { + adapter := &testLinearAdapter{} + stop := newTestEvent(t, adapter, &chat.Message{Text: "stop", Raw: &linear.RawMessage{Signal: "stop"}}) + + stopped, err := confirmStop(context.Background(), stop) + if err != nil || !stopped { + t.Fatalf("stopped = %v, err = %v", stopped, err) + } + if len(adapter.posted) != 1 { + t.Fatalf("posted = %#v", adapter.posted) + } + + normal := newTestEvent(t, adapter, &chat.Message{Text: "keep going", Raw: &linear.RawMessage{}}) + stopped, err = confirmStop(context.Background(), normal) + if err != nil || stopped { + t.Fatalf("stopped = %v, err = %v", stopped, err) + } + if len(adapter.posted) != 1 { + t.Fatalf("posted after non-stop = %#v", adapter.posted) + } +} diff --git a/examples/linear-agent-hello-world/main.go b/examples/linear-agent-hello-world/main.go index a201aa1..f6c3272 100644 --- a/examples/linear-agent-hello-world/main.go +++ b/examples/linear-agent-hello-world/main.go @@ -57,20 +57,9 @@ func main() { panic("linear adapter is not registered") } - bot.OnNewMention(newMentionHandler(linearAccess)) - - bot.OnSubscribedMessage(func(ctx context.Context, ev *chat.MessageEvent) error { - // A "comment"-kind thread is an ordinary issue comment (ADR 0013); an - // "agent_session" thread is an agent session (ADR 0008). Thread.Post routes - // by kind automatically. - if raw, ok := linear.RawMessageFrom(ev.Message); ok && raw.StopRequested() { - _, err := ev.Thread.Post(ctx, chat.Text("Stopping as requested.")) - return err - } - _, _ = linearAccess.PostThought(ctx, ev.Thread.ID(), "Reading your follow-up...") - _, err := ev.Thread.Post(ctx, chat.Text("Follow-up received: "+ev.Message.Text)) - return err - }) + pending := newPendingSelections() + bot.OnNewMention(newMentionHandler(linearAccess, pending)) + bot.OnSubscribedMessage(newFollowUpHandler(linearAccess, pending)) linearWebhook, err := bot.Webhook("linear") if err != nil { @@ -95,9 +84,47 @@ type linearAgentAccess interface { PostElicitation(context.Context, chat.ThreadID, linear.ElicitationInput) (*chat.SentMessage, error) PostError(context.Context, chat.ThreadID, linear.ErrorInput) (*chat.SentMessage, error) UpdateSession(context.Context, chat.ThreadID, linear.AgentSessionUpdateInput) error + CreateSessionOnIssue(context.Context, linear.CreateSessionOnIssueInput) (*linear.CreatedAgentSession, error) + SuggestRepositories(context.Context, chat.ThreadID, []linear.CandidateRepository) ([]linear.RepositorySuggestion, error) +} + +// newFollowUpHandler routes follow-up prompts on subscribed threads: a stop is +// confirmed first (also abandoning any pending selection), then a pending +// select answer is consumed, and everything else is handled as a normal +// follow-up. +func newFollowUpHandler(linearAccess linearAgentAccess, pending *pendingSelections) chat.MessageHandler { + return func(ctx context.Context, ev *chat.MessageEvent) error { + // A "comment"-kind thread is an ordinary issue comment (ADR 0013); an + // "agent_session" thread is an agent session (ADR 0008). Thread.Post routes + // by kind automatically. + if stopped, err := confirmStop(ctx, ev); stopped { + // A stopped session will not answer its elicitation: drop any pending + // selection so a later message is not misread as a choice. + _, _ = pending.take(ev.Thread.ID()) + return err + } + // A follow-up may answer the latest select elicitation: interpret it as a + // choice only while one is pending on this thread. An unmatched free-text + // reply consumes the pending state for good (Linear dismisses the + // elicitation UI), but a matched choice whose acknowledgement fails to + // post is re-registered: under DispatchDeferred a handler error is only + // observed, not redelivered, so the user's retry must still be + // interpreted as an answer. + if sel, ok := pending.take(ev.Thread.ID()); ok { + if handled, err := handleSelection(ctx, ev, sel); handled { + if err != nil { + pending.set(ev.Thread.ID(), sel) + } + return err + } + } + _, _ = linearAccess.PostThought(ctx, ev.Thread.ID(), "Reading your follow-up...") + _, err := ev.Thread.Post(ctx, chat.Text("Follow-up received: "+ev.Message.Text)) + return err + } } -func newMentionHandler(linearAccess linearAgentAccess) chat.MessageHandler { +func newMentionHandler(linearAccess linearAgentAccess, pending *pendingSelections) chat.MessageHandler { return func(ctx context.Context, ev *chat.MessageEvent) error { // On a comment thread (ADR 0013), reply with an ordinary comment. if raw, ok := linear.RawMessageFrom(ev.Message); ok && raw.Kind == "comment" { @@ -122,13 +149,18 @@ func newMentionHandler(linearAccess linearAgentAccess) chat.MessageHandler { ExternalURLs: []linear.ExternalURL{{URL: "https://example.com/pr/1", Label: "Draft PR"}}, }) - // Ask the user to choose, completing the session via an elicitation. + // Ask the user to choose, completing the session via an elicitation, and + // remember what was offered so only a pending thread's follow-up is + // interpreted as an answer. if shouldAsk(ev.Message.Text) { _, err := linearAccess.PostElicitation(ctx, ev.Thread.ID(), linear.ElicitationInput{ Body: "Which environment should I target?", Signal: "select", SignalMetadata: linear.SelectSignalMetadata{Options: []linear.SelectOption{{Value: "staging"}, {Value: "prod"}}}, }) + if err == nil { + pending.set(ev.Thread.ID(), pendingSelection{Kind: selectionKindDeploy, Values: []string{"staging", "prod"}}) + } return err } diff --git a/examples/linear-agent-hello-world/main_test.go b/examples/linear-agent-hello-world/main_test.go index 6459c5a..d8154d3 100644 --- a/examples/linear-agent-hello-world/main_test.go +++ b/examples/linear-agent-hello-world/main_test.go @@ -63,7 +63,7 @@ func TestNewMentionHandlerRollsBackSubscriptionWhenReplyFails(t *testing.T) { t.Fatalf("thread: %v", err) } - err = newMentionHandler(adapter)(ctx, &chat.MessageEvent{ + err = newMentionHandler(adapter, newPendingSelections())(ctx, &chat.MessageEvent{ Thread: thread, Message: &chat.Message{Text: "hello"}, }) @@ -80,8 +80,17 @@ func TestNewMentionHandlerRollsBackSubscriptionWhenReplyFails(t *testing.T) { } } +// testLinearAdapter is a fake chat.Adapter and linearAgentAccess that records +// what the handlers send so the worked examples in capabilities.go are testable +// without a live Linear org. Tests drive handlers synchronously, so no locking. type testLinearAdapter struct { - postErr error + postErr error + posted []string + thoughts []string + elicitations []linear.ElicitationInput + sessionUpdates []linear.AgentSessionUpdateInput + sessionCreates []linear.CreateSessionOnIssueInput + suggestions []linear.RepositorySuggestion } func (a *testLinearAdapter) Name() string { return "linear" } @@ -100,10 +109,11 @@ func (a *testLinearAdapter) ValidateThreadID(id chat.ThreadID) (chat.ThreadRef, return chat.ThreadRef{ID: id, Adapter: "linear"}, nil } -func (a *testLinearAdapter) PostMessage(_ context.Context, thread chat.ThreadRef, _ chat.PostableMessage) (*chat.SentMessage, error) { +func (a *testLinearAdapter) PostMessage(_ context.Context, thread chat.ThreadRef, msg chat.PostableMessage) (*chat.SentMessage, error) { if a.postErr != nil { return nil, a.postErr } + a.posted = append(a.posted, msg.Text) return &chat.SentMessage{ID: "sent-1", ThreadID: thread.ID}, nil } @@ -111,7 +121,8 @@ func (a *testLinearAdapter) BotActor() chat.Actor { return chat.Actor{Adapter: "linear", Tenant: "org", ID: "bot", BotKind: chat.BotBot} } -func (a *testLinearAdapter) PostThought(_ context.Context, id chat.ThreadID, _ string) (*chat.SentMessage, error) { +func (a *testLinearAdapter) PostThought(_ context.Context, id chat.ThreadID, text string) (*chat.SentMessage, error) { + a.thoughts = append(a.thoughts, text) return &chat.SentMessage{ID: "thought-1", ThreadID: id}, nil } @@ -119,7 +130,8 @@ func (a *testLinearAdapter) PostAction(_ context.Context, id chat.ThreadID, _ li return &chat.SentMessage{ID: "action-1", ThreadID: id}, nil } -func (a *testLinearAdapter) PostElicitation(_ context.Context, id chat.ThreadID, _ linear.ElicitationInput) (*chat.SentMessage, error) { +func (a *testLinearAdapter) PostElicitation(_ context.Context, id chat.ThreadID, in linear.ElicitationInput) (*chat.SentMessage, error) { + a.elicitations = append(a.elicitations, in) return &chat.SentMessage{ID: "elicitation-1", ThreadID: id}, nil } @@ -127,6 +139,16 @@ func (a *testLinearAdapter) PostError(_ context.Context, id chat.ThreadID, _ lin return &chat.SentMessage{ID: "error-1", ThreadID: id}, nil } -func (a *testLinearAdapter) UpdateSession(context.Context, chat.ThreadID, linear.AgentSessionUpdateInput) error { +func (a *testLinearAdapter) UpdateSession(_ context.Context, _ chat.ThreadID, in linear.AgentSessionUpdateInput) error { + a.sessionUpdates = append(a.sessionUpdates, in) return nil } + +func (a *testLinearAdapter) CreateSessionOnIssue(_ context.Context, in linear.CreateSessionOnIssueInput) (*linear.CreatedAgentSession, error) { + a.sessionCreates = append(a.sessionCreates, in) + return &linear.CreatedAgentSession{ThreadID: chat.ThreadID("linear:v1:new-session"), SessionID: "S-NEW", IssueID: "ISSUE-NEW"}, nil +} + +func (a *testLinearAdapter) SuggestRepositories(context.Context, chat.ThreadID, []linear.CandidateRepository) ([]linear.RepositorySuggestion, error) { + return a.suggestions, nil +}