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
549 lines
19 KiB
Go
549 lines
19 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
mcplib "github.com/mark3labs/mcp-go/mcp"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/zzet/gortex/internal/config"
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/graph"
|
|
"github.com/zzet/gortex/internal/indexer"
|
|
"github.com/zzet/gortex/internal/query"
|
|
)
|
|
|
|
// setupOverlayServer builds a fully-wired MCP server with an attached
|
|
// OverlayManager and an indexed temp repo of two interlinked Go
|
|
// files: target.go defines `Target()`, caller.go has a `Caller()`
|
|
// that calls `Target()`. The shape lets tests verify (a) overlays
|
|
// surface buffer-defined symbols, (b) cross-file edges from base
|
|
// (caller→target) survive overlay since base is never mutated, and
|
|
// (c) two concurrent sessions see their own overlay independently.
|
|
func setupOverlayServer(t *testing.T) (srv *Server, dir, targetFile, callerFile string) {
|
|
t.Helper()
|
|
dir = t.TempDir()
|
|
targetFile = filepath.Join(dir, "target.go")
|
|
callerFile = filepath.Join(dir, "caller.go")
|
|
require.NoError(t, os.WriteFile(targetFile, []byte(`package main
|
|
|
|
func Target() {}
|
|
`), 0o644))
|
|
require.NoError(t, os.WriteFile(callerFile, []byte(`package main
|
|
|
|
func Caller() {
|
|
Target()
|
|
}
|
|
`), 0o644))
|
|
|
|
g := graph.New()
|
|
reg := testRegistry()
|
|
cfg := config.Default()
|
|
idx := indexer.New(g, reg, cfg.Index, zap.NewNop())
|
|
_, err := idx.Index(dir)
|
|
require.NoError(t, err)
|
|
idx.ResolveAll()
|
|
|
|
eng := query.NewEngine(g)
|
|
srv = NewServer(eng, g, idx, nil, zap.NewNop(), nil)
|
|
srv.SetOverlayManager(daemon.NewOverlayManager(time.Minute))
|
|
srv.RunAnalysis()
|
|
return srv, dir, targetFile, callerFile
|
|
}
|
|
|
|
func callToolByName(t *testing.T, srv *Server, ctx context.Context, name string, args map[string]any) *mcplib.CallToolResult {
|
|
t.Helper()
|
|
tool := srv.MCPServer().GetTool(name)
|
|
require.NotNilf(t, tool, "tool %q must be registered", name)
|
|
req := mcplib.CallToolRequest{Params: mcplib.CallToolParams{
|
|
Name: name,
|
|
Arguments: args,
|
|
}}
|
|
res, err := tool.Handler(ctx, req)
|
|
require.NoError(t, err)
|
|
return res
|
|
}
|
|
|
|
func toolText(res *mcplib.CallToolResult) string {
|
|
var sb strings.Builder
|
|
for _, c := range res.Content {
|
|
if tc, ok := c.(mcplib.TextContent); ok {
|
|
sb.WriteString(tc.Text)
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// TestOverlay_BaseGraphIsImmutable is the load-bearing isolation
|
|
// guarantee: pushing and querying an overlay must NOT mutate the
|
|
// base graph. A snapshot of base node IDs taken before and after a
|
|
// full overlay round-trip must be byte-identical.
|
|
func TestOverlay_BaseGraphIsImmutable(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
beforeIDs := baseNodeIDs(srv)
|
|
|
|
sessID := "test-immutability"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: `package main
|
|
|
|
func Target() {}
|
|
|
|
func Overlay() {}
|
|
`,
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
require.False(t, res.IsError)
|
|
require.Contains(t, toolText(res), "Overlay")
|
|
|
|
afterIDs := baseNodeIDs(srv)
|
|
require.Equal(t, beforeIDs, afterIDs,
|
|
"base graph must be byte-identical before and after an overlay round-trip")
|
|
}
|
|
|
|
// TestOverlay_FindUsagesPreservesCrossFileCallers exercises the
|
|
// regression the in-place-mutation design had: an editor overlays
|
|
// target.go (defining Target), and find_usages(target.go::Target)
|
|
// must still surface caller.go's call site — base's resolved edge
|
|
// from caller→target survives because the base graph isn't touched.
|
|
func TestOverlay_FindUsagesPreservesCrossFileCallers(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "test-find-usages"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
// Overlay rewrites target.go but keeps Target() with the same
|
|
// signature, so its node ID is unchanged.
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: `package main
|
|
|
|
func Target() {}
|
|
|
|
func NewSibling() {}
|
|
`,
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "find_usages", map[string]any{
|
|
"id": "target.go::Target",
|
|
})
|
|
require.False(t, res.IsError, "find_usages: %s", toolText(res))
|
|
require.Contains(t, toolText(res), "Caller",
|
|
"overlay must preserve base's caller.go → target.go::Target edge")
|
|
}
|
|
|
|
// TestOverlay_DriftSurfacesAsToolError: a stale BaseSHA must turn
|
|
// the very next tool call into an MCP error result so the client
|
|
// re-reads and resubmits.
|
|
func TestOverlay_DriftSurfacesAsToolError(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "test-session-drift"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n",
|
|
BaseSHA: "0000000000000000000000000000000000000000",
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
require.True(t, res.IsError, "drift must surface as an MCP tool error")
|
|
require.Contains(t, toolText(res), "overlay base SHA mismatch")
|
|
}
|
|
|
|
// TestOverlay_BaseSHA_MatchProceeds: when the editor's base SHA
|
|
// agrees with the on-disk hash, the overlay applies and the new
|
|
// symbol is visible.
|
|
func TestOverlay_BaseSHA_MatchProceeds(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
data, err := os.ReadFile(targetFile)
|
|
require.NoError(t, err)
|
|
h := sha1.New()
|
|
fmt.Fprintf(h, "blob %d\x00", len(data))
|
|
_, _ = h.Write(data)
|
|
baseSHA := hex.EncodeToString(h.Sum(nil))
|
|
|
|
sessID := "test-session-match"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n\nfunc Overlay() {}\n",
|
|
BaseSHA: baseSHA,
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
require.False(t, res.IsError)
|
|
require.Contains(t, toolText(res), "Overlay")
|
|
}
|
|
|
|
// TestOverlay_NoSessionNoOp: a tools/call with no overlay session
|
|
// bound to ctx must NOT pay any overlay cost and must observe the
|
|
// on-disk view. Failing this would mean every non-overlay call
|
|
// pays an extra parse pass.
|
|
func TestOverlay_NoSessionNoOp(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID("idle", ""))
|
|
ctx := WithSessionID(context.Background(), "idle")
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
require.False(t, res.IsError)
|
|
require.Contains(t, toolText(res), "Target")
|
|
require.NotContains(t, toolText(res), "Overlay")
|
|
}
|
|
|
|
// TestOverlay_TwoSessionsIsolated proves multi-tenant isolation: two
|
|
// sessions with conflicting overlays on the same path each see their
|
|
// own overlay, run concurrently, and don't contaminate each other.
|
|
// This was the failure mode of the prior in-place-mutation design.
|
|
func TestOverlay_TwoSessionsIsolated(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessA := "alpha"
|
|
sessB := "beta"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessA, ""))
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessB, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessA, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n\nfunc AlphaOnly() {}\n",
|
|
}, nil))
|
|
require.NoError(t, srv.OverlayManager().Push(sessB, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n\nfunc BetaOnly() {}\n",
|
|
}, nil))
|
|
|
|
const iterations = 8
|
|
var wg sync.WaitGroup
|
|
var aErr, bErr error
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
ctx := WithSessionID(context.Background(), sessA)
|
|
for i := 0; i < iterations; i++ {
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
body := toolText(res)
|
|
if !strings.Contains(body, "AlphaOnly") {
|
|
aErr = fmt.Errorf("alpha did not see AlphaOnly: %s", body)
|
|
return
|
|
}
|
|
if strings.Contains(body, "BetaOnly") {
|
|
aErr = fmt.Errorf("alpha leaked BetaOnly: %s", body)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
ctx := WithSessionID(context.Background(), sessB)
|
|
for i := 0; i < iterations; i++ {
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
body := toolText(res)
|
|
if !strings.Contains(body, "BetaOnly") {
|
|
bErr = fmt.Errorf("beta did not see BetaOnly: %s", body)
|
|
return
|
|
}
|
|
if strings.Contains(body, "AlphaOnly") {
|
|
bErr = fmt.Errorf("beta leaked AlphaOnly: %s", body)
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
wg.Wait()
|
|
require.NoError(t, aErr)
|
|
require.NoError(t, bErr)
|
|
}
|
|
|
|
// TestOverlay_OverlayAndBaseSessionsIsolated: a session WITH an
|
|
// overlay and a session WITHOUT any overlay run concurrently. The
|
|
// overlay session sees its buffer; the bare session sees disk.
|
|
// Neither contaminates the other. This is the case the prior
|
|
// in-place design failed because non-overlay calls observed the
|
|
// graph in its mid-mutation state.
|
|
func TestOverlay_OverlayAndBaseSessionsIsolated(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
overlaySess := "with-overlay"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(overlaySess, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(overlaySess, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n\nfunc EditorOnly() {}\n",
|
|
}, nil))
|
|
|
|
const iterations = 8
|
|
var wg sync.WaitGroup
|
|
var withErr, withoutErr error
|
|
wg.Add(2)
|
|
go func() {
|
|
defer wg.Done()
|
|
ctx := WithSessionID(context.Background(), overlaySess)
|
|
for i := 0; i < iterations; i++ {
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
if !strings.Contains(toolText(res), "EditorOnly") {
|
|
withErr = fmt.Errorf("overlay session lost overlay: %s", toolText(res))
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
defer wg.Done()
|
|
ctx := context.Background() // no session ID
|
|
for i := 0; i < iterations; i++ {
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
if strings.Contains(toolText(res), "EditorOnly") {
|
|
withoutErr = fmt.Errorf("base session leaked overlay: %s", toolText(res))
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
wg.Wait()
|
|
require.NoError(t, withErr)
|
|
require.NoError(t, withoutErr)
|
|
}
|
|
|
|
// TestOverlay_GetSymbolSourceReturnsBufferContent: get_symbol_source
|
|
// must surface the editor's unsaved bytes for the overlaid file,
|
|
// not the on-disk version. Without overlay-aware content reads the
|
|
// I19 contract for source-reading tools is unmet.
|
|
func TestOverlay_GetSymbolSourceReturnsBufferContent(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "src-test"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: `package main
|
|
|
|
// EditorSentinel is a unique comment line that only exists in the
|
|
// overlay buffer, never on disk.
|
|
func Target() {}
|
|
`,
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "get_symbol_source", map[string]any{
|
|
"id": "target.go::Target",
|
|
"context_lines": 10,
|
|
})
|
|
require.False(t, res.IsError, "get_symbol_source: %s", toolText(res))
|
|
require.Contains(t, toolText(res), "EditorSentinel",
|
|
"get_symbol_source must return overlay buffer content, not disk")
|
|
}
|
|
|
|
// TestOverlay_DeletionTombstone: deleted=true overlays hide the
|
|
// file's symbols. find_usages on a deleted symbol returns no
|
|
// results in the overlay view.
|
|
func TestOverlay_DeletionTombstone(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "tombstone-test"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Deleted: true,
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
// Deletion overlay: either an error (no nodes) or an empty body,
|
|
// but Target must not appear.
|
|
require.NotContains(t, toolText(res), "Target",
|
|
"deletion overlay must hide every symbol from the file")
|
|
}
|
|
|
|
// TestOverlay_MCP_RegisterPushList exercises the MCP-tool surface
|
|
// for overlay management: overlay_register, overlay_push,
|
|
// overlay_list. The MCP-native path is what IDE extensions speaking
|
|
// MCP take instead of the /v1/overlay/* HTTP endpoints.
|
|
func TestOverlay_MCP_RegisterPushList(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "mcp-register"
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
regRes := callToolByName(t, srv, ctx, "overlay_register", map[string]any{})
|
|
require.False(t, regRes.IsError, "overlay_register: %s", toolText(regRes))
|
|
|
|
pushRes := callToolByName(t, srv, ctx, "overlay_push", map[string]any{
|
|
"path": targetFile,
|
|
"content": "package main\n\nfunc Target() {}\n\nfunc PushedViaMCP() {}\n",
|
|
})
|
|
require.False(t, pushRes.IsError, "overlay_push: %s", toolText(pushRes))
|
|
|
|
listRes := callToolByName(t, srv, ctx, "overlay_list", map[string]any{})
|
|
listText := toolText(listRes)
|
|
require.Contains(t, listText, targetFile)
|
|
require.Contains(t, listText, `"count":1`)
|
|
|
|
summary := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
require.Contains(t, toolText(summary), "PushedViaMCP")
|
|
}
|
|
|
|
// TestOverlay_CompareWithOverlay_DiffSurface exercises the diff
|
|
// tool: an overlay adds a NewSibling function inside target.go but
|
|
// leaves caller.go untouched. find_usages of NewSibling against
|
|
// base returns nothing (the symbol doesn't exist on disk); against
|
|
// overlay it returns nothing either (caller.go doesn't call it).
|
|
// The base and overlay sides should disagree on the existence of
|
|
// NewSibling itself via the layer's overlay_paths metadata.
|
|
func TestOverlay_CompareWithOverlay_DiffSurface(t *testing.T) {
|
|
srv, _, targetFile, callerFile := setupOverlayServer(t)
|
|
// Edit caller.go in the overlay so it now calls NewSibling
|
|
// instead of Target. Edit target.go in the overlay to define
|
|
// NewSibling. compare_with_overlay against caller.go::Caller
|
|
// should show NewSibling as an added dependency that doesn't
|
|
// exist in base.
|
|
sessID := "diff-test"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: `package main
|
|
|
|
func Target() {}
|
|
|
|
func NewSibling() {}
|
|
`,
|
|
}, nil))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: callerFile,
|
|
Content: `package main
|
|
|
|
func Caller() {
|
|
NewSibling()
|
|
}
|
|
`,
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "compare_with_overlay", map[string]any{
|
|
"kind": "get_call_chain",
|
|
"id": "caller.go::Caller",
|
|
})
|
|
require.False(t, res.IsError, "compare_with_overlay: %s", toolText(res))
|
|
body := toolText(res)
|
|
require.Contains(t, body, `"overlay_paths"`)
|
|
require.Contains(t, body, "target.go")
|
|
require.Contains(t, body, "caller.go")
|
|
}
|
|
|
|
// TestOverlay_DroppedOnMCPSessionRelease is the security-critical
|
|
// guarantee: when an MCP session ends, its overlay must die
|
|
// immediately so no future connection that learns or guesses the
|
|
// same session ID can re-attach to abandoned buffers. The TTL is a
|
|
// fail-safe; ReleaseSession is the fast path.
|
|
func TestOverlay_DroppedOnMCPSessionRelease(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "secure-bind"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n\nfunc Secret() {}\n",
|
|
}, nil))
|
|
require.True(t, srv.OverlayManager().Has(sessID))
|
|
|
|
srv.ReleaseSession(sessID)
|
|
require.False(t, srv.OverlayManager().Has(sessID),
|
|
"MCP session release must drop the overlay synchronously")
|
|
|
|
// A subsequent tools/call carrying the (now-stale) session ID
|
|
// must fall through to base — NOT re-attach to abandoned state.
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "get_file_summary", map[string]any{
|
|
"path": filepath.Base(targetFile),
|
|
})
|
|
require.NotContains(t, toolText(res), "Secret",
|
|
"a tools/call after MCP session release must not see the dropped overlay")
|
|
}
|
|
|
|
// TestOverlay_KeepaliveExtendsLease exercises the MCP keepalive
|
|
// tool: an editor that's paused (on a breakpoint, in a long
|
|
// refactor wizard) can extend the lease without re-pushing buffer
|
|
// content.
|
|
func TestOverlay_KeepaliveExtendsLease(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "keepalive-test"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n",
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "overlay_keepalive", map[string]any{})
|
|
require.False(t, res.IsError, "overlay_keepalive: %s", toolText(res))
|
|
body := toolText(res)
|
|
require.Contains(t, body, `"session_id":"keepalive-test"`)
|
|
require.Contains(t, body, `"idle_seconds"`)
|
|
require.Contains(t, body, `"idle_ttl_seconds"`)
|
|
}
|
|
|
|
// TestOverlay_KeepaliveOnMissingSessionReturnsError: keepalive on
|
|
// an unknown / reaped session surfaces a clear "session has been
|
|
// dropped" error so the editor knows to overlay_register and re-push.
|
|
func TestOverlay_KeepaliveOnMissingSessionReturnsError(t *testing.T) {
|
|
srv, _, _, _ := setupOverlayServer(t)
|
|
ctx := WithSessionID(context.Background(), "ghost-session")
|
|
res := callToolByName(t, srv, ctx, "overlay_keepalive", map[string]any{})
|
|
require.True(t, res.IsError, "missing session must surface as a structured error")
|
|
require.Contains(t, toolText(res), "dropped or never registered")
|
|
}
|
|
|
|
// TestOverlay_ListExposesExpiryMetadata: overlay_list reports the
|
|
// per-session liveness fields so editor extensions can schedule
|
|
// the next keepalive proactively.
|
|
func TestOverlay_ListExposesExpiryMetadata(t *testing.T) {
|
|
srv, _, targetFile, _ := setupOverlayServer(t)
|
|
sessID := "expires-test"
|
|
require.NoError(t, srv.OverlayManager().RegisterWithID(sessID, ""))
|
|
require.NoError(t, srv.OverlayManager().Push(sessID, daemon.OverlayFile{
|
|
Path: targetFile,
|
|
Content: "package main\n\nfunc Target() {}\n",
|
|
}, nil))
|
|
|
|
ctx := WithSessionID(context.Background(), sessID)
|
|
res := callToolByName(t, srv, ctx, "overlay_list", map[string]any{})
|
|
require.False(t, res.IsError)
|
|
body := toolText(res)
|
|
require.Contains(t, body, `"expires_at"`)
|
|
require.Contains(t, body, `"last_used_at"`)
|
|
require.Contains(t, body, `"idle_seconds"`)
|
|
require.Contains(t, body, `"idle_ttl_seconds"`)
|
|
}
|
|
|
|
// baseNodeIDs returns a sorted slice of every node ID in the base
|
|
// graph. Used to verify the shadow-graph design's load-bearing
|
|
// invariant: base is never mutated during overlay processing.
|
|
func baseNodeIDs(srv *Server) []string {
|
|
nodes := srv.graph.AllNodes()
|
|
ids := make([]string, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
ids = append(ids, n.ID)
|
|
}
|
|
return ids
|
|
}
|