Files
oikos/plans/2026-08-03-nomos-chat-reliability-and-ux-audit.md
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

21 KiB
Raw Blame History

2026-08-03 — Nomos chat: reliability & predictability audit

Status: Implemented (F1F7) in v0.15.0; F8 deferred. See Resolution at the end.

Scope: The live chat/task UX across one production session, audited through the code paths behind each operator-reported symptom — cmd/nomos/{main.go,agent.go,continue.go,store.go}, web/src/lib/stores/{chat,activity,execstream,events,workspace}.ts, web/src/lib/components/{ChatThread,AgentTrace,ToolCallCard,UnifiedTimeline,TaskContextPanel,SessionChatWindow}.svelte. Trigger: Operator report — streaming invisible in the tool card; the activity/plan panel wrong about parallel/nested runs and timestamps with no clear sequence; no links to artifacts/knowledge referenced in chat; agent "thinking" flickers/overwrites itself; layout jumps when a chat goes from empty to content; "Agent connection lost / Error in input stream" messages that aren't actionable and don't self-resolve; overall flaky/disconnected feel where the task never cleanly ended.

The prior round (2026-07-30-session-review-plan-drift-and-dead-activity-panel.md, shipped in 467589d) fixed the plan-seq and fabricated-timestamp rendering bugs. This round's symptoms are a different layer: turn orchestration, streaming wiring, and connection-state UX. One architectural gap (F1) is the common cause behind several of them.


The one root cause that compounds everything: F1

F1 — No per-session turn serialization (concurrent turns corrupt the view)

handleChat runs a.chat(ctx, ...) directly in the HTTP request goroutine, and every "resume" path (resumeSession, the reconnect empty-message path, the auto-continuation worker, the idle sweep, answer-question) launches another goroutine (safego.Go) running a full turn. There is no mutex keyed on sessionID anywhere. The codebase already knows this is a hazard — agent.go:316-323 marks approved executions continued specifically because "two concurrent LLM calls for the same session cause empty responses and race conditions" — but the fix is per-path patching, not a general lock.

What this produces, deterministically:

  • A network blip on the browser↔nomos stream fires handleDisconnect (chat.ts:383), which POSTs an empty-message reconnectmain.go:194-206 spawns resumeSession as a new goroutine. If the original turn is still alive (or finishes its current tool call), two turns now run for one session: interleaved tool_use/text_delta events, a re-proposed plan, and "the agent is repeating itself."
  • The activity timeline (activity.ts:119-185) groups tools under a plan step by inferring currentStepSeq from update_plan_step calls in the message stream. Two interleaved turns make that inference wrong → tools land under the wrong step, steps appear to nest/parallelize that never did, the sequence reads as garbage. This is the "parallel runs / nesting / no clear sequence" report.
  • Two turns appending to the same session's messages is also the source of the duplicate-tool-call/empty-response class of bugs the prior plan docs keep patching individually.

This is why the experience "felt flaky and disconnected" and "the task didn't end": the panel is faithfully rendering a corrupted, interleaved event stream.

Fix (proposed)

  1. One in-flight turn per session, server-side. Add a per-sessionID turn mutex (a sync.Map[string]*singleflight or a keyed sync.Mutex) in handleChat/resumeSession/continue.go. A second attempt to start a turn for a session that already has one running must queue (preferred — the operator's message waits its turn) or return 409 "turn in progress" (the frontend then just re-polls; no new goroutine). This single change removes the interleaving that drives F2/F3/F8.
  2. Make the empty-message reconnect a no-op when a turn is already running. Today it always spawns resumeSession. Gate it on "is any turn active for this session?" — if yes, return 202 and let the existing turn + the poller do the work. A blip should never create work.

F2 — Reconnect spawns a new turn and surfaces raw, non-actionable errors

chat.ts:383-426 handleDisconnect: on a dropped SSE it sets connectionState='disconnected', starts the 3s poller, shows "Agent connection lost. The task is still running — retrying…", then calls streamChat('', sid, …) up to 3× — each of which is the empty-message POST that triggers F1's new resumeSession goroutine. Separately, the LLM stream errors surface verbatim: agent.go:388 does emitError("llm: %v", err), so an OpenRouter transport break reaches the operator as llm: error in input stream: … (the openai-go SDK's SSE-reader text), shown raw in ChatThread's error bar.

Combined with F1, this is the exact "messages not actionable and not self-resolving" + "task didn't end" experience: a blip both invents a duplicate turn and paints a scary, unfixable error that lingers.

Secondary defects in the same path:

  • streaming stays true for the entire reconnect window, so the composer is disabled and the poller's if (streaming && connected) return guard (chat.ts:177) suppresses updates except while disconnected — fragile.
  • The per-window error path (sendSessionMessage, startTask) does not auto-reconnect at all — it only polls. Its onReconnect in SessionChatWindow.svelte:110 is () => loadSessionChat(sessionId), which just re-fetches the transcript and never re-attaches to a live stream. And the global reconnect() (chat.ts:428) keys off the global currentSession, so a floating window's Reconnect button can target the wrong session. Two different, both-broken reconnect behaviors.

Fix (proposed)

  1. Stop the empty-message-reconnect from creating turns (depends on F1.2). Reconnect should mean "catch up," not "run more."
  2. Humanize + bucket error strings. Map known transport errors to operator-readable, actionable copy with a single primary action:
    • llm: …input stream… / 502/503/timeout → "The model connection dropped. The task is still running in the background — it'll catch up automatically." (auto-dismiss when the next event/poll lands)
    • HTTP 401/403 → "Session expired — reconnect." (action: re-auth)
    • unknown → show the raw text but behind a "Details" toggle, not as the headline.
  3. Make errors self-resolving. Clear the error + connection-lost banner the moment the poller sees a newer message or any live event for the session arrives (wire eventsConnected / a session-scoped event into the banner's visibility). Today the banner stays until manual dismiss even after recovery.
  4. Unify reconnect. One reconnect(sessionId) that (a) re-fetches the transcript, (b) if no turn is active, is a pure no-op refresh; used by both the main view and windows. Drop the global-currentSession coupling.

F3 — The UI can't tell when a turn truly ended (so it never looks "done")

When the SSE stream ends without a done event, streamChat's onDone (chat.ts:355-368) calls handleDisconnect. Even if the backend turn then finishes and persists its final message, the frontend only learns via the 3s poller re-setting messages — but nothing transitions streamingfalse or connectionStateconnected from that path, so the spinner/indicator and the "connection lost" banner can persist indefinitely. That is "the task didn't end / backend connection was lost."

The backend does emit a terminal signal — task.status events on complete_task/auto-complete (workspace.ts:82-88 STATUS_AFFECTING) — but nothing in the chat store reacts to a terminal task.status to force streaming=false + clear the banner. The signal exists; the chat ignores it.

Fix (proposed)

  1. Treat a terminal task.status (done/failed) for the viewed session as authoritative end-of-turn in chat.ts: set streaming=false, connectionState='connected', dismiss any connection-lost error. The poller already refreshes messages; this just closes the loop on the state flags.
  2. Add a task.completed / turn.ended SSE event from the backend on every terminal path (today done is a chat-stream-only event; background turns have no equivalent). The always-on events stream already reaches the panel — route the same signal to the chat store so background-completed turns clear the UI without waiting on a poll.

F4 — Command streaming isn't shown where the operator looks

Streaming exists (execstream.ts liveExecutionOutputFor, fed by fetchExecutionLogs via the always-on events stream) and the UnifiedTimeline does render tool.liveOutput with tail-pinned scroll (UnifiedTimeline.svelte:451-457). But:

  • The global activityLog (activity.ts:236) — used by the main Chat page's panel — never calls withLiveOutput. Only the per-window activityLogFor(sessionId) (activity.ts:271) attaches live output. So the main chat view's timeline shows no streaming at all.
  • The inline chat tool cardsToolCallCard.svelte (rendered inside AgentTrace.svelte) — show only args/result/error. They never read liveOutput. Expanding a running run call in the transcript (the natural place to "check the tool") shows nothing live; output appears all at once when the tool_result lands.

This is the report: "I expected checking on the tool to let me see the streaming."

Fix (proposed)

  1. Wire live output into the global activityLog so the main chat panel streams too (call withLiveOutput in the activityLog derivation, same as activityLogFor).
  2. Show streaming in the inline tool card. Pass the session's live-output store into AgentTrace/ToolCallCard (or attach liveOutput to the running run tool entry the way the timeline does) and render a tail-pinned <pre> while the call is tool_use/running. Reuse the UnifiedTimeline's scroll-pin pattern. Gated runs (queued-for-approval) should instead show a "queued — watch in entity detail" affordance (per execstream.ts header comment).

F5 — Artifacts and knowledge referenced in chat aren't navigable

When the agent records knowledge, the activity panel shows Recorded: <title> (activity.ts:188-203) but it's plain text — no link. The backend already emits knowledge.recorded and links the note to the task (store.go:1572 linkKnowledgeToTask, agent.go:594), and the Wiki reader exists (web/src/lib/components/knowledge/WikiReader.svelte). Nothing connects them. Same for get_entity/run results: slugs and execution ids appear in tool output but aren't clickable to open the entity window or execution view.

Fix (proposed)

  1. Make activity/tool entries link-bearing. Add an optional link?: { kind: 'knowledge'|'entity'|'execution', id: string } to ActivityEntry. Populate it from upsert_knowledge (title→knowledge id from the result), get_entity (slug), and run (execution id). Render a clickable chip that opens the right surface: knowledge → Wiki reader (new tab / window), entity → entity detail window, execution → execution log pane (already fetched by EntityDetailContent.svelte).
  2. Render entity/knowledge mentions in assistant markdown as links when they resolve to known slugs (lightweight: a post-process pass on rendered text, or let the model emit explicit [slug](entity:…) markers it already has tools to discover).

F6 — "Thinking" is an unstable single-line headline, not a predictable trace

ChatThread's indicatorLabel (ChatThread.svelte:83-89) returns the first running activity entry's description; AgentTrace's headline mirrors it. As tools fire sequentially the running entry changes, so the one line rewrites itself every call — "the thinking overwrites itself." There is no persistent, additive reasoning surface, and no predictable turn structure (plan → steps → answer) the operator can learn to read. Claude-Code-style predictability is absent.

Fix (proposed)

  1. A stable, additive per-turn reasoning block. Keep the collapsed trace as a summary ("Step 2 of 4 · running run"), but when expanded show an append-only log of (a) the model's intermediate text (reasoning before each tool call — already emitted at agent.go:458-460 and persisted) and (b) each tool call as a fixed row, instead of a single mutating headline.
  2. Predictable turn shape. Enforce/cue a consistent sequence in the UI — Goal → Plan → Steps (each with its tools nested) → Final answer — and render each phase as a stable section that fills in rather than a line that overwrites. The UnifiedTimeline already models most of this; surface the same model in the inline trace so chat and panel tell one story.

F7 — Layout jumps when a chat goes from empty to content

SessionChatWindow.svelte:58-63 gates the right rail on hasContext: empty task → ChatThread full-width; first activity/touched entity → switches to Splitpanes with the TaskContextPanel rail. The swap is instant and reflows the chat column width the moment the first event lands — "switching from empty to chat with something, the layout was off." Compounded by the NewTaskChat → real SessionChatWindow window-swap on first send (NewTaskChat.svelte:17-22).

Fix (proposed)

  1. Reserve the rail's space from the start (collapse to a thin sliver / icon rail when empty) instead of mounting it on demand, so adding content doesn't change the chat column width. Or animate the rail in.
  2. Avoid the window swap on first send — let the new-task window become the session window in place once the id is assigned (same component, swap the store source) rather than close+open.

F8 — Activity/plan ordering & parallelism (largely a symptom of F1)

With F1 fixed (no interleaved turns) the heuristic step-grouping in activity.ts becomes reliable again. Remaining standalone items:

  • The timeline is newest-first with ts-0 goal/pending parked at the bottom (UnifiedTimeline.svelte:119-127); for a long task this can read as "sequence is off." Consider an explicit oldest-first / seq-ordered mode toggle, and always show the step number prominently so order is unambiguous regardless of sort.
  • Background/auto-continued turns still rely on the 3s poller for their result to appear; until F3's terminal event lands, the panel can lag. The always-on events stream already carries plan.* and entity.touched live — extend it to carry per-tool tool.* deltas for background turns so the panel is live, not polled, during autonomous work.

Order Item Why first
1 F1 per-session turn mutex + no-op reconnect-when-busy Removes the interleaving that is the root cause of F2/F3/F8 symptoms; everything else is cosmetics on top of a corrupted stream.
2 F3 terminal-event → clear chat state Once turns can't double, make "the task ended" unambiguous so the UI stops lingering.
3 F2 humanized/self-resolving errors + unified reconnect Turns the scary, sticky "connection lost / input stream" into recoverable, auto-clearing UX.
4 F4 streaming in the global log + inline tool card Highest-visibility "I can't see what it's doing" fix; small, isolated change.
5 F6 stable additive reasoning trace Predictability of the interaction model (the Claude-Code feel).
6 F5 artifact/knowledge deep links Navigation completeness.
7 F7 layout stability Polish.
8 F8 ordering mode + live background deltas Polish, partly free after F1.

Verification hooks (when implementing)

  • cmd/nomos: a test that starts two turns for the same session and asserts the second queues/is-rejected (no interleaved tool_use order in persisted messages).
  • web/src/lib/stores: extend activity.test.ts/execstream.test.ts — global activityLog now carries liveOutput; tool-card live output renders while tool_use and clears on tool_result.
  • A reconnect/integration test: drop the SSE mid-turn, assert (a) no duplicate resumeSession goroutine, (b) banner auto-clears on next event, (c) streaming returns to false on terminal task.status.

Note on method

This audit was done against the code paths behind the reported symptoms, not a single session transcript (no MCP/DB access from this session). To tie a specific finding to a specific past session, pull the session via docker exec oikos-postgres-1 psql -U oikos oikos -c "select id,goal,outcome from agent_sessions order by last_active_at desc limit 5" and cross-reference its agent_activity rows / persisted messages against the F1 interleaving signature (two assistant turns' tool ids interleaved in one message shell).


Resolution (2026-08-03)

Implemented F1F7 in v0.15.0 (VERSION 0.14.2 → 0.15.0). F8 deferred (its primary symptom — interleaved/out-of-order entries — is removed by F1; the ordering toggle and live background tool-delta streaming remain as nice-to- haves).

Item What shipped Where
F1 Per-session single-flight turn gate (turnGate): at most one in-flight turn per session. Background resume paths (resumeSession — covers the continuation worker, idle sweep, answer-question, /resume, and the empty-message reconnect) skip non-blocking when busy; the live chat path waits briefly then bails with an actionable error instead of stacking a second turn. cmd/nomos/turngate.go (+turngate_test.go), wired in agent.go (struct/init), continue.go (resumeSession), main.go (handleChat).
F3 Terminal task.status events (done/failed/abandoned/awaiting_input) now clear a stuck chat view's streaming/connectionState and dismiss the connection-lost toasts — the authoritative "turn ended" signal the UI was ignoring. Poller safety net catches the edge where the event fired during the disconnect window. web/src/lib/stores/chat.ts (clearTurnState, liveEvents subscription, startSessionPolling).
F2 Raw errors humanized ("The model connection dropped. The task keeps running…") and bucketed; one connection surface per drop (not banner+toast+raw error); errors self-clear via F3. The turn-spawning reconnect attempt loop is gone (dead global path simplified to a turn-free refresh); window "Reconnect" re-fetches + resets state. web/src/lib/stores/chat.ts (humanizeChatError, error handlers, loadSessionChat, handleDisconnect/reconnect), web/src/lib/components/ChatThread.svelte (banner copy).
F4 Command streaming now shows (a) in the global activity timeline (live output wired into activityLog, was only per-window) and (b) in the inline chat tool card — expanding a running run shows live output auto-opened and tail-pinned. web/src/lib/types.ts (liveOutput), web/src/lib/stores/activity.ts (currentLiveOutput), web/src/lib/components/ChatThread.svelte (toolsWithLive), web/src/lib/components/ToolCallCard.svelte.
F6 The "thinking" headline is now step-first (stable across a step's many tool calls) instead of rewriting per command; falls back to the current tool / "thinking…" only when no step is active. web/src/lib/components/ChatThread.svelte (indicatorLabel).
F5 Activity entries now carry a deep link: recorded knowledge docs and get_entity lookups get an "open artifact" chip that opens the entity/knowledge window directly. web/src/lib/stores/activity.ts (link, knowledgeLinkFromResult, entityLinkFromArgs), web/src/lib/components/UnifiedTimeline.svelte.
F7 The empty→content layout reflow is gone: SessionChatWindow now has one stable Splitpanes+ChatThread from open (no more destroy/remount of the thread or column reflow when the rail appears). web/src/lib/components/SessionChatWindow.svelte.

Verification:

  • go test ./cmd/nomos/ green (incl. new turngate_test.go: non-blocking skip, blocking-waits-for-release, timeout, and a 50-goroutine single-flight concurrency test asserting max in-flight = 1). go vet clean.
  • Web vitest 70/70 green (incl. activity.test.ts/execstream.test.ts); the activity.test.ts chat mock gained currentSession for the new currentLiveOutput derivation.
  • vite build succeeds (all Svelte components compile). Pre-existing tsc strictness errors in unrelated files (ui/*, oidc.ts, windows.ts, workspace.ts) are unchanged; no new errors in any touched file.

Follow-ups (not in this pass):

  • F8: oldest-first ordering toggle; emit per-tool tool.* events on the always-on stream during background resumeSession turns so the panel is live (not 3s-polled) during autonomous work.
  • F5: run execution deep-links (open the entity detail's execution pane) — needs an execution-view opener; knowledge/entity links shipped first as the explicit complaint.
  • F7: the NewTaskChat → SessionChatWindow window-swap on first send (a windows.ts open/close) still causes a brief flash; an in-place handoff (same window, swap store source) would remove it.