Files
Agent-Native/adapters/native/contract_regression_test.go
T
weichen afbb567301 Bind session scope identity, enforce write sets, repair torn JSONL
Why: F02/F04/F15 - contract requests shared one transcript across
session scopes, Writable was dropped, and a torn JSONL tail poisoned
the next append; pure-text contract results lost their text.
Evidence: adapters/native, runtime/session, runtime/tools and
conformance tests pass (verify-u2.json); scope-isolation, write-set and
torn-tail regression cases added.
Impact: empty scope keeps legacy one-shot semantics; identity-less old
transcripts are never auto-adopted by a new scope.
Refs: feature/agent-stack-audit-remediation-b1 U2
2026-10-21 16:03:04 +00:00

292 lines
9.0 KiB
Go

package native
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"agent-native/exec"
"agent-native/runtime/llm"
"agent-native/runtime/session"
"agent-native/runtime/tools"
)
func TestContractScopeIsolationNoSharedTranscript(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "from-a", StopReason: llm.StopEnd},
),
},
}).Contract()
scopeA := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-a"}
scopeB := exec.SessionScope{Tenant: "t1", Project: "p1", Session: "s-b"}
resA, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-a",
Prompt: "hello-a",
SessionScope: scopeA,
Workspace: exec.WorkspaceHandle{Root: dir},
})
if err != nil || resA.Outcome != exec.OutcomeSucceeded {
t.Fatalf("scope A: %+v %v", resA, err)
}
// Fresh backend/stream for scope B so replies differ.
b2 := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, Content: "from-b", StopReason: llm.StopEnd},
),
},
}).Contract()
resB, err := b2.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-b",
Prompt: "hello-b",
SessionScope: scopeB,
Workspace: exec.WorkspaceHandle{Root: dir},
})
if err != nil || resB.Outcome != exec.OutcomeSucceeded {
t.Fatalf("scope B: %+v %v", resB, err)
}
pathA := transcriptPathForScope(dir, scopeIdentity(scopeA))
pathB := transcriptPathForScope(dir, scopeIdentity(scopeB))
if pathA == pathB {
t.Fatal("different scopes must not share a transcript path")
}
if pathA == filepath.Join(dir, ".agentx", "transcript.jsonl") {
t.Fatal("non-empty scope must not use the legacy fixed transcript path")
}
storeA, err := session.Open(pathA)
if err != nil {
t.Fatal(err)
}
storeB, err := session.Open(pathB)
if err != nil {
t.Fatal(err)
}
msgsA, err := storeA.Load()
if err != nil {
t.Fatal(err)
}
msgsB, err := storeB.Load()
if err != nil {
t.Fatal(err)
}
joinedA := messagesBlob(msgsA)
joinedB := messagesBlob(msgsB)
if strings.Contains(joinedA, "hello-b") || strings.Contains(joinedA, "from-b") {
t.Fatalf("scope A transcript leaked B context: %s", joinedA)
}
if strings.Contains(joinedB, "hello-a") || strings.Contains(joinedB, "from-a") {
t.Fatalf("scope B transcript leaked A context: %s", joinedB)
}
}
func TestContractMatchingScopeCanResume(t *testing.T) {
dir := t.TempDir()
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "resume-me"}
b1 := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "first", StopReason: llm.StopEnd}),
},
}).Contract()
if res, err := b1.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e1", Prompt: "turn-1", SessionScope: scope,
Workspace: exec.WorkspaceHandle{Root: dir},
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
t.Fatalf("first turn: %+v %v", res, err)
}
var sawPrior bool
b2 := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: func(ctx context.Context, model string, messages []llm.Message, decls []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
for _, m := range messages {
if m.Role == llm.RoleUser && m.Content == "turn-1" {
sawPrior = true
}
if m.Role == llm.RoleAssistant && m.Content == "first" {
sawPrior = true
}
}
ch := make(chan llm.Event, 2)
go func() {
defer close(ch)
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "second"}
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "second", StopReason: llm.StopEnd}}
}()
return ch
},
},
}).Contract()
if res, err := b2.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e2", Prompt: "turn-2", SessionScope: scope,
Workspace: exec.WorkspaceHandle{Root: dir},
}); err != nil || res.Outcome != exec.OutcomeSucceeded {
t.Fatalf("resume turn: %+v %v", res, err)
}
if !sawPrior {
t.Fatal("matching scope must resume prior transcript context")
}
}
func TestContractOldStoreWithoutIdentityNotAdopted(t *testing.T) {
dir := t.TempDir()
scope := exec.SessionScope{Tenant: "t", Project: "p", Session: "orphan"}
path := transcriptPathForScope(dir, scopeIdentity(scope))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
// Pre-seed records with no scope identity in meta.
body := "{\"role\":\"user\",\"content\":\"old\"}\n{\"role\":\"assistant\",\"content\":\"ghost\"}\n"
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "new", StopReason: llm.StopEnd}),
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-orphan", Prompt: "hi", SessionScope: scope,
Workspace: exec.WorkspaceHandle{Root: dir},
})
if err != nil {
t.Fatalf("transport: %v", err)
}
if res.Outcome == exec.OutcomeSucceeded {
t.Fatal("must not auto-adopt a store lacking scope identity")
}
if res.Err == nil || !strings.Contains(res.Err.Reason, "scope identity") {
t.Fatalf("want scope-identity refusal, got %+v", res.Err)
}
}
func TestContractWritableFileToolHonorsAllowlist(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c1", Name: "write_file", Arguments: `{"path":"allowed.md","content":"ok"}`,
}}},
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{
ID: "c2", Name: "write_file", Arguments: `{"path":"denied.md","content":"no"}`,
}}},
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
),
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-w",
Prompt: "write",
Workspace: exec.WorkspaceHandle{
Root: dir,
Writable: []string{"allowed.md"},
},
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
Targets: []string{"allowed.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Outcome != exec.OutcomeSucceeded {
// Tool denial is soft (IsError to model); run may still succeed if allowed.md exists.
t.Logf("outcome=%s err=%+v", res.Outcome, res.Err)
}
if _, err := os.Stat(filepath.Join(dir, "allowed.md")); err != nil {
t.Fatalf("allowed write missing: %v (outcome=%s)", err, res.Outcome)
}
if _, err := os.Stat(filepath.Join(dir, "denied.md")); !os.IsNotExist(err) {
t.Fatal("denied.md must not be written under Writable allowlist")
}
}
func TestContractWritableRejectsExecInPreflight(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
},
ToolOptions: ToolOptions{
Exec: &tools.ExecConfig{Bash: "/bin/bash"},
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-exec",
Prompt: "x",
Workspace: exec.WorkspaceHandle{
Root: dir,
Writable: []string{"a.md"},
},
ToolPolicy: exec.ToolPolicy{Profile: exec.ToolProfileWorkspace},
Targets: []string{"a.md"},
})
if err != nil {
t.Fatal(err)
}
if res.Outcome == exec.OutcomeSucceeded {
t.Fatal("Writable + exec tools enabled must be rejected in preflight")
}
if res.Err == nil || !strings.Contains(res.Err.Reason, "writable") {
t.Fatalf("want writable preflight error, got %+v", res.Err)
}
}
func TestContractPureTextSuccessCarriesText(t *testing.T) {
dir := t.TempDir()
b := New(Options{
ModelOptions: ModelOptions{
Model: "fake",
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "纯文本答复内容", StopReason: llm.StopEnd}),
},
}).Contract()
res, err := b.Execute(context.Background(), exec.ContractRequest{
ExecutionID: "e-text",
Prompt: "说点什么",
Workspace: exec.WorkspaceHandle{Root: dir},
// empty ToolPolicy → reasoning; empty Targets → CapText, no file delivery
})
if err != nil {
t.Fatal(err)
}
if res.Outcome != exec.OutcomeSucceeded {
t.Fatalf("outcome=%s err=%+v", res.Outcome, res.Err)
}
if strings.TrimSpace(res.Text) == "" {
t.Fatal("CapText pure-text success must carry non-empty Text")
}
if !strings.Contains(res.Text, "纯文本答复内容") {
t.Fatalf("Text=%q", res.Text)
}
if len(res.Files) != 0 {
t.Fatalf("pure-text must not force file delivery, Files=%v", res.Files)
}
if _, err := os.Stat(filepath.Join(dir, "output.md")); !os.IsNotExist(err) {
t.Fatal("pure-text must not implicitly write output.md")
}
}
func messagesBlob(msgs []llm.Message) string {
var b strings.Builder
for _, m := range msgs {
b.WriteString(string(m.Role))
b.WriteByte(':')
b.WriteString(m.Content)
b.WriteByte('\n')
}
return b.String()
}