| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "slices" |
| 8 | "strings" |
| 9 | "testing" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/event" |
| 13 | "reasonix/internal/memory" |
| 14 | "reasonix/internal/provider" |
| 15 | "reasonix/internal/sessioncontext" |
| 16 | "reasonix/internal/skill" |
| 17 | "reasonix/internal/tool" |
| 18 | ) |
| 19 | |
| 20 | func TestTurnContextUsesLiveSkillCatalogAcrossAddEditDelete(t *testing.T) { |
| 21 | project := t.TempDir() |
| 22 | home := t.TempDir() |
| 23 | writeControlSkill(t, project, ".reasonix/skills/alpha/SKILL.md", "---\ndescription: alpha one\n---\nbody") |
| 24 | store := skill.New(skill.Options{HomeDir: home, ProjectRoot: project, DisableBuiltins: true}) |
| 25 | sess := agent.NewSession("stable system") |
| 26 | executor := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard) |
| 27 | c := newOwnedTestController(t, Options{ |
| 28 | Executor: executor, SkillStore: store, Skills: store.List(), |
| 29 | SessionContextStatic: sessioncontext.Sections{Workspace: "workspace"}, |
| 30 | }) |
| 31 | |
| 32 | appendCurrent := func() sessioncontext.Snapshot { |
| 33 | t.Helper() |
| 34 | if !executor.AppendTurnContext(c.withTurnContext(context.Background(), true)) { |
| 35 | t.Fatal("expected a replacement context") |
| 36 | } |
| 37 | for i := range slices.Backward(sess.Messages) { |
| 38 | if snapshot, ok := sessioncontext.Parse(sess.Messages[i].Content); ok { |
| 39 | return snapshot |
| 40 | } |
| 41 | } |
| 42 | t.Fatal("no context found") |
| 43 | return sessioncontext.Snapshot{} |
| 44 | } |
| 45 | |
| 46 | first := appendCurrent() |
| 47 | if !strings.Contains(first.Sections.SkillsCatalog, "alpha one") { |
| 48 | t.Fatalf("first catalog = %q", first.Sections.SkillsCatalog) |
| 49 | } |
| 50 | betaPath := filepath.Join(project, ".reasonix", "skills", "beta", "SKILL.md") |
| 51 | writeControlSkill(t, project, ".reasonix/skills/beta/SKILL.md", "---\ndescription: beta one\n---\nbody") |
| 52 | store.Invalidate("test skill added") |
| 53 | second := appendCurrent() |
| 54 | if second.Digest == first.Digest || !strings.Contains(second.Sections.SkillsCatalog, "beta one") { |
| 55 | t.Fatalf("added-skill snapshot = %+v", second) |
| 56 | } |
| 57 | writeControlSkill(t, project, ".reasonix/skills/beta/SKILL.md", "---\ndescription: beta edited\n---\nbody") |
| 58 | store.Invalidate("test skill edited") |
| 59 | third := appendCurrent() |
| 60 | if third.Digest == second.Digest || !strings.Contains(third.Sections.SkillsCatalog, "beta edited") { |
| 61 | t.Fatalf("edited-skill snapshot = %+v", third) |
| 62 | } |
| 63 | if err := os.Remove(betaPath); err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | store.Invalidate("test skill deleted") |
| 67 | fourth := appendCurrent() |
| 68 | if fourth.Digest == third.Digest || strings.Contains(fourth.Sections.SkillsCatalog, "beta") { |
| 69 | t.Fatalf("deleted-skill snapshot = %+v", fourth) |
| 70 | } |
| 71 | if got := sessionContextCount(sess.Messages); got != 4 { |
| 72 | t.Fatalf("context count = %d, want one full snapshot per change", got) |
| 73 | } |
| 74 | if executor.AppendTurnContext(c.withTurnContext(context.Background(), true)) { |
| 75 | t.Fatal("unchanged live catalog should deduplicate") |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | func TestTurnContextPublishesBackgroundMemoryReplacementWithoutLegacyUpdate(t *testing.T) { |
| 80 | root := t.TempDir() |
| 81 | userDir := filepath.Join(root, "user") |
| 82 | project := filepath.Join(root, "project") |
| 83 | if err := os.MkdirAll(project, 0o755); err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | mem := memory.Load(memory.Options{CWD: project, UserDir: userDir}) |
| 87 | sess := agent.NewSession("stable system") |
| 88 | executor := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard) |
| 89 | c := newOwnedTestController(t, Options{Executor: executor, Memory: mem, SessionContextStatic: sessioncontext.Sections{Workspace: "workspace"}}) |
| 90 | if !executor.AppendTurnContext(c.withTurnContext(context.Background(), true)) { |
| 91 | t.Fatal("initial runtime snapshot was not published") |
| 92 | } |
| 93 | if _, err := c.SaveMemory(memory.Memory{ |
| 94 | Name: "currency", Description: "balance currency", Scope: memory.FactScopeGlobal, |
| 95 | Type: memory.TypeUser, Body: "Use RMB.", |
| 96 | }); err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | if composed := c.Compose("hello"); strings.Contains(composed, "<memory-update>") { |
| 100 | t.Fatalf("background save generated legacy update: %q", composed) |
| 101 | } |
| 102 | if !executor.AppendTurnContext(c.withTurnContext(context.Background(), true)) { |
| 103 | t.Fatal("memory change did not publish a context") |
| 104 | } |
| 105 | snapshot, ok := latestControlSessionContext(sess.Messages) |
| 106 | if !ok || !strings.Contains(snapshot.Sections.BackgroundMemory, "currency") || !strings.Contains(snapshot.Sections.BackgroundMemory, "Use RMB.") { |
| 107 | t.Fatalf("memory snapshot = %+v", snapshot) |
| 108 | } |
| 109 | if err := c.ForgetMemory("currency"); err != nil { |
| 110 | t.Fatal(err) |
| 111 | } |
| 112 | if !executor.AppendTurnContext(c.withTurnContext(context.Background(), true)) { |
| 113 | t.Fatal("forget did not publish a replacement context") |
| 114 | } |
| 115 | latest, ok := latestControlSessionContext(sess.Messages) |
| 116 | if !ok || strings.Contains(latest.Sections.BackgroundMemory, "currency") { |
| 117 | t.Fatalf("forgotten fact remained in latest snapshot: %+v", latest) |
| 118 | } |
| 119 | } |
| 120 | |
| 121 | func sessionContextCount(messages []provider.Message) int { |
| 122 | count := 0 |
| 123 | for _, message := range messages { |
| 124 | if _, ok := sessioncontext.Parse(message.Content); ok && message.Origin == provider.MessageOriginHost { |
| 125 | count++ |
| 126 | } |
| 127 | } |
| 128 | return count |
| 129 | } |
| 130 | |
| 131 | func latestControlSessionContext(messages []provider.Message) (sessioncontext.Snapshot, bool) { |
| 132 | for i := range slices.Backward(messages) { |
| 133 | if snapshot, ok := sessioncontext.Parse(messages[i].Content); ok && messages[i].Origin == provider.MessageOriginHost { |
| 134 | return snapshot, true |
| 135 | } |
| 136 | } |
| 137 | return sessioncontext.Snapshot{}, false |
| 138 | } |
| 139 |