| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "slices" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/agent/testutil" |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/provider" |
| 12 | "reasonix/internal/sessioncontext" |
| 13 | "reasonix/internal/tool" |
| 14 | ) |
| 15 | |
| 16 | func TestRunInjectsSessionContextBeforeRealUserAndDeduplicatesDigest(t *testing.T) { |
| 17 | first := sessioncontext.Build(sessioncontext.Sections{ |
| 18 | Environment: "go/linux", |
| 19 | Workspace: `Current workspace: "/work"`, |
| 20 | }) |
| 21 | second := sessioncontext.Build(sessioncontext.Sections{ |
| 22 | Environment: "go/linux", |
| 23 | Workspace: `Current workspace: "/work"`, |
| 24 | BackgroundMemory: "new fact index", |
| 25 | }) |
| 26 | prov := testutil.NewMock("m", |
| 27 | testutil.Turn{Text: "one"}, testutil.Turn{Text: "two"}, testutil.Turn{Text: "three"}) |
| 28 | sess := NewSession("stable system") |
| 29 | a := New(prov, tool.NewRegistry(), sess, Options{}, event.Discard) |
| 30 | ctx := WithTurnContextBundle(context.Background(), TurnContextBundle{Executor: first}) |
| 31 | |
| 32 | if err := a.Run(ctx, "hello"); err != nil { |
| 33 | t.Fatal(err) |
| 34 | } |
| 35 | msgs := sess.Snapshot() |
| 36 | if len(msgs) != 4 || msgs[1].Origin != provider.MessageOriginHost || msgs[1].Content != first.Content || |
| 37 | !IsUserAuthoredTurnMessage(msgs[2]) || msgs[2].RawContent != "hello" || msgs[2].CreatedAt == 0 { |
| 38 | t.Fatalf("first turn history = %+v, want system/context/user/assistant", msgs) |
| 39 | } |
| 40 | if msgs[1].RawContent != "" || msgs[1].CreatedAt != 0 || IsUserAuthoredTurnMessage(msgs[1]) { |
| 41 | t.Fatalf("context leaked user-turn metadata: %+v", msgs[1]) |
| 42 | } |
| 43 | if preview, turns := SessionPreviewFromMessages(msgs); preview != "hello" || turns != 1 || strings.Contains(preview, "session-context") { |
| 44 | t.Fatalf("context leaked into preview/turn count: preview=%q turns=%d", preview, turns) |
| 45 | } |
| 46 | if err := a.Run(ctx, "again"); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if got := countSessionContexts(sess.Snapshot()); got != 1 { |
| 50 | t.Fatalf("same digest produced %d context messages, want 1", got) |
| 51 | } |
| 52 | ctx = WithTurnContextBundle(context.Background(), TurnContextBundle{Executor: second}) |
| 53 | if err := a.Run(ctx, "after memory change"); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | if got := countSessionContexts(sess.Snapshot()); got != 2 { |
| 57 | t.Fatalf("replacement digest produced %d context messages, want 2", got) |
| 58 | } |
| 59 | lastRequest := prov.LastRequest() |
| 60 | if lastRequest == nil { |
| 61 | t.Fatal("provider received no request") |
| 62 | } |
| 63 | for _, message := range lastRequest.Messages { |
| 64 | if message.Origin != "" || message.RawContent != "" || message.CreatedAt != 0 { |
| 65 | t.Fatalf("provider request leaked host provenance/display metadata: %+v", message) |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | func TestSessionContextPlannerSelectionAndSyntheticBootstrap(t *testing.T) { |
| 71 | executor := sessioncontext.Build(sessioncontext.Sections{SkillsCatalog: "executor skill"}) |
| 72 | planner := sessioncontext.Build(sessioncontext.Sections{SkillsCatalog: "read-only skill"}) |
| 73 | bundle := TurnContextBundle{Executor: executor, Planner: planner} |
| 74 | sess := NewSession("system") |
| 75 | sess.Add(provider.Message{Role: provider.RoleUser, Content: "legacy request"}) |
| 76 | sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "legacy answer"}) |
| 77 | a := New(nil, tool.NewRegistry(), sess, Options{}, event.Discard) |
| 78 | |
| 79 | ctx := withPlannerTurnContext(WithTurnContextBundle(context.Background(), TurnContextBundle{ |
| 80 | Executor: executor, Planner: planner, BootstrapOnly: true, |
| 81 | })) |
| 82 | if !a.AppendTurnContext(ctx) { |
| 83 | t.Fatal("legacy synthetic continuation should bootstrap one v1 snapshot") |
| 84 | } |
| 85 | if got := sess.Messages[len(sess.Messages)-1].Content; got != planner.Content { |
| 86 | t.Fatalf("planner received %q, want read-only snapshot", got) |
| 87 | } |
| 88 | ctx = withPlannerTurnContext(WithTurnContextBundle(context.Background(), TurnContextBundle{ |
| 89 | Executor: executor, Planner: sessioncontext.Build(sessioncontext.Sections{SkillsCatalog: "changed"}), BootstrapOnly: true, |
| 90 | })) |
| 91 | if a.AppendTurnContext(ctx) { |
| 92 | t.Fatal("synthetic continuation consumed a pending catalog change") |
| 93 | } |
| 94 | if got := countSessionContexts(sess.Snapshot()); got != 1 { |
| 95 | t.Fatalf("bootstrap context count = %d, want 1", got) |
| 96 | } |
| 97 | |
| 98 | ordinary := withPlannerTurnContext(WithTurnContextBundle(context.Background(), bundle)) |
| 99 | if a.AppendTurnContext(ordinary) { |
| 100 | t.Fatal("same planner digest should deduplicate") |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestSessionContextDiagnosticsAreContentFreeAndAttributeChanges(t *testing.T) { |
| 105 | prov := &cacheDiagProvider{chunks: [][]provider.Chunk{ |
| 106 | {{Type: provider.ChunkText, Text: "one"}, {Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 20, CacheMissTokens: 20}}}, |
| 107 | {{Type: provider.ChunkText, Text: "two"}, {Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 20, CacheHitTokens: 10, CacheMissTokens: 10}}}, |
| 108 | }} |
| 109 | var diagnostics []*event.CacheDiagnostics |
| 110 | sink := event.FuncSink(func(e event.Event) { |
| 111 | if e.Kind == event.Usage { |
| 112 | diagnostics = append(diagnostics, e.CacheDiagnostics) |
| 113 | } |
| 114 | }) |
| 115 | a := New(prov, tool.NewRegistry(), NewSession("stable"), Options{}, sink) |
| 116 | first := sessioncontext.Build(sessioncontext.Sections{Workspace: "/secret/path", BackgroundMemory: "secret fact"}) |
| 117 | second := sessioncontext.Build(sessioncontext.Sections{Workspace: "/secret/path", BackgroundMemory: "changed secret fact"}) |
| 118 | for i, snapshot := range []sessioncontext.Snapshot{first, second} { |
| 119 | ctx := WithTurnContextBundle(context.Background(), TurnContextBundle{Executor: snapshot}) |
| 120 | if err := a.Run(ctx, []string{"first", "second"}[i]); err != nil { |
| 121 | t.Fatal(err) |
| 122 | } |
| 123 | } |
| 124 | if len(diagnostics) != 2 || diagnostics[0] == nil || diagnostics[1] == nil { |
| 125 | t.Fatalf("diagnostics = %+v", diagnostics) |
| 126 | } |
| 127 | firstDiag, secondDiag := diagnostics[0].SessionContext, diagnostics[1].SessionContext |
| 128 | if firstDiag == nil || secondDiag == nil || firstDiag.Digest != first.Digest || secondDiag.Digest != second.Digest { |
| 129 | t.Fatalf("session diagnostics = first %+v second %+v", firstDiag, secondDiag) |
| 130 | } |
| 131 | if strings.Join(firstDiag.Reasons, ",") != "first_seen" || strings.Join(secondDiag.Reasons, ",") != "memory_changed" { |
| 132 | t.Fatalf("context reasons = %v then %v", firstDiag.Reasons, secondDiag.Reasons) |
| 133 | } |
| 134 | if !secondDiagHasPrefixReason(diagnostics[1], "session_context") || secondDiag.TargetRole != "executor" { |
| 135 | t.Fatalf("second diagnostics = %+v", diagnostics[1]) |
| 136 | } |
| 137 | encoded := firstDiag.Digest + firstDiag.Workspace.Digest + firstDiag.BackgroundMemory.Digest |
| 138 | if strings.Contains(encoded, "secret") || firstDiag.Workspace.Chars != len("/secret/path") { |
| 139 | t.Fatalf("diagnostics leaked content or wrong count: %+v", firstDiag) |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | func TestSubagentDoesNotInheritParentTurnContext(t *testing.T) { |
| 144 | parentSnapshot := sessioncontext.Build(sessioncontext.Sections{ |
| 145 | Workspace: "/parent/workspace", |
| 146 | BackgroundMemory: "PARENT-MEMORY-MARKER", |
| 147 | SkillsCatalog: "parent-only-skill", |
| 148 | }) |
| 149 | prov := testutil.NewMock("child", testutil.Turn{Text: "child done"}) |
| 150 | childSession := NewSession("child system") |
| 151 | ctx := WithTurnContextBundle(context.Background(), TurnContextBundle{Executor: parentSnapshot}) |
| 152 | |
| 153 | if _, err := RunSubAgentWithSession(ctx, prov, tool.NewRegistry(), childSession, "inspect the task", Options{}, event.Discard); err != nil { |
| 154 | t.Fatalf("RunSubAgentWithSession: %v", err) |
| 155 | } |
| 156 | for _, message := range childSession.Snapshot() { |
| 157 | if sessioncontext.IsContent(message.Content) { |
| 158 | t.Fatalf("child session inherited parent session-context: %+v", message) |
| 159 | } |
| 160 | } |
| 161 | if request := prov.LastRequest(); request != nil { |
| 162 | for _, message := range request.Messages { |
| 163 | if sessioncontext.IsContent(message.Content) || strings.Contains(message.Content, "PARENT-MEMORY-MARKER") || strings.Contains(message.Content, "parent-only-skill") { |
| 164 | t.Fatalf("child provider request inherited parent context: %+v", request.Messages) |
| 165 | } |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | func TestAppendTurnContextAndUserCommitsOneAdmissionBatch(t *testing.T) { |
| 171 | snapshot := sessioncontext.Build(sessioncontext.Sections{Workspace: "/workspace"}) |
| 172 | sess := NewSession("system") |
| 173 | a := New(nil, tool.NewRegistry(), sess, Options{}, event.Discard) |
| 174 | |
| 175 | if !a.AppendTurnContextAndUser( |
| 176 | WithTurnContextBundle(context.Background(), TurnContextBundle{Executor: snapshot}), |
| 177 | provider.Message{Role: provider.RoleUser, Origin: provider.MessageOriginUser, Content: "request"}, |
| 178 | ) { |
| 179 | t.Fatal("expected the first context snapshot to be appended") |
| 180 | } |
| 181 | messages := sess.Snapshot() |
| 182 | if len(messages) != 3 || messages[1].Content != snapshot.Content || messages[2].Content != "request" { |
| 183 | t.Fatalf("admission batch = %+v, want system/context/user", messages) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func countSessionContexts(messages []provider.Message) int { |
| 188 | count := 0 |
| 189 | for _, message := range messages { |
| 190 | if isSessionContextMessage(message) { |
| 191 | count++ |
| 192 | } |
| 193 | } |
| 194 | return count |
| 195 | } |
| 196 | |
| 197 | func secondDiagHasPrefixReason(diagnostics *event.CacheDiagnostics, want string) bool { |
| 198 | return slices.Contains(diagnostics.PrefixChangeReasons, want) |
| 199 | } |
| 200 |