Move ten completed plans from plans/ to plans/done/ and update the index:
- 2026-07-18 session-review-three-sessions, 2026-07-20 desktop-mascot,
2026-07-20 session-review-ten-sessions, 2026-07-21 chat-full-polish,
2026-07-29 health-check-reality-and-knowledge-graph,
2026-07-30 session-review-plan-drift, and the four 2026-08-03 chat plans
(changes-review, reliability-and-ux-audit, cyberspace-style-adoption,
working-visibility).
- Refresh two stale statuses: cyberspace-style-adoption ("Draft" -> shipped as
full replacement in v0.16.0/757ef2f) and health-check-reality ("ready for
implementation" -> shipped across the v0.14.x-0.16.x check commits).
- .gitignore: ignore local tooling artifacts (.playwright-mcp/, config-screen.png).
No code change. index.md Active/Done tables now match the filesystem (no orphans).
VERSION: 0.17.0 -> 0.17.1
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.