a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
290 lines
11 KiB
Go
290 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/zzet/gortex/internal/persistence"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// cannedToolProfileCountsJSON mirrors the full-mode tool_profile response shape
|
|
// the daemon returns: top-level preset / preset_mode / live_count /
|
|
// deferred_count fields (handleToolProfile).
|
|
const cannedToolProfileCountsJSON = `{
|
|
"lazy_enabled": true,
|
|
"preset": "core",
|
|
"preset_mode": "defer",
|
|
"total": 175,
|
|
"live_count": 34,
|
|
"deferred_count": 141,
|
|
"live": ["search_symbols", "get_callers"],
|
|
"deferred": ["edit_file", "rename_symbol"]
|
|
}`
|
|
|
|
// newToolsReceiptTestCmd resets receipt flag state and binds a buffer. It also
|
|
// redirects Part B's CLI-verb ledger to an isolated temp sidecar and clears the
|
|
// session-id seam, so a receipt build in a test never reads the developer's
|
|
// real ~/.gortex (and Part B is empty unless the test seeds it).
|
|
func newToolsReceiptTestCmd(t *testing.T) (*cobra.Command, *bytes.Buffer) {
|
|
t.Helper()
|
|
toolsIndex = "."
|
|
toolsReceiptFormat = "yaml"
|
|
toolsReceiptSince = time.Hour
|
|
|
|
path := filepath.Join(t.TempDir(), "sidecar.sqlite")
|
|
origPath := receiptSidecarPath
|
|
origSession := receiptSessionID
|
|
receiptSidecarPath = func() string { return path }
|
|
receiptSessionID = func() string { return "" }
|
|
t.Cleanup(func() {
|
|
receiptSidecarPath = origPath
|
|
receiptSessionID = origSession
|
|
})
|
|
|
|
buf := &bytes.Buffer{}
|
|
cmd := &cobra.Command{Use: "receipt", RunE: runToolsReceipt}
|
|
cmd.SetOut(buf)
|
|
cmd.SetErr(buf)
|
|
return cmd, buf
|
|
}
|
|
|
|
// TestToolsReceipt_DaemonReachable_YAML asserts a daemon-reachable receipt
|
|
// reports skill_cli transport and the live / deferred counts from tool_profile.
|
|
func TestToolsReceipt_DaemonReachable_YAML(t *testing.T) {
|
|
orig := toolsDaemonTool
|
|
t.Cleanup(func() { toolsDaemonTool = orig })
|
|
|
|
var gotTool string
|
|
toolsDaemonTool = func(_ string, tool string, _ map[string]any) (json.RawMessage, error) {
|
|
gotTool = tool
|
|
return json.RawMessage(cannedToolProfileCountsJSON), nil
|
|
}
|
|
|
|
cmd, buf := newToolsReceiptTestCmd(t)
|
|
require.NoError(t, runToolsReceipt(cmd, nil))
|
|
require.Equal(t, "tool_profile", gotTool)
|
|
|
|
var env receiptEnvelope
|
|
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
|
|
r := env.GortexContextBudget
|
|
require.Equal(t, "skill_cli", r.Transport)
|
|
require.Equal(t, "core", r.DaemonPreset)
|
|
require.Equal(t, "defer", r.DaemonPresetMode)
|
|
require.Equal(t, 34, r.AdvertisedTools)
|
|
require.Equal(t, 0, r.RegisteredToolSchemas)
|
|
require.Equal(t, 141, r.DeferredCapabilities.Count)
|
|
require.Equal(t, "full_surface_not_mounted", r.Decision)
|
|
// repo is the absolute path of the index.
|
|
require.NotEmpty(t, r.Repo)
|
|
|
|
// The top-level key is present in the raw YAML text.
|
|
require.Contains(t, buf.String(), "gortex_context_budget:")
|
|
}
|
|
|
|
// TestToolsReceipt_DaemonReachable_JSON asserts the JSON rendering carries the
|
|
// same structure under the gortex_context_budget key.
|
|
func TestToolsReceipt_DaemonReachable_JSON(t *testing.T) {
|
|
orig := toolsDaemonTool
|
|
t.Cleanup(func() { toolsDaemonTool = orig })
|
|
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
|
|
return json.RawMessage(cannedToolProfileCountsJSON), nil
|
|
}
|
|
|
|
cmd, buf := newToolsReceiptTestCmd(t)
|
|
toolsReceiptFormat = "json"
|
|
require.NoError(t, runToolsReceipt(cmd, nil))
|
|
|
|
var env receiptEnvelope
|
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &env))
|
|
r := env.GortexContextBudget
|
|
require.Equal(t, "skill_cli", r.Transport)
|
|
require.Equal(t, "core", r.DaemonPreset)
|
|
require.Equal(t, "defer", r.DaemonPresetMode)
|
|
require.Equal(t, 34, r.AdvertisedTools)
|
|
require.Equal(t, 0, r.RegisteredToolSchemas)
|
|
require.Equal(t, 141, r.DeferredCapabilities.Count)
|
|
require.Equal(t, "full_surface_not_mounted", r.Decision)
|
|
}
|
|
|
|
// TestToolsReceipt_NoDaemon asserts that when the daemon call fails, the receipt
|
|
// degrades to cli_only / no_surface_mounted without a non-zero error exit.
|
|
func TestToolsReceipt_NoDaemon(t *testing.T) {
|
|
orig := toolsDaemonTool
|
|
t.Cleanup(func() { toolsDaemonTool = orig })
|
|
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
|
|
// The shape of the no-daemon signal the real path returns.
|
|
return nil, errors.New("no gortex daemon is running — start it with `gortex daemon start --detach`")
|
|
}
|
|
|
|
cmd, buf := newToolsReceiptTestCmd(t)
|
|
require.NoError(t, runToolsReceipt(cmd, nil))
|
|
|
|
var env receiptEnvelope
|
|
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
|
|
r := env.GortexContextBudget
|
|
require.Equal(t, "cli_only", r.Transport)
|
|
require.Equal(t, 0, r.AdvertisedTools)
|
|
require.Equal(t, 0, r.RegisteredToolSchemas)
|
|
require.Equal(t, 0, r.DeferredCapabilities.Count)
|
|
require.Equal(t, "no_surface_mounted", r.Decision)
|
|
// daemon preset / mode are omitted when nothing is mounted.
|
|
require.Empty(t, r.DaemonPreset)
|
|
require.Empty(t, r.DaemonPresetMode)
|
|
}
|
|
|
|
// TestToolsReceipt_CountFallback asserts a profile that omits the *_count
|
|
// fields still yields faithful counts from the live / deferred arrays.
|
|
func TestToolsReceipt_CountFallback(t *testing.T) {
|
|
orig := toolsDaemonTool
|
|
t.Cleanup(func() { toolsDaemonTool = orig })
|
|
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
|
|
// No live_count / deferred_count — only the arrays.
|
|
return json.RawMessage(`{"preset":"nav","preset_mode":"hide","live":["a","b","c"],"deferred":["d"]}`), nil
|
|
}
|
|
|
|
cmd, buf := newToolsReceiptTestCmd(t)
|
|
require.NoError(t, runToolsReceipt(cmd, nil))
|
|
|
|
var env receiptEnvelope
|
|
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
|
|
r := env.GortexContextBudget
|
|
require.Equal(t, "skill_cli", r.Transport)
|
|
require.Equal(t, 3, r.AdvertisedTools)
|
|
require.Equal(t, 1, r.DeferredCapabilities.Count)
|
|
}
|
|
|
|
// TestToolsReceipt_BadFormat asserts an unknown --format value is a clean error.
|
|
func TestToolsReceipt_BadFormat(t *testing.T) {
|
|
orig := toolsDaemonTool
|
|
t.Cleanup(func() { toolsDaemonTool = orig })
|
|
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
|
|
return json.RawMessage(cannedToolProfileCountsJSON), nil
|
|
}
|
|
|
|
cmd, _ := newToolsReceiptTestCmd(t)
|
|
toolsReceiptFormat = "toml"
|
|
err := runToolsReceipt(cmd, nil)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "unknown --format")
|
|
}
|
|
|
|
// stubDaemonReachable points toolsDaemonTool at the canned tool_profile so a
|
|
// Part B test gets a normal Part A without a live daemon.
|
|
func stubDaemonReachable(t *testing.T) {
|
|
t.Helper()
|
|
orig := toolsDaemonTool
|
|
t.Cleanup(func() { toolsDaemonTool = orig })
|
|
toolsDaemonTool = func(string, string, map[string]any) (json.RawMessage, error) {
|
|
return json.RawMessage(cannedToolProfileCountsJSON), nil
|
|
}
|
|
}
|
|
|
|
// seedCLIEvents writes verbs for a session id into the receipt's redirected
|
|
// sidecar, so Part B has something to read back.
|
|
func seedCLIEvents(t *testing.T, sessionID string, verbs ...string) {
|
|
t.Helper()
|
|
sc, err := persistence.OpenSidecar(receiptSidecarPath())
|
|
require.NoError(t, err)
|
|
defer sc.Close()
|
|
base := time.Date(2026, 6, 1, 9, 0, 0, 0, time.UTC)
|
|
for i, v := range verbs {
|
|
require.NoError(t, sc.AddCLIEvent(base.Add(time.Duration(i)*time.Minute), sessionID, v))
|
|
}
|
|
}
|
|
|
|
// TestToolsReceipt_PartB_BySession: with GORTEX_SESSION_ID set, the receipt
|
|
// reports that session's distinct verbs and the safety steps derived from them,
|
|
// while Part A is unchanged.
|
|
func TestToolsReceipt_PartB_BySession(t *testing.T) {
|
|
stubDaemonReachable(t)
|
|
cmd, buf := newToolsReceiptTestCmd(t)
|
|
// Scope to a session and seed verbs (with a duplicate to prove dedup, and a
|
|
// non-safety verb to prove it contributes no safety step).
|
|
receiptSessionID = func() string { return "agent-sess" }
|
|
seedCLIEvents(t, "agent-sess", "edit.verify", "edit.guards", "edit.verify", "query.stats")
|
|
|
|
require.NoError(t, runToolsReceipt(cmd, nil))
|
|
|
|
var env receiptEnvelope
|
|
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
|
|
r := env.GortexContextBudget
|
|
|
|
// Part A unchanged.
|
|
require.Equal(t, "skill_cli", r.Transport)
|
|
require.Equal(t, 34, r.AdvertisedTools)
|
|
|
|
// Part B: distinct verbs in first-seen order; safety steps sorted + deduped.
|
|
require.Equal(t, []string{"edit.verify", "edit.guards", "query.stats"}, r.CLIVerbsUsed)
|
|
require.Equal(t, []string{"check_guards", "verify_change"}, r.SafetyStepsRun)
|
|
// With a session id set there is no "set GORTEX_SESSION_ID" note.
|
|
require.Empty(t, r.Note)
|
|
}
|
|
|
|
// TestToolsReceipt_PartB_NoSession: with no session id, Part B falls back to the
|
|
// --since window and carries the note. Events outside the window are excluded.
|
|
func TestToolsReceipt_PartB_NoSession(t *testing.T) {
|
|
stubDaemonReachable(t)
|
|
cmd, buf := newToolsReceiptTestCmd(t)
|
|
// No session id (helper already cleared it). Seed under any id — the Since
|
|
// read ignores the session dimension. Pin the clock so the seeded events
|
|
// (base 2026-06-01 09:00) fall inside the window.
|
|
origNow := receiptNow
|
|
t.Cleanup(func() { receiptNow = origNow })
|
|
receiptNow = func() time.Time { return time.Date(2026, 6, 1, 9, 30, 0, 0, time.UTC) }
|
|
toolsReceiptSince = time.Hour
|
|
seedCLIEvents(t, "whoever", "edit.tests", "edit.contract")
|
|
|
|
require.NoError(t, runToolsReceipt(cmd, nil))
|
|
|
|
var env receiptEnvelope
|
|
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
|
|
r := env.GortexContextBudget
|
|
require.Equal(t, []string{"edit.tests", "edit.contract"}, r.CLIVerbsUsed)
|
|
require.Equal(t, []string{"change_contract", "get_test_targets"}, r.SafetyStepsRun)
|
|
require.Contains(t, r.Note, "GORTEX_SESSION_ID")
|
|
}
|
|
|
|
// TestToolsReceipt_PartB_EmptyWhenNoEvents: with nothing recorded the two
|
|
// fields are present as empty lists (never nil/omitted), and Part A still
|
|
// renders. The redirected sidecar is created on read but holds no rows.
|
|
func TestToolsReceipt_PartB_EmptyWhenNoEvents(t *testing.T) {
|
|
stubDaemonReachable(t)
|
|
cmd, buf := newToolsReceiptTestCmd(t)
|
|
|
|
require.NoError(t, runToolsReceipt(cmd, nil))
|
|
|
|
var env receiptEnvelope
|
|
require.NoError(t, yaml.Unmarshal(buf.Bytes(), &env))
|
|
r := env.GortexContextBudget
|
|
require.NotNil(t, r.CLIVerbsUsed)
|
|
require.NotNil(t, r.SafetyStepsRun)
|
|
require.Empty(t, r.CLIVerbsUsed)
|
|
require.Empty(t, r.SafetyStepsRun)
|
|
// The raw YAML carries both keys explicitly.
|
|
require.Contains(t, buf.String(), "cli_verbs_used:")
|
|
require.Contains(t, buf.String(), "safety_steps_run:")
|
|
}
|
|
|
|
// TestSafetyStepsForVerbs unit-tests the verb→safety-tool map: it dedups
|
|
// (two verbs that map to feedback yield one), preserves the documented
|
|
// mappings, and a non-safety verb contributes nothing.
|
|
func TestSafetyStepsForVerbs(t *testing.T) {
|
|
require.Equal(t,
|
|
[]string{"change_contract", "check_guards", "get_test_targets", "verify_change"},
|
|
safetyStepsForVerbs([]string{"edit.verify", "edit.guards", "edit.tests", "edit.contract"}),
|
|
)
|
|
// feedback.record + feedback.query both map to the same tool — dedup.
|
|
require.Equal(t, []string{"feedback"}, safetyStepsForVerbs([]string{"feedback.record", "feedback.query"}))
|
|
// A non-safety verb maps to nothing.
|
|
require.Empty(t, safetyStepsForVerbs([]string{"query.stats", "daemon.start"}))
|
|
// Empty input is empty output.
|
|
require.Empty(t, safetyStepsForVerbs(nil))
|
|
}
|