Why: the product conversation path needs the intent journal and recovery pairing, but making the transcript the authoritative context would freeze the assembled system prefix (memory/plans/injections go stale). JournalOnly keeps the session as journal-only: recovery runs against it, the context always comes from History, and the recovered outcomes are injected as a system note. Separately, the one-time History seed was memory-only and silently lost from the second run on (authority mode); it is now persisted except in JournalOnly mode. Evidence: GOWORK=off go vet + go test -count=1 ./... green; TestJournalOnlyRecoversWithoutTranscriptAuthority (journal gets the replayed pair exactly once, history never enters the journal, the recovery note precedes the new user message, seeds stay out); persistedProjection honors JournalOnly; all pre-existing durable and harness tests pass unchanged (default semantics untouched). Impact: new opt-in Config.JournalOnly; default (false) behavior is byte-identical. Refs: Agent-System-Documents#43 (N3 plan WP2)
443 lines
14 KiB
Go
443 lines
14 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))
|
||
}
|
||
}
|
||
|
||
// ---- N3 WP2:JournalOnly(journal 只作日志,上下文由 History 组装)----
|
||
|
||
// JournalOnly:journal 有杀窗口尾巴 → 恢复配对写入 journal;上下文来自
|
||
// History(不含 journal 内容),恢复摘要以系统消息注入;种子不进 journal。
|
||
func TestJournalOnlyRecoversWithoutTranscriptAuthority(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)
|
||
}
|
||
reg := tools.NewRegistry(dir)
|
||
reg.Register(&replayTool{countingTool: countingTool{name: "read_file"}, replay: tools.ReplaySafe})
|
||
var gotReq []llm.Message
|
||
res, err := Run(context.Background(), Config{
|
||
Model: "fake", Session: st, JournalOnly: true, Tools: reg, MaxTurns: 2,
|
||
History: []llm.Message{
|
||
{Role: llm.RoleSystem, Content: "sys"},
|
||
{Role: llm.RoleUser, Content: "历史一"},
|
||
{Role: llm.RoleAssistant, Content: "回复一"},
|
||
},
|
||
Stream: func(ctx context.Context, model string, msgs []llm.Message, _ []llm.ToolDecl, _ llm.StreamOptions) <-chan llm.Event {
|
||
gotReq = append([]llm.Message{}, msgs...)
|
||
ch := make(chan llm.Event, 1)
|
||
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{Role: llm.RoleAssistant, Content: "继续", StopReason: llm.StopEnd}}
|
||
close(ch)
|
||
return ch
|
||
},
|
||
}, llm.Message{Role: llm.RoleUser, Content: "最新"})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// journal 里 c1 被 safe 重放配对(一次真实结果)。
|
||
journal, _ := st.Load()
|
||
var journaled bool
|
||
for _, m := range journal {
|
||
if m.Role == llm.RoleTool && m.ToolCallID == "c1" && m.Content == "ok" {
|
||
journaled = true
|
||
}
|
||
if m.Content == "历史一" {
|
||
t.Fatal("种子历史不得进 journal")
|
||
}
|
||
}
|
||
if !journaled {
|
||
t.Fatalf("journal 缺恢复配对:%#v", journal)
|
||
}
|
||
// 上下文:History 内容在;恢复摘要在;且在新 user 消息之前。
|
||
var sawHist, sawNote int
|
||
noteIdx, userIdx := -1, -1
|
||
for i, m := range gotReq {
|
||
if m.Content == "历史一" {
|
||
sawHist++
|
||
}
|
||
if m.Role == llm.RoleSystem && strings.Contains(m.Content, "已完成恢复") && strings.Contains(m.Content, "c1") {
|
||
sawNote++
|
||
noteIdx = i
|
||
}
|
||
if m.Content == "最新" {
|
||
userIdx = i
|
||
}
|
||
}
|
||
if sawHist != 1 || sawNote != 1 {
|
||
t.Fatalf("历史=%d 摘要=%d:%#v", sawHist, sawNote, gotReq)
|
||
}
|
||
if noteIdx < 0 || userIdx < 0 || noteIdx > userIdx {
|
||
t.Fatalf("摘要必须在新 user 消息之前:note=%d user=%d", noteIdx, userIdx)
|
||
}
|
||
_ = res
|
||
}
|