Why: native must compile without agent-intent, and the two execution packages were the same contract under two names. Evidence: GOWORK=off go test -count=1 ./... passed, including schema.TestRequestAndResultRoundTrip and the native adapter tests after Options was split into embedded sub-structs. Impact: adapters/intentgate is gone, go.mod no longer requires agent-intent, and the nested gitlink stays for the release task. Callers of Options composite literals name the sub-struct. Refs: D1 Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
3.7 KiB
Go
121 lines
3.7 KiB
Go
package native
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
"agent-native/exec"
|
|
"agent-native/runtime/llm"
|
|
)
|
|
|
|
// failThenSucceed 首次流返回 context overflow 错误,之后正常结束。
|
|
func failThenSucceed(failMsg string) llm.StreamFunc {
|
|
var mu sync.Mutex
|
|
failed := false
|
|
return func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
|
ch := make(chan llm.Event, 2)
|
|
go func() {
|
|
defer close(ch)
|
|
mu.Lock()
|
|
first := !failed
|
|
failed = true
|
|
mu.Unlock()
|
|
if first {
|
|
ch <- llm.Event{Kind: llm.KindError, Err: errors.New(failMsg), ErrorMessage: failMsg}
|
|
return
|
|
}
|
|
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "恢复后的正文输出。"}
|
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "恢复后的正文输出。", StopReason: llm.StopEnd}}
|
|
}()
|
|
return ch
|
|
}
|
|
}
|
|
|
|
func TestSessionRotationAfterPoisonedFailure(t *testing.T) {
|
|
dir := t.TempDir()
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: failThenSucceed("maximum context length exceeded (context_overflow)"),
|
|
},
|
|
})
|
|
|
|
// 第一次执行:失败(上下文溢出),留下毒化会话。
|
|
res1, err := b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res1.Success || !strings.Contains(res1.ErrorReason, "context") {
|
|
t.Fatalf("first execute should fail with overflow: %+v", res1)
|
|
}
|
|
// 手工模拟毒化会话存在(runtime 假流没写 transcript,用 session.Open 布置)。
|
|
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
|
if err := os.MkdirAll(filepath.Dir(transcript), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(transcript, []byte(`{"role":"user","content":"poisoned"}`+"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// 第二次执行:毒化检测应轮转旧会话并成功(reasoning 档直写文本;
|
|
// workspace 档需模型主动调写盘工具,假流不模拟工具调用)。
|
|
res2, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: dir, Prompt: "p", Targets: []string{"output.md"},
|
|
ToolProfile: exec.ToolProfileReasoning,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res2.Success {
|
|
t.Fatalf("second execute should succeed on fresh session: %+v", res2)
|
|
}
|
|
// 旧会话归档保留(审计),新会话文件存在。
|
|
entries, _ := os.ReadDir(filepath.Join(dir, ".agentx"))
|
|
rotated, fresh := false, false
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "transcript.poisoned.") {
|
|
rotated = true
|
|
}
|
|
if e.Name() == "transcript.jsonl" {
|
|
fresh = true
|
|
}
|
|
}
|
|
if !rotated || !fresh {
|
|
t.Fatalf("rotation missing: rotated=%v fresh=%v entries=%v", rotated, fresh, entries)
|
|
}
|
|
}
|
|
|
|
func TestNoRotationOnRetryableNetworkFailure(t *testing.T) {
|
|
dir := t.TempDir()
|
|
b := New(Options{
|
|
ModelOptions: ModelOptions{
|
|
Model: "fake",
|
|
Stream: failThenSucceed("connection reset by peer (provider_network)"),
|
|
},
|
|
})
|
|
_, _ = b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
|
transcript := filepath.Join(dir, ".agentx", "transcript.jsonl")
|
|
_ = os.MkdirAll(filepath.Dir(transcript), 0o755)
|
|
_ = os.WriteFile(transcript, []byte(`{"role":"user","content":"keep"}`+"\n"), 0o644)
|
|
|
|
_, err := b.Execute(context.Background(), execReq(dir, []string{"output.md"}))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
entries, _ := os.ReadDir(filepath.Join(dir, ".agentx"))
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e.Name(), "transcript.poisoned.") {
|
|
t.Fatal("network failure must not rotate session (resume-safe)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func execReq(dir string, targets []string) exec.Request {
|
|
return exec.Request{WorkDir: dir, Prompt: "p", Targets: targets}
|
|
}
|