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
21 KiB
2026-08-03 — Nomos chat: reliability & predictability audit
Status: Implemented (F1–F7) 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 reconnect →main.go:194-206spawnsresumeSessionas a new goroutine. If the original turn is still alive (or finishes its current tool call), two turns now run for one session: interleavedtool_use/text_deltaevents, a re-proposed plan, and "the agent is repeating itself." - The activity timeline (
activity.ts:119-185) groups tools under a plan step by inferringcurrentStepSeqfromupdate_plan_stepcalls 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)
- One in-flight turn per session, server-side. Add a per-
sessionIDturn mutex (async.Map[string]*singleflightor a keyedsync.Mutex) inhandleChat/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. - 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:
streamingstaystruefor the entire reconnect window, so the composer is disabled and the poller'sif (streaming && connected) returnguard (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. ItsonReconnectinSessionChatWindow.svelte:110is() => loadSessionChat(sessionId), which just re-fetches the transcript and never re-attaches to a live stream. And the globalreconnect()(chat.ts:428) keys off the globalcurrentSession, so a floating window's Reconnect button can target the wrong session. Two different, both-broken reconnect behaviors.
Fix (proposed)
- Stop the empty-message-reconnect from creating turns (depends on F1.2). Reconnect should mean "catch up," not "run more."
- 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.
- 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. - 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-currentSessioncoupling.
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 streaming→false or
connectionState→connected 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)
- Treat a terminal
task.status(done/failed) for the viewed session as authoritative end-of-turn inchat.ts: setstreaming=false,connectionState='connected', dismiss any connection-lost error. The poller already refreshes messages; this just closes the loop on the state flags. - Add a
task.completed/turn.endedSSE event from the backend on every terminal path (todaydoneis 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 callswithLiveOutput. Only the per-windowactivityLogFor(sessionId)(activity.ts:271) attaches live output. So the main chat view's timeline shows no streaming at all. - The inline chat tool cards —
ToolCallCard.svelte(rendered insideAgentTrace.svelte) — show only args/result/error. They never readliveOutput. Expanding a runningruncall in the transcript (the natural place to "check the tool") shows nothing live; output appears all at once when thetool_resultlands.
This is the report: "I expected checking on the tool to let me see the streaming."
Fix (proposed)
- Wire live output into the global
activityLogso the main chat panel streams too (callwithLiveOutputin theactivityLogderivation, same asactivityLogFor). - Show streaming in the inline tool card. Pass the session's live-output
store into
AgentTrace/ToolCallCard(or attachliveOutputto the runningruntool entry the way the timeline does) and render a tail-pinned<pre>while the call istool_use/running. Reuse the UnifiedTimeline's scroll-pin pattern. Gated runs (queued-for-approval) should instead show a "queued — watch in entity detail" affordance (perexecstream.tsheader 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)
- Make activity/tool entries link-bearing. Add an optional
link?: { kind: 'knowledge'|'entity'|'execution', id: string }toActivityEntry. Populate it fromupsert_knowledge(title→knowledge id from the result),get_entity(slug), andrun(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 byEntityDetailContent.svelte). - 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)
- 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 intermediatetext(reasoning before each tool call — already emitted atagent.go:458-460and persisted) and (b) each tool call as a fixed row, instead of a single mutating headline. - 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)
- 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.
- 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.*andentity.touchedlive — extend it to carry per-tooltool.*deltas for background turns so the panel is live, not polled, during autonomous work.
Recommended sequence
| 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 interleavedtool_useorder in persisted messages).web/src/lib/stores: extendactivity.test.ts/execstream.test.ts— globalactivityLognow carriesliveOutput; tool-card live output renders whiletool_useand clears ontool_result.- A reconnect/integration test: drop the SSE mid-turn, assert (a) no duplicate
resumeSessiongoroutine, (b) banner auto-clears on next event, (c)streamingreturns to false on terminaltask.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 F1–F7 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. newturngate_test.go: non-blocking skip, blocking-waits-for-release, timeout, and a 50-goroutine single-flight concurrency test asserting max in-flight = 1).go vetclean.- Web
vitest70/70 green (incl.activity.test.ts/execstream.test.ts); theactivity.test.tschat mock gainedcurrentSessionfor the newcurrentLiveOutputderivation. vite buildsucceeds (all Svelte components compile). Pre-existingtscstrictness 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 backgroundresumeSessionturns so the panel is live (not 3s-polled) during autonomous work. - F5:
runexecution 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 → SessionChatWindowwindow-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.