feat: live visibility into what the agent is running (no more silent waiting)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -72,16 +72,49 @@ func (a *agent) processContinuations(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// continueSession re-invokes the agent for one finished execution, persisting
|
// continueSession re-invokes the agent for one finished execution. Persists
|
||||||
// the resulting assistant turn just like handleChat does. The operator sees it
|
// progress LIVE — a placeholder row immediately, updated in place as each
|
||||||
// on their next load of the session (live push is a follow-up).
|
// 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) {
|
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
||||||
note := buildContinuationNote(p)
|
note := buildContinuationNote(p)
|
||||||
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
|
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 toolCalls []map[string]any
|
||||||
var finalText, errText string
|
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 /
|
// One retry if the LLM call itself produced nothing (transient flake /
|
||||||
// empty-response) — the whole point of this mechanism is "don't give up
|
// empty-response) — the whole point of this mechanism is "don't give up
|
||||||
// on the first error," which should apply to the continuation call
|
// 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
|
m["type"] = ev.Type
|
||||||
toolCalls = append(toolCalls, m)
|
toolCalls = append(toolCalls, m)
|
||||||
}
|
}
|
||||||
|
persist() // live: a poller sees this step land within seconds
|
||||||
}
|
}
|
||||||
if ev.Type == "text" {
|
if ev.Type == "text" {
|
||||||
finalText, _ = ev.Data.(string)
|
finalText, _ = ev.Data.(string)
|
||||||
@@ -116,18 +150,10 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
text := finalText
|
if errText != "" && 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)
|
|
||||||
slog.Error("nomos: auto-continuation produced no response after retry", "session", p.SessionID, "execution", p.ExecID, "error", errText)
|
slog.Error("nomos: auto-continuation produced no response after retry", "session", p.SessionID, "execution", p.ExecID, "error", errText)
|
||||||
}
|
}
|
||||||
assistantMsg, _ := json.Marshal(map[string]any{
|
persist() // final state — same row, updated one last time with the concluding text
|
||||||
"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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildContinuationNote frames the finished execution for the model: what
|
// buildContinuationNote frames the finished execution for the model: what
|
||||||
|
|||||||
@@ -77,6 +77,34 @@ func (s *store) saveMessage(ctx context.Context, sessionID, role string, content
|
|||||||
return err
|
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 {
|
func truncateToolResults(content json.RawMessage) json.RawMessage {
|
||||||
var m map[string]any
|
var m map[string]any
|
||||||
if err := json.Unmarshal(content, &m); err != nil {
|
if err := json.Unmarshal(content, &m); err != nil {
|
||||||
|
|||||||
@@ -95,11 +95,8 @@ function mergeToolCalls(raw: ToolCallResult[] | undefined): ToolCallResult[] {
|
|||||||
return Array.from(byId.values())
|
return Array.from(byId.values())
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadSessionMessages(sessionId: string) {
|
function toChatMessages(msgs: Message[]): ChatMessage[] {
|
||||||
currentSession.set(sessionId)
|
return msgs.map((m) => {
|
||||||
const msgs = await fetchMessages(sessionId)
|
|
||||||
sessionMessages.set(msgs)
|
|
||||||
const chatMsgs: ChatMessage[] = msgs.map((m) => {
|
|
||||||
const tools = mergeToolCalls(m.content?.tool_calls)
|
const tools = mergeToolCalls(m.content?.tool_calls)
|
||||||
return {
|
return {
|
||||||
id: m.id,
|
id: m.id,
|
||||||
@@ -109,7 +106,50 @@ export async function loadSessionMessages(sessionId: string) {
|
|||||||
pendingApprovals: extractApprovals(tools)
|
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<typeof setInterval> | 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) {
|
export function sendMessage(text: string) {
|
||||||
@@ -202,7 +242,12 @@ export function sendMessage(text: string) {
|
|||||||
}
|
}
|
||||||
return [...ms]
|
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') {
|
} else if (ev.type === 'error') {
|
||||||
error.set(ev.data)
|
error.set(ev.data)
|
||||||
}
|
}
|
||||||
@@ -220,6 +265,7 @@ export function sendMessage(text: string) {
|
|||||||
|
|
||||||
export function newChat() {
|
export function newChat() {
|
||||||
cancelStream()
|
cancelStream()
|
||||||
|
stopPolling()
|
||||||
currentSession.set(null)
|
currentSession.set(null)
|
||||||
messages.set([])
|
messages.set([])
|
||||||
error.set(null)
|
error.set(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user