package main import ( "context" "encoding/json" "log/slog" "strings" "time" "github.com/google/uuid" ) // runChatTurn is the shared core of an operator-initiated turn: insert an // assistant placeholder, run a.chat with incremental persistence (so whatever // happened before an abort is never lost), finalize the row, and derive a // title. It is agnostic to the transport: `sink` receives every agent event // for delivery (SSE for a live handleChat, a no-op for a queued turn that has // no client attached — the frontend learns about those via the poller + the // status-driven "working" signal). The caller MUST already hold the session's // turn-gate permit. func (a *agent) runChatTurn(pctx, ctx context.Context, sessionID, message string, sink func(agentEvent)) { toolCalls := []map[string]any{} // P3: accumulate per-iteration reasoning instead of overwriting with the // final `text` event (see the original inline comment in handleChat). var textParts []string var thinkingParts []string var finalText string var finalThinking string placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""}) msgID, err := a.store.insertMessageReturningID(pctx, sessionID, "assistant", placeholder) if err != nil { slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err) } persist := func() { if msgID == uuid.Nil { return } body, _ := json.Marshal(map[string]any{ "role": "assistant", "text": finalText, "thinking": finalThinking, "tool_calls": toolCalls, }) a.store.updateMessage(pctx, msgID, body) } a.chat(ctx, sessionID, message, func(ev agentEvent) { if ev.Type == "tool_use" || ev.Type == "tool_result" { if m, ok := ev.Data.(map[string]any); ok { m["type"] = ev.Type // One entry per tool call: tool_use creates it, tool_result // merges the result into the same entry (matched by id). id, _ := m["id"].(string) if id != "" && ev.Type == "tool_result" { for _, existing := range toolCalls { if eID, _ := existing["id"].(string); eID == id { for k, v := range m { existing[k] = v } break } } } else { toolCalls = append(toolCalls, m) } } persist() // live: survives even if the client disconnects right after } if ev.Type == "text" { if t, ok := ev.Data.(string); ok && t != "" { if ev.IsThinking { thinkingParts = append(thinkingParts, t) finalThinking = strings.Join(thinkingParts, "\n\n") } else { textParts = append(textParts, t) finalText = strings.Join(textParts, "\n\n") } persist() } } sink(ev) }) // B.6: if the turn ended with no text and no tool calls (the model // empty-response'd and all retries failed), delete the placeholder row // instead of persisting an empty bubble. if finalText == "" && len(toolCalls) == 0 && msgID != uuid.Nil { a.store.deleteMessage(pctx, msgID) } else { persist() // final state — same row, updated one last time } // Title: prefer the goal once set; else the first assistant answer. if finalText != "" && sessionID != "ephemeral" { var goalTitle string if sess, gerr := a.store.getSession(pctx, sessionID); gerr == nil && sess.Goal != "" { goalTitle = truncate(sess.Goal, 120) } title := goalTitle if title == "" { title = truncate(finalText, 80) } if title != "" { a.store.updateSessionTitle(pctx, sessionID, title) } } } // drainAcquireWait is how long drainQueued blocks for a busy gate before // re-queuing and deferring to the holder's own release-drain. A package var so // tests can shorten it; in production it just needs to outlast the brief // release→drain handoff window. var drainAcquireWait = 5 * time.Second // drainQueued runs every queued operator message for a session as its own turn, // one at a time, under the turn gate. Called (in a goroutine) whenever a turn // releases the gate — from handleChat (live) and resumeSession (background) — // so a message queued while the agent was busy is acted on as soon as it's // free, without the operator re-sending. See messagequeue.go (plan 2026-08-03 // F2). // // Each queued turn is persisted incrementally and has no SSE client (the // browser detached after receiving the `queued` event); the frontend sees the // result via the 3s poller and the status-driven "working" indicator. func (a *agent) drainQueued(ctx context.Context, sessionID string) { for { msg, ok := a.queue.dequeue(sessionID) if !ok { return } // Block briefly for the gate. If a live turn grabbed it first, put the // message back — that turn's release will drain it again. Never stack. if !a.gate.acquire(sessionID, drainAcquireWait) { a.queue.requeueFront(sessionID, msg) return } slog.Info("nomos: running queued operator message", "session", sessionID) pctx := context.Background() // Run the turn inside a per-iteration closure so the gate release is // deferred to the end of THIS turn (and runs even if runChatTurn // panics — safego recovers the panic at the goroutine boundary, so a // non-deferred release would be skipped and the session's permit held // forever, deadlocking all future turns). A bare `defer release` in // the loop would be wrong too: Go defers run at function exit, not // iteration exit, so the gate would stay held across iterations. func() { defer a.gate.release(sessionID) a.runChatTurn(pctx, ctx, sessionID, msg, func(agentEvent) {}) }() } }