Files
kandev/apps/backend/internal/slack/trigger_test.go
T
João Salavisa 7e0b46c227 feat(slack): cookie-mode integration that triages threads via a utility agent (#775)
* feat(slack): add cookie-mode integration that triages messages via utility agent

Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.

Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
  right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(slack): address PR review feedback round 9

- Drop dead `var _ = time.Second` no-op and its misleading comment in
  trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
  fails, so the poller self-recovers from credential rotation without
  requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
  singleton migration so promoted-row selection is deterministic on
  `updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
  `VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
  `!kandev` triage as v1, with bot install / User OAuth / reaction &
  slash & shortcut triggers moved under Future scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:03:42 +01:00

630 lines
23 KiB
Go

package slack
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility"
"github.com/kandev/kandev/internal/common/logger"
utilitymodels "github.com/kandev/kandev/internal/utility/models"
utilityservice "github.com/kandev/kandev/internal/utility/service"
"github.com/kandev/kandev/internal/utility/template"
)
// --- prefix / parser tests ---
func TestStartsWithPrefix(t *testing.T) {
cases := []struct {
text string
prefix string
want bool
}{
{"!kandev fix login bug", "!kandev", true},
{" !kandev fix login bug", "!kandev", true},
{"!kandev: do the thing", "!kandev", true},
{"!kandev", "!kandev", true},
{"!kandevops handle this", "!kandev", false},
{"hey !kandev fix this", "!kandev", false},
{"> !kandev quoted reply", "!kandev", true},
{"!KANDEV case insensitive", "!kandev", true},
{"", "!kandev", false},
}
for _, c := range cases {
if got := startsWithPrefix(c.text, c.prefix); got != c.want {
t.Errorf("startsWithPrefix(%q, %q) = %v, want %v", c.text, c.prefix, got, c.want)
}
}
}
func TestStripPrefix(t *testing.T) {
cases := []struct {
text, prefix, want string
}{
{"!kandev fix login bug", "!kandev", "fix login bug"},
{" !kandev fix login bug ", "!kandev", "fix login bug"},
{"!kandev: handle this", "!kandev", "handle this"},
{"!kandev,please", "!kandev", "please"},
{"> !kandev quoted reply", "!kandev", "quoted reply"},
{"!kandev", "!kandev", ""},
}
for _, c := range cases {
if got := stripPrefix(c.text, c.prefix); got != c.want {
t.Errorf("stripPrefix(%q, %q) = %q, want %q", c.text, c.prefix, got, c.want)
}
}
}
func TestNewMatchesAfter(t *testing.T) {
matches := []SlackMessage{
{TS: "100.000005", Text: "!kandev five"},
{TS: "100.000003", Text: "!kandev three"},
{TS: "100.000004", Text: "!kandevops noop"},
{TS: "100.000002", Text: "!kandev two"},
}
got := newMatchesAfter(matches, "100.000003", "!kandev")
if len(got) != 1 || got[0].TS != "100.000005" {
t.Errorf("expected only the freshest matching message, got %+v", got)
}
}
func TestNewMatchesAfter_OrdersOldestFirst(t *testing.T) {
matches := []SlackMessage{
{TS: "100.000010", Text: "!kandev ten"},
{TS: "100.000005", Text: "!kandev five"},
{TS: "100.000007", Text: "!kandev seven"},
}
got := newMatchesAfter(matches, "", "!kandev")
if len(got) != 3 {
t.Fatalf("expected 3, got %d", len(got))
}
if got[0].TS != "100.000005" || got[1].TS != "100.000007" || got[2].TS != "100.000010" {
t.Errorf("expected oldest-first order, got %v", []string{got[0].TS, got[1].TS, got[2].TS})
}
}
func TestCompareTS(t *testing.T) {
cases := []struct {
a, b string
want int
}{
{"", "", 0},
{"", "100.0", -1},
{"100.0", "", 1},
{"100.0", "100.0", 0},
{"100.0", "200.0", -1},
{"200.0", "100.0", 1},
}
for _, c := range cases {
if got := compareTS(c.a, c.b); got != c.want {
t.Errorf("compareTS(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
}
}
}
// --- prompt composition ---
func TestComposePrompt_DefaultSystemWhenEmptyTemplate(t *testing.T) {
r := &Runner{}
thread := []SlackMessage{{TS: "100", UserName: "alice", Text: "thread message"}}
prompt := r.composePrompt("", "",
SlackMessage{UserName: "alice"}, "do thing", "https://slack/p100", thread)
if !strings.Contains(prompt, "Kandev triage assistant") {
t.Error("expected default system prompt when template is empty")
}
if !strings.Contains(prompt, "https://slack/p100") {
t.Error("expected permalink in appended block")
}
if !strings.Contains(prompt, "do thing") {
t.Error("expected instruction in appended block")
}
}
func TestComposePrompt_TemplateOwnsLayoutWhenSubstituted(t *testing.T) {
r := &Runner{}
rawTemplate := "Custom: {{SlackInstruction}} for {{KandevWorkspaceID}}"
resolved := "Custom: do thing for ws-1"
prompt := r.composePrompt(rawTemplate, resolved, SlackMessage{}, "do thing", "", nil)
if prompt != "Custom: do thing for ws-1" {
t.Errorf("expected the resolved template only, got: %q", prompt)
}
}
func TestComposePrompt_FallsBackWhenTemplateHasNoVars(t *testing.T) {
// Template with no Slack-specific vars → resolved == raw (after trim) →
// fall back so the agent still gets the Slack context block appended.
r := &Runner{}
template := "Be concise."
prompt := r.composePrompt(template, template,
SlackMessage{UserName: "alice"}, "do x", "https://slack/p", nil)
if !strings.Contains(prompt, "Be concise.") {
t.Error("expected user template at top")
}
if !strings.Contains(prompt, "Slack thread:") && !strings.Contains(prompt, "Request from") {
t.Error("expected Slack context block to be appended for non-templated systems")
}
}
// --- runner end-to-end ---
type fakeUtility struct {
agents map[string]*utilitymodels.UtilityAgent
}
func (f fakeUtility) GetAgentByID(ctx context.Context, id string) (*utilitymodels.UtilityAgent, error) {
a, ok := f.agents[id]
if !ok {
return nil, errors.New("not found")
}
return a, nil
}
func (f fakeUtility) PreparePromptRequest(
ctx context.Context,
utilityID string,
tmplCtx *template.Context,
defaults *utilityservice.DefaultUtilitySettings,
sessionless bool,
) (*utilityservice.PromptRequest, error) {
a, ok := f.agents[utilityID]
if !ok {
return nil, errors.New("not found")
}
// Resolve via the real engine so Custom vars actually substitute.
resolved, err := template.NewEngine().ResolveWithOptions(a.Prompt, tmplCtx, template.ResolveOptions{MissingAsEmpty: sessionless})
if err != nil {
return nil, err
}
agentCLI, model := a.AgentID, a.Model
if agentCLI == "" && defaults != nil {
agentCLI = defaults.AgentID
}
if model == "" && defaults != nil {
model = defaults.Model
}
return &utilityservice.PromptRequest{
UtilityID: utilityID,
ResolvedPrompt: resolved,
AgentCLI: agentCLI,
Model: model,
}, nil
}
type fakeUserDefaults struct {
agentID, model string
err error
}
func (f fakeUserDefaults) GetDefaultUtilitySettings(ctx context.Context) (string, string, error) {
return f.agentID, f.model, f.err
}
type fakeHostRunner struct {
mu sync.Mutex
calls []hostCall
response string
err error
}
type hostCall struct {
agentType, model, mode, prompt string
mcpServers []agentctlutil.MCPServerDTO
}
func (f *fakeHostRunner) ExecutePromptWithMCP(
ctx context.Context,
agentType, model, mode, prompt string,
mcpServers []agentctlutil.MCPServerDTO,
) (HostPromptResult, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, hostCall{agentType, model, mode, prompt, mcpServers})
if f.err != nil {
return HostPromptResult{}, f.err
}
return HostPromptResult{Response: f.response, Model: model, DurationMs: 1}, nil
}
func TestRunner_RunForMatch_HappyPath(t *testing.T) {
utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{
"ua-1": {
ID: "ua-1", Name: "Slack triage", AgentID: "claude-acp",
Model: "claude-haiku-4-5", Enabled: true,
Prompt: "You are a Slack triage helper.",
},
}}
host := &fakeHostRunner{response: "Created task 'Fix checkout bug' in workspace ws-1."}
mcp := []MCPDescriptor{{Name: "kandev", URL: "http://localhost:38429/mcp"}}
r := NewRunner(utility, fakeUserDefaults{}, host, mcp, logger.Default())
cfg := &SlackConfig{UtilityAgentID: "ua-1"}
msg := SlackMessage{TS: "100.0001", ChannelID: "C1", UserID: "U1", UserName: "alice", Text: "!kandev fix checkout"}
thread := []SlackMessage{msg}
reply, err := r.RunForMatch(context.Background(), cfg, msg, "fix checkout", "https://example/p100", thread)
if err != nil {
t.Fatalf("RunForMatch: %v", err)
}
if !strings.Contains(reply, "Created task") {
t.Errorf("unexpected reply: %q", reply)
}
if len(host.calls) != 1 {
t.Fatalf("expected one host call, got %d", len(host.calls))
}
call := host.calls[0]
if call.agentType != "claude-acp" || call.model != "claude-haiku-4-5" {
t.Errorf("expected agent_id+model passthrough, got %+v", call)
}
if !strings.Contains(call.prompt, "You are a Slack triage helper.") {
t.Error("expected utility agent's custom system prompt to be used")
}
if !strings.Contains(call.prompt, "fix checkout") {
t.Error("expected user instruction in prompt")
}
if len(call.mcpServers) != 1 || call.mcpServers[0].Name != "kandev" || call.mcpServers[0].URL != "http://localhost:38429/mcp" {
t.Errorf("expected kandev MCP wired, got %+v", call.mcpServers)
}
if call.mcpServers[0].Type != "http" {
t.Errorf("expected http transport, got %q", call.mcpServers[0].Type)
}
}
func TestRunner_RunForMatch_NoUtilityAgent(t *testing.T) {
r := NewRunner(fakeUtility{}, fakeUserDefaults{}, &fakeHostRunner{}, nil, logger.Default())
cfg := &SlackConfig{}
_, err := r.RunForMatch(context.Background(), cfg, SlackMessage{}, "x", "", nil)
if !errors.Is(err, ErrNoUtilityAgent) {
t.Errorf("expected ErrNoUtilityAgent, got %v", err)
}
}
func TestRunner_RunForMatch_DisabledBuiltinFallsBackToDefaults(t *testing.T) {
// Built-in utility agents ship with enabled=false and empty agent_id /
// model — they're meant to use the user's default agent + model. The
// runner shouldn't reject this configuration; PreparePromptRequest
// substitutes the defaults at resolution time, matching how the rest of
// Kandev runs utility agents.
utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{
"builtin-x": {ID: "builtin-x", Name: "x", Builtin: true, Enabled: false, Prompt: ""},
}}
host := &fakeHostRunner{response: "ok"}
r := NewRunner(utility, fakeUserDefaults{agentID: "claude-acp", model: "claude-haiku-4-5"},
host, nil, logger.Default())
cfg := &SlackConfig{UtilityAgentID: "builtin-x"}
if _, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil); err != nil {
t.Fatalf("expected disabled built-in to run with user defaults, got: %v", err)
}
if len(host.calls) != 1 || host.calls[0].agentType != "claude-acp" || host.calls[0].model != "claude-haiku-4-5" {
t.Errorf("expected default agent_id+model used, got %+v", host.calls)
}
}
func TestRunner_RunForMatch_NoAgentResolvedErrors(t *testing.T) {
// When the chosen agent has no agent_id and there are no user defaults
// either, we can't pick an inference subprocess to spawn — surface a
// clear error rather than silently failing.
utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{
"ua-1": {ID: "ua-1", Name: "no-config", Builtin: false, Enabled: false},
}}
r := NewRunner(utility, fakeUserDefaults{}, &fakeHostRunner{}, nil, logger.Default())
cfg := &SlackConfig{UtilityAgentID: "ua-1"}
_, err := r.RunForMatch(context.Background(), cfg, SlackMessage{}, "x", "", nil)
if err == nil || !strings.Contains(err.Error(), "no agent_id resolved") {
t.Errorf("expected 'no agent_id resolved' error, got %v", err)
}
}
func TestRunner_RunForMatch_EmptyResponseFallsBack(t *testing.T) {
utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{
"ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true},
}}
host := &fakeHostRunner{response: ""}
r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default())
cfg := &SlackConfig{UtilityAgentID: "ua-1"}
reply, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil)
if err != nil {
t.Fatalf("expected success with fallback reply, got error: %v", err)
}
if reply == "" {
t.Error("expected fallback reply text, got empty string")
}
}
func TestRunner_RunForMatch_HostErrorBubbles(t *testing.T) {
utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{
"ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true},
}}
host := &fakeHostRunner{err: errors.New("subprocess crashed")}
r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default())
cfg := &SlackConfig{UtilityAgentID: "ua-1"}
_, err := r.RunForMatch(context.Background(), cfg, SlackMessage{TS: "100"}, "x", "", nil)
if err == nil || !strings.Contains(err.Error(), "subprocess crashed") {
t.Errorf("expected host error to bubble, got %v", err)
}
}
// --- trigger end-to-end with fakes ---
type fakeClient struct {
mu sync.Mutex
thread []SlackMessage
permalink string
posted []postRecord
reactions []reactionRecord
}
type postRecord struct{ channel, threadTS, text string }
type reactionRecord struct{ channel, ts, name string }
func (f *fakeClient) AuthTest(ctx context.Context) (*TestConnectionResult, error) {
return &TestConnectionResult{OK: true, UserID: "U1", TeamID: "T1"}, nil
}
func (f *fakeClient) SearchMessages(ctx context.Context, query string) ([]SlackMessage, error) {
return nil, nil
}
func (f *fakeClient) ConversationsReplies(ctx context.Context, channelID, threadTS, triggerTS string) ([]SlackMessage, error) {
return f.thread, nil
}
func (f *fakeClient) ChatGetPermalink(ctx context.Context, channelID, ts string) (string, error) {
return f.permalink, nil
}
func (f *fakeClient) ChatPostMessage(ctx context.Context, channelID, threadTS, text string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.posted = append(f.posted, postRecord{channelID, threadTS, text})
return nil
}
func (f *fakeClient) ReactionsAdd(ctx context.Context, channelID, ts, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.reactions = append(f.reactions, reactionRecord{channelID, ts, name})
return nil
}
func TestTrigger_HandleOne_PostsAgentReplyInThread(t *testing.T) {
utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{
"ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "claude-haiku-4-5", Enabled: true},
}}
host := &fakeHostRunner{response: "Created task X."}
store := newTestStore(t)
factory := func(_ *SlackConfig, _ string, _ string) Client {
return &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev x"}}}
}
svc := NewService(store, nil, NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default()), factory, logger.Default())
ctx := context.Background()
_ = svc.Store().UpsertConfig(ctx, &SlackConfig{
AuthMethod: AuthMethodCookie,
UtilityAgentID: "ua-1", CommandPrefix: "!kandev",
})
client := &fakeClient{thread: []SlackMessage{{TS: "100.0001", Text: "!kandev fix it"}}, permalink: "https://example/p100"}
svc.client = client
cfg, _ := svc.Store().GetConfig(ctx)
trig := NewTrigger(svc, logger.Default())
reply, err := trig.handleOne(ctx, cfg, "!kandev", SlackMessage{
TS: "100.0001", ChannelID: "C1", Text: "!kandev fix it",
}, client)
if err != nil {
t.Fatalf("handleOne: %v", err)
}
if reply != "Created task X." {
t.Errorf("expected agent reply passthrough, got %q", reply)
}
if len(client.posted) != 1 {
t.Fatalf("expected one Slack reply, got %d", len(client.posted))
}
if client.posted[0].text != "Created task X." || client.posted[0].channel != "C1" {
t.Errorf("unexpected slack post: %+v", client.posted[0])
}
if client.posted[0].threadTS != "100.0001" {
t.Errorf("expected thread anchor on the trigger ts, got %q", client.posted[0].threadTS)
}
if len(client.reactions) != 1 {
t.Fatalf("expected one ack reaction, got %d", len(client.reactions))
}
r := client.reactions[0]
if r.channel != "C1" || r.ts != "100.0001" || r.name != AcknowledgeReaction {
t.Errorf("unexpected reaction: %+v", r)
}
}
// --- helper assertions ---
func TestRunner_PromptIncludesAllSlackContext(t *testing.T) {
// Sanity: run a single match and inspect the prompt the host saw to
// confirm permalink + workspace + thread all made it through. Belt and
// braces against future refactors of buildAgentPrompt.
utility := fakeUtility{agents: map[string]*utilitymodels.UtilityAgent{
"ua-1": {ID: "ua-1", Name: "x", AgentID: "claude-acp", Model: "x", Enabled: true},
}}
host := &fakeHostRunner{response: "ok"}
r := NewRunner(utility, fakeUserDefaults{}, host, nil, logger.Default())
cfg := &SlackConfig{UtilityAgentID: "ua-1"}
msg := SlackMessage{TS: "200", UserName: "bob", Text: "!kandev investigate"}
thread := []SlackMessage{msg, {TS: "199", UserName: "alice", Text: "saw a 500 on /api/foo"}}
if _, err := r.RunForMatch(context.Background(), cfg, msg, "investigate", "https://slack/p200", thread); err != nil {
t.Fatalf("RunForMatch: %v", err)
}
prompt := host.calls[0].prompt
for _, want := range []string{"https://slack/p200", "investigate", "saw a 500", "@bob"} {
if !strings.Contains(prompt, want) {
t.Errorf("expected %q in prompt; got:\n%s", want, prompt)
}
}
}
// --- watermark advance under partial failure ---
// failingRunner fails handleOne the Nth time it's called (1-indexed).
type failingRunner struct {
mu sync.Mutex
calls int
failOn int
fakeErr error
}
func (f *failingRunner) RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
if f.calls == f.failOn {
return "", f.fakeErr
}
return "ok " + msg.TS, nil
}
// TestTrigger_ProcessMatches_BreaksOnTransientError exercises the regression
// the previous "continue on error" loop introduced: in a batch [A, B, C]
// where B fails with a transient error and C would succeed, the watermark
// must advance only past A — not past C — so the next tick re-processes B
// and C. This is the core promise of the !kandev capture flow ("no message
// silently dropped") and easy to break with a refactor that reverts to
// `continue`.
func TestTrigger_ProcessMatches_BreaksOnTransientError(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
_ = store.UpsertConfig(ctx, &SlackConfig{
AuthMethod: AuthMethodCookie,
UtilityAgentID: "ua-1",
CommandPrefix: "!kandev",
})
cfg, _ := store.GetConfig(ctx)
runner := &failingRunner{failOn: 2, fakeErr: errors.New("transient agent failure")}
factory := func(_ *SlackConfig, _ string, _ string) Client {
return &fakeClient{}
}
svc := NewService(store, nil, runner, factory, logger.Default())
client := &fakeClient{thread: []SlackMessage{{TS: "100.000001", Text: "!kandev a"}}}
svc.client = client
trig := NewTrigger(svc, logger.Default())
matches := []SlackMessage{
{TS: "100.000001", ChannelID: "C1", Text: "!kandev a"},
{TS: "100.000002", ChannelID: "C1", Text: "!kandev b"},
{TS: "100.000003", ChannelID: "C1", Text: "!kandev c"},
}
trig.processMatches(ctx, cfg, "!kandev", matches, client)
got, _ := svc.Store().GetConfig(ctx)
if got.LastSeenTS != "100.000001" {
t.Errorf("expected watermark to advance only past A (the consecutive prefix of successes), got %q", got.LastSeenTS)
}
if runner.calls != 2 {
t.Errorf("expected loop to break after first failure (2 calls: A success, B fail); got %d calls", runner.calls)
}
}
// TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak verifies the
// other branch of the failure logic: ErrNoUtilityAgent is unrecoverable, so
// processing continues to subsequent matches and the watermark spans all
// observed messages. Previously this was the only configured behaviour and
// is the only one a production user can hit until they pick an agent.
func TestTrigger_ProcessMatches_NoUtilityAgentSkipsWithoutBreak(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
_ = store.UpsertConfig(ctx, &SlackConfig{
AuthMethod: AuthMethodCookie,
CommandPrefix: "!kandev",
// Note: no UtilityAgentID — runner will return ErrNoUtilityAgent.
})
cfg, _ := store.GetConfig(ctx)
// Track invocations directly so we can assert the loop *didn't* break
// on ErrNoUtilityAgent (only transient errors should break the loop).
var calls int
var callsMu sync.Mutex
noAgentRunner := agentRunnerFunc(func(ctx context.Context, _ *SlackConfig, msg SlackMessage, _, _ string, _ []SlackMessage) (string, error) {
callsMu.Lock()
calls++
callsMu.Unlock()
return "", ErrNoUtilityAgent
})
factory := func(_ *SlackConfig, _ string, _ string) Client { return &fakeClient{} }
svc := NewService(store, nil, noAgentRunner, factory, logger.Default())
svc.client = &fakeClient{}
trig := NewTrigger(svc, logger.Default())
matches := []SlackMessage{
{TS: "100.000001", ChannelID: "C1", Text: "!kandev a"},
{TS: "100.000002", ChannelID: "C1", Text: "!kandev b"},
}
trig.processMatches(ctx, cfg, "!kandev", matches, &fakeClient{})
got, _ := svc.Store().GetConfig(ctx)
if got.LastSeenTS != "100.000002" {
t.Errorf("expected watermark to advance past *all* matches under ErrNoUtilityAgent, got %q", got.LastSeenTS)
}
callsMu.Lock()
finalCalls := calls
callsMu.Unlock()
if finalCalls != 2 {
t.Errorf("expected runner to be invoked for every match (no break), got %d calls", finalCalls)
}
}
// agentRunnerFunc adapts a function to the AgentRunner interface for tests.
type agentRunnerFunc func(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error)
func (f agentRunnerFunc) RunForMatch(ctx context.Context, cfg *SlackConfig, msg SlackMessage, instruction, permalink string, thread []SlackMessage) (string, error) {
return f(ctx, cfg, msg, instruction, permalink, thread)
}
// TestComposePrompt_GenericTemplateGetsResolvedSubstitution verifies the
// follow-up fix to the composePrompt heuristic: when a template uses only
// non-Slack placeholders (e.g. {{UserPrompt}}), the fallback path uses the
// *resolved* template text rather than the raw template, so the agent sees
// the substituted value instead of the literal {{UserPrompt}} placeholder.
func TestComposePrompt_GenericTemplateGetsResolvedSubstitution(t *testing.T) {
r := &Runner{}
rawTemplate := "Be brief. Task: {{UserPrompt}}"
resolved := "Be brief. Task: fix checkout"
prompt := r.composePrompt(rawTemplate, resolved,
SlackMessage{UserName: "alice"}, "fix checkout", "https://slack/p", nil)
if !strings.Contains(prompt, "Task: fix checkout") {
t.Errorf("expected resolved {{UserPrompt}} in fallback path; got:\n%s", prompt)
}
if strings.Contains(prompt, "{{UserPrompt}}") {
t.Errorf("expected no literal placeholder to leak through; got:\n%s", prompt)
}
}
// --- install-wide cadence ---
func TestTrigger_DueForScan(t *testing.T) {
trig := &Trigger{}
if !trig.dueForScan(30) {
t.Error("expected fresh trigger to be due immediately")
}
trig.markScanned()
if trig.dueForScan(30) {
t.Error("expected trigger to not be due immediately after scan")
}
// Backdate the marker to simulate enough elapsed time. Using a direct
// field write here rather than time.Sleep keeps the test deterministic.
trig.scannedMu.Lock()
trig.lastScannedAt = time.Now().Add(-31 * time.Second)
trig.scannedMu.Unlock()
if !trig.dueForScan(30) {
t.Error("expected trigger to be due again after 31s with 30s interval")
}
// Bogus interval (below floor) falls back to the default — guards against
// a config row with 0 (or a corrupted row) holding the loop hostage.
trig.scannedMu.Lock()
trig.lastScannedAt = time.Now()
trig.scannedMu.Unlock()
if trig.dueForScan(0) {
t.Error("expected zero interval to behave as default 30s, not as 'always due'")
}
}