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
292 lines
9.0 KiB
Go
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()
|
|
}
|