Files
oikos/plans/done/2026-08-03-nomos-chat-reliability-and-ux-audit.md
dtoro 195d45a0e9
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
docs(plans): reconcile plan statuses; archive 10 done plans
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
2026-08-03 22:52:25 +02:00

357 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 2026-08-03 — Nomos chat: reliability & predictability audit
**Status:** Implemented (F1F7) 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 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.