feat: Phase 8 — nomos packages extracted into internal/nomos/{turngate,retrycap,messagequeue,assent,session}

Mechanical extraction of nomos internal components into plain-Go subpackages
per the hexagonal plan (ADR 0016 §3.1 rule 3):
  turngate/   — per-session turn serialization (plan 2026-08-03 F1)
  retrycap/   — per-turn run retry cap (maxRunRetries=3)
  messagequeue/ — operator-message queue for busy-turn re-entry (F2)
  assent/     — chat-assent detection (isAssent, isTypedConfirmation,
                ExtractPendingApprovals), decoupled from agent via
                []string input instead of persistedCall
  session/    — store (chat sessions, plan execution, DB persistence),
                migration runner + local emitEvent to break adapter
                dependency

internal/migrate/ — shared migration runner extracted from postgres pool,
                    used by both the oikos postgres adapter and session tests.

session package export-rename finishing touches remain; the four smaller
packages compile with passing tests. Depguard rules and ADR-0016 leaf-note
update deferred to a followup. VERSION 0.35.1.
This commit is contained in:
2026-08-16 10:31:45 +02:00
parent 7c9f4ec79f
commit 7a7d390718
22 changed files with 629 additions and 683 deletions

View File

@@ -0,0 +1,128 @@
package assent
import (
"testing"
)
func TestIsAssent_Positive(t *testing.T) {
cases := []string{
"go ahead", "Go ahead.", "yes", "Yes!", "yeah", "yep", "do it",
"proceed", "approve", "ship it", "sounds good", "lgtm", "please do",
"ok go ahead and run it",
}
for _, c := range cases {
if !isAssent(c) {
t.Errorf("isAssent(%q) = false, want true", c)
}
}
}
func TestIsAssent_Negative(t *testing.T) {
cases := []string{
"no", "no, don't", "wait", "hold on", "not yet", "cancel that",
"nevermind", "what's the plan for tomorrow?", "how many CPUs does strong have?",
"maybe later", "",
}
for _, c := range cases {
if isAssent(c) {
t.Errorf("isAssent(%q) = true, want false", c)
}
}
}
func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
// Contains "yes" as a substring pattern risk word but is clearly not
// assent — negation must win.
cases := []string{
"no, don't do it yet",
"wait, not yet please",
}
for _, c := range cases {
if isAssent(c) {
t.Errorf("isAssent(%q) = true, want false (negation should block)", c)
}
}
}
// TestIsAssent_WholeWordBoundary regression-tests a real false positive found
// live: the old substring check matched "yes" inside "yesterday" (and would
// equally match "confirm" inside "confirmed"/"unconfirmed" for
// isTypedConfirmation below) because only negation used a word-boundary
// check — assent/confirm words used a bare strings.Contains. Confirmed via a
// throwaway probe before being fixed; kept here permanently so a future
// change can't silently reintroduce it.
func TestIsAssent_WholeWordBoundary(t *testing.T) {
cases := []string{
"not sure, maybe yesterday's logs show something useful",
"my eyesight isn't great, what does that say",
}
for _, c := range cases {
if isAssent(c) {
t.Errorf("isAssent(%q) = true, want false (word-boundary: 'yes' must not match inside 'yesterday'/'eyesight')", c)
}
}
}
// TestIsTypedConfirmation_ContractedNegation regression-tests the other real
// false positive: isTypedConfirmation gates DESTRUCTIVE actions, and
// "confirm" matching inside "confirmed" combined with contracted negatives
// ("haven't") not being in negationWords meant a message that explicitly
// says the operator has NOT confirmed something could read as confirming it.
func TestIsTypedConfirmation_ContractedNegation(t *testing.T) {
cases := []string{
"I haven't confirmed anything yet, let me think",
"that isn't confirmed on my end",
"we can't confirm that until tomorrow",
}
for _, c := range cases {
if isTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = true, want false (contracted negation should block)", c)
}
}
}
func TestIsTypedConfirmation(t *testing.T) {
positive := []string{
"I confirm destroy 135 in strong",
"confirm",
"Confirmed.",
"yes I confirm",
}
for _, c := range positive {
if !isTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = false, want true", c)
}
}
negative := []string{
"yes", "go ahead", "do it", "proceed", "lgtm", // loose assent must NOT satisfy this
"no, don't confirm yet", "wait", "",
}
for _, c := range negative {
if isTypedConfirmation(c) {
t.Errorf("isTypedConfirmation(%q) = true, want false (only explicit confirm should pass)", c)
}
}
}
func TestExtractPendingApprovals(t *testing.T) {
got := ExtractPendingApprovals([]string{
"run on host:strong requires approval (risk: config_mutation) - execution 019f4930-e22b-7c47-8c6e-715dcd59df19 queued. Present the command...",
"some unrelated read-only result, no approval here",
"run on lxc:caddy requires approval (risk: destructive) - execution 019f4931-aaaa-7c47-8c6e-715dcd59df20 queued. This is classified DESTRUCTIVE - flag that clearly.",
})
if len(got) != 2 {
t.Fatalf("expected 2 pending approvals, got %d", len(got))
}
if got[0].ExecID != "019f4930-e22b-7c47-8c6e-715dcd59df19" || got[0].Destructive {
t.Errorf("first approval: ExecID=%s Destructive=%v", got[0].ExecID, got[0].Destructive)
}
if got[1].ExecID != "019f4931-aaaa-7c47-8c6e-715dcd59df20" || !got[1].Destructive {
t.Errorf("second approval should be flagged destructive: %+v", got[1])
}
}
func TestExtractPendingApprovals_NoneWhenNoneQueued(t *testing.T) {
if got := ExtractPendingApprovals(nil); len(got) != 0 {
t.Errorf("expected 0, got %d", len(got))
}
}