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

**Status:** Implemented (F1–F7) in v0.15.0; F8 deferred. See
[Resolution](#resolution-2026-08-03) 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-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 `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)

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 cards** — `ToolCallCard.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.

---

## 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 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 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. 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.
