Skip to content
24 changes: 24 additions & 0 deletions docs/guides/dev/behaviour-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,30 @@ Background:

The `Given a fork` step remaps the logical name as above and is idempotent for that actual fork repo. Each scenario then creates its own branch and PR within the fork.

### Fork PR behaviour contract
Comment thread
ifireball marked this conversation as resolved.

The `fork-dispatch.feature` file defines the canonical fork-PR behaviour contract for `harness-dispatch`. Each CEL port PR ([#2896](https://github.com/fullsend-ai/fullsend/issues/2896)–[#2901](https://github.com/fullsend-ai/fullsend/issues/2901)) should follow this contract when adding fork-PR rows to the agent's harness behaviour feature file.

| Scenario | Expected | How tested |
|----------|----------|------------|
| Fork PR matches CEL trigger; authorized actor | Agent runs via `harness-dispatch`; workflow completes | Positive dispatch + artifact assertion |
| Kill switch active (`kill_switch: true`) on fork event | Empty matrix, exit 0 | Separate scenario; `the kill switch is active` step + assert agent did not run |
| Disabled harness (`enabled: false`) on fork event | Empty matrix, exit 0 | Disabled harness in positive scenario; assert agent did not run |
| Fork PR `synchronize` + label dispatches harness | Harness dispatched exactly 1 time; workflow completes | Separate scenario with sync commit + label |
| CEL `is_fork` exclusion (`!event.state.change_proposal.is_fork`) | Empty matrix, exit 0 | Harness with `is_fork` guard in positive scenario; assert agent did not run |

Comment thread
ifireball marked this conversation as resolved.
**Kill switch vs disabled harness:** These are distinct mechanisms. The **kill switch** (`kill_switch: true` in `config.yaml`) is a global emergency stop that blocks *all* harness dispatch for the repo — tested in its own scenario because no positive harness can run alongside it. A **disabled harness** (`enabled: false` per agent entry) only prevents that single agent from running — tested as a piggyback negative assertion in the positive-path scenario.

**Consolidation pattern:** To conserve parallel execution slots, add negative-path harnesses (disabled agent, CEL exclusion) alongside the positive-path harness in a single scenario rather than creating separate scenarios. The positive harness wait acts as the settle window for negative assertions (piggyback pattern — see `negativeSettleDuration` in `dispatch.go`). The kill switch scenario cannot be consolidated because it blocks all harnesses.

**Unauthorized-actor denial** ([ADR 0054](../../ADRs/0054-require-authorization-on-all-agent-dispatch-paths.md)) for fork PRs is tracked separately in [#5613](https://github.com/fullsend-ai/fullsend/issues/5613) and is not part of this contract.

### Dispatch step reference

**`a disabled custom harness "<name>" with:`** — Registers the harness YAML under `.fullsend/harness/<name>.yaml` and adds an agent entry with `enabled: false` to the repo's `config.yaml`. Use this step for negative dispatch assertions where a single agent should be excluded while other agents in the same scenario continue to run. This is *not* the kill switch; for the global emergency stop that blocks all harnesses, use `the kill switch is active`.

**`the kill switch is active`** — Sets `kill_switch: true` in the repo's `config.yaml`, causing `Dispatch` to return an empty matrix for *all* agents. Use this step in a dedicated scenario where no harness should run. Because the kill switch blocks everything, it cannot share a scenario with a positive-path harness.

## Forge operational constraints

When modifying behaviour test repo provisioning, fork handling, or workflow dispatch, be aware of these constraints. They are not enforced by the compiler or linter — violations surface as cryptic API errors or silently dropped events in CI.
Expand Down
45 changes: 45 additions & 0 deletions e2e/behaviour/features/dispatch/fork-dispatch.feature
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,31 @@ Feature: Fork PR dispatch
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-fork-ping"
"""
And a disabled custom harness "fork-pr-killed" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-fork-pr-killed
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "change_proposal"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-fork-ping"
"""
And a custom harness "fork-pr-nofork" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-fork-pr-nofork
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "change_proposal"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-fork-ping"
&& !event.state.change_proposal.is_fork
"""
And a dummy agent that would:
| description | op | args |
| Fork PR payload | assert_json | .fullsend/dispatch/event-payload.json,pull_request.head.repo.fork |
Expand All @@ -39,6 +64,26 @@ Feature: Fork PR dispatch
And the agent will succeed to Prove fork execution
And the harness "fork-pr-ping" was dispatched exactly 1 time
And the harness "fork-issue-ping" agent did not run
And the harness "fork-pr-killed" agent did not run
And the harness "fork-pr-nofork" agent did not run

Scenario: Fork PR kill switch blocks all harnesses
Given a custom harness "fork-pr-killswitch" with:
"""
agent: agents/triage.md
role: triage
slug: fullsend-ai-fork-pr-killswitch
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
trigger: |
event.entity.kind == "change_proposal"
&& event.transition.kind == "label_changed"
&& event.transition.label.name == "ready-for-fork-killswitch"
"""
And the kill switch is active
When a fork pull request is opened
And the fork pull request is labeled "ready-for-fork-killswitch"
Then the harness "fork-pr-killswitch" agent did not run

Scenario: Fork PR sync + label dispatches harness
Given a custom harness "fork-pr-sync" with:
Expand Down
11 changes: 11 additions & 0 deletions pkg/behaviourtest/steps/cleanup.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ func CleanupScenario(w *world.World) {
}
}

// --- Kill switch cleanup ---
// Deactivate the kill switch so the next scenario on this slot is
// not blocked by sticky state. Runs before dummy-script cleanup
// because the kill switch is a repo-level config that affects all
// harnesses.
if w.KillSwitchActivated {
if err := DeactivateKillSwitch(w); err != nil {
worldLogf(w, "behaviour cleanup: deactivate kill switch: %v", err)
}
}

// --- Dummy script cleanup ---
if len(w.DummyOps) > 0 {
empty := []byte("ops: []\n")
Expand Down
60 changes: 59 additions & 1 deletion pkg/behaviourtest/steps/cleanup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ type fakeCleanupSCM struct {
deleteRepoErr error
commitFileCalled bool
commitFileErr error
fileContent []byte
getFileErr error
}

type closedIssueRecord struct {
Expand Down Expand Up @@ -491,7 +493,7 @@ func (f *fakeCleanupSCM) GetIssue(context.Context, string, string, int) (*forge.
}

func (f *fakeCleanupSCM) GetFileContent(context.Context, string, string, string) ([]byte, error) {
return nil, nil
return f.fileContent, f.getFileErr
}

func (f *fakeCleanupSCM) CommitFile(_ context.Context, _, _, _, _ string, _ []byte) error {
Expand Down Expand Up @@ -634,6 +636,62 @@ func TestCleanupScenario_ClearsDummyOps_Error(t *testing.T) {
assert.Contains(t, logged[0], "clear dummy script")
}

// --- Kill switch cleanup tests ---

func TestCleanupScenario_DeactivatesKillSwitch(t *testing.T) {
t.Parallel()

scmDriver := &fakeCleanupSCM{
fileContent: []byte("version: \"1\"\nkill_switch: true\nroles:\n - triage\n"),
}
installDriver := &fakeCleanupInstall{owner: "org", repo: "repo"}
w := &world.World{
RepoOwner: "org",
RepoName: "repo",
KillSwitchActivated: true,
Install: installDriver,
SCM: scmDriver,
}
CleanupScenario(w)
assert.True(t, scmDriver.commitFileCalled, "should commit config to deactivate kill switch")
}

func TestCleanupScenario_SkipsKillSwitchWhenNotActivated(t *testing.T) {
t.Parallel()

scmDriver := &fakeCleanupSCM{}
w := &world.World{
RepoOwner: "org",
RepoName: "repo",
KillSwitchActivated: false,
SCM: scmDriver,
}
CleanupScenario(w)
assert.False(t, scmDriver.commitFileCalled, "should not commit when kill switch was not activated")
}

func TestCleanupScenario_DeactivateKillSwitch_Error(t *testing.T) {
t.Parallel()

var logged []string
scmDriver := &fakeCleanupSCM{
fileContent: []byte("version: \"1\"\nkill_switch: true\nroles:\n - triage\n"),
commitFileErr: fmt.Errorf("commit failed"),
}
installDriver := &fakeCleanupInstall{owner: "org", repo: "repo"}
w := &world.World{
RepoOwner: "org",
RepoName: "repo",
KillSwitchActivated: true,
Install: installDriver,
SCM: scmDriver,
Logf: func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) },
}
CleanupScenario(w)
require.Len(t, logged, 1)
assert.Contains(t, logged[0], "deactivate kill switch")
}

// fakeCleanupInstall satisfies the Install interface for cleanup tests.
type fakeCleanupInstall struct {
owner string
Expand Down
53 changes: 53 additions & 0 deletions pkg/behaviourtest/steps/dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,59 @@ func registerDispatchSteps(sc *godog.ScenarioContext) {
sc.Step(`^a review comment is submitted on the pull request$`, func(ctx context.Context) (context.Context, error) {
return ctx, whenPullRequestReviewComment(world.FromContext(ctx))
})
sc.Step(`^the kill switch is active$`, func(ctx context.Context) (context.Context, error) {
return ctx, givenKillSwitchActive(world.FromContext(ctx))
})
}

// givenKillSwitchActive sets kill_switch: true in the enrolled repo's
// config.yaml, causing Dispatch to return an empty matrix for all agents.
// It also marks w.KillSwitchActivated so CleanupScenario deactivates the
// switch before the slot is reused by another scenario.
func givenKillSwitchActive(w *world.World) error {
cfgPath := filepath.Join(".fullsend", "config.yaml")
cfgData, err := w.SCM.GetFileContent(context.Background(), w.Install.ConfigOwner(), w.Install.ConfigRepo(), cfgPath)
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
cfg, err := config.ParsePerRepoConfigWriter(cfgData)
if err != nil {
return fmt.Errorf("parsing config: %w", err)
}
cfg.SetKillSwitch(true)
merged, err := cfg.Marshal()
if err != nil {
return err
}
if err := w.SCM.CommitFile(context.Background(), w.Install.ConfigOwner(), w.Install.ConfigRepo(), cfgPath, "behaviour: activate kill switch", merged); err != nil {
return fmt.Errorf("updating config: %w", err)
}
w.KillSwitchActivated = true
return nil
}

// DeactivateKillSwitch sets kill_switch: false in the enrolled repo's
// config.yaml. Exported so CleanupScenario (in package steps) can call
// it during scenario teardown.
func DeactivateKillSwitch(w *world.World) error {
cfgPath := filepath.Join(".fullsend", "config.yaml")
cfgData, err := w.SCM.GetFileContent(context.Background(), w.Install.ConfigOwner(), w.Install.ConfigRepo(), cfgPath)
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
cfg, err := config.ParsePerRepoConfigWriter(cfgData)
if err != nil {
return fmt.Errorf("parsing config: %w", err)
}
cfg.SetKillSwitch(false)
merged, err := cfg.Marshal()
if err != nil {
return err
}
if err := w.SCM.CommitFile(context.Background(), w.Install.ConfigOwner(), w.Install.ConfigRepo(), cfgPath, "behaviour: deactivate kill switch", merged); err != nil {
return fmt.Errorf("updating config: %w", err)
}
return nil
}

func givenDisabledCustomHarness(w *world.World, name, doc string) error {
Expand Down
Loading
Loading