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
357 lines
21 KiB
Markdown
357 lines
21 KiB
Markdown
# 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.
|