Files
pulse/internal/api/issue1640_readiness_gate_test.go
T
courtmanr@gmail.com 4d972a68ee Gate Patrol readiness on the completed overall verdict (#1640)
The "Patrol tools" readiness check read the cached model-readiness
snapshot's tool-protocol dimension on its own. Since the interrupted-run
handling landed (8d0d74e35, b78330405), a run cancelled after every tool
scenario already passed keeps ToolProtocol at pass while the overall
status reports not_assessed, so the check reported "Patrol ready" from an
evaluation that never completed.

The check now requires the snapshot's own overall verdict (Success)
before reporting ready. A snapshot carrying no verdict at all — overall
status not_assessed, or the interrupted or internal_error cause — is not
turned into a failure either: it falls back to the base-config classifier
exactly as an absent snapshot does, capped at a warning. That cap matters
because not_ready is a blocking status in this payload: it clears
readiness.ready, which disables the Patrol run control in
usePatrolIntelligenceState and drops the page into the setup-only view.
#1640 promises a severed or cancelled check never blames the model and
never blocks Patrol from running in Watch mode, and the runtime gate on
POST /api/ai/patrol/run (PatrolRuntimeReadiness) already treats an
unassessed mode as a warning, so a blocking tools check would have
contradicted the route that actually runs Patrol.

A completed run whose tool protocol passed while the overall verdict fell
short now warns instead of claiming ready. It must not block either: the
dimension that actually failed carries the verdict on its own check
(context quality blocks, latency warns), so blocking here would have
turned today's latency warning into a hard stop.

Regression tests: internal/api/issue1640_readiness_gate_test.go covers
the gate across interrupted, internal-error, completed-pass,
completed-fail, and short-of-pass snapshots, asserting the resulting
runnability of the readiness payload;
internal/ai/issue1640_readiness_gate_test.go drives a real evaluation
that is cancelled at the continuation probe to produce the
ToolProtocol=pass / status=not_assessed snapshot end to end and pins that
PatrolRuntimeReadiness keeps Patrol runnable. The new API test file is
registered in the subsystem verification registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 13:57:48 +01:00

205 lines
7.6 KiB
Go

package api
// Regression coverage for issue #1640: the "Patrol tools" readiness check read
// the cached snapshot's tool-protocol dimension on its own, so an evaluation
// that was interrupted after every tool scenario passed — tool protocol pass,
// overall status not assessed — reported "Patrol ready" from a check that
// never completed. Readiness now requires the completed overall verdict, and
// an inconclusive snapshot may not record a failure either: a not-ready tools
// check disables the Patrol run control, and #1640 promises an interrupted or
// cancelled check never blocks Patrol from running in Watch mode.
import (
"testing"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// issue1640Snapshot builds a cached readiness snapshot with the given overall
// verdict and tool-protocol dimension, leaving the rest of the shape as the
// evaluator produces it.
func issue1640Snapshot(status string, success bool, cause ai.PatrolFailureCause, toolStatus, toolSummary string) *ai.PatrolModelReadinessResult {
snapshot := &ai.PatrolModelReadinessResult{
ProbeVersion: ai.PatrolModelReadinessProbeVersion,
Status: status,
Success: success,
Cause: cause,
Provider: "ollama",
Model: "test-model",
}
snapshot.Dimensions.ToolProtocol = ai.PatrolModelReadinessDimension{
Status: toolStatus,
Summary: toolSummary,
Attempts: 3,
Passed: 3,
}
return snapshot
}
func issue1640OllamaStaticCheck() patrolToolsCheck {
return patrolToolsCheck{
Status: patrolReadinessWarning,
Cause: ai.PatrolFailureCauseModelToolSupportUnverified,
Message: "Ollama connectivity alone does not prove tool support.",
Action: "open_provider_settings",
}
}
func TestIssue1640ToolsCheckRequiresCompletedVerdict(t *testing.T) {
t.Parallel()
tests := []struct {
name string
snapshot *ai.PatrolModelReadinessResult
static patrolToolsCheck
wantStatus string
wantRunnable bool
wantCause ai.PatrolFailureCause
wantMessagePart string
}{
{
// The regression: every tool scenario passed, then the run was cut
// short. The dimension says pass; the run has no verdict.
name: "interrupted run with a passing tool dimension is not readiness",
snapshot: issue1640Snapshot(
ai.PatrolModelReadinessNotAssessed, false, ai.PatrolFailureCauseInterrupted,
ai.PatrolModelReadinessPass,
"All scenarios passed before the run was interrupted; multi-turn continuation was not assessed.",
),
static: issue1640OllamaStaticCheck(),
wantStatus: patrolReadinessWarning,
wantRunnable: true,
wantCause: ai.PatrolFailureCauseInterrupted,
wantMessagePart: "did not complete",
},
{
name: "interrupted run with a partial tool dimension does not blame the model",
snapshot: issue1640Snapshot(
ai.PatrolModelReadinessNotAssessed, false, ai.PatrolFailureCauseInterrupted,
ai.PatrolModelReadinessNotAssessed,
"Interrupted after 1/3 scenarios passed; the remaining scenarios were not assessed.",
),
static: issue1640OllamaStaticCheck(),
wantStatus: patrolReadinessWarning,
wantRunnable: true,
wantCause: ai.PatrolFailureCauseInterrupted,
wantMessagePart: "did not complete",
},
{
// A recovered panic on the evaluation path is a Pulse defect, not a
// model verdict, and carries the same non-evidence status.
name: "internal error carries no verdict either",
snapshot: issue1640Snapshot(
ai.PatrolModelReadinessFail, false, ai.PatrolFailureCauseInternalError,
ai.PatrolModelReadinessNotAssessed, "Not assessed.",
),
static: issue1640OllamaStaticCheck(),
wantStatus: patrolReadinessWarning,
wantRunnable: true,
wantCause: ai.PatrolFailureCauseInternalError,
},
{
name: "completed passing evaluation reports ready",
snapshot: issue1640Snapshot(
ai.PatrolModelReadinessPass, true, ai.PatrolFailureCauseNone,
ai.PatrolModelReadinessPass,
"Exact tool protocol passed 3/3 scenarios.",
),
static: issue1640OllamaStaticCheck(),
wantStatus: patrolReadinessReady,
wantRunnable: true,
wantCause: ai.PatrolFailureCauseNone,
wantMessagePart: "passed 3/3 scenarios",
},
{
name: "completed failing evaluation blocks",
snapshot: issue1640Snapshot(
ai.PatrolModelReadinessWarning, false, ai.PatrolFailureCauseModelUnsupportedTools,
ai.PatrolModelReadinessFail,
"Exact tool protocol passed 0/3 scenarios.",
),
static: issue1640OllamaStaticCheck(),
wantStatus: patrolReadinessNotReady,
wantRunnable: false,
wantCause: ai.PatrolFailureCauseModelUnsupportedTools,
wantMessagePart: "last readiness evaluation",
},
{
// A completed run whose tool protocol passed but whose overall
// verdict did not: the dimension that failed carries the verdict on
// its own check, so the tools check reports what it measured
// without turning a latency warning into a hard block.
name: "completed run short of an overall pass warns instead of claiming ready",
snapshot: issue1640Snapshot(
ai.PatrolModelReadinessWarning, false, ai.PatrolFailureCauseLatencyUnsuitable,
ai.PatrolModelReadinessPass,
"Exact tool protocol passed 3/3 scenarios.",
),
static: issue1640OllamaStaticCheck(),
wantStatus: patrolReadinessWarning,
wantRunnable: true,
wantCause: ai.PatrolFailureCauseLatencyUnsuitable,
wantMessagePart: "did not verify the model",
},
{
// An incomplete run proves nothing that could clear a config-level
// block, so the base classifier's verdict stands.
name: "config-level block survives an inconclusive snapshot",
snapshot: issue1640Snapshot(
ai.PatrolModelReadinessNotAssessed, false, ai.PatrolFailureCauseInterrupted,
ai.PatrolModelReadinessPass, "All scenarios passed before the run was interrupted.",
),
static: patrolToolsCheck{
Status: patrolReadinessNotReady,
Cause: ai.PatrolFailureCauseModelUnsupportedTools,
Message: "The selected Patrol model is a reasoning-only model family.",
Action: "open_provider_settings",
},
wantStatus: patrolReadinessNotReady,
wantRunnable: false,
wantCause: ai.PatrolFailureCauseModelUnsupportedTools,
wantMessagePart: "reasoning-only",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := patrolToolsCheckFromModelReadiness(tt.snapshot, "2m ago", tt.static)
assert.Equal(t, tt.wantStatus, got.Status)
assert.Equal(t, tt.wantCause, got.Cause)
if tt.wantMessagePart != "" {
assert.Contains(t, got.Message, tt.wantMessagePart)
}
require.NotEmpty(t, got.Message)
// The readiness payload the Patrol page consumes: a not-ready check
// is what disables the run control, so assert the runnability the
// operator actually gets.
readiness := summarizePatrolReadiness("ollama", "ollama:test-model", []PatrolReadinessCheck{{
ID: "tools",
Status: got.Status,
Cause: patrolFailureCauseResponse(got.Cause),
Label: "Patrol tools",
Message: got.Message,
Action: got.Action,
}})
assert.Equal(t, tt.wantStatus, readiness.Status)
assert.Equal(t, tt.wantRunnable, readiness.Ready)
})
}
}
// An absent snapshot keeps the base-config classifier untouched — the fallback
// an inconclusive snapshot mirrors.
func TestIssue1640AbsentSnapshotKeepsBaseConfigReadiness(t *testing.T) {
t.Parallel()
static := issue1640OllamaStaticCheck()
assert.Equal(t, static, patrolToolsCheckFromModelReadiness(nil, "just now", static))
}