Newer
Older
navi-1 / webclient / src / stores / chat / tools.js
import { useSessionsStore } from '@/stores/sessions.js'

/**
 * Tool cards inside a turn: created from tool_started, resolved by tool_call,
 * matched by tool_call_id through the non-reactive index (which is what lets a
 * result land even after stream_end closed the turn).
 */

export function createToolTracking({ state, content, recall: recallActions, messageHelpers }) {
  const { messages, streamingMsg, toolIndex, terminals, currentId, recall } = state
  const { upsertArtifact, fetchFiles, fetchArtifacts } = content
  const { loadRecall } = recallActions
  const { _addViewedImage } = messageHelpers

  // Find the last spawn_agent card that's still collecting subagent events
  function _lastSpawnCard(msg) {
    // Custom findLast for older browser compatibility (ES2023 polyfill-free)
    for (let i = msg.tools.length - 1; i >= 0; i--) {
      const t = msg.tools[i]
      if (t.kind === 'tool' && t.name === 'spawn_agent') return t
    }
    return undefined
  }

  function onToolStarted(data) {
    const msg = streamingMsg.value
    if (!msg) return
    const card = {
      kind: 'tool',
      id: `tool_${Date.now()}`,
      toolCallId: data.tool_call_id ?? null,
      name: data.tool,
      args: data.args,
      result: null,
      success: null,
      pending: true,
      startedAt: Date.now(),
      isSubagent: data.is_subagent ?? false,
      steps: [],
      terminalName: data.tool === 'terminal' ? (data.args?.terminal_name || null) : null,
    }
    card.parentMsg = msg
    // The index holds the reactive proxy, not `card`: results are applied
    // through this reference long after the card was pushed, and a write to
    // the raw object never wakes a computed that read it through the proxy
    // (a running content_publish card stayed an error card until the turn's
    // message was rebuilt).
    if (data.is_subagent) {
      const spawn = _lastSpawnCard(msg)
      if (spawn) {
        spawn.steps.push(card)
        if (card.toolCallId) toolIndex.set(card.toolCallId, spawn.steps[spawn.steps.length - 1])
        return
      }
    }
    msg.tools.push(card)
    if (card.toolCallId) toolIndex.set(card.toolCallId, msg.tools[msg.tools.length - 1])

    // Pre-seed terminal metadata when a terminal open starts
    if (data.tool === 'terminal' && data.args?.action === 'open') {
      const name = data.args.terminal_name
      if (name) {
        const prev = terminals.value[name] || {}
        terminals.value = {
          ...terminals.value,
          [name]: {
            ...prev,
            name,
            description: data.args.description || '',
            command: data.args.command || '',
            background: data.args.background || false,
            output: prev.output || '',
            closed: false,
          }
        }
      }
    }
  }

  function onToolCall(data) {
    // A vision tool read an image (metadata carries the payload) — capture it
    // live for the artifacts panel's viewed-images group.
    if (data.metadata?.is_image && data.metadata?.base64) {
      _addViewedImage([data.metadata.data_url ?? `data:image/jpeg;base64,${data.metadata.base64}`])
    }
    // Sub-agent tool call → update inside spawn_agent steps. Legacy events
    // without tool_call_id need the name+pending search.
    if (data.is_subagent && !data.tool_call_id) {
      const msg = streamingMsg.value ?? _lastAssistantMsg()
      const spawn = msg ? _lastSpawnCard(msg) : null
      if (spawn) {
        for (let i = spawn.steps.length - 1; i >= 0; i--) {
          const t = spawn.steps[i]
          if (t.kind === 'tool' && t.name === data.tool && t.pending) {
            _applyToolResult(t, data)
            return
          }
        }
      }
    }
    // Id-first: exact card match — works even for events arriving after the
    // turn's stream_end (long tools finishing late).
    let card = data.tool_call_id ? (toolIndex.get(data.tool_call_id) ?? null) : null
    // Legacy fallback: name+pending reverse search in the live turn
    if (!card && !data.tool_call_id) {
      const msg = streamingMsg.value
      if (msg) {
        for (let i = msg.tools.length - 1; i >= 0; i--) {
          const t = msg.tools[i]
          if (t.kind === 'tool' && t.name === data.tool && t.pending) { card = t; break }
        }
      }
    }
    // Bare tool_call (no tool_started was seen — late event after the index
    // was cleared, or a synthetic result): synthesize a card so it's visible.
    if (!card) card = _synthesizeToolCard(data)
    if (!card) return
    _applyToolResult(card, data)
  }

  // Shared tool-result application: completes the card and runs the side
  // effects (UI components, artifacts, file refresh, terminal metadata, recall).
  function _applyToolResult(card, data) {
    {
      card.result = data.result
      card.success = data.success !== false
      card.pending = false
      if (data.metadata) card.metadata = data.metadata
      // Any tool may hand the client a component — navi_ui is the general
      // route, and a builtin like `skills` returns one directly for a protocol
      // proposal. The client renders only names it knows.
      if (card.success && data.metadata?.ui_component) {
        // Render the UI component inline as part of the same assistant turn.
        // The card knows the turn it belongs to (parentMsg set at creation);
        // subagent cards stay nested inside their spawn card.
        const parentMsg = card.parentMsg ?? (streamingMsg.value ?? _lastAssistantMsg())
        if (parentMsg && !card.isSubagent) {
          parentMsg.tools.push({
            kind: 'ui_component',
            component: data.metadata.ui_component.component ?? '',
            payload: data.metadata.ui_component.payload ?? {},
          })
        }
      }
      if (data.tool === 'content_publish' && card.success && data.metadata) {
        upsertArtifact(data.metadata)
      }
      if (data.tool === 'filesystem' && card.success && currentId.value) {
        const action = typeof card.args === 'object' ? card.args?.action : null
        if (['write', 'edit', 'append', 'mkdir', 'rm', 'cp', 'mv'].includes(action)) {
          fetchFiles(currentId.value)
        }
        if (['write', 'edit'].includes(action)) fetchArtifacts(currentId.value)
      }
      if ((data.tool === 'terminal' || data.tool === 'code_exec') && card.success && currentId.value) {
        fetchFiles(currentId.value)
      }
      // Merge terminal metadata from tool results into terminals store
      if (data.tool === 'terminal' && card.success && currentId.value) {
        const action = typeof card.args === 'object' ? card.args?.action : null
        const name = card.args?.terminal_name
        if (data.metadata) {
          if (action === 'open' || action === 'status') {
            if (name) {
              const prev = terminals.value[name] || {}
              terminals.value = {
                ...terminals.value,
                [name]: { ...prev, ...data.metadata, name, closed: false },
              }
            }
          } else if (action === 'list' && Array.isArray(data.metadata.terminals)) {
            for (const t of data.metadata.terminals) {
              if (!t.name) continue
              const prev = terminals.value[t.name] || {}
              terminals.value = {
                ...terminals.value,
                [t.name]: { ...prev, ...t, closed: false },
              }
            }
          } else if (action === 'close' && name) {
            const prev = terminals.value[name] || {}
            terminals.value = {
              ...terminals.value,
              [name]: { ...prev, closed: true, closeReason: 'explicit' },
            }
          }
        }
      }
      // Update recall banner live when scheduling or managing recalls
      if (data.tool === 'schedule_recall' && card.success && currentId.value) {
        loadRecall(currentId.value)
        useSessionsStore().updateRecallStatus(currentId.value, true)
      }
      if (data.tool === 'manage_recall' && card.success && currentId.value) {
        const action = card.args?.action
        if (action === 'cancel') {
          recall.value = null
          useSessionsStore().updateRecallStatus(currentId.value, false)
        } else if (action === 'skip') {
          loadRecall(currentId.value)
        }
      }
    }
  }

  // Last assistant message in the transcript with a tools array — target for
  // tool events that arrive after the live turn has ended.
  function _lastAssistantMsg() {
    for (let i = messages.value.length - 1; i >= 0; i--) {
      const m = messages.value[i]
      if (m.role === 'assistant' && Array.isArray(m.tools)) return m
    }
    return null
  }

  // Create a card for a tool_call event with no matching tool_started.
  function _synthesizeToolCard(data) {
    const msg = streamingMsg.value ?? _lastAssistantMsg()
    if (!msg) return null
    const card = {
      kind: 'tool',
      id: `tool_${Date.now()}`,
      toolCallId: data.tool_call_id ?? null,
      name: data.tool,
      args: null,
      result: null,
      success: null,
      pending: false,
      isSubagent: data.is_subagent ?? false,
      steps: [],
    }
    // Keep the owning message on the card: a late tool_result can arrive
    // after streamingMsg moved on, and side effects like inline ui_component
    // still need to land in the turn that owns this card.
    card.parentMsg = msg
    // Return and index the array's proxy, so the result this card is about to
    // receive goes through reactivity (see onToolStarted).
    if (data.is_subagent) {
      const spawn = _lastSpawnCard(msg)
      if (spawn) {
        spawn.steps.push(card)
        const stored = spawn.steps[spawn.steps.length - 1]
        if (card.toolCallId) toolIndex.set(card.toolCallId, stored)
        return stored
      }
    }
    msg.tools.push(card)
    const stored = msg.tools[msg.tools.length - 1]
    if (card.toolCallId) toolIndex.set(card.toolCallId, stored)
    return stored
  }

  return {
    _lastSpawnCard,
    onToolStarted,
    onToolCall,
    _applyToolResult,
    _lastAssistantMsg,
    _synthesizeToolCard,
  }
}