a06f331eb8
install-script / powershell-syntax (push) Waiting to run
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
install-script / install (macos-14) (push) Waiting to run
install-script / install (ubuntu-latest) (push) Waiting to run
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
445 lines
18 KiB
Go
445 lines
18 KiB
Go
package mcp
|
|
|
|
// Integration tests for preview_edit and simulate_chain. These hit the
|
|
// full MCP tool dispatch path (registered tools + handler) and rely on
|
|
// the shared setupOverlayServer harness defined in overlay_e2e_test.go.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
)
|
|
|
|
// buildSingleFileEdit constructs a WorkspaceEdit that replaces the
|
|
// full body of `path` with `newContent`. The range spans (0,0) ..
|
|
// (very-far-end), which lspPositionToByteOffset clamps to len(content).
|
|
// Used by every test below so the WorkspaceEdit JSON is constructed
|
|
// in one place.
|
|
func buildSingleFileEdit(path, newContent string) string {
|
|
edit := map[string]any{
|
|
"documentChanges": []any{
|
|
map[string]any{
|
|
"textDocument": map[string]any{"uri": path, "version": 0},
|
|
"edits": []any{
|
|
map[string]any{
|
|
"range": map[string]any{
|
|
"start": map[string]any{"line": 0, "character": 0},
|
|
"end": map[string]any{"line": 100000, "character": 0},
|
|
},
|
|
"newText": newContent,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
b, _ := json.Marshal(edit)
|
|
return string(b)
|
|
}
|
|
|
|
// TestPreviewEdit_ToolRegistered confirms the tool wired up correctly
|
|
// after NewServer + SetOverlayManager. Without registration every
|
|
// subsequent simulation test would explode in the lookup; isolating
|
|
// the registration check lets us read the failure faster.
|
|
func TestPreviewEdit_ToolRegistered(t *testing.T) {
|
|
srv, _, _, _ := setupOverlayServer(t)
|
|
require.NotNil(t, srv.MCPServer().GetTool("preview_edit"), "preview_edit must be registered")
|
|
require.NotNil(t, srv.MCPServer().GetTool("simulate_chain"), "simulate_chain must be registered")
|
|
}
|
|
|
|
// TestPreviewEdit_RejectsInvalidJSON: malformed workspace_edit must
|
|
// surface as an MCP tool error so the agent knows to retry, not fail
|
|
// silently with an empty impact report.
|
|
func TestPreviewEdit_RejectsInvalidJSON(t *testing.T) {
|
|
srv, _, _, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
|
|
"workspace_edit": "{not valid json}",
|
|
})
|
|
require.True(t, res.IsError, "preview_edit must reject malformed JSON")
|
|
require.Contains(t, toolText(res), "invalid workspace_edit")
|
|
}
|
|
|
|
// TestPreviewEdit_RejectsEmptyEdit: a WorkspaceEdit with no document
|
|
// changes is a no-op; treating it as a success would mislead the
|
|
// caller into thinking they had a meaningful preview.
|
|
func TestPreviewEdit_RejectsEmptyEdit(t *testing.T) {
|
|
srv, _, _, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
|
|
"workspace_edit": `{"changes":{}}`,
|
|
})
|
|
require.True(t, res.IsError, "preview_edit must reject empty workspace_edit")
|
|
require.Contains(t, toolText(res), "no document changes")
|
|
}
|
|
|
|
// TestPreviewEdit_RenameSymbol simulates renaming a function with a
|
|
// distinctive signature so the rename heuristic can confidently pair
|
|
// the missing-base node with the added-overlay node. We can't use
|
|
// the harness's Target() because its parameterless void signature is
|
|
// the trivial shape that the heuristic deliberately rejects (every
|
|
// `func ()` would otherwise pair with every other `func ()`). The
|
|
// test seeds a fresh helper file with a distinctive signature and
|
|
// then renames it.
|
|
func TestPreviewEdit_RenameSymbol(t *testing.T) {
|
|
srv, dir, _, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
|
|
// Push a helper file via overlay so the base graph picks up
|
|
// `Encode(payload []byte) []byte`. Then simulate renaming it to
|
|
// `Marshal(payload []byte) []byte` — same signature shape, new
|
|
// name. The heuristic should classify this as a rename.
|
|
helperPath := filepath.Join(dir, "encoder.go")
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID("seed", ""))
|
|
require.NoError(t, srv.OverlayManager().Push("seed", daemon.OverlayFile{
|
|
Path: helperPath,
|
|
Content: "package main\n\nfunc Encode(payload []byte) []byte { return payload }\n",
|
|
}, nil))
|
|
// Commit the seeded file to disk so it shows up in base on
|
|
// reindex.
|
|
require.NoError(t, writeSeedFile(helperPath,
|
|
"package main\n\nfunc Encode(payload []byte) []byte { return payload }\n"))
|
|
srv.OverlayManager().Drop("seed")
|
|
_, err := srv.indexer.IncrementalReindex(dir)
|
|
require.NoError(t, err)
|
|
srv.indexer.ResolveAll()
|
|
|
|
newContent := "package main\n\nfunc Marshal(payload []byte) []byte { return payload }\n"
|
|
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
|
|
"workspace_edit": buildSingleFileEdit(helperPath, newContent),
|
|
"diagnostics": false, // no LSP setup in test harness
|
|
})
|
|
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
|
|
|
|
body := toolText(res)
|
|
require.Contains(t, body, "symbols_renamed", "rename surface must be present")
|
|
require.Contains(t, body, "encoder.go::Marshal", "renamed-to symbol must surface")
|
|
require.Contains(t, body, "encoder.go::Encode", "renamed-from symbol must surface")
|
|
}
|
|
|
|
// writeSeedFile is a thin os.WriteFile wrapper kept local to this
|
|
// test file so the test imports don't grow.
|
|
func writeSeedFile(path, content string) error {
|
|
return os.WriteFile(path, []byte(content), 0o644)
|
|
}
|
|
|
|
// TestPreviewEdit_RemoveSymbol simulates a true removal: Target() is
|
|
// gone and nothing else replaces it (rename heuristic can't match).
|
|
// The broken_callers payload MUST include caller.go::Caller because
|
|
// the cross-file call edge can no longer resolve.
|
|
func TestPreviewEdit_RemoveSymbol(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
|
|
// Replace target.go with an empty package — Target() simply
|
|
// disappears and there's no rename candidate.
|
|
newContent := "package main\n"
|
|
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
|
|
"workspace_edit": buildSingleFileEdit(targetFile, newContent),
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
|
|
|
|
body := toolText(res)
|
|
require.Contains(t, body, "Caller", "caller.go::Caller must show in broken_callers")
|
|
require.Contains(t, body, "target removed or renamed")
|
|
}
|
|
|
|
// TestPreviewEdit_AddOnly: adding a brand-new function alongside
|
|
// Target() should produce a non-empty symbols_added list and no
|
|
// broken callers (Target stays put, the original caller resolves).
|
|
func TestPreviewEdit_AddOnly(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
|
|
newContent := "package main\n\nfunc Target() {}\n\nfunc NewHelper() {}\n"
|
|
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
|
|
"workspace_edit": buildSingleFileEdit(targetFile, newContent),
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
|
|
|
|
body := toolText(res)
|
|
require.Contains(t, body, "NewHelper", "new symbol must surface in symbols_added")
|
|
require.NotContains(t, body, "target removed or renamed",
|
|
"the unchanged Target() must not surface as broken")
|
|
}
|
|
|
|
// TestPreviewEdit_OverlayPathsReported: the response must include the
|
|
// overlay_paths field so the caller knows exactly which paths the
|
|
// simulation touched (useful when the WorkspaceEdit spans multiple
|
|
// files and the agent needs a checklist).
|
|
func TestPreviewEdit_OverlayPathsReported(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
|
|
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
|
|
"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Added() {}\n"),
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
|
|
body := toolText(res)
|
|
require.Contains(t, body, "overlay_paths")
|
|
require.Contains(t, body, "touched_files")
|
|
}
|
|
|
|
// TestPreviewEdit_DoesNotMutateBase is the load-bearing isolation
|
|
// guarantee for the simulation engine — the same property the
|
|
// overlay layer enforces, but verified through the simulation tool
|
|
// surface to catch any future refactor that accidentally takes the
|
|
// promote-to-session path implicitly.
|
|
func TestPreviewEdit_DoesNotMutateBase(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
beforeIDs := baseNodeIDs(srv)
|
|
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
|
|
"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Renamed() {}\n"),
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
|
|
|
|
afterIDs := baseNodeIDs(srv)
|
|
require.Equal(t, beforeIDs, afterIDs,
|
|
"base graph must be byte-identical before and after preview_edit")
|
|
}
|
|
|
|
// TestSimulateChain_AppliesStepsInOrder: a two-step chain that first
|
|
// adds Helper(), then removes Target() entirely. The response must
|
|
// include both step records — step 0 reports Helper as added, step 1
|
|
// reports the removal and the resulting broken-caller surface.
|
|
func TestSimulateChain_AppliesStepsInOrder(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
|
|
step1 := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Helper() {}\n")
|
|
step2 := buildSingleFileEdit(targetFile, "package main\n\nfunc Helper() {}\n")
|
|
steps := fmt.Sprintf(`[%s,%s]`, step1, step2)
|
|
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": steps,
|
|
"diagnostics": false,
|
|
"stop_on_error": false,
|
|
})
|
|
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
|
|
|
|
body := toolText(res)
|
|
require.Contains(t, body, `"total_steps":2`, "chain must record two applied steps")
|
|
require.Contains(t, body, `"applied_steps":2`)
|
|
require.Contains(t, body, "Helper", "step 1 must surface the added Helper symbol")
|
|
require.Contains(t, body, "target removed or renamed",
|
|
"step 2 must report the broken caller after Target is removed")
|
|
}
|
|
|
|
// TestSimulateChain_RejectsEmpty: an empty chain is an obvious caller
|
|
// error; the response must say so explicitly so the agent doesn't
|
|
// chase a phantom no-op result.
|
|
func TestSimulateChain_RejectsEmpty(t *testing.T) {
|
|
srv, _, _, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": "[]",
|
|
})
|
|
require.True(t, res.IsError)
|
|
require.Contains(t, toolText(res), "steps array is empty")
|
|
}
|
|
|
|
// TestSimulateChain_RejectsNonArray: a JSON object passed where the
|
|
// schema demands an array must error with a helpful message rather
|
|
// than panic in the unmarshaller.
|
|
func TestSimulateChain_RejectsNonArray(t *testing.T) {
|
|
srv, _, _, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": `{"changes":{}}`,
|
|
})
|
|
require.True(t, res.IsError)
|
|
require.Contains(t, toolText(res), "JSON array")
|
|
}
|
|
|
|
// TestSimulateChain_KeepPromotesOverlay verifies the `keep: true`
|
|
// promotion path: after a successful chain, the resulting state is
|
|
// pushed into a real overlay session bound to the caller's MCP
|
|
// session ID. A follow-up overlay_list must report the simulated file.
|
|
func TestSimulateChain_KeepPromotesOverlay(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "sim-keep"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
|
|
step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n\nfunc Kept() {}\n")
|
|
steps := fmt.Sprintf(`[%s]`, step)
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": steps,
|
|
"keep": true,
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
|
|
|
|
body := toolText(res)
|
|
require.Contains(t, body, `"kept":true`)
|
|
require.Contains(t, body, `"overlay_session_id":"`+sessID+`"`)
|
|
|
|
// And the overlay manager itself must now hold the file.
|
|
files, err := srv.OverlayManager().Files(sessID)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, files, "keep must persist the simulated overlay")
|
|
|
|
// A subsequent get_file_summary on the touched file must reflect
|
|
// the promoted overlay state.
|
|
listRes := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
require.False(t, listRes.IsError)
|
|
require.Contains(t, toolText(listRes), "Kept",
|
|
"after keep:true the overlay must surface the simulated symbol")
|
|
}
|
|
|
|
// TestSimulateChain_InheritOverlay: when a session already has an
|
|
// overlay pushed, simulate_chain with inherit_overlay:true must
|
|
// layer on top of that buffer state — not on top of base.
|
|
func TestSimulateChain_InheritOverlay(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "sim-inherit"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
// Editor has an unsaved buffer that adds Helper().
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n\nfunc Helper() {}\n",
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
// The simulation adds a third function on top of the inherited
|
|
// buffer; the resulting state must contain Helper AND Helper2.
|
|
step := buildSingleFileEdit(targetFile,
|
|
"package main\n\nfunc Target() {}\n\nfunc Helper() {}\n\nfunc Helper2() {}\n")
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": fmt.Sprintf(`[%s]`, step),
|
|
"inherit_overlay": true,
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
|
|
|
|
body := toolText(res)
|
|
require.Contains(t, body, "Helper2", "added symbol must be visible")
|
|
require.Contains(t, body, `"inherit_overlay":true`)
|
|
}
|
|
|
|
// TestSimulateChain_StopsOnError: when stop_on_error is true (default)
|
|
// the chain must abort the moment a step introduces a new ERROR-level
|
|
// diagnostic delta. Without an LSP wired we can't drive this directly,
|
|
// so we exercise the parameter plumbing only — the diagnostics path
|
|
// is covered separately when an LSP is available.
|
|
func TestSimulateChain_StopsOnErrorFlag(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "sim-stop")
|
|
step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n")
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": fmt.Sprintf(`[%s]`, step),
|
|
"stop_on_error": true,
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
|
|
body := toolText(res)
|
|
require.Contains(t, body, `"stopped_at":-1`,
|
|
"with no errors stopped_at must be -1")
|
|
}
|
|
|
|
// TestSimulateChain_KeepRequiresOverlayManager: the keep flag is a
|
|
// no-op (with kept:false) when the calling session has no MCP
|
|
// session ID — embedded stdio without ctx-attached session. Verify
|
|
// we don't drop a stack trace in that case.
|
|
func TestSimulateChain_KeepWithoutSessionDegrades(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
step := buildSingleFileEdit(targetFile, "package main\n\nfunc Target() {}\n")
|
|
ctx := context.Background() // no session ID
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": fmt.Sprintf(`[%s]`, step),
|
|
"keep": true,
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "simulate_chain must not error when no MCP session: %s", toolText(res))
|
|
require.Contains(t, toolText(res), `"kept":false`)
|
|
}
|
|
|
|
// TestPreviewEdit_CumulativeImpactReported confirms the response shape
|
|
// includes the impact rollup that the agent consumes to decide on
|
|
// test selection.
|
|
func TestPreviewEdit_CumulativeImpactReported(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "test")
|
|
res := callToolByName(t, srv, ctx, "preview_edit", map[string]any{
|
|
"workspace_edit": buildSingleFileEdit(targetFile, "package main\n\nfunc Renamed() {}\n"),
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "preview_edit failed: %s", toolText(res))
|
|
body := toolText(res)
|
|
require.Contains(t, body, "impact", "impact rollup must be present")
|
|
require.Contains(t, body, "summary", "summary line must be present")
|
|
}
|
|
|
|
// TestSimulateChain_ConcurrentSessions: two MCP sessions running
|
|
// simulate_chain in parallel against the same workspace must not
|
|
// step on each other's overlay state, mirroring the OverlayManager
|
|
// multi-tenant guarantee.
|
|
func TestSimulateChain_ConcurrentSessions(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
|
|
run := func(t *testing.T, name string, contentTag string, expect string) {
|
|
t.Helper()
|
|
ctx := WithSessionID(context.Background(), name)
|
|
step := buildSingleFileEdit(targetFile, fmt.Sprintf("package main\n\nfunc Target() {}\n\nfunc %s() {}\n", contentTag))
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": fmt.Sprintf(`[%s]`, step),
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
|
|
require.Contains(t, toolText(res), expect)
|
|
}
|
|
|
|
done := make(chan struct{}, 2)
|
|
go func() {
|
|
defer func() { done <- struct{}{} }()
|
|
run(t, "sim-a", "AlphaSym", "AlphaSym")
|
|
}()
|
|
go func() {
|
|
defer func() { done <- struct{}{} }()
|
|
run(t, "sim-b", "BetaSym", "BetaSym")
|
|
}()
|
|
for range 2 {
|
|
select {
|
|
case <-done:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatalf("simulation goroutines did not finish in time")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSimulateChain_NewFile exercises the new-file path: a
|
|
// WorkspaceEdit whose target file doesn't exist on disk should
|
|
// produce an overlay that emits the file's contents as a fresh
|
|
// buffer. The simulator treats the (0,0) range with newText as the
|
|
// initial content for an absent file.
|
|
func TestSimulateChain_NewFile(t *testing.T) {
|
|
srv, dir, _, _ := setupOverlayServer(t)
|
|
newPath := filepath.Join(dir, "fresh.go")
|
|
step := buildSingleFileEdit(newPath, "package main\n\nfunc Fresh() {}\n")
|
|
ctx := WithSessionID(context.Background(), "sim-new")
|
|
res := callToolByName(t, srv, ctx, "simulate_chain", map[string]any{
|
|
"steps": fmt.Sprintf(`[%s]`, step),
|
|
"diagnostics": false,
|
|
})
|
|
require.False(t, res.IsError, "simulate_chain failed: %s", toolText(res))
|
|
body := toolText(res)
|
|
require.True(t, strings.Contains(body, "Fresh") || strings.Contains(body, "fresh.go"),
|
|
"new-file simulation must surface the new file: %s", body)
|
|
}
|