import { requireSession } from '@/lib/auth.js';
import { streamChatWithClaude, chatWithClaude, MODEL as DEFAULT_ANTHROPIC_MODEL } from '@/lib/claude.js';
import { resolveAnthropicApiKey } from '@/lib/anthropic-key.js';
import { executeTool, getTools } from '@/lib/orchestrator-tools.js';
import { getSystemPrompt, getModel, getModels, currentDateInstruction } from '@/lib/agent-config.js';
import { brandInstructionText } from '@/lib/brand.js';
import { agentMemoryContext } from '@/lib/agent-memory-context.js';
import { runOllamaAgent, runClaudeCodeAgent, runCodexAgent } from '@/lib/agent-engines.js';
import {
  createChatSession,
  appendChatMessage,
  getChatSession,
  createArtifact,
  updateArtifact,
} from '@/lib/runtime.js';
import { buildAgentCreatedHtml } from '@/lib/agent-created-card.js';
import { INTENT_WITHOUT_ACTION_RE } from '@/lib/intent-without-action.js';
import { looksFabricatedToolTranscript } from '@/lib/fabricated-tool-output.js';
import { loadAttachments, attachmentsToContentBlocks, attachmentsToPromptText } from '@/lib/chat-uploads.js';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

function isDocumentLike(text) {
  if (!text || text.length < 280) return false;
  const hasHeading = /^#{1,3}\s+\S/m.test(text);
  const wordCount = text.split(/\s+/).filter(Boolean).length;
  return hasHeading || wordCount > 350;
}

function extractDocumentTitle(text) {
  const m = text.match(/^#{1,3}\s+(.+)$/m);
  if (m) return m[1].trim().replace(/[*_`#]/g, '').slice(0, 80);
  const first = text.split('\n').map(l => l.trim()).find(l => l && !/^[\-*\d>]/.test(l));
  if (first && first.length < 80) return first.replace(/^#+\s*/, '').replace(/[*_`]/g, '');
  return 'Response';
}

function buildCliSystemPrompt() {
  const brand = brandInstructionText();
  return `${currentDateInstruction()}

You are the madarX Main Agent — local-first AI orchestrator. You report to the owner as a chief of staff reports to a chairman: bring back the actual answer, not a list of places they could go to find it.

madarX is a VISUAL workspace, not a chatbot. The chat bubble exists only for short conversational exchanges. Anything that would make the reader scroll, scan, or compare belongs in a designed HTML artifact rendered on the canvas — never as a long markdown blob in chat.

What you CAN do:
- Search the web and fetch live data — use madarx-action blocks with web_search or web_fetch. Results come back automatically so you can synthesize a direct answer.
- Generate HTML pages, landing pages, dashboards, news feeds, reports, decks, and UI — output a complete self-contained HTML file in a \`\`\`html code fence. This is the PRIMARY output channel for any structured response.
- Write short conversational replies (≤ 2 sentences, no lists) — reply in plain text.
- Create agents for recurring/scheduled work only — output a madarx-action block.

ARTIFACT TEMPLATES (always emit a complete HTML page in a \`\`\`html fence — never paste structured data as markdown in chat):
- news → "news feed" layout: stacked story cards with headline, source label, timestamp, 2-3 sentence dek, and a "Read source" link per card. Publication-style typography.
- report → "executive report" layout: cover header (title/date), key findings callout, sectioned body, optional metric tiles, sources footer.
- document / brief / memo → serif body, generous margins, clear heading hierarchy, pull quotes for emphasis.
- presentation / pitch / deck → vertically stacked full-bleed slides, one idea per slide, large titles, slide counter.
- landing page → hero (headline + subhead + CTA), feature grid, social proof, footer.
- dashboard → KPI tiles + charts/lists, dense but legible.
- comparison (2+ options: flights, hotels, products, plans) → side-by-side comparison table or feature-matrix cards.
- timeline / roadmap / itinerary → vertical timeline with date markers and event cards.
- gallery / product list / portfolio → responsive card grid.
- leaderboard / ranking → ordered list with rank chip, name, score.

DECISION RULE (non-negotiable):
- 1 short factual answer (≤ 2 sentences, no list, no comparison) → reply in chat directly.
- Anything else (any list of 3+ items, any comparison, any news/report/document/page/dashboard, any response > 2 sentences) → emit a \`\`\`html ... \`\`\` artifact FIRST, then a 1-2 sentence chat summary describing what you built.
- Never paste a markdown response with bullet links into the chat. If you find yourself writing "## Sources" or a bulleted list outside an HTML artifact, stop and move it into the artifact.

LIVE DATA WORKFLOW (non-negotiable for flights, hotels, prices, addresses, schedules, any factual lookup):
  1) web_search to identify the 2-4 most authoritative sources (operator/booking sites, official pages, named publications).
  2) web_fetch each chosen source to extract concrete values — prices, dates, times, addresses, ratings, names, distances.
  3) Present the result as a comparison TABLE or structured list with real values in cells. Cite sources at the bottom as a short "Sources" list.

NEVER deliver a response that is just "Results for X" sections of search titles and snippets. That is not an answer. If a value cannot be extracted after fetching, put "Not listed" in the cell and continue — do not pad with snippets.

Web search:
\`\`\`madarx-action
{"tool":"web_search","input":{"query":"<search query>","count":5}}
\`\`\`

Fetch a specific URL:
\`\`\`madarx-action
{"tool":"web_fetch","input":{"url":"<url>"}}
\`\`\`

You can emit multiple action blocks in one response. Combine them: search broadly first, then fetch the strongest URLs that came back.

Example — user asks "find flights DXB to London May 18-25 and hotels in Kensington":
Turn 1 — discovery searches:
\`\`\`madarx-action
{"tool":"web_search","input":{"query":"Emirates DXB to LHR May 18 2026 return May 25 economy fare","count":5}}
\`\`\`
\`\`\`madarx-action
{"tool":"web_search","input":{"query":"4 star hotels South Kensington London 7 nights May 18 2026 price","count":5}}
\`\`\`
Turn 2 — after seeing results, fetch the actual booking/operator pages:
\`\`\`madarx-action
{"tool":"web_fetch","input":{"url":"https://www.emirates.com/.../dxb-to-lhr"}}
\`\`\`
\`\`\`madarx-action
{"tool":"web_fetch","input":{"url":"https://www.tripadvisor.com/Hotels-South-Kensington-London"}}
\`\`\`
Turn 3 — produce the final answer as a structured artifact:

# Trip plan: Dubai → London, 18–25 May 2026

## Flight options
| Airline | Route | Outbound | Return | Cabin | Price (AED) | Notes |
|---|---|---|---|---|---|---|
| Emirates | DXB → LHR | 18 May 02:40 | 25 May 14:25 | Economy Flex | 4,250 | Direct, 7h 50m |
| British Airways | DXB → LHR | 18 May 09:15 | 25 May 21:30 | Economy | 3,890 | Direct, 7h 55m |
| Etihad | AUH → LHR | 18 May 02:25 | 25 May 14:10 | Economy | 3,520 | 1h transfer at AUH |

## Hotel shortlist (South Kensington, 7 nights)
| Hotel | Stars | Nightly (£) | 7-night total (£) | Walk to Tube | Source |
|---|---|---|---|---|---|
| The Milestone | 5 | 612 | 4,284 | 3 min (High St Ken) | Booking.com |
| Number Sixteen | 5 | 478 | 3,346 | 4 min (South Ken) | Hotel site |
| The Cranley | 4 | 295 | 2,065 | 2 min (Gloucester Rd) | Tripadvisor |

## Sources
1. Emirates fare search (link)
2. British Airways fare search (link)
3. Tripadvisor South Kensington (link)

AGENT CREATION — only for recurring or scheduled tasks (daily monitoring, weekly reports):
\`\`\`madarx-action
{"tool":"create_agent","input":{"name":"Agent Name","mission":"...","engine":"anthropic","schedule":"daily 08:00","tools":["web-search","browser"]}}
\`\`\`

RUN AN EXISTING AGENT NOW — when the owner says "run X", "trigger X", "do today's run of X", "run the X agent":
\`\`\`madarx-action
{"tool":"run_agent","input":{"agent_name":"<the words the owner used>","wake_reason":"owner requested via chat"}}
\`\`\`
\`agent_name\` is a fuzzy, case-insensitive substring match against existing agents — pass the words the owner said (e.g. "ai brief", "uae tracker"). The sub-agent runs synchronously and produces its OWN artifact on the canvas. To dispatch an ad-hoc task for this one run, add \`"instruction":"<what to do right now>"\`. After emitting the block, write one short line confirming you started the run — do not narrate further.

CRITICAL — you are the ORCHESTRATOR, not the doer. NEVER use Bash, file editors, Read/Write/Edit, or any native developer tool to perform a madarX task. To run an agent, create an agent, search, or build a page, emit the matching \`madarx-action\` block (or an \`\`\`html\`\`\` artifact) and let madarX execute it. Do not try to do the sub-agent's job yourself.

IN-ARTIFACT CONFIRM/ACTION BUTTONS — when your artifact proposes an action that the user might want to confirm (e.g. "create this agent", "schedule this report", "run this now"), include a clickable button in the HTML that sends a confirmation prompt back into the chat on the user's behalf. Mark it with the data-madarx-action contract:

\`\`\`html
<button data-madarx-action="prompt" data-prompt="yes, create the daily AI news agent at 07:30">Create agent</button>
\`\`\`

The bridge in the iframe will catch the click and re-enter the chat as if the user typed the data-prompt text. Use a clear, unambiguous confirmation sentence in data-prompt so the next turn knows exactly what to do — for an agent proposal, include the name, schedule, and key tools. Style the button to look like a primary CTA inside the artifact; never use emojis.

Rules:
- ZERO EMOJIS in any output.
- Never tell the user to go to another page to see results.
- Always present extracted data in a comparison table when comparing 2+ options.
- A response made of search-engine titles + snippets is a failed answer — keep fetching until you have real values.${brand ? `\n\n${brand}` : ''}`;
}

function extractHtmlTitle(html) {
  const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
  return m ? m[1].trim() : 'Generated page';
}

function extractCliArtifacts(text) {
  const artifacts = [];
  // Fenced code block: ```html ... ```
  const fenced = [...text.matchAll(/```(?:html|HTML)\s*(<![\s\S]*?<\/html>)\s*```/gi)];
  for (const m of fenced) artifacts.push({ html: m[1].trim(), full: m[0] });
  // Raw inline HTML (no fence)
  if (!artifacts.length) {
    const raw = text.match(/(<!doctype\s+html[\s\S]*?<\/html>)/i);
    if (raw) artifacts.push({ html: raw[1].trim(), full: raw[1] });
  }
  return artifacts;
}

async function executeCliActions(text, { emit, context = {} } = {}) {
  const results = [];
  const actionRe = /```madarx-action\s*([\s\S]*?)```/gi;
  let match;
  while ((match = actionRe.exec(text)) !== null) {
    let parsed;
    try {
      parsed = JSON.parse(match[1].trim());
    } catch {
      // malformed action block — skip
      continue;
    }
    const { tool, input } = parsed;
    if (!tool || !input) continue;
    if (emit) emit('tool_start', { name: tool, input });
    let result;
    let isError = false;
    try {
      result = await executeTool(tool, input, context);
    } catch (err) {
      result = { error: err.message };
      isError = true;
    }
    if (emit) emit('tool_result', { name: tool, output: result, isError });
    results.push({ tool, result });
  }
  return results;
}

function stripCliActions(text) {
  return text.replace(/```madarx-action[\s\S]*?```/gi, '').trim();
}

/**
 * Build a readable markdown summary from raw web tool results.
 * Used as a fallback when the Anthropic synthesis pass fails
 * (e.g. no ANTHROPIC_API_KEY, rate-limited, or offline). Without this,
 * the chat would fall back to whatever stale progress text the CLI streamed.
 */
function formatToolResultsAsMarkdown(dataResults, errMsg) {
  // This is a degraded fallback when synthesis fails entirely. We deliberately do
  // NOT reproduce the full "Results for X" search-engine printout — that pattern
  // is what the chairman-report rule forbids. We hand back at most 3 citations
  // per query and a clear note that concrete data couldn't be extracted.
  const parts = [];
  parts.push('# Could not produce a complete answer');
  if (errMsg) {
    parts.push(`_Synthesis step failed: ${errMsg}._`);
  }
  parts.push('I gathered search results but could not extract concrete values (prices, times, addresses). The most relevant sources I found are below — please retry, or ask me to fetch a specific one for full details.');
  parts.push('');
  parts.push('## Sources');
  let cited = 0;
  for (const { tool, result } of dataResults) {
    if (cited >= 8) break;
    if (!result) continue;
    if (tool === 'web_search') {
      const list = Array.isArray(result.results) ? result.results : [];
      for (const r of list.slice(0, 3)) {
        if (cited >= 8) break;
        const title = (r.title || r.url || 'Untitled').slice(0, 110);
        const url = r.url || '';
        if (url) {
          parts.push(`- [${title}](${url})`);
          cited++;
        }
      }
    } else if (tool === 'web_fetch') {
      const url = result.url || '';
      if (url) {
        parts.push(`- Fetched: ${url}`);
        cited++;
      }
    }
  }
  if (cited === 0) parts.push('_No sources returned._');
  return parts.join('\n').trim();
}

function processCliOutput(rawOutput, { sessionArtifactId, projectId = null } = {}) {
  const found = extractCliArtifacts(rawOutput);
  if (!found.length) return { text: rawOutput, artifacts: [], updated: [] };
  const created = [];
  const updated = [];
  let cleaned = rawOutput;
  let activeId = sessionArtifactId;
  for (const block of found) {
    const title = extractHtmlTitle(block.html);
    if (activeId) {
      try {
        const result = updateArtifact(activeId, { title, content: block.html });
        updated.push({ title, artifact_id: result.artifact_id });
        cleaned = cleaned.replace(block.full, `\n[Artifact updated: "${title}"]\n`);
        activeId = null; // only update once per turn; subsequent HTML blocks become new artifacts
        continue;
      } catch {
        // Existing artifact deleted — fall through to create.
      }
    }
    try {
      const result = createArtifact({ title, kind: 'html', content: block.html, project_id: projectId });
      created.push({ title, artifact_id: result.artifact_id });
      cleaned = cleaned.replace(block.full, `\n[Artifact created: "${title}"]\n`);
    } catch { /* non-fatal */ }
  }
  return { text: cleaned.trim(), artifacts: created, updated };
}

function lastUserText(messages) {
  for (let i = messages.length - 1; i >= 0; i--) {
    const m = messages[i];
    if (m.role === 'user' && typeof m.content === 'string') return m.content;
  }
  return '';
}

// Attachment refs ([{ id, name, mime, ... }]) ride on the latest user message.
// Return the refs from the last user turn that carries any.
function lastUserAttachments(messages) {
  for (let i = messages.length - 1; i >= 0; i--) {
    const m = messages[i];
    if (m.role === 'user' && Array.isArray(m.attachments) && m.attachments.length) {
      return m.attachments;
    }
  }
  return [];
}

/**
 * Auto-create or update an artifact for a substantial response.
 * Returns { artifact_id, created: bool } or null if the text is just a
 * conversational reply and does not warrant an artifact.
 *
 * Gating rules:
 * - If a session artifact already exists, refining text updates it in place
 *   (any non-trivial follow-up is treated as an iteration on the result).
 * - Otherwise, only mint a new artifact when the text is document-like
 *   (headings, list-of-items, or long-form). Short chat replies never
 *   create an artifact.
 */
function autoArtifact(text, { sessionArtifactId, projectId = null } = {}) {
  const trimmed = text ? text.trim() : '';
  if (!trimmed) return null;
  // A plain-text reply only belongs on the canvas if it is itself a full
  // document. updateArtifact does a *full content replace*, so anything short
  // of a real document — confirmations, acknowledgements, status updates
  // ("All six dispatched…") — must never reach the canvas: it would stay in
  // chat anyway and, worse, overwrite a previously designed artifact with
  // conversational filler. Gate both update and create on the same bar.
  if (!isDocumentLike(trimmed)) return null;
  if (sessionArtifactId) {
    try {
      const result = updateArtifact(sessionArtifactId, { content: text });
      return { artifact_id: result.artifact_id, created: false };
    } catch {
      // Artifact was deleted or invalid — fall through to the create gate.
    }
  }
  try {
    const result = createArtifact({ title: extractDocumentTitle(text) || 'Response', kind: 'markdown', content: text, project_id: projectId });
    return { artifact_id: result.artifact_id, created: true };
  } catch {
    return null;
  }
}

/**
 * SSE helper — writes a single named event.
 * @param {ReadableStreamDefaultController} ctrl
 * @param {string} event
 * @param {unknown} data
 */
function send(ctrl, event, data) {
  const payload = typeof data === 'string' ? data : JSON.stringify(data);
  ctrl.enqueue(`event: ${event}\ndata: ${payload}\n\n`);
}

export async function POST(req) {
  let session;
  try {
    session = await requireSession(req);
  } catch (err) {
    if (err && typeof err === 'object' && err.status) {
      return Response.json({ error: err.error || 'unauthorized' }, { status: err.status });
    }
    throw err;
  }

  let body;
  try {
    body = await req.json();
  } catch {
    return new Response('Invalid JSON', { status: 400 });
  }
  // Attach user_id to the body so orchestrator context carries the session identity
  body = { ...body, user_id: session.user_id };

  const messages = Array.isArray(body.messages) ? body.messages : [];
  if (!messages.length) {
    return new Response('messages required', { status: 400 });
  }
  const allModels = await getModels();
  const requestedModel = body.model && allModels.find(m => m.id === body.model) ? body.model : null;
  const activeModel = requestedModel ?? getModel();
  const activeModelMeta = allModels.find(m => m.id === activeModel);
  const activeEngine = activeModelMeta?.engine || 'anthropic';

  // Resolve or create the chat session for persistence.
  let sessionId = typeof body.session_id === 'string' ? body.session_id : null;
  if (sessionId && !getChatSession(sessionId)) sessionId = null;
  if (!sessionId) {
    const created = createChatSession({ model: activeModel });
    sessionId = created.id;
  }

  // Optional: caller passes the artifact ID from the previous turn so follow-ups can update it.
  const sessionArtifactId = typeof body.session_artifact_id === 'string' ? body.session_artifact_id : null;

  // Active project scope for artifact creation. Defaults to "personal" when caller omits.
  const projectId = typeof body.project_id === 'string' && body.project_id.trim()
    ? body.project_id.trim()
    : 'personal';
  // Thread tenancy so chat-initiated tool calls (e.g. mines_run_report →
  // runSingleReport → executeRegistryTool) attribute audit/approval rows to the
  // real acting user/org, not the 'local-user'/'personal-org' fallbacks.
  const toolContext = { project_id: projectId, user_id: session.user_id, org_id: session.org_id };

  // pending_approvals: [{ approval_id, tool_name }] — sent by the chat client
  // after the user clicks Approve in an inline tool-step. We inject
  // approval_id into the next matching tool call so the registry's
  // consumeApprovalRequest gate can pass on retry. Without this the LLM would
  // need to echo approval_id back itself, which it does not reliably do.
  const pendingApprovals = Array.isArray(body.pending_approvals)
    ? body.pending_approvals
        .filter(p => p && typeof p.approval_id === 'string' && typeof p.tool_name === 'string')
        .map(p => ({ approval_id: String(p.approval_id), tool_name: String(p.tool_name), used: false }))
    : [];

  // Mirror of REFUNDABLE_ERRORS in lib/tools/registry.js — when the adapter
  // errors with one of these codes, the registry refunds the approval, so we
  // must not mark the pending entry as used either.
  const REFUNDABLE_ERRORS = new Set([
    'spec_invalid',
    'spec_required',
    'slug_required',
    'slug_in_use',
    'version_bump_required',
    'not_found',
    'insufficient_role',
  ]);
  function takePendingApproval(toolName) {
    return pendingApprovals.find(p => p.tool_name === toolName && !p.used) || null;
  }
  function injectPendingApproval(toolName, input) {
    const entry = takePendingApproval(toolName);
    if (!entry) return { input, entry: null };
    return { input: { ...(input || {}), approval_id: entry.approval_id }, entry };
  }

  // Resolve any files attached to the latest user turn. Bytes are loaded from
  // the local uploads store by opaque id (client never sends paths). These feed
  // the model as native content blocks (Anthropic vision/PDF) or inlined text
  // (CLI / Ollama engines).
  const loadedAttachments = loadAttachments(lastUserAttachments(messages));
  const attachmentPromptText = attachmentsToPromptText(loadedAttachments);

  // Persist the latest user turn now (the rest of the history was already saved on previous turns).
  const userText = lastUserText(messages);
  if (userText) {
    try { appendChatMessage(sessionId, { role: 'user', content: userText, model: activeModel }); }
    catch { /* session row may have been deleted between calls — fall through */ }
  }
  const isCliEngine = activeEngine === 'claude-code' || activeEngine === 'codex';
  const artifactContext = sessionArtifactId
    ? `\n\nActive artifact for this conversation: artifact_id="${sessionArtifactId}". Use update_artifact with this ID instead of create_artifact when refining, extending, or following up on the existing result.`
    : '';
  const cliArtifactContext = sessionArtifactId
    ? `\n\nACTIVE ARTIFACT IN VIEW: There is already a designed HTML artifact open on the canvas from a previous turn. If the user is asking to refine, edit, restyle, extend, or change anything about it, output the FULL UPDATED HTML page in your \`\`\`html ... \`\`\` fence — madarX will replace the existing artifact in place. Do NOT start from scratch unless the user explicitly asks for a brand-new page.`
    : '';
  // Inject the active model so the LLM stops hallucinating its own identity.
  // CLI engines (claude-code, codex) run on whatever model the local CLI is bound to,
  // not `activeModel` — do not inject there.
  const modelIdentityContext = isCliEngine
    ? ''
    : `\n\nActive model: ${activeModelMeta?.label || activeModel} (id: ${activeModel}). If the user asks which model you are, answer with this exact name. Do not guess from training-data priors.`;
  // Use `let` so the CLI fallback path can override to the Anthropic system prompt.
  let systemPrompt = isCliEngine
    ? (buildCliSystemPrompt() + cliArtifactContext)
    : getSystemPrompt() + agentMemoryContext(userText) + artifactContext + modelIdentityContext;

  const encoder = new TextEncoder();
  // The CLI subprocess can keep streaming chunks back via `cliOnChunk` even after
  // the response has finished (synthesis path returned early, client disconnected,
  // error branch taken). Without this flag we crash with
  // `Invalid state: Controller is already closed` on the next enqueue. Hoisted to
  // the outer scope so the ReadableStream's `cancel()` hook can flip it too.
  const streamState = { closed: false };

  const stream = new ReadableStream({
    async start(ctrl) {
      function enqueue(chunk) {
        if (streamState.closed) return;
        try {
          ctrl.enqueue(encoder.encode(chunk));
        } catch (err) {
          if (err?.code === 'ERR_INVALID_STATE') {
            streamState.closed = true;
            return;
          }
          throw err;
        }
      }
      function emit(event, data) {
        if (streamState.closed) return;
        const payload = typeof data === 'string' ? data : JSON.stringify(data);
        enqueue(`event: ${event}\ndata: ${payload}\n\n`);
      }
      function closeStream() {
        if (streamState.closed) return;
        streamState.closed = true;
        try { ctrl.close(); } catch { /* already closed by client cancel */ }
      }

      try {
        // Tell the client which session this turn belongs to (so it can persist + restore).
        emit('session', { id: sessionId });

        const baseUserContent = userText || messages.map(m => typeof m.content === 'string' ? m.content : JSON.stringify(m.content)).join('\n');
        const userContent = baseUserContent + attachmentPromptText;

        // For CLI engines, embed conversation history directly in the prompt since they are stateless subprocesses.
        const cliUserContent = (messages.length > 1
          ? messages.map(m => {
              const role = m.role === 'assistant' ? 'Assistant' : 'User';
              const text = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
              return `${role}: ${text}`;
            }).join('\n\n')
          : baseUserContent) + attachmentPromptText;

        if (activeEngine === 'ollama') {
          const output = await runOllamaAgent({ model: activeModel, systemPrompt, userMessage: userContent, maxTokens: 2048 });
          // Ollama runs single-shot with NO madarX tool loop. A Hermes/Nous-template model
          // satisfies a tool task by role-playing the whole loop — emitting <tool_call> and a
          // fabricated <tool_response> in plain text. Never save that fiction as an artifact.
          if (looksFabricatedToolTranscript(output)) {
            const honest = 'This local model has no tool access, so it fabricated a tool-call transcript with made-up results — nothing actually ran. Switch to the Anthropic API or Claude Code engine (Settings > Model) for tasks that need live data or real tools.';
            emit('text', { delta: honest });
            emit('usage', { model: activeModel, input: 0, output: 0, cache_read: 0, cache_creation: 0, context: 0 });
            try { appendChatMessage(sessionId, { role: 'assistant', content: honest, model: activeModel }); } catch { /* ok */ }
            emit('done', { ok: false, engine: 'ollama' });
            return;
          }
          emit('text', { delta: output });
          if (isDocumentLike(output)) {
            try {
              const result = createArtifact({ title: extractDocumentTitle(output), kind: 'markdown', content: output, project_id: projectId });
              emit('artifacts', { created: [{ title: result.title, artifact_id: result.artifact_id }] });
            } catch { /* non-fatal */ }
          }
          emit('usage', { model: activeModel, input: 0, output: 0, cache_read: 0, cache_creation: 0, context: 0 });
          try { appendChatMessage(sessionId, { role: 'assistant', content: output, model: activeModel }); } catch { /* ok */ }
          emit('done', { ok: true, engine: 'ollama' });
          return;
        }

        if (activeEngine === 'claude-code' || activeEngine === 'codex') {
          // Surface the subprocess boot so the user sees something while the CLI cold-starts.
          const cliLabel = activeEngine === 'claude-code' ? 'claude_code_cli' : 'codex_cli';
          emit('tool_start', { name: cliLabel, input: { mode: 'subprocess' } });
          let raw;
          // Codex still streams plain text — keep the accumulator path for it. For
          // Claude Code we drive the chat off the parsed assistant text only, so
          // the user never sees raw stream-json envelopes (init, hooks, system).
          let cliAccumulated = '';
          const codexOnChunk = (chunk) => {
            cliAccumulated += chunk;
            const displayText = cliAccumulated.replace(/```madarx-action[\s\S]*?```/gi, '').trim();
            if (displayText) emit('cli_stream', { text: displayText });
          };
          const ccOnAssistantText = (text) => {
            const displayText = text.replace(/```madarx-action[\s\S]*?```/gi, '').trim();
            if (displayText) emit('cli_stream', { text: displayText });
          };
          const ccOnToolStart = ({ name, input }) => {
            emit('tool_start', { name, input });
          };
          const ccOnToolResult = ({ name, isError }) => {
            emit('tool_result', { name, output: { ok: !isError }, isError: Boolean(isError) });
          };
          const invokeCli = (userMessage) => activeEngine === 'claude-code'
            ? runClaudeCodeAgent({
                systemPrompt,
                userMessage,
                allowedTools: [],
                maxTurns: 5,
                timeoutMs: Number(process.env.CLAUDE_CODE_CHAT_TIMEOUT_MS || 600000),
                onAssistantText: ccOnAssistantText,
                onToolStart: ccOnToolStart,
                onToolResult: ccOnToolResult,
              })
            : runCodexAgent({ systemPrompt, userMessage, onChunk: codexOnChunk });
          try {
            raw = await invokeCli(cliUserContent);
            emit('tool_result', { name: cliLabel, output: { ok: true }, isError: false });
          } catch (cliErr) {
            emit('tool_result', { name: cliLabel, output: { error: cliErr.message }, isError: true });
            const hint = cliErr.message?.includes('max turns')
              ? 'This task requires madarX tool access (agent creation, scheduling, state reads). Switch to the Anthropic engine in Settings > Model.'
              : `CLI error: ${cliErr.message}`;
            emit('text', { delta: hint });
            emit('done', { ok: false, engine: activeEngine });
            return;
          }
          let actions = await executeCliActions(raw, { emit, context: toolContext });
          let cliHasHtml = extractCliArtifacts(raw).length > 0;

          // Intent-without-action guard: the CLI narrated a task ("Started AI brief run.
          // Artifact lands on canvas when done.") but emitted NO ```madarx-action``` block,
          // so nothing actually ran — the user gets a promise + silence. Re-prompt the CLI
          // ONCE, forcing the action block. Only fires when the reply expresses action intent
          // and produced zero actions and zero HTML, so genuine conversational answers are
          // untouched. Mirrors lib/whatsapp-agent.js's safety net.
          //
          // ALSO fires on a FABRICATED tool transcript: when asked to run an agent, the CLI
          // sometimes role-plays *being* the agent — emitting `<tool_call>`/`<tool_response>`
          // XML with invented results in plain text — instead of emitting the real run_agent
          // action. That blob has 0 real actions, so without this it would slip through and be
          // delivered as the answer. Detect it and re-prompt to dispatch the real action.
          const initialFabricated = looksFabricatedToolTranscript(raw);
          if (actions.length === 0 && !cliHasHtml && (INTENT_WITHOUT_ACTION_RE.test(stripCliActions(raw)) || initialFabricated)) {
            const fabricationClause = initialFabricated
              ? ' You SIMULATED a tool run by writing <tool_call>/<tool_response> text with made-up results — that is fiction, nothing actually ran. Do NOT simulate or narrate tool output. To run another agent, emit a real run_agent madarx-action block with the target agent id; madarX will execute it and return the real artifact.'
              : '';
            const nudge = `${cliUserContent}\n\n[SYSTEM] Your previous reply said you would do that, but you did NOT emit a \`\`\`madarx-action\`\`\` block, so nothing actually ran and the user is waiting.${fabricationClause} If this task needs a tool (run_agent, create_agent, generate_ui, create_artifact, web_search, start_hermes_job, etc.), emit the madarx-action block NOW in this turn. Do not reply with another promise or a simulated transcript — emit the action.`;
            emit('cli_stream', { text: 'No action ran on the first pass — retrying…' });
            let retryRaw = null;
            try {
              retryRaw = await invokeCli(nudge);
            } catch (retryErr) {
              if (process.env.MADARX_DEBUG) console.error('[madarx] intent-nudge retry failed:', retryErr.message);
            }
            if (retryRaw) {
              const retryActions = await executeCliActions(retryRaw, { emit, context: toolContext });
              if (retryActions.length > 0 || extractCliArtifacts(retryRaw).length > 0) {
                raw = retryRaw;
                actions = retryActions;
                cliHasHtml = extractCliArtifacts(retryRaw).length > 0;
              }
            }
          }

          // CLI produced no action blocks and no HTML artifacts — this happens when Codex or
          // Claude Code outputs plain natural-language text for a task that needs real tool
          // access (web search, live data, artifact creation). Fall through to the Anthropic
          // agentic loop below so tools like web_search and generate_ui are actually called.
          // Fall back to Anthropic when CLI produced no useful output OR when it only
          // created an agent (Codex misroutes live-data queries as agent-creation tasks).
          const cliOnlyCreatedAgent = actions.length > 0 && actions.every(a => a.tool === 'create_agent');
          const cliPlainText = stripCliActions(raw).trim();
          // A fabricated tool transcript (still no real actions after the retry) must NOT count
          // as "substantive output" — otherwise its length masks it as a valid answer, blocks the
          // Anthropic fallback, and gets delivered verbatim as fiction. Treat it as no useful output.
          const cliFabricated = actions.length === 0 && !cliHasHtml && looksFabricatedToolTranscript(raw);
          const cliProducedSubstantiveText = cliPlainText.length >= 40 && !cliFabricated;
          const hasAnthropicKey = Boolean(resolveAnthropicApiKey());
          const wouldFallback = (actions.length === 0 && !cliHasHtml) || cliOnlyCreatedAgent;
          // Only fall back when (a) the CLI gave essentially nothing AND (b) we actually have an
          // Anthropic key to fall back to. A conversational answer ("yes, I can search the web…")
          // is a valid response — don't second-guess it by triggering Anthropic, which would just
          // surface "ANTHROPIC_API_KEY not configured" on top of the real reply.
          //
          // Engine-intent guard: when the user explicitly picked a CLI engine (Claude Code /
          // Codex), NEVER silently fall back to the Anthropic API. Doing so spends Anthropic
          // credit the user did not opt into and surfaces billing errors (e.g. "credit balance
          // is too low") in chat even though they chose the local CLI. Deliver the CLI's reply
          // instead. (Anthropic-tool fallback remains available for users who select an
          // Anthropic model.)
          if (wouldFallback && hasAnthropicKey && !cliProducedSubstantiveText && !isCliEngine) {
            if (process.env.MADARX_DEBUG) console.error('[madarx] CLI produced no actions/HTML or only create_agent — falling back to Anthropic agentic loop');
            const fallbackMeta = allModels.find(m => m.id === DEFAULT_ANTHROPIC_MODEL);
            const fallbackIdentityContext = `\n\nActive model: ${fallbackMeta?.label || DEFAULT_ANTHROPIC_MODEL} (id: ${DEFAULT_ANTHROPIC_MODEL}). If the user asks which model you are, answer with this exact name. Do not guess from training-data priors.`;
            systemPrompt = getSystemPrompt() + agentMemoryContext(userText) + artifactContext + fallbackIdentityContext;
            // Do NOT return — execution continues into the agentic loop.
          } else if (wouldFallback && cliFabricated) {
            // The CLI fabricated a tool transcript and there is no Anthropic fallback available
            // (CLI engine and/or no key). NEVER deliver the fiction as the answer or autoArtifact
            // it. Surface an honest "did not run" error so the user knows nothing executed.
            const honest = 'The CLI engine simulated a tool run instead of dispatching it — it wrote a tool-call transcript with made-up results, so the action did not actually run and no real result was produced. Try again, or switch this conversation to the Anthropic API engine (Settings > Model) so madarX runs the tools for real.';
            emit('text', { delta: honest });
            emit('usage', { model: activeModel, input: 0, output: 0, cache_read: 0, cache_creation: 0, context: 0 });
            try { appendChatMessage(sessionId, { role: 'assistant', content: honest, model: activeModel }); } catch { /* ok */ }
            emit('done', { ok: false, engine: activeEngine });
            return;
          } else if (wouldFallback) {
            // CLI engine answered conversationally (or has nothing more we can do without an
            // Anthropic key). Deliver the CLI's reply as the final answer and exit cleanly.
            const text = cliPlainText || 'No response.';
            emit('text', { delta: text });
            if (!cliHasHtml) {
              const auto = autoArtifact(text, { sessionArtifactId, projectId });
              if (auto) {
                if (auto.created) {
                  emit('artifacts', { created: [{ artifact_id: auto.artifact_id }] });
                } else {
                  emit('artifact_updated', { artifact_id: auto.artifact_id });
                }
                emit('session_artifact', { artifact_id: auto.artifact_id });
              }
            }
            emit('usage', { model: activeModel, input: 0, output: 0, cache_read: 0, cache_creation: 0, context: 0 });
            try { appendChatMessage(sessionId, { role: 'assistant', content: text, model: activeModel }); } catch { /* ok */ }
            emit('done', { ok: true, engine: activeEngine });
            return;
          } else {
            // CLI produced useful output (action blocks executed or HTML generated).
            // Synthesis pass: if web/data tools returned results, feed them back to an
            // Anthropic call so the agent produces a direct answer instead of pointing
            // the user elsewhere.
            const DATA_TOOLS = new Set(['web_search', 'web_fetch', 'web_extract']);
            const dataResults = actions.filter(({ tool, result }) =>
              DATA_TOOLS.has(tool) && result && !result.error
            );
            if (dataResults.length > 0) {
              // Replace the CLI's progress text with a clear "still working" message so the
              // chat bubble doesn't sit frozen on stale interim text during synthesis.
              emit('cli_stream', { text: 'Composing answer from search results…' });
              emit('tool_start', {
                name: 'synthesize_answer',
                input: { sources: dataResults.map(d => d.tool) },
              });
              let synthOk = false;
              let usedFallback = false;
              let synthErrMsg = null;
              const toolContext = dataResults.map(({ tool, result }) => {
                const body = typeof result === 'string' ? result : JSON.stringify(result, null, 2);
                return `### ${tool}\n${body.slice(0, 6000)}`;
              }).join('\n\n');
              const fetchCount = dataResults.filter(d => d.tool === 'web_fetch' || d.tool === 'web_extract').length;
              const onlySearchData = fetchCount === 0;
              const synthSystem = `${currentDateInstruction()}

You are the madarX synthesis pass. You are reporting to the owner like a chief of staff to a chairman — bring back the actual answer, not a list of links to it.

OUTPUT FORMAT: Emit a complete self-contained HTML page inside a single \`\`\`html ... \`\`\` code fence. madarX will lift the HTML into a designed artifact rendered on the canvas. Outside the fence, write only a 1-2 sentence chat summary of what you built (no lists, no markdown headings, no links).

Pick the layout that fits the data:
- News stories → stacked news-card feed (headline, source, timestamp, 2-3 sentence dek, "Read source" link).
- Comparable options (flights, hotels, products, plans) → comparison table or feature-matrix cards with real values per cell.
- Reports / analyses → executive-report layout (cover header, key-findings callout, sectioned body, sources footer).
- Itinerary / schedule → vertical timeline with date markers and event cards.
- Dashboard / metrics → KPI tiles + supporting lists.
Use a clean publication-style typography hierarchy (serif or modern sans), generous whitespace, no emojis, no inline-style aggression. Always include a "Sources" section at the end of the HTML with 2-4 cited links.

Rules (non-negotiable):
- NEVER produce "Results for X" sections with raw search titles and snippets. That is a failed answer.
- Extract concrete values from the data below: prices, dates, times, addresses, names, numbers, ratings.
- Use "Not listed" where a value is genuinely missing — do NOT pad with snippets.
- ZERO emojis. No "click here", "see link", "visit the website" language.
- The HTML must be a full document (\`<!doctype html>\` ... \`</html>\`) with embedded \`<style>\`. No external scripts.${onlySearchData ? '\n- Important: only search-snippet data is available below. If concrete values cannot be extracted, build the HTML page with a short "What we know / What needs fetching" section — do NOT pad with raw search snippets.' : ''}${brandInstructionText() ? `\n\n${brandInstructionText()}` : ''}`;
              const synthUser = `User question:\n${userContent}\n\nLive tool results:\n\n${toolContext}\n\nProduce the final answer now as a complete HTML page in a \`\`\`html ... \`\`\` fence, with real values (not titles) and a Sources block at the bottom of the page. Then add a 1-2 sentence chat summary after the fence describing what you built.`;
              try {
                let synthText = '';
                // Use the same engine the user picked so we don't require ANTHROPIC_API_KEY
                // when the CLI engines (Claude Code / Codex) are already authenticated locally.
                if (activeEngine === 'claude-code') {
                  synthText = (await runClaudeCodeAgent({
                    systemPrompt: synthSystem,
                    userMessage: synthUser,
                    allowedTools: [],
                    maxTurns: 1,
                    timeoutMs: Number(process.env.CLAUDE_CODE_CHAT_TIMEOUT_MS || 600000),
                  }))?.trim() || '';
                } else if (activeEngine === 'codex') {
                  synthText = (await runCodexAgent({
                    systemPrompt: synthSystem,
                    userMessage: synthUser,
                  }))?.trim() || '';
                } else {
                  const synthResp = await chatWithClaude(
                    [
                      { role: 'user', content: userContent },
                      { role: 'assistant', content: '[Searching…]' },
                      { role: 'user', content: `Live search results:\n\n${toolContext}\n\nProduce the final answer as a complete HTML page in a single \`\`\`html ... \`\`\` fence. Pick the best layout (news feed, comparison table, executive report, timeline, dashboard). Embed CSS, no external scripts. Real values in cells, no titles or snippets. Include a Sources section at the bottom of the page. After the fence, add a 1-2 sentence chat summary. No emojis.` },
                    ],
                    synthSystem,
                    [],
                    { model: DEFAULT_ANTHROPIC_MODEL }
                  );
                  synthText = (synthResp.content ?? [])
                    .filter(b => b.type === 'text').map(b => b.text).join('\n').trim();
                }
                if (synthText) {
                  raw = synthText;
                  synthOk = true;
                }
              } catch (err) {
                synthErrMsg = err.message || String(err);
                if (process.env.MADARX_DEBUG) console.error('[madarx] synthesis failed:', synthErrMsg);
              }
              // Fallback: if Anthropic synthesis failed or produced nothing, build a
              // readable markdown summary from the raw search results. Better to show
              // actual data than the CLI's stale "Searching..." progress text.
              if (!synthOk) {
                const fallback = formatToolResultsAsMarkdown(dataResults, synthErrMsg);
                if (fallback) {
                  raw = fallback;
                  usedFallback = true;
                }
              }
              emit('tool_result', {
                name: 'synthesize_answer',
                output: synthOk
                  ? { ok: true }
                  : usedFallback
                    ? { ok: true, fallback: true, reason: synthErrMsg }
                    : { error: synthErrMsg || 'no output' },
                isError: !synthOk && !usedFallback,
              });
            }

            const cleanedRaw = actions.length ? stripCliActions(raw) : raw;
            const { text, artifacts, updated } = processCliOutput(cleanedRaw, { sessionArtifactId, projectId });
            emit('text', { delta: text });
            if (updated.length) {
              for (const u of updated) emit('artifact_updated', { artifact_id: u.artifact_id, title: u.title });
              emit('session_artifact', { artifact_id: updated[0].artifact_id });
            }
            if (artifacts.length) {
              emit('artifacts', { created: artifacts });
              emit('session_artifact', { artifact_id: artifacts[0].artifact_id });
            }
            const createdAgents = actions
              .filter(a => a.tool === 'create_agent' && a.result?.agent)
              .map(a => ({ agent: a.result.agent, files: a.result.files ?? [] }));
            for (const { agent, files } of createdAgents) {
              try {
                const html = buildAgentCreatedHtml(agent, files);
                const card = createArtifact({
                  title: `${agent.name} — Agent created`,
                  kind: 'html',
                  content: html,
                  project_id: projectId,
                });
                emit('artifacts', { created: [{ title: agent.name, artifact_id: card.artifact_id }] });
                emit('session_artifact', { artifact_id: card.artifact_id });
              } catch { /* non-fatal — keep the agent_created event below */ }
            }
            // Surface results of run_agent actions: the sub-agent already
            // produced its own artifact — pin it to the canvas and confirm the
            // run in chat. Without this the CLI turn often returns no text and
            // the user sees nothing happen.
            const ranAgents = actions
              .filter(a => a.tool === 'run_agent' && a.result?.ok)
              .map(a => a.result);
            const ranAgentLines = [];
            for (const r of ranAgents) {
              const artId = r.artifact_id || r.artifact?.artifact_id || r.artifact?.id || null;
              if (artId) {
                emit('artifacts', { created: [{ title: r.title || r.agent_name, artifact_id: artId }] });
                emit('session_artifact', { artifact_id: artId });
              }
              const preview = (r.preview || '').trim();
              ranAgentLines.push(
                `Ran **${r.agent_name}** — ${r.status || 'complete'}.${r.title ? ` Output: "${r.title}".` : ''}${preview ? `\n\n${preview}` : ''}`
              );
            }
            const failedRuns = actions
              .filter(a => a.tool === 'run_agent' && a.result && a.result.ok === false)
              .map(a => `Could not run agent: ${a.result.error || 'unknown error'}.`);

            if (!artifacts.length && !updated.length && createdAgents.length === 0 && ranAgents.length === 0) {
              const auto = autoArtifact(text, { sessionArtifactId, projectId });
              if (auto) {
                if (auto.created) {
                  emit('artifacts', { created: [{ artifact_id: auto.artifact_id }] });
                } else {
                  emit('artifact_updated', { artifact_id: auto.artifact_id });
                }
                emit('session_artifact', { artifact_id: auto.artifact_id });
              }
            }
            for (const { agent, files } of createdAgents) {
              emit('agent_created', { agent, files });
            }
            // If the CLI emitted only action blocks (no prose), the streamed text
            // is empty — back-fill chat with the run confirmation so the turn is
            // never silent.
            const runConfirmation = [...ranAgentLines, ...failedRuns].join('\n\n');
            if (runConfirmation && !text.trim()) {
              emit('text', { delta: runConfirmation });
            }
            const persistedText = text.trim() || runConfirmation || '';
            emit('usage', { model: activeModel, input: 0, output: 0, cache_read: 0, cache_creation: 0, context: 0 });
            try { appendChatMessage(sessionId, { role: 'assistant', content: persistedText, model: activeModel }); } catch { /* ok */ }
            emit('done', { ok: true, engine: activeEngine });
            return;
          }
        }

        // Agentic loop — handles tool_use turns automatically.
        // Defense in depth: if we somehow arrive here without an Anthropic key, return a
        // clear, actionable error instead of letting `streamChatWithClaude` throw the raw
        // "ANTHROPIC_API_KEY not configured" message into the chat.
        if (!resolveAnthropicApiKey()) {
          emit('text', {
            delta: isCliEngine
              ? 'Configured engine answered without tool calls. To enable Anthropic-powered tools (web search, artifact generation) add ANTHROPIC_API_KEY to the secrets vault (Settings → Secrets) or set it in .env.local and restart the dev server.'
              : 'ANTHROPIC_API_KEY is not configured. Add it to the secrets vault (Settings → Secrets) or to .env.local and restart the dev server, or switch to a CLI engine (Claude Code / Codex) in Settings.',
          });
          emit('done', { ok: false, engine: activeEngine });
          return;
        }
        // When reached via CLI fallback (codex/claude-code with no useful output), the
        // active model is a CLI ID and not a valid Anthropic model — use the default instead.
        const loopModel = isCliEngine ? DEFAULT_ANTHROPIC_MODEL : activeModel;
        // Normalize client messages for the Anthropic API: keep only role +
        // content (the `attachments` field is client-side metadata the SDK
        // rejects). Then splice any uploaded files into the LAST user turn as
        // native content blocks (image / document / inlined text).
        let currentMessages = messages.map(m => ({ role: m.role, content: m.content }));
        if (loadedAttachments.length) {
          const attachmentBlocks = attachmentsToContentBlocks(loadedAttachments);
          for (let i = currentMessages.length - 1; i >= 0; i--) {
            if (currentMessages[i].role !== 'user') continue;
            const existing = currentMessages[i].content;
            const textBlocks = typeof existing === 'string'
              ? (existing ? [{ type: 'text', text: existing }] : [])
              : (Array.isArray(existing) ? existing : []);
            currentMessages[i] = { role: 'user', content: [...textBlocks, ...attachmentBlocks] };
            break;
          }
        }
        // Anthropic API requires messages to start with a user turn.
        // Drop any leading assistant messages (e.g. welcome text loaded from session).
        while (currentMessages.length && currentMessages[0].role === 'assistant') {
          currentMessages.shift();
        }
        let iterations = 0;
        const MAX_TURNS = 8;
        let artifactCreated = false;
        let totalAgentText = '';
        let totalInputTokens = 0;
        let totalOutputTokens = 0;
        let totalCacheReadTokens = 0;
        let totalCacheCreationTokens = 0;
        let lastContextTokens = 0;

        while (iterations < MAX_TURNS) {
          iterations++;

          const claudeStream = await streamChatWithClaude(
            currentMessages,
            systemPrompt,
            getTools(),
            loopModel
          );

          let textBuffer = '';
          let toolUseBlocks = [];
          let currentToolBlock = null;
          let inputJsonBuffer = '';
          let stopReason = null;
          let turnInputTokens = 0;
          let turnOutputTokens = 0;
          let turnCacheReadTokens = 0;
          let turnCacheCreationTokens = 0;

          // Stream events from the Anthropic SDK stream object
          for await (const event of claudeStream) {
            if (event.type === 'message_start') {
              const u = event.message?.usage || {};
              turnInputTokens = u.input_tokens ?? 0;
              turnCacheReadTokens = u.cache_read_input_tokens ?? 0;
              turnCacheCreationTokens = u.cache_creation_input_tokens ?? 0;
            } else if (event.type === 'content_block_start') {
              if (event.content_block.type === 'tool_use') {
                currentToolBlock = {
                  id: event.content_block.id,
                  name: event.content_block.name,
                  input: {},
                };
                inputJsonBuffer = '';
              }
            } else if (event.type === 'content_block_delta') {
              if (event.delta.type === 'text_delta') {
                textBuffer += event.delta.text;
                emit('text', { delta: event.delta.text });
              } else if (event.delta.type === 'input_json_delta') {
                inputJsonBuffer += event.delta.partial_json;
              }
            } else if (event.type === 'content_block_stop') {
              if (currentToolBlock) {
                try {
                  currentToolBlock.input = JSON.parse(inputJsonBuffer || '{}');
                } catch {
                  currentToolBlock.input = {};
                }
                toolUseBlocks.push(currentToolBlock);
                currentToolBlock = null;
                inputJsonBuffer = '';
              }
            } else if (event.type === 'message_delta') {
              if (event.delta.stop_reason) stopReason = event.delta.stop_reason;
              if (event.usage?.output_tokens) turnOutputTokens = event.usage.output_tokens;
            }
          }

          totalInputTokens += turnInputTokens;
          totalOutputTokens += turnOutputTokens;
          totalCacheReadTokens += turnCacheReadTokens;
          totalCacheCreationTokens += turnCacheCreationTokens;
          lastContextTokens = turnInputTokens + turnCacheReadTokens + turnCacheCreationTokens;
          emit('usage', {
            model: activeModel,
            input: totalInputTokens,
            output: totalOutputTokens,
            cache_read: totalCacheReadTokens,
            cache_creation: totalCacheCreationTokens,
            context: lastContextTokens,
          });

          // Build the assistant message for the agentic loop
          const assistantContent = [];
          if (textBuffer) {
            assistantContent.push({ type: 'text', text: textBuffer });
          }
          for (const block of toolUseBlocks) {
            assistantContent.push({
              type: 'tool_use',
              id: block.id,
              name: block.name,
              input: block.input,
            });
          }

          currentMessages = [
            ...currentMessages,
            { role: 'assistant', content: assistantContent },
          ];

          // Persist the assistant turn (text + tool_calls) with this turn's token counts.
          try {
            appendChatMessage(sessionId, {
              role: 'assistant',
              content: textBuffer,
              tool_calls: toolUseBlocks.length ? toolUseBlocks.map(b => ({
                id: b.id, name: b.name, input: b.input,
              })) : null,
              model: activeModel,
              input_tokens: turnInputTokens,
              output_tokens: turnOutputTokens,
              cache_read_tokens: turnCacheReadTokens,
              cache_creation_tokens: turnCacheCreationTokens,
            });
          } catch { /* session deleted mid-stream — keep streaming, drop persistence */ }

          totalAgentText += textBuffer;

          // If no tool calls, we are done
          if (stopReason !== 'tool_use' || toolUseBlocks.length === 0) {
            break;
          }

          // Execute tools and collect results
          const toolResults = [];
          for (const tool of toolUseBlocks) {
            emit('tool_start', { name: tool.name, input: tool.input });
            let output;
            let isError = false;
            let pendingEntry = null;
            try {
              const injected = injectPendingApproval(tool.name, tool.input);
              pendingEntry = injected.entry;
              output = await executeTool(tool.name, injected.input, toolContext);
            } catch (err) {
              output = { error: err.message };
              isError = true;
            }
            // Burn the pending approval unless the registry refunded it (only
            // pre-execution validator errors are refundable — see
            // REFUNDABLE_ERRORS).
            if (pendingEntry) {
              const adapterErrored = isError || output?.status === 'error' || output?.ok === false;
              const refunded = adapterErrored && REFUNDABLE_ERRORS.has(output?.error);
              if (!refunded) pendingEntry.used = true;
            }
            if (!isError && output?.artifact_id &&
                (tool.name === 'create_artifact' || tool.name === 'generate_ui' ||
                 tool.name === 'send_to_hermes' || tool.name === 'start_hermes_job')) {
              artifactCreated = true;
            }
            if (!isError && tool.name === 'create_agent' && output?.agent) {
              try {
                const html = buildAgentCreatedHtml(output.agent, output.files ?? []);
                const card = createArtifact({
                  title: `${output.agent.name} — Agent created`,
                  kind: 'html',
                  content: html,
                  project_id: projectId,
                });
                emit('artifacts', { created: [{ title: output.agent.name, artifact_id: card.artifact_id }] });
                emit('session_artifact', { artifact_id: card.artifact_id });
                emit('agent_created', { agent: output.agent, files: output.files ?? [] });
                artifactCreated = true;
              } catch { /* non-fatal — fall through to normal tool_result emission */ }
            }
            emit('tool_result', { name: tool.name, output, isError });
            toolResults.push({
              type: 'tool_result',
              tool_use_id: tool.id,
              content: JSON.stringify(output),
              is_error: isError,
            });
          }

          // Feed results back for the next turn
          currentMessages = [
            ...currentMessages,
            { role: 'user', content: toolResults },
          ];
        }

        if (!artifactCreated) {
          const auto = autoArtifact(totalAgentText, { sessionArtifactId, projectId });
          if (auto) {
            if (auto.created) {
              emit('artifacts', { created: [{ artifact_id: auto.artifact_id }] });
            } else {
              emit('artifact_updated', { artifact_id: auto.artifact_id });
            }
            emit('session_artifact', { artifact_id: auto.artifact_id });
          }
        }

        // Surface any approvals the client supplied that were not consumed (e.g.
        // the adapter errored and they were refunded). Client puts them back
        // into its pending ref so the next retry within this conversation can
        // reuse the same approval without forcing the user to click again.
        const remaining = pendingApprovals.filter(p => !p.used)
          .map(p => ({ approval_id: p.approval_id, tool_name: p.tool_name }));
        if (remaining.length) emit('pending_approvals', { approvals: remaining });
        emit('done', { ok: true });
      } catch (err) {
        emit('error', { message: err.message });
      } finally {
        closeStream();
      }
    },
    cancel() {
      // Client disconnected (navigation, refresh, tab close). Mark closed so any
      // in-flight CLI chunks that arrive after this point are dropped instead of
      // crashing the process.
      streamState.closed = true;
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
      Connection: 'keep-alive',
      'X-Accel-Buffering': 'no',
    },
  });
}
