Files
oikos/cmd/nomos/continue_test.go
dtoro 39e9227fdb
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
ci / web (push) Has been cancelled
Desktop App / Build Linux (amd64) (push) Has been cancelled
Desktop App / Attach to Release (push) Has been cancelled
feat(nomos): per-session turn serialization + chat reliability/UX fixes
The agent could run two turns for one session at once (a reconnect resumed
while the live turn was still going), and their interleaved tool calls
corrupted the activity panel, fabricated a confusing "parallel/nested"
sequence, and made tasks feel stuck/never-ending. Several UX gaps compounded it.

Turn serialization (F1):
- turnGate: at most one in-flight turn per session. Background resume paths
  (continuation worker, idle sweep, answer-question, /resume, reconnect)
  skip non-blocking when busy; the live chat path waits briefly then bails
  cleanly instead of stacking a second turn.
- resumeSession returns whether it ran; continueSession marks an execution
  "continued" only after a real run (review P0) so a busy-skip can't lose a
  finished-execution result. Idle nudge bumps only after delivery (P1).

Connection state (F2/F3, web):
- humanize/bucket raw errors ("model connection dropped..."); one surface
  per drop; a terminal task.status event clears stuck streaming/disconnected
  state and dismisses the connection toast. Reconnect no longer spawns turns.

Streaming where you look (F4, web):
- live command output in the global activity timeline and in the inline
  tool card (auto-opened, tail-pinned) -- not just the per-window rail.

Other (web): artifact/knowledge deep links (F5); step-first stable
"thinking" headline (F6); stable chat layout, no empty->content reflow (F7);
lazy event sync (P2.2); reconnect skips a terminal session (P2.1).

VERSION: 0.14.2 -> 0.15.0
2026-08-03 15:42:10 +02:00

79 lines
3.3 KiB
Go

package main
import (
"context"
"testing"
"github.com/google/uuid"
)
func TestExtractExecutionIDs(t *testing.T) {
// Real tool-result phrasings that should yield an execution id.
pos := map[string]string{
`"pct_create on host:strong auto-approved via assent window — execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c running."`: "019f4b19-eafd-74ed-baa6-d24a27b3f52c",
`"run on lxc:caddy requires approval (risk: config_mutation) — execution 019f4af7-7eff-7723-b38c-b540b267f407 queued."`: "019f4af7-7eff-7723-b38c-b540b267f407",
`"apt_upgrade on host:hubris auto-approved via assent window — execution 019f4b58-c88c-7767-87dd-044608ced913 running."`: "019f4b58-c88c-7767-87dd-044608ced913",
}
for in, want := range pos {
ids := extractExecutionIDs(in)
if len(ids) != 1 || ids[0].String() != want {
t.Errorf("extractExecutionIDs(%q) = %v, want [%s]", in, ids, want)
}
}
// Synchronous auto-run and read-only results carry no "execution <uuid>"
// phrasing — they've already completed inline and must NOT be linked for
// continuation.
neg := []string{
`"run on host:strong (read_only, auto): 09:30 up 8 days"`,
`"run on lxc:caddy (config_mutation, auto via assent window): done"`,
`[{"slug":"lxc:caddy","health":"healthy"}]`,
`"target not found: lxc:nope"`,
}
for _, in := range neg {
if ids := extractExecutionIDs(in); len(ids) != 0 {
t.Errorf("extractExecutionIDs(%q) = %v, want none", in, ids)
}
}
// De-dupes repeated ids in one result.
dup := `execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c queued ... execution 019f4b19-eafd-74ed-baa6-d24a27b3f52c running`
if ids := extractExecutionIDs(dup); len(ids) != 1 {
t.Errorf("expected de-dup to 1 id, got %v", ids)
}
}
// TestResumeSession_SkipsWhenBusy guards the P0 fix
// (plans/2026-08-03-nomos-chat-changes-review.md): resumeSession must skip —
// return false, body never executed — when a turn is already active for the
// session. continueSession relies on this so it only marks a continuation
// "continued" after a turn really ran (otherwise the result is lost: marked
// continued, never re-queued by pendingContinuations).
//
// A minimal agent with only a gate is enough: if the body ever ran, chatWith
// would dereference the nil provider and panic. Returning false cleanly proves
// the body was skipped.
func TestResumeSession_SkipsWhenBusy(t *testing.T) {
a := &agent{gate: newTurnGate()}
if !a.gate.acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session")
}
ran := a.resumeSession(context.Background(), "sess", "note")
if ran {
t.Fatal("resumeSession must return false (skip) while a turn is active for the session")
}
}
// TestContinueSession_DefersWhenBusy guards the other half of P0: when the
// session is busy, continueSession defers (leaves the execution pending for the
// next worker tick) instead of running or marking it. It must return cleanly
// without reaching resumeSession's body (nil provider → panic) or markContinued.
func TestContinueSession_DefersWhenBusy(t *testing.T) {
a := &agent{gate: newTurnGate()}
if !a.gate.acquire("sess", 0) {
t.Fatal("precondition: initial acquire should succeed on a free session")
}
p := pendingContinuation{ExecID: uuid.New(), SessionID: "sess", Status: "completed"}
a.continueSession(context.Background(), p) // must not panic; must not run/mark
}