Why: a kill between persist(assistant) and commitToolIntent left an unpaired tail that no recovery path repaired (acceptance clause 1's library gap), and Stop.Replacement mutated the in-memory assistant while the transcript kept the original (audit N6 residual). Evidence: GOWORK=off go vet + go test -count=1 ./... green; TestResumePairsOrphanTailAfterCrashWindow (no-intent window pairs with crashInterrupted, run continues), TestResumeMixedIntentAndOrphanTail (intent path and tail repair never double-pair), TestRepairOrphanTailNoopOnHealthyTranscript; the harness assertion now pins replacement-as-user-note (forged=4, notes=3). Impact: every next Run repairs the trailing assistant's resultless calls before the first request; replacement text now appears only as a user message in both memory and transcript (intentional, recorded in the N3 card). Refs: Agent-System-Documents#43 (N3 plan WP1)
365 lines
11 KiB
Go
365 lines
11 KiB
Go
package runtime
|
||
|
||
import (
|
||
"context"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"agent-native/runtime/llm"
|
||
"agent-native/runtime/session"
|
||
"agent-native/runtime/tools"
|
||
)
|
||
|
||
type replayTool struct {
|
||
countingTool
|
||
replay string
|
||
}
|
||
|
||
func (r *replayTool) Replay() string {
|
||
if r.replay == "" {
|
||
return tools.ReplayNever
|
||
}
|
||
return r.replay
|
||
}
|
||
|
||
func TestPendingUnsafeToolIsNotRerun(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.Append(llm.Message{
|
||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "write_file", Arguments: `{}`}},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "write_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tool := &countingTool{name: "write_file"}
|
||
reg := tools.NewRegistry(dir)
|
||
reg.Register(tool)
|
||
_, err = Run(context.Background(), Config{
|
||
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
|
||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
|
||
}, llm.Message{Role: llm.RoleUser, Content: "next"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if tool.calls != 0 {
|
||
t.Fatalf("unsafe tool reran: %d", tool.calls)
|
||
}
|
||
msgs, err := st.Load()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
var paired bool
|
||
for _, m := range msgs {
|
||
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == unknownSideEffect {
|
||
paired = true
|
||
}
|
||
}
|
||
if !paired {
|
||
t.Fatalf("pair missing: %#v", msgs)
|
||
}
|
||
}
|
||
|
||
func TestPendingSafeToolRerunsOnce(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.Append(llm.Message{
|
||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe}
|
||
reg := tools.NewRegistry(dir)
|
||
reg.Register(tool)
|
||
_, err = Run(context.Background(), Config{
|
||
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
|
||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "later", StopReason: llm.StopEnd}),
|
||
}, llm.Message{Role: llm.RoleUser, Content: "next"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if tool.calls != 1 {
|
||
t.Fatalf("calls %d", tool.calls)
|
||
}
|
||
msgs, err := st.Load()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, m := range msgs {
|
||
if m.Role == llm.RoleTool && m.ToolCallID == "c1" {
|
||
if m.Content != "ok" || strings.Contains(m.Content, unknownSideEffect) {
|
||
t.Fatalf("result %#v", m)
|
||
}
|
||
return
|
||
}
|
||
}
|
||
t.Fatal("missing tool result")
|
||
}
|
||
|
||
func TestStoredSafeCurrentNeverDoesNotRerun(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
tool := &replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplayNever}
|
||
reg := tools.NewRegistry(dir)
|
||
reg.Register(tool)
|
||
if err := recoverPending(context.Background(), Config{Session: st, Tools: reg}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if tool.calls != 0 {
|
||
t.Fatalf("calls %d", tool.calls)
|
||
}
|
||
}
|
||
|
||
func TestFollowUpIsPersistedBeforeNextRequest(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
sent := false
|
||
var saw bool
|
||
stream := func(ctx context.Context, model string, messages []llm.Message, tools []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
||
ch := make(chan llm.Event, 1)
|
||
go func() {
|
||
defer close(ch)
|
||
for _, m := range messages {
|
||
if m.Content == "follow-please" {
|
||
loaded, err := st.Load()
|
||
if err != nil {
|
||
t.Errorf("load %v", err)
|
||
}
|
||
for _, got := range loaded {
|
||
if got.Content == "follow-please" {
|
||
saw = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "ok", StopReason: llm.StopEnd}}
|
||
}()
|
||
return ch
|
||
}
|
||
_, err = Run(context.Background(), Config{
|
||
Model: "fake", Session: st, MaxTurns: 3, Stream: stream,
|
||
GetFollowUp: func() []llm.Message {
|
||
if sent {
|
||
return nil
|
||
}
|
||
sent = true
|
||
return []llm.Message{{Role: llm.RoleUser, Content: "follow-please"}}
|
||
},
|
||
}, llm.Message{Role: llm.RoleUser, Content: "go"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !saw {
|
||
t.Fatal("follow-up entered the request before it was stored")
|
||
}
|
||
}
|
||
|
||
func TestSpansUseAgreedNamesWithoutPrompt(t *testing.T) {
|
||
var names []string
|
||
var leaked bool
|
||
reg := tools.NewRegistry(t.TempDir())
|
||
reg.Register(&countingTool{name: "read_file"})
|
||
_, err := Run(context.Background(), Config{
|
||
Model: "fake", Tools: reg, MaxTurns: 2,
|
||
Trace: func(name string, attrs map[string]string) func() {
|
||
names = append(names, name)
|
||
if _, ok := attrs["prompt"]; ok || attrs["content"] != "" {
|
||
leaked = true
|
||
}
|
||
return func() {}
|
||
},
|
||
Stream: script(
|
||
llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "1", Name: "read_file", Arguments: `{}`}}},
|
||
llm.Message{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||
),
|
||
}, llm.Message{Role: llm.RoleUser, Content: "用户的秘密提示词"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
joined := strings.Join(names, ",")
|
||
for _, want := range []string{"invoke_agent", "chat", "execute_tool"} {
|
||
if !strings.Contains(joined, want) {
|
||
t.Fatalf("names %s", joined)
|
||
}
|
||
}
|
||
if leaked {
|
||
t.Fatal("prompt leaked into span attrs")
|
||
}
|
||
}
|
||
|
||
// ---- N3 WP1:崩溃窗口尾部配对兜底(验收① 库层机器核心)----
|
||
|
||
// 窗口内死亡(persist(assistant) 之后、任何 intent 之前):转录只有带
|
||
// tool_calls 的 assistant。恢复后每个调用都有「进程中断」配对,且 run 继续。
|
||
func TestResumePairsOrphanTailAfterCrashWindow(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.Append(llm.Message{Role: llm.RoleSystem, Content: "sys"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.Append(llm.Message{Role: llm.RoleUser, Content: "干个活"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 崩溃窗口形态:assistant 落盘、零意图、零结果。
|
||
if err := st.Append(llm.Message{
|
||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||
ToolCalls: []llm.ToolCall{
|
||
{ID: "c1", Name: "write_file", Arguments: `{}`},
|
||
{ID: "c2", Name: "read_file", Arguments: `{}`},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg := tools.NewRegistry(dir)
|
||
tool := &countingTool{name: "write_file"}
|
||
reg.Register(tool)
|
||
reg.Register(&countingTool{name: "read_file"})
|
||
_, err = Run(context.Background(), Config{
|
||
Model: "fake", Session: st, Tools: reg, MaxTurns: 2,
|
||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}),
|
||
}, llm.Message{Role: llm.RoleUser, Content: "还在吗"})
|
||
if err != nil {
|
||
t.Fatalf("恢复后的 Run 不得失败(配对修复应让提供方接受历史):%v", err)
|
||
}
|
||
msgs, err := st.Load()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 双向配对断言:每个调用有结果、每个结果有调用。
|
||
calls, results := map[string]string{}, map[string]bool{}
|
||
for _, m := range msgs {
|
||
for _, c := range m.ToolCalls {
|
||
calls[c.ID] = c.Name
|
||
}
|
||
if m.Role == llm.RoleTool && m.ToolCallID != "" {
|
||
results[m.ToolCallID] = true
|
||
if m.Content != crashInterrupted && m.Content != unknownSideEffect {
|
||
t.Fatalf("兜底文案不对:%q", m.Content)
|
||
}
|
||
if !m.IsError {
|
||
t.Fatal("兜底结果必须 IsError")
|
||
}
|
||
}
|
||
}
|
||
for id := range calls {
|
||
if !results[id] {
|
||
t.Fatalf("孤立 tool_call:%s", id)
|
||
}
|
||
}
|
||
if tool.calls != 0 {
|
||
t.Fatalf("兜底不得重放写工具:%d", tool.calls)
|
||
}
|
||
}
|
||
|
||
// 执行中死亡(c1 意图+结果、c2 意图无结果)+ 窗口死亡(c3 无意图)混合:
|
||
// 意图路径配 c2、尾部兜底配 c3,互不重复。
|
||
func TestResumeMixedIntentAndOrphanTail(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.Append(llm.Message{
|
||
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
||
ToolCalls: []llm.ToolCall{
|
||
{ID: "c1", Name: "read_file", Arguments: `{}`},
|
||
{ID: "c2", Name: "read_file", Arguments: `{}`},
|
||
{ID: "c3", Name: "write_file", Arguments: `{}`},
|
||
},
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c1", Name: "read_file", Arguments: `{}`, Replay: tools.ReplaySafe}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.Append(llm.Message{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "真实结果"}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := st.AppendToolIntent(session.ToolIntent{CallID: "c2", Name: "read_file", Arguments: `{}`, Replay: tools.ReplayNever}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reg := tools.NewRegistry(dir)
|
||
reg.Register(&countingTool{name: "read_file"})
|
||
reg.Register(&countingTool{name: "write_file"})
|
||
_, err = Run(context.Background(), Config{
|
||
Model: "fake", Session: st, Tools: reg, MaxTurns: 1,
|
||
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
||
}, llm.Message{Role: llm.RoleUser, Content: "next"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
msgs, err := st.Load()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
byID := map[string][]string{}
|
||
for _, m := range msgs {
|
||
if m.Role == llm.RoleTool && m.ToolCallID != "" {
|
||
byID[m.ToolCallID] = append(byID[m.ToolCallID], m.Content)
|
||
}
|
||
}
|
||
if got := byID["c1"]; len(got) != 1 || got[0] != "真实结果" {
|
||
t.Fatalf("c1 必须保持原结果且不重复:%v", got)
|
||
}
|
||
if got := byID["c2"]; len(got) != 1 || got[0] != unknownSideEffect {
|
||
t.Fatalf("c2 走意图路径:%v", got)
|
||
}
|
||
if got := byID["c3"]; len(got) != 1 || got[0] != crashInterrupted {
|
||
t.Fatalf("c3 走尾部兜底:%v", got)
|
||
}
|
||
}
|
||
|
||
// 健康转录(尾部是完整配对或 user 消息)不做任何改写。
|
||
func TestRepairOrphanTailNoopOnHealthyTranscript(t *testing.T) {
|
||
dir := t.TempDir()
|
||
st, err := session.Open(filepath.Join(dir, "s.jsonl"))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
for _, m := range []llm.Message{
|
||
{Role: llm.RoleSystem, Content: "sys"},
|
||
{Role: llm.RoleUser, Content: "u"},
|
||
{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "read_file", Arguments: `{}`}}},
|
||
{Role: llm.RoleTool, ToolCallID: "c1", Name: "read_file", Content: "r"},
|
||
{Role: llm.RoleAssistant, Content: "done", StopReason: llm.StopEnd},
|
||
{Role: llm.RoleUser, Content: "u2"},
|
||
} {
|
||
if err := st.Append(m); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
before, _ := st.Load()
|
||
if err := repairOrphanTail(Config{Session: st}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
after, _ := st.Load()
|
||
if len(after) != len(before) {
|
||
t.Fatalf("健康转录被改写:%d → %d", len(before), len(after))
|
||
}
|
||
}
|