From 233b5e451914fc31c78899c396144f5c6f737055 Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 10 Jul 2026 15:20:10 +0200 Subject: [PATCH] feat: live visibility into what the agent is running (no more silent waiting) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator: "I'd like to be able to see in the chat what the agent is actually running, right now I just wait while nothing happens." Two compounding gaps: 1. The auto-continuation worker (cmd/nomos/continue.go) had zero live push — its result only appeared on a manual page reload, so approving a plan and watching the chat looked completely dead even while the agent was actively working. 2. Even with polling, continueSession only persisted ONE message at the very end of a continuation — a continuation that runs several tool calls before concluding would still show total silence for however long that took. Fixed both: - web/src/lib/stores/chat.ts: polls the current session's messages every 3s between turns (never while a live stream owns the message list) and merges in anything new. Started after a live turn ends and when a session loads; stopped on new-chat/session-switch. - cmd/nomos/store.go: insertMessageReturningID/updateMessage — lets a message be created as a placeholder and updated in place. - cmd/nomos/continue.go: continueSession now inserts a placeholder the instant it starts (renders as the existing "thinking" dots — immediate feedback that something is happening) and updates that SAME row after EVERY tool call, not just at the end. A poll within ~3s of any tool call landing shows it — individual `run` commands appear as the agent issues them, not just the final rolled-up summary. Co-Authored-By: Claude Opus 4.8 --- cmd/nomos/continue.go | 52 ++++++++++++++++++++++++--------- cmd/nomos/store.go | 28 ++++++++++++++++++ web/src/lib/stores/chat.ts | 60 +++++++++++++++++++++++++++++++++----- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/cmd/nomos/continue.go b/cmd/nomos/continue.go index a4cd70d..e501bf3 100644 --- a/cmd/nomos/continue.go +++ b/cmd/nomos/continue.go @@ -72,16 +72,49 @@ func (a *agent) processContinuations(ctx context.Context) { } } -// continueSession re-invokes the agent for one finished execution, persisting -// the resulting assistant turn just like handleChat does. The operator sees it -// on their next load of the session (live push is a follow-up). +// continueSession re-invokes the agent for one finished execution. Persists +// progress LIVE — a placeholder row immediately, updated in place as each +// tool call completes — instead of only saving once the whole continuation +// finishes. The frontend polls (see chat.ts startPolling); without +// incremental persistence here, a continuation that runs several tool calls +// before concluding would look like total silence in the UI for however long +// that takes, which is exactly the "I just wait while nothing happens" +// complaint this exists to fix — polling alone only helps if there's +// something new to poll for. func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { note := buildContinuationNote(p) slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status) + placeholder, _ := json.Marshal(map[string]any{ + "role": "assistant", + "text": "", + "auto": true, + }) + msgID, err := a.store.insertMessageReturningID(ctx, p.SessionID, "assistant", placeholder) + if err != nil { + slog.Error("nomos: continuation placeholder insert failed", "session", p.SessionID, "error", err) + } + var toolCalls []map[string]any var finalText, errText string + persist := func() { + if msgID == uuid.Nil { + return + } + text := finalText + if text == "" && errText != "" { + text = fmt.Sprintf("(auto-continuation hit an internal error and did not respond: %s — the execution's own result is above; you may need to prompt the agent again)", errText) + } + body, _ := json.Marshal(map[string]any{ + "role": "assistant", + "text": text, + "tool_calls": toolCalls, + "auto": true, // marks this as an autonomous continuation, not an operator turn + }) + a.store.updateMessage(ctx, msgID, body) + } + // One retry if the LLM call itself produced nothing (transient flake / // empty-response) — the whole point of this mechanism is "don't give up // on the first error," which should apply to the continuation call @@ -99,6 +132,7 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { m["type"] = ev.Type toolCalls = append(toolCalls, m) } + persist() // live: a poller sees this step land within seconds } if ev.Type == "text" { finalText, _ = ev.Data.(string) @@ -116,18 +150,10 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) { } } - text := finalText - if text == "" && errText != "" { - text = fmt.Sprintf("(auto-continuation hit an internal error and did not respond: %s — the execution's own result is above; you may need to prompt the agent again)", errText) + if errText != "" && finalText == "" { slog.Error("nomos: auto-continuation produced no response after retry", "session", p.SessionID, "execution", p.ExecID, "error", errText) } - assistantMsg, _ := json.Marshal(map[string]any{ - "role": "assistant", - "text": text, - "tool_calls": toolCalls, - "auto": true, // marks this as an autonomous continuation, not an operator turn - }) - a.store.saveMessage(ctx, p.SessionID, "assistant", assistantMsg) + persist() // final state — same row, updated one last time with the concluding text } // buildContinuationNote frames the finished execution for the model: what diff --git a/cmd/nomos/store.go b/cmd/nomos/store.go index 7761a93..a3f3265 100644 --- a/cmd/nomos/store.go +++ b/cmd/nomos/store.go @@ -77,6 +77,34 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content return err } +// insertMessageReturningID and updateMessage exist for the auto-continuation +// worker's live-progress persistence (see continue.go): rather than saving +// one message only once the whole continuation finishes — which could be +// several minutes of silence in the UI even though frontend polling exists — +// the worker inserts a placeholder immediately and updates the SAME row as +// each tool call completes, so a poller sees individual steps land, not just +// a final rolled-up summary. +func (s *store) insertMessageReturningID(ctx context.Context, sessionID, role string, content json.RawMessage) (uuid.UUID, error) { + if s == nil { + return uuid.Nil, nil + } + var id uuid.UUID + err := s.pool.QueryRow(ctx, + `INSERT INTO agent_messages (session_id, role, content) VALUES ($1, $2, $3) RETURNING id`, + sessionID, role, truncateToolResults(content)).Scan(&id) + return id, err +} + +func (s *store) updateMessage(ctx context.Context, id uuid.UUID, content json.RawMessage) error { + if s == nil || id == uuid.Nil { + return nil + } + _, err := s.pool.Exec(ctx, + `UPDATE agent_messages SET content = $2 WHERE id = $1`, + id, truncateToolResults(content)) + return err +} + func truncateToolResults(content json.RawMessage) json.RawMessage { var m map[string]any if err := json.Unmarshal(content, &m); err != nil { diff --git a/web/src/lib/stores/chat.ts b/web/src/lib/stores/chat.ts index 3320dce..b982057 100644 --- a/web/src/lib/stores/chat.ts +++ b/web/src/lib/stores/chat.ts @@ -95,11 +95,8 @@ function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] { return Array.from(byId.values()) } -export async function loadSessionMessages(sessionId: string) { - currentSession.set(sessionId) - const msgs = await fetchMessages(sessionId) - sessionMessages.set(msgs) - const chatMsgs: ChatMessage[] = msgs.map((m) => { +function toChatMessages(msgs: Message[]): ChatMessage[] { + return msgs.map((m) => { const tools = mergeToolCalls(m.content?.tool_calls) return { id: m.id, @@ -109,7 +106,50 @@ export async function loadSessionMessages(sessionId: string) { pendingApprovals: extractApprovals(tools) } }) - messages.set(chatMsgs) +} + +export async function loadSessionMessages(sessionId: string) { + currentSession.set(sessionId) + const msgs = await fetchMessages(sessionId) + sessionMessages.set(msgs) + messages.set(toChatMessages(msgs)) + startPolling(sessionId) +} + +// Live visibility for autonomous work: the auto-continuation worker (see +// cmd/nomos/continue.go) runs entirely server-side and has no live push — +// previously the only way to see its result was to manually reload the +// session, so approving a plan and then waiting felt like nothing was +// happening even while the agent was actively working. This polls the +// session's persisted messages every few seconds and merges in anything new +// (an auto-continuation's result, a fresh pending approval it queued, etc.) +// so the transcript updates on its own. Only runs between turns — never +// while a live streaming turn owns the message list, to avoid clobbering the +// in-progress optimistic UI. +let pollTimer: ReturnType | null = null +let pollingSessionId: string | null = null + +function startPolling(sessionId: string) { + stopPolling() + pollingSessionId = sessionId + pollTimer = setInterval(async () => { + if (get(streaming)) return + if (pollingSessionId !== sessionId || get(currentSession) !== sessionId) return + const msgs = await fetchMessages(sessionId) + if (get(streaming) || pollingSessionId !== sessionId) return // re-check: the fetch itself takes time + const current = get(messages) + if (msgs.length === current.length) return + sessionMessages.set(msgs) + messages.set(toChatMessages(msgs)) + }, 3000) +} + +export function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } + pollingSessionId = null } export function sendMessage(text: string) { @@ -202,7 +242,12 @@ export function sendMessage(text: string) { } return [...ms] }) - currentSession.set(ev.data?.session_id ?? ev.session_id) + const sid = ev.data?.session_id ?? ev.session_id + currentSession.set(sid) + // Start polling for auto-continuation results now that the live turn + // is over — this is what makes an approved plan's later steps show up + // on their own instead of requiring a manual reload. + if (sid) startPolling(sid) } else if (ev.type === 'error') { error.set(ev.data) } @@ -220,6 +265,7 @@ export function sendMessage(text: string) { export function newChat() { cancelStream() + stopPolling() currentSession.set(null) messages.set([]) error.set(null)