diff --git a/nx2/blocks/chat/chat-controller.js b/nx2/blocks/chat/chat-controller.js deleted file mode 100644 index e5ae95c50..000000000 --- a/nx2/blocks/chat/chat-controller.js +++ /dev/null @@ -1,458 +0,0 @@ -import { loadIms } from '../../utils/ims.js'; -import { AGENT_EVENT, ROLE, TOOL_NAME, TOOL_STATE } from './constants.js'; -import { readStream } from './utils/stream.js'; -import { loadMessages, saveMessages, resetSession } from './utils/persistence.js'; - -function affectedFolders(toolName, input) { - const { org, repo } = input ?? {}; - if (!org || !repo) return []; - const toParent = (p) => { - const parts = (p ?? '').replace(/^\//, '').split('/').filter(Boolean); - parts.pop(); - return `/${org}/${repo}${parts.length ? `/${parts.join('/')}` : ''}`; - }; - if (toolName === TOOL_NAME.CONTENT_MOVE) { - return [...new Set([toParent(input.sourcePath), toParent(input.destinationPath)])]; - } - if (toolName === TOOL_NAME.CONTENT_COPY) return [toParent(input.destinationPath)]; - return input.path ? [toParent(input.path)] : []; -} - -const AGENT_URL = new URLSearchParams(window.location.search).get('ref') === 'local' - ? 'http://localhost:4200/chat' - : 'https://agent.da.live/chat'; - -/** - * Drop assistant array-content messages whose tool-call IDs have no matching - * tool-result anywhere in the history. These orphans appear when the agent's - * streamText step-limit fires mid-tool-execution or when the client strips - * virtual (non-approval) tool results. Without this filter the Anthropic API - * rejects the request with "tool_use ids without tool_result blocks". - */ -function stripOrphanedToolCallMessages(messages) { - const resolvedIds = new Set(); - const requestedApprovalIds = new Set(); - const respondedApprovalIds = new Set(); - for (const msg of messages) { - if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { - for (const p of msg.content) { - if (p.type === AGENT_EVENT.TOOL_APPROVAL_REQUEST && p.approvalId) { - requestedApprovalIds.add(p.approvalId); - } - } - } - if (msg.role === ROLE.TOOL && Array.isArray(msg.content)) { - for (const p of msg.content) { - if (p.type === AGENT_EVENT.TOOL_RESULT && p.toolCallId) resolvedIds.add(p.toolCallId); - if (p.type === AGENT_EVENT.TOOL_APPROVAL_RESPONSE && p.approvalId) { - respondedApprovalIds.add(p.approvalId); - } - } - } - } - // An approval is "complete" only when both request and response exist. - // Incomplete approvals (e.g. session interrupted mid-flow) are treated as orphans. - const completeApprovalIds = new Set( - [...respondedApprovalIds].filter((id) => requestedApprovalIds.has(id)), - ); - - return messages.filter((msg) => { - // Strip dangling approval-response messages whose request was already dropped. - if (msg.role === ROLE.TOOL && Array.isArray(msg.content)) { - const resp = msg.content.find((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_RESPONSE); - if (resp) return completeApprovalIds.has(resp.approvalId); - return true; - } - if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) return true; - const calls = msg.content.filter((p) => p.type === AGENT_EVENT.TOOL_CALL); - if (calls.length === 0) return true; - const approvals = msg.content.filter((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_REQUEST); - if (approvals.length > 0) { - // Keep only if every approval in this message has a corresponding response. - return approvals.every((a) => completeApprovalIds.has(a.approvalId)); - } - return calls.every((c) => resolvedIds.has(c.toolCallId)); - }); -} - -export default class ChatController { - constructor({ onUpdate, onToolDone }) { - this._onUpdate = onUpdate; - this._onToolDone = onToolDone; - this._sessionId = crypto.randomUUID(); - this._currentTurnId = crypto.randomUUID(); - } - - setContext(context) { - this._context = context; - this._room = null; - } - - _pageContextForAgent() { - const { org, site, path, view } = this._context ?? {}; - return org && site ? { org, site, path: path ?? '', view } : undefined; - } - - async _getRoom() { - if (this._room) return this._room; - const { userId } = await loadIms(); - const { org, site } = this._context ?? {}; - this._room = org && site && userId ? `${org}--${site}--${userId}` : 'default'; - return this._room; - } - - async loadInitialMessages() { - this._messages = []; - const room = await this._getRoom(); - const { messages: cached, sessionId } = await loadMessages(room); - this._sessionId = sessionId ?? this._sessionId; - if (!cached.length) return; - this._messages = stripOrphanedToolCallMessages(cached); - // Reconstruct tool cards from persisted approval messages so they render on reload. - this._toolCards = new Map(); - for (const msg of this._messages) { - if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { - const call = msg.content.find((p) => p.type === AGENT_EVENT.TOOL_CALL); - if (call) { - const { toolCallId, toolName, input } = call; - this._toolCards.set(toolCallId, { toolName, input, state: TOOL_STATE.DONE }); - } - } - } - this._update(); - } - - _update() { - this._onUpdate({ - messages: this._messages, - thinking: this._thinking, - streamingText: this._streamingText, - connected: this._connected, - toolCards: this._toolCards, - }); - } - - async connect(attempt = 0) { - try { - await fetch(AGENT_URL, { method: 'HEAD', signal: AbortSignal.timeout(5000) }); - this._connected = true; - } catch { - this._connected = false; - const delay = 1000 * 2 ** attempt; - if (delay < 30000) this._retryTimeout = setTimeout(() => this.connect(attempt + 1), delay); - } finally { - this._update(); - } - } - - _done() { - this._abortController = null; - this._thinking = false; - this._streamingText = undefined; - this._update(); - } - - stop() { - this._abortController?.abort(); - this._done(); - } - - async clear() { - if (this._thinking) this.stop(); - this._messages = undefined; - this._streamingText = undefined; - this._toolCards = new Map(); - this._autoApprovedTools = new Set(); - this._sessionId = crypto.randomUUID(); - this._currentTurnId = crypto.randomUUID(); - this._update(); - const room = await this._getRoom(); - resetSession(room, this._sessionId); - } - - destroy() { - clearTimeout(this._retryTimeout); - this.stop(); - } - - _onToolEvent = ({ - type, toolCallId, toolName, input, output, isError, approvalId, scope, - }) => { - const next = new Map(this._toolCards ?? []); - - if (type === AGENT_EVENT.TOOL_CALL) { - if (next.has(toolCallId)) return; // duplicate — ignore - next.set(toolCallId, { toolName, input, state: TOOL_STATE.RUNNING }); - } else if (type === AGENT_EVENT.TOOL_APPROVAL_REQUEST) { - const existingCard = next.get(toolCallId); - const settled = existingCard?.state; - if (settled === TOOL_STATE.APPROVED || settled === TOOL_STATE.REJECTED - || settled === TOOL_STATE.DONE || settled === TOOL_STATE.ERROR) return; - // prior carries the toolName from the earlier TOOL_CALL event; the TOOL_APPROVAL_REQUEST - // event from da-agent omits toolName, so we cannot rely on the destructured value here. - const prior = existingCard ?? { toolName, input: {} }; - const autoApprove = this._autoApprovedTools?.has(prior.toolName ?? toolName); - // Promote to _messages now that we know approval is needed. - // Both parts go in one message — resolveApprovals() matches tool-approval-request - // to tool-call by toolCallId within the same assistant message. - this._messages = [ - ...this._messages, - { - role: ROLE.ASSISTANT, - content: [ - { - type: AGENT_EVENT.TOOL_CALL, - toolCallId, - toolName: prior.toolName, - input: prior.input, - }, - { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId, toolCallId }, - ], - }, - ]; - const state = autoApprove ? TOOL_STATE.APPROVED : TOOL_STATE.APPROVAL_REQUESTED; - next.set(toolCallId, { ...prior, state, approvalId }); - this._toolCards = next; - this._update(); - if (autoApprove) queueMicrotask(() => this.approveToolCall(toolCallId, true)); - return; - } else { - const prior = next.get(toolCallId) ?? { toolName, input: {} }; - const state = isError ? TOOL_STATE.ERROR : TOOL_STATE.DONE; - next.set(toolCallId, { ...prior, state, output }); - if (state === TOOL_STATE.DONE) { - // Skip if a real message already exists for this toolCallId (approval flow adds one). - const hasApprovalMessage = this._messages.some( - (m) => !m.virtual && Array.isArray(m.content) && m.content.some( - (p) => p.type === AGENT_EVENT.TOOL_CALL && p.toolCallId === toolCallId, - ), - ); - if (!hasApprovalMessage) { - // Virtual message: renders the tool card and persists across refreshes. - // turnId + toolResult let _messagesForAgent() replay this read to the agent. - this._messages = [ - ...this._messages, - { - role: ROLE.ASSISTANT, - virtual: true, - turnId: this._currentTurnId, - toolResult: { output }, - content: [{ - type: AGENT_EVENT.TOOL_CALL, - toolCallId, - toolName: prior.toolName, - input: prior.input, - }], - }, - ]; - } - - // Once content_upload succeeds, replace dataBase64 with contentUrl so - // continuation POSTs don't retransmit bytes already in storage. - const contentUrl = output?.source?.contentUrl; - if (prior.toolName === 'content_upload' && prior.input?.attachmentRef && contentUrl) { - this._pendingAttachments = (this._pendingAttachments ?? []).map((a) => ( - a.id === prior.input.attachmentRef - ? { id: a.id, fileName: a.fileName, mediaType: a.mediaType, contentUrl, ...(typeof a.sizeBytes === 'number' ? { sizeBytes: a.sizeBytes } : {}) } - : a - )); - } - - this._onToolDone?.(scope, affectedFolders(toolName, prior.input)); - } - } - - this._toolCards = next; - this._update(); - }; - - approveToolCall = async (toolCallId, approved, always = false) => { - const card = this._toolCards.get(toolCallId); - if (!card?.approvalId) return; - - if (always) { - this._autoApprovedTools ??= new Set(); - this._autoApprovedTools.add(card.toolName); - } - - const next = new Map(this._toolCards ?? []); - next.set(toolCallId, { ...card, state: approved ? TOOL_STATE.APPROVED : TOOL_STATE.REJECTED }); - - // When "always approve" is clicked, bulk-approve any other pending parallel calls - // with the same tool name so they don't surface their own popovers. - const bulkApprovalMessages = []; - if (always && approved) { - for (const [id, c] of next) { - if (id !== toolCallId && c.toolName === card.toolName - && c.state === TOOL_STATE.APPROVAL_REQUESTED && c.approvalId) { - next.set(id, { ...c, state: TOOL_STATE.APPROVED }); - bulkApprovalMessages.push({ - role: ROLE.TOOL, - content: [{ - type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId: c.approvalId, approved: true, - }], - }); - } - } - } - - this._toolCards = next; - - const { approvalId } = card; - this._messages = [ - ...this._messages, - { - role: ROLE.TOOL, - content: [{ type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId, approved }], - }, - ...bulkApprovalMessages, - ]; - this._thinking = approved; - this._update(); - - if (approved) { - try { - await this._stream(this._pageContextForAgent()); - } catch (err) { - if (err.name !== 'AbortError') { - this._messages = [...this._messages, { role: ROLE.ASSISTANT, content: `Error: ${err.message}` }]; - } - } finally { - this._done(); - } - } else { - this._done(); - } - }; - - // Adds in the tool calls and tool results for the current turn so the agent can replay them. - _messagesForAgent() { - const represented = new Set(); - this._messages.forEach((msg) => { - if (msg.virtual || msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) return; - msg.content.forEach((part) => { - if (part.type === AGENT_EVENT.TOOL_CALL) represented.add(part.toolCallId); - }); - }); - - return this._messages.flatMap((msg) => { - if (!msg.virtual) return [msg]; - if (msg.turnId !== this._currentTurnId || !msg.toolResult) return []; - const call = msg.content?.find((p) => p.type === AGENT_EVENT.TOOL_CALL); - if (!call || represented.has(call.toolCallId)) return []; - const { output } = msg.toolResult; - const { toolCallId, toolName, input } = call; - const wrapped = typeof output === 'string' - ? { type: 'text', value: output } - : { type: 'json', value: output }; - return [ - { - role: ROLE.ASSISTANT, - content: [{ type: AGENT_EVENT.TOOL_CALL, toolCallId, toolName, input }], - }, - { - role: ROLE.TOOL, - content: [{ type: AGENT_EVENT.TOOL_RESULT, toolCallId, toolName, output: wrapped }], - }, - ]; - }); - } - - async _stream(pageContext) { - const [{ accessToken }, room] = await Promise.all([loadIms(), this._getRoom()]); - this._abortController = new AbortController(); - - const resp = await fetch(AGENT_URL, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - messages: stripOrphanedToolCallMessages(this._messagesForAgent()), - pageContext, - imsToken: accessToken?.token ?? null, - room, - sessionId: this._sessionId, - ...(this._requestedSkills?.length ? { requestedSkills: this._requestedSkills } : {}), - ...(this._pendingAttachments?.length ? { attachments: this._pendingAttachments } : {}), - ...this._mcpPayload(), - }), - signal: this._abortController.signal, - }); - - if (!resp.ok) { - throw new Error(`Agent responded with ${resp.status}: ${await resp.text()}`); - } - - await readStream(resp.body, { - onDelta: (next) => { this._streamingText = next; this._update(); }, - onText: (text) => { - this._messages = [...this._messages, { role: ROLE.ASSISTANT, content: text }]; - this._streamingText = ''; - this._update(); - saveMessages(room, this._messages, this._sessionId); - }, - onTool: this._onToolEvent, - }); - } - - setMcpConfig(mcpServers, mcpServerHeaders) { - this._mcpServers = mcpServers; - this._mcpServerHeaders = mcpServerHeaders; - } - - _mcpPayload() { - const s = this._mcpServers; - const h = this._mcpServerHeaders; - return { - ...(s && Object.keys(s).length ? { mcpServers: s } : {}), - ...(h && Object.keys(h).length ? { mcpServerHeaders: h } : {}), - }; - } - - async sendMessage(message, context = [], { requestedSkills = [], attachments = [] } = {}) { - if (this._thinking || !this._connected) return; - - this._currentTurnId = crypto.randomUUID(); - this._requestedSkills = requestedSkills; - const selectionContext = context - .filter((item) => typeof item.proseIndex === 'number' || item.blockName) - .map(({ proseIndex, blockName, innerText }) => ({ - ...(typeof proseIndex === 'number' && { proseIndex }), - ...(blockName && { blockName }), - ...(innerText && { innerText }), - })); - - const attachmentsMeta = attachments.map(({ id, fileName, mediaType, sizeBytes }) => ({ - id, - fileName, - mediaType, - ...(typeof sizeBytes === 'number' ? { sizeBytes } : {}), - })); - - const userMessage = { - role: ROLE.USER, - content: message, - ...(selectionContext.length && { selectionContext }), - ...(attachmentsMeta.length && { attachmentsMeta }), - }; - - this._pendingAttachments = attachments; - this._messages = [...(this._messages ?? []), userMessage]; - this._thinking = true; - this._update(); - - this._toolCards = new Map(); - - try { - await this._stream(this._pageContextForAgent()); - } catch (err) { - if (err.name !== 'AbortError') { - this._messages = [ - ...this._messages, - { role: ROLE.ASSISTANT, content: `Error: ${err.message}` }, - ]; - } - } finally { - this._done(); - } - } -} diff --git a/nx2/blocks/chat/chat.css b/nx2/blocks/chat/chat.css index 0a13d467b..2b1efe77c 100644 --- a/nx2/blocks/chat/chat.css +++ b/nx2/blocks/chat/chat.css @@ -83,7 +83,7 @@ padding: 0 var(--s2-spacing-300); box-sizing: border-box; justify-content: flex-end; - gap: var(--s2-spacing-300); + gap: var(--s2-spacing-100); .chat-header-btn { display: inline-flex; @@ -98,7 +98,7 @@ color: var(--s2-gray-800); cursor: pointer; font-family: inherit; - font-size: var(--s2-body-size-s); + font-size: var(--s2-body-size-xs); &.clear-btn { padding: 0 var(--s2-spacing-100); @@ -149,7 +149,7 @@ width: 10px; height: 10px; background-color: currentcolor; - mask-image: url("/nx2/img/icons/S2_Icon_ChevronUp_20_N.svg"); + mask-image: url("https://da.live/img/icons/s2-icon-chevronup-20-n.svg"); mask-size: contain; mask-repeat: no-repeat; mask-position: center; @@ -190,6 +190,10 @@ color: var(--s2-gray-500); overflow-wrap: break-word; } + + &.tool-card-detail { + margin-left: 14px; + } } .directive { @@ -203,6 +207,7 @@ .directive-checklist ul { list-style: none; + padding: 0; } .directive-toggle-list { @@ -300,21 +305,29 @@ gap: var(--s2-spacing-75); } - .message-content p { - margin: 0; - } + .message-content { + p { + margin: 0; + } - .message-content code { - background: var(--s2-gray-75); - border-radius: var(--s2-corner-radius-75); - padding: var(--s2-spacing-50); - overflow-x: auto; - margin: 0; - font-size: var(--s2-body-size-xs); + code { + background: var(--s2-gray-75); + border-radius: var(--s2-corner-radius-75); + padding: var(--s2-spacing-50); + overflow-x: auto; + margin: 0; + font-size: var(--s2-body-size-xs); + } + + hr { + border: none; + height: 1px; + background: var(--s2-gray-200); + } } .message-action-copy { - align-self: flex-end; + align-self: flex-start; visibility: hidden; box-sizing: border-box; width: 20px; diff --git a/nx2/blocks/chat/chat.js b/nx2/blocks/chat/chat.js index 814bdca42..4c40c7831 100644 --- a/nx2/blocks/chat/chat.js +++ b/nx2/blocks/chat/chat.js @@ -2,7 +2,7 @@ import { LitElement, html, nothing } from 'da-lit'; import { loadStyle, hashChange } from '../../utils/utils.js'; import { readFileAsBase64 } from './utils/stream.js'; import '../shared/menu/menu.js'; -import ChatController from './chat-controller.js'; +import ChatController from './controllers/chat-controller.js'; import { renderMessage, renderApprovalCard } from './renderers.js'; import './welcome/welcome.js'; import './prompts/prompts.js'; @@ -180,12 +180,16 @@ class NxChat extends LitElement { })); }, onUpdate: ({ messages, thinking, streamingText, connected, toolCards }) => { - this.messages = streamingText + const newMessages = streamingText ? [...(messages ?? []), { role: ROLE.ASSISTANT, content: streamingText, streaming: true }] : messages; - this.thinking = thinking; - this.connected = connected; - this.toolCards = toolCards; + cancelAnimationFrame(this._updateRaf); + this._updateRaf = requestAnimationFrame(() => { + this.messages = newMessages; + this.thinking = thinking; + this.connected = connected; + this.toolCards = toolCards; + }); }, }); if (this._context) this._controller.setContext(this._context); @@ -200,6 +204,7 @@ class NxChat extends LitElement { disconnectedCallback() { super.disconnectedCallback(); + cancelAnimationFrame(this._updateRaf); (this._items ?? []).forEach((item) => { if (item.thumbnail) URL.revokeObjectURL(item.thumbnail); }); @@ -243,7 +248,8 @@ class NxChat extends LitElement { if (changed.has('messages')) { const log = this.shadowRoot.querySelector('.chat-scroll-container'); if (log && this._wasNearBottom) { - requestAnimationFrame(() => { log.scrollTop = log.scrollHeight; }); + cancelAnimationFrame(this._scrollRaf); + this._scrollRaf = requestAnimationFrame(() => { log.scrollTop = log.scrollHeight; }); } } if (changed.has('thinking') && !this.thinking && changed.get('thinking')) { diff --git a/nx2/blocks/chat/constants.js b/nx2/blocks/chat/constants.js index d980a6236..5fd58fe4e 100644 --- a/nx2/blocks/chat/constants.js +++ b/nx2/blocks/chat/constants.js @@ -8,18 +8,14 @@ const MENU_OPTIONS = { const ADD_MENU_ITEMS = [ { section: 'Add' }, - { id: 'files', label: 'Files or images', icon: 'link' }, - { id: MENU_OPTIONS.PROMPT, label: 'Prompt' }, - { id: 'command', label: '"/" Command' }, + { id: MENU_OPTIONS.FILES, label: 'Files or images', icon: 'link' }, + { id: MENU_OPTIONS.PROMPT, label: 'Prompt', icon: 'commentremove' }, + { id: MENU_OPTIONS.COMMAND, label: '"/" Command', icon: 'prompt' }, { divider: true }, { id: 'prompts', label: 'Manage Prompts' }, { id: 'skills', label: 'Manage Skills' }, ]; -const CHAT_ICONS = { - add: 'Add', clear: 'RemoveCircle', close: 'SplitLeft', send: 'ArrowUpSend', stop: 'Stop', up: 'ChevronUp', -}; - /** * Agent stream event types. * Source: Vercel AI SDK v6 UIMessageStream format, as emitted by da-agent. @@ -86,6 +82,10 @@ const TOOL_SCOPE = { [TOOL_NAME.CONTENT_UPLOAD]: 'document', }; +const FINISH_REASON = { + TOOL_CALLS: 'tool-calls', +}; + const ROLE = { USER: 'user', ASSISTANT: 'assistant', @@ -96,7 +96,7 @@ export { ADOBE_AI_GUIDELINES_URL, ADD_MENU_ITEMS, AGENT_EVENT, - CHAT_ICONS, + FINISH_REASON, MENU_OPTIONS, ROLE, TOOL_INPUT, diff --git a/nx2/blocks/chat/controllers/chat-controller.js b/nx2/blocks/chat/controllers/chat-controller.js new file mode 100644 index 000000000..195ad144c --- /dev/null +++ b/nx2/blocks/chat/controllers/chat-controller.js @@ -0,0 +1,541 @@ +import { loadIms } from '../../../utils/ims.js'; +import { + AGENT_EVENT, FINISH_REASON, ROLE, TOOL_NAME, TOOL_SCOPE, TOOL_STATE, +} from '../constants.js'; +import { readStream } from '../utils/stream.js'; +import { loadMessages, saveMessages, resetSession } from '../utils/persistence.js'; +import { buildAgentMessages, stripOrphanedToolCallMessages, buildUserMessage, wrapOutput } from '../utils/messages.js'; +import { affectedFolders } from '../utils/tools.js'; +import Turn from './turn.js'; + +const AGENT_URL = new URLSearchParams(window.location.search).get('ref') === 'local' + ? 'http://localhost:4200/chat' + : 'https://agent.da.live/chat'; + +const emptyBatch = () => ({ toolCalls: [], approvalRequests: [] }); + +export default class ChatController { + constructor({ onUpdate, onToolDone }) { + this._onUpdate = onUpdate; + this._onToolDone = onToolDone; + this._sessionId = crypto.randomUUID(); + this._turn = new Turn(); + this._pendingBatch = emptyBatch(); + } + + setContext(context) { + this._context = context; + this._room = null; + } + + setMcpConfig(mcpServers, mcpServerHeaders) { + this._mcpServers = mcpServers; + this._mcpServerHeaders = mcpServerHeaders; + } + + async connect(attempt = 0) { + try { + await fetch(AGENT_URL, { method: 'HEAD', signal: AbortSignal.timeout(5000) }); + this._isConnected = true; + } catch { + this._isConnected = false; + const delay = 1000 * 2 ** attempt; + if (delay < 30000) this._retryTimeout = setTimeout(() => this.connect(attempt + 1), delay); + } finally { + this._update(); + } + } + + async loadInitialMessages() { + this._messages = []; + const room = await this._getRoom(); + const { messages: cached, sessionId } = await loadMessages(room); + this._sessionId = sessionId ?? this._sessionId; + if (!cached.length) return; + this._messages = stripOrphanedToolCallMessages(cached); + // Reconstruct tool cards from persisted approval messages so they render on reload. + this._toolCards = new Map(); + + for (const msg of this._messages) { + if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { + const call = msg.content.find((p) => p.type === AGENT_EVENT.TOOL_CALL); + if (call) { + const { toolCallId, toolName, input } = call; + this._toolCards.set(toolCallId, { toolName, input, state: TOOL_STATE.DONE }); + } + } + } + this._update(); + } + + async sendMessage(message, context = [], { requestedSkills = [], attachments = [] } = {}) { + if (this._turn.isActive || !this._isConnected) return; + + this._turn.begin(); + this._pendingBatch = emptyBatch(); + this._requestedSkills = requestedSkills; + this._pendingAttachments = attachments; + this._messages = [...(this._messages ?? []), buildUserMessage(message, context, attachments)]; + this._toolCards = new Map(); + this._skippedToolCallIds = undefined; + this._update(); + + try { + await this._stream(); + } catch (err) { + if (err.name !== 'AbortError') { + this._messages = [ + ...this._messages, + { role: ROLE.ASSISTANT, content: `Error: ${err.message}` }, + ]; + } + } finally { + this._done(); + this._persistMessages(); + } + } + + approveToolCall = async (toolCallId, approved, always = false) => { + const card = this._toolCards?.get(toolCallId); + if (!card?.approvalId) return; + + if (always) { + this._autoApprovedTools ??= new Set(); + this._autoApprovedTools.add(card.toolName); + } + + const bulkCards = (always && approved) + ? [...(this._toolCards ?? [])].filter(([id, c]) => ( + id !== toolCallId && c.toolName === card.toolName + && c.state === TOOL_STATE.APPROVAL_REQUESTED && c.approvalId + )) + : []; + + const next = new Map(this._toolCards ?? []); + next.set(toolCallId, { ...card, state: approved ? TOOL_STATE.APPROVED : TOOL_STATE.REJECTED }); + for (const [id, c] of bulkCards) { + next.set(id, { ...c, state: TOOL_STATE.APPROVED }); + } + this._toolCards = next; + + // Cards that stay APPROVED after the stream had their tool executed server-side without + // a result being streamed back. Add synthetic tool-results post-stream so the next + // payload treats them as resolved and doesn't re-trigger the tool. + const justApproved = approved + ? new Set([toolCallId, ...bulkCards.map(([id]) => id)]) + : new Set(); + + const toResponse = (approvalId, wasApproved) => ({ + role: ROLE.TOOL, + content: [{ type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId, approved: wasApproved }], + }); + this._messages = [ + ...this._messages, + toResponse(card.approvalId, approved), + ...bulkCards.map(([, c]) => toResponse(c.approvalId, true)), + ]; + + // Stream per approval — batching all approvals causes the agent to execute tools + // internally without streaming results back, so the client never receives them. + this._turn.resume(); + this._update(); + + // Counter (not boolean) so overlapping approval streams don't clear each other's guard. + this._activeApprovalStreams = (this._activeApprovalStreams ?? 0) + 1; + let streamErrored = false; + try { + await this._stream(); + } catch (err) { + streamErrored = true; + if (err.name !== 'AbortError') { + this._messages = [ + ...this._messages, + { role: ROLE.ASSISTANT, content: `Error: ${err.message}` }, + ]; + } + } finally { + this._activeApprovalStreams -= 1; + // Close out any approved cards whose tool-results weren't returned by the stream. + if (!streamErrored) { + for (const cardId of justApproved) { + const current = this._toolCards?.get(cardId); + if (current?.state === TOOL_STATE.APPROVED) { + const successOutput = wrapOutput({ success: true }); + const updatedCards = new Map(this._toolCards ?? []); + updatedCards.set(cardId, { + ...current, state: TOOL_STATE.DONE, output: { success: true }, + }); + this._toolCards = updatedCards; + this._messages = [ + ...this._messages, + { + role: ROLE.TOOL, + content: [{ + type: AGENT_EVENT.TOOL_RESULT, + toolCallId: cardId, + toolName: current.toolName, + output: successOutput, + }], + }, + ]; + const scope = TOOL_SCOPE[current.toolName]; + this._onToolDone?.(scope, affectedFolders(current.toolName, current.input)); + } + } + } + this._done(); + this._persistMessages(); + } + }; + + stop() { + this._abortController?.abort(); + this._abortController = null; + this._streamingText = undefined; + this._pendingContinuation = false; + this._turn.cancel(); + this._update(); + } + + async clear() { + if (this._turn.isActive) this.stop(); + this._messages = undefined; + this._streamingText = undefined; + this._toolCards = new Map(); + this._autoApprovedTools = new Set(); + this._pendingBatch = emptyBatch(); + this._pendingContinuation = false; + this._sessionId = crypto.randomUUID(); + this._activeApprovalStreams = 0; + this._turn.cancel(); + this._update(); + const room = await this._getRoom(); + resetSession(room, this._sessionId); + } + + destroy() { + clearTimeout(this._retryTimeout); + this.stop(); + } + + async _stream() { + const [{ accessToken }, room] = await Promise.all([loadIms(), this._getRoom()]); + this._abortController = new AbortController(); + + const payload = this._buildPayload(room, accessToken); + + const resp = await fetch(AGENT_URL, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(payload), + signal: this._abortController.signal, + }); + + if (!resp.ok) { + throw new Error(`Agent responded with ${resp.status}: ${await resp.text()}`); + } + + await readStream(resp.body, { + onDelta: (next) => { this._streamingText = next; this._update(); }, + onText: (text) => { + this._messages = [...this._messages, { role: ROLE.ASSISTANT, content: text }]; + this._streamingText = ''; + this._update(); + saveMessages(room, this._messages, this._sessionId); + }, + onTool: this._onToolEvent, + onFinish: (finishReason) => this._onStreamFinish(room, finishReason), + }); + } + + _buildPayload(room, accessToken) { + const liveToolCallIds = new Set( + [...(this._toolCards ?? [])].filter(([, c]) => ( + c.state !== TOOL_STATE.DONE && c.state !== TOOL_STATE.ERROR + )).map(([id]) => id), + ); + return { + messages: buildAgentMessages(this._messages ?? [], { liveToolCallIds }), + pageContext: this._pageContextForAgent(), + imsToken: accessToken?.token ?? null, + room, + sessionId: this._sessionId, + ...(this._requestedSkills?.length ? { requestedSkills: this._requestedSkills } : {}), + ...(this._pendingAttachments?.length ? { attachments: this._pendingAttachments } : {}), + ...(this._mcpServers && Object.keys(this._mcpServers).length + ? { mcpServers: this._mcpServers } : {}), + ...(this._mcpServerHeaders && Object.keys(this._mcpServerHeaders).length + ? { mcpServerHeaders: this._mcpServerHeaders } : {}), + }; + } + + _done() { + this._abortController = null; + this._streamingText = undefined; + if (this._pendingContinuation) { + this._continueTurn(); return; + } + this._pendingBatch = emptyBatch(); + // Turn ends only when all approval streams have finished and no cards are awaiting decision. + const hasPendingCards = [...(this._toolCards ?? [])].some( + ([, c]) => c.state === TOOL_STATE.APPROVAL_REQUESTED, + ); + if (!this._activeApprovalStreams && !hasPendingCards) this._turn.end(); + this._update(); + } + + _continueTurn() { + this._pendingContinuation = false; + this._pendingBatch = emptyBatch(); + this._turn.resume(); + this._update(); + this._stream().catch((err) => { + if (err.name !== 'AbortError') { + this._messages = [ + ...this._messages, + { role: ROLE.ASSISTANT, content: `Error: ${err.message}` }, + ]; + } + }).finally(() => { this._done(); this._persistMessages(); }); + } + + _onToolEvent = ({ + type, toolCallId, toolName, input, output, isError, approvalId, scope, + }) => { + if (type === AGENT_EVENT.TOOL_CALL) this._onToolCall(toolCallId, toolName, input); + else if (type === AGENT_EVENT.TOOL_APPROVAL_REQUEST) { + this._onApprovalRequest(toolCallId, toolName, approvalId); + } else { + this._onToolResult(toolCallId, toolName, output, isError, scope); + } + }; + + _onToolCall(toolCallId, toolName, input) { + const isDuplicate = this._toolCards?.has(toolCallId); + + // Same toolName+path in one LLM step is a model error — skip the duplicate and block + // its _onApprovalRequest to keep it out of message history. + const pathKey = input?.path ?? input?.destinationPath; + const isPathDuplicate = !isDuplicate && !!pathKey + && [...(this._toolCards ?? [])].some(([, c]) => ( + c.toolName === toolName + && (c.input?.path ?? c.input?.destinationPath) === pathKey + && (c.state === TOOL_STATE.RUNNING + || c.state === TOOL_STATE.APPROVAL_REQUESTED + || c.state === TOOL_STATE.APPROVED) + )); + + if (isDuplicate || isPathDuplicate) { + if (isPathDuplicate) { + this._skippedToolCallIds ??= new Set(); + this._skippedToolCallIds.add(toolCallId); + } + return; + } + const next = new Map(this._toolCards ?? []); + next.set(toolCallId, { toolName, input, state: TOOL_STATE.RUNNING }); + this._pendingBatch.toolCalls.push({ toolCallId, toolName, input }); + this._toolCards = next; + this._update(); + } + + _onApprovalRequest(toolCallId, toolName, approvalId) { + if (this._skippedToolCallIds?.has(toolCallId)) return; + const existingCard = this._toolCards?.get(toolCallId); + const settled = existingCard?.state; + if (settled && settled !== TOOL_STATE.RUNNING) return; + // prior carries toolName/input from the earlier TOOL_CALL event. + const prior = existingCard ?? { toolName, input: {} }; + const autoApprove = this._autoApprovedTools?.has(prior.toolName ?? toolName); + this._pendingBatch.approvalRequests.push({ approvalId, toolCallId }); + this._turn.pause(); + const next = new Map(this._toolCards ?? []); + next.set(toolCallId, { + ...prior, + state: autoApprove ? TOOL_STATE.APPROVED : TOOL_STATE.APPROVAL_REQUESTED, + approvalId, + }); + this._toolCards = next; + + // Commit immediately — waiting until _onStreamFinish risks the user approving before the + // message is in _messages, which orphans the response and causes a prefill error. + const toolCallPart = { + type: AGENT_EVENT.TOOL_CALL, + toolCallId, + toolName: prior.toolName, + input: prior.input, + }; + const approvalRequestPart = { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId, toolCallId }; + // Remove this tool-call from the pending batch so _onStreamFinish doesn't re-commit it. + this._pendingBatch.toolCalls = this._pendingBatch.toolCalls + .filter((c) => c.toolCallId !== toolCallId); + this._appendAssistantMessage([toolCallPart, approvalRequestPart]); + this._update(); + } + + _onToolResult(toolCallId, toolName, output, isError, scope) { + const prior = this._toolCards?.get(toolCallId) ?? { toolName, input: {} }; + const state = isError ? TOOL_STATE.ERROR : TOOL_STATE.DONE; + const next = new Map(this._toolCards ?? []); + next.set(toolCallId, { ...prior, state, output }); + this._toolCards = next; + + // Evict from batch regardless of outcome — error results must not bleed into a later commit. + const pendingCall = this._pendingBatch.toolCalls.find((c) => c.toolCallId === toolCallId); + this._pendingBatch.toolCalls = this._pendingBatch.toolCalls + .filter((c) => c.toolCallId !== toolCallId); + + if (state === TOOL_STATE.DONE) { + const wrapped = wrapOutput(output); + if (pendingCall) { + // Non-approval tool: commit call+result, merging with any preceding text message. + const toolCallPart = { + type: AGENT_EVENT.TOOL_CALL, + toolCallId, + toolName: pendingCall.toolName, + input: pendingCall.input, + }; + const toolResultMsg = { + role: ROLE.TOOL, + content: [{ + type: AGENT_EVENT.TOOL_RESULT, + toolCallId, + toolName: pendingCall.toolName, + output: wrapped, + }], + }; + this._appendAssistantMessage([toolCallPart], toolResultMsg); + } else { + // Approval tool result arriving in the continuation stream. + this._messages = [ + ...this._messages, + { + role: ROLE.TOOL, + content: [{ + type: AGENT_EVENT.TOOL_RESULT, + toolCallId, + toolName: prior.toolName, + output: wrapped, + }], + }, + ]; + } + + this._replaceUploadRef(prior, output); + this._onToolDone?.(scope, affectedFolders(toolName, prior.input)); + } + + this._update(); + } + + // Handle stream finish: approval messages were already committed eagerly in _onApprovalRequest, + // so here we only need to add auto-approve responses and set the continuation flag. + _onStreamFinish(room, finishReason) { + const { approvalRequests } = this._pendingBatch; + this._pendingBatch = emptyBatch(); + if (approvalRequests.length) { + // Auto-approved: add responses and continue. Skip any already added by approveToolCall + // (race: user approved before this stream ended). + const autoApproved = approvalRequests.filter(({ toolCallId }) => ( + this._toolCards?.get(toolCallId)?.state === TOOL_STATE.APPROVED + )); + if (autoApproved.length === approvalRequests.length) { + let added = 0; + for (const { approvalId } of autoApproved) { + const alreadyResponded = this._messages.some( + (m) => m.role === ROLE.TOOL && Array.isArray(m.content) + && m.content.some((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_RESPONSE + && p.approvalId === approvalId), + ); + if (!alreadyResponded) { + this._messages = [ + ...this._messages, + { + role: ROLE.TOOL, + content: [{ type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId, approved: true }], + }, + ]; + added += 1; + } + } + if (added > 0) this._pendingContinuation = true; + } + this._update(); + } + + const lastRole = this._messages[this._messages.length - 1]?.role; + if (!this._pendingContinuation && !approvalRequests.length + && finishReason === FINISH_REASON.TOOL_CALLS + && lastRole !== ROLE.ASSISTANT) { + this._pendingContinuation = true; + } + + saveMessages(room, this._messages, this._sessionId); + } + + // Appends an assistant message whose content is `parts`, merging into the preceding + // text-only assistant message if one exists (consecutive assistant messages are rejected). + _appendAssistantMessage(parts, ...trailing) { + const prev = this._messages[this._messages.length - 1]; + if (prev?.role === ROLE.ASSISTANT && typeof prev.content === 'string') { + this._messages = [ + ...this._messages.slice(0, -1), + { role: ROLE.ASSISTANT, content: [{ type: 'text', text: prev.content }, ...parts] }, + ...trailing, + ]; + } else { + this._messages = [...this._messages, { role: ROLE.ASSISTANT, content: parts }, ...trailing]; + } + } + + // Once content_upload succeeds, replace dataBase64 with contentUrl so + // continuation POSTs don't retransmit bytes already in storage. + _replaceUploadRef(prior, output) { + const contentUrl = output?.source?.contentUrl; + if (prior.toolName !== TOOL_NAME.CONTENT_UPLOAD || !prior.input?.attachmentRef || !contentUrl) { + return; + } + this._pendingAttachments = (this._pendingAttachments ?? []).map((a) => { + if (a.id !== prior.input.attachmentRef) return a; + return { + id: a.id, + fileName: a.fileName, + mediaType: a.mediaType, + contentUrl, + ...(typeof a.sizeBytes === 'number' ? { sizeBytes: a.sizeBytes } : {}), + }; + }); + } + + // Persists current messages. Called at stream end (success or error) so that the user's + // input and any error message survive a page reload even when no text was streamed. + _persistMessages() { + if (!this._messages) return; + this._getRoom() + .then((room) => saveMessages(room, this._messages, this._sessionId)) + .catch(() => { }); + } + + _update() { + this._onUpdate({ + messages: this._messages, + thinking: this._turn.isActive, + streamingText: this._streamingText, + connected: this._isConnected, + toolCards: this._toolCards, + }); + } + + async _getRoom() { + if (this._room) return this._room; + const { userId } = await loadIms(); + const { org, site } = this._context ?? {}; + this._room = org && site && userId ? `${org}--${site}--${userId}` : 'default'; + return this._room; + } + + _pageContextForAgent() { + const { org, site, path, view } = this._context ?? {}; + return org && site ? { org, site, path: path ?? '', view } : undefined; + } +} diff --git a/nx2/blocks/chat/controllers/turn.js b/nx2/blocks/chat/controllers/turn.js new file mode 100644 index 000000000..1ba60463b --- /dev/null +++ b/nx2/blocks/chat/controllers/turn.js @@ -0,0 +1,44 @@ +const PHASE = { + IDLE: 'idle', + STREAMING: 'streaming', + APPROVAL_PENDING: 'approval-pending', + RESUMING: 'resuming', +}; + +/* + * Tracks the lifecycle of a single agent turn — from user message to final response, + * including any approval pauses in between. + * + * Transitions: + * idle → streaming begin() + * streaming → idle end() + * streaming → approval-pending pause() + * approval-pending → resuming resume() + * resuming → idle end() + * resuming → approval-pending pause() (nested approval) + * any → idle cancel() (stop / reject / clear) + */ +export default class Turn { + _phase = PHASE.IDLE; + + // end() stays in approval-pending to avoid a false idle between stream end and user decision. + pause() { + if (this._phase === PHASE.STREAMING || this._phase === PHASE.RESUMING) { + this._phase = PHASE.APPROVAL_PENDING; + } + } + + begin() { this._phase = PHASE.STREAMING; } + + resume() { + if (this._phase === PHASE.APPROVAL_PENDING) this._phase = PHASE.RESUMING; + } + + end() { + if (this._phase !== PHASE.APPROVAL_PENDING) this._phase = PHASE.IDLE; + } + + cancel() { this._phase = PHASE.IDLE; } + + get isActive() { return this._phase !== PHASE.IDLE; } +} diff --git a/nx2/blocks/chat/renderers.js b/nx2/blocks/chat/renderers.js index ff4d9aadd..bd71a4a9a 100644 --- a/nx2/blocks/chat/renderers.js +++ b/nx2/blocks/chat/renderers.js @@ -34,33 +34,35 @@ function renderMessageContent(text) { if (!text) return nothing; return parseDirectives(text).map(({ kind, type, content }) => { + if (!content) return nothing; const dom = toDOM(mdast2hast(parser.parse(content))); return kind === 'directive' ? html`
${dom}
` : dom; }); } -function approvalSummary(input) { +function approvalSummary(input, { json = false } = {}) { if (!input) return null; const { HUMAN_READABLE_SUMMARY, SOURCE_PATH, DESTINATION_PATH, PATH, SKILL_ID, NAME, } = TOOL_INPUT; return input[HUMAN_READABLE_SUMMARY] ?? (input[SOURCE_PATH] && input[DESTINATION_PATH] ? `${input[SOURCE_PATH]} → ${input[DESTINATION_PATH]}` : null) - ?? input[PATH] ?? input[SKILL_ID] ?? input[NAME] ?? null; + ?? input[PATH] ?? input[SKILL_ID] ?? input[NAME] + ?? (json ? JSON.stringify(input, null, 2) : null); } function renderToolCard(toolCallId, toolCards) { const card = toolCards?.get(toolCallId); if (!card || card.state === TOOL_STATE.APPROVAL_REQUESTED) return nothing; const { toolName, state, input } = card; - const detail = approvalSummary(input); + const detail = approvalSummary(input, { json: true }); const failed = state === TOOL_STATE.ERROR || state === TOOL_STATE.REJECTED; - return html` + const status = failed ? html`${state}` : nothing; + return detail ? html`
- ${toolName}${failed ? html`${state}` : nothing} - ${detail ? html`${detail}` : nothing} -
- `; + ${toolName}${status} + ${detail} + ` : html`${toolName}${status}`; } function renderApprovalCard(pending, onApprove) { diff --git a/nx2/blocks/chat/utils/messages.js b/nx2/blocks/chat/utils/messages.js new file mode 100644 index 000000000..b0805d1a7 --- /dev/null +++ b/nx2/blocks/chat/utils/messages.js @@ -0,0 +1,131 @@ +import { AGENT_EVENT, ROLE } from '../constants.js'; + +export const wrapOutput = (output) => ( + typeof output === 'string' + ? { type: 'text', value: output } + : { type: 'json', value: output } +); + +export function buildUserMessage(message, context, attachments) { + const selectionContext = context + .filter(({ proseIndex, blockName }) => typeof proseIndex === 'number' || blockName) + .map(({ proseIndex, blockName, innerText }) => { + const item = {}; + if (typeof proseIndex === 'number') item.proseIndex = proseIndex; + if (blockName) item.blockName = blockName; + if (innerText) item.innerText = innerText; + return item; + }); + + const attachmentsMeta = attachments.map(({ id, fileName, mediaType, sizeBytes }) => ({ + id, + fileName, + mediaType, + ...(typeof sizeBytes === 'number' ? { sizeBytes } : {}), + })); + + return { + role: ROLE.USER, + content: message, + ...(selectionContext.length && { selectionContext }), + ...(attachmentsMeta.length && { attachmentsMeta }), + }; +} + +// Without this the agent rejects with "tool_use ids without tool_result blocks". +export function stripOrphanedToolCallMessages(messages, { liveToolCallIds = new Set() } = {}) { + const assistantParts = messages + .filter((msg) => msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) + .flatMap((msg) => msg.content); + + const toolParts = messages + .filter((msg) => msg.role === ROLE.TOOL && Array.isArray(msg.content)) + .flatMap((msg) => msg.content); + + const resolvedIds = new Set( + toolParts.filter((p) => p.type === AGENT_EVENT.TOOL_RESULT).map((p) => p.toolCallId), + ); + const requestedApprovalIds = new Set( + assistantParts + .filter((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_REQUEST) + .map((p) => p.approvalId), + ); + const respondedApprovalIds = new Set( + toolParts + .filter((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_RESPONSE) + .map((p) => p.approvalId), + ); + + const completeApprovalIds = new Set( + [...respondedApprovalIds].filter((id) => requestedApprovalIds.has(id)), + ); + + const approvalIdToCallId = new Map( + assistantParts + .filter((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_REQUEST && p.approvalId && p.toolCallId) + .map((p) => [p.approvalId, p.toolCallId]), + ); + + // Stripping the approval-response requires stripping its approval-request too — otherwise a + // re-run sees a request with no response, strips the whole assistant message, and orphans + // the tool-result. + const staleApprovalIds = new Set( + [...completeApprovalIds].filter((id) => { + const callId = approvalIdToCallId.get(id); + return callId && resolvedIds.has(callId); + }), + ); + + return messages + .filter((msg) => { + if (msg.role === ROLE.TOOL && Array.isArray(msg.content)) { + const resp = msg.content.find((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_RESPONSE); + if (!resp) return true; + if (!completeApprovalIds.has(resp.approvalId)) return false; // orphaned request + const callId = approvalIdToCallId.get(resp.approvalId); + return callId ? liveToolCallIds.has(callId) : true; // strip when stale or abandoned + } + + if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content)) return true; + + const calls = msg.content.filter((p) => p.type === AGENT_EVENT.TOOL_CALL); + if (!calls.length) return true; + + // Keep if any call is live so the agent sees all pending approvals. + if (calls.some((c) => liveToolCallIds.has(c.toolCallId))) return true; + + const approvals = msg.content.filter((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_REQUEST); + + // liveToolCallIds handles the in-flight case — everything reaching here is historical. + return approvals.length + ? approvals.every((a) => completeApprovalIds.has(a.approvalId)) + && calls.every((c) => resolvedIds.has(c.toolCallId)) + : calls.every((c) => resolvedIds.has(c.toolCallId)); + }) + .map((msg) => { + if (msg.role !== ROLE.ASSISTANT || !Array.isArray(msg.content) || !staleApprovalIds.size) { + return msg; + } + const isStale = (p) => ( + p.type === AGENT_EVENT.TOOL_APPROVAL_REQUEST && staleApprovalIds.has(p.approvalId) + ); + const cleaned = msg.content.filter((p) => !isStale(p)); + return cleaned.length !== msg.content.length ? { ...msg, content: cleaned } : msg; + }); +} + +// Previous turns' tool sequences are stripped to avoid agent errors from imperfect pairs. +// The current turn (from the last user message onward) is kept for active tool context. +export function buildAgentMessages(messages, { liveToolCallIds = new Set() } = {}) { + const lastUserIdx = messages.reduce((acc, msg, i) => (msg.role === ROLE.USER ? i : acc), -1); + const sanitized = messages.flatMap((msg, idx) => { + if (idx >= lastUserIdx) return [msg]; + if (msg.role === ROLE.TOOL) return []; + if (msg.role === ROLE.ASSISTANT && Array.isArray(msg.content)) { + const text = msg.content.filter((p) => p.type === 'text').map((p) => p.text).join('\n'); + return text ? [{ role: ROLE.ASSISTANT, content: text }] : []; + } + return [msg]; + }); + return stripOrphanedToolCallMessages(sanitized, { liveToolCallIds }); +} diff --git a/nx2/blocks/chat/utils/stream.js b/nx2/blocks/chat/utils/stream.js index eb107feea..a95ec1b08 100644 --- a/nx2/blocks/chat/utils/stream.js +++ b/nx2/blocks/chat/utils/stream.js @@ -7,7 +7,7 @@ function processEvent(event, streaming, callbacks) { } if (event.type === EVENT.FINISH_MESSAGE || event.type === EVENT.FINISH) { - return { streaming, done: true }; + return { streaming, done: true, finishReason: event.finishReason }; } if (event.type === EVENT.TEXT_END) { if (streaming) onText(streaming); @@ -67,6 +67,7 @@ export async function readStream(body, callbacks) { let buffer = ''; let streaming = ''; let finished = false; + let finishReason; for await (const chunk of body) { if (finished) break; @@ -84,11 +85,14 @@ export async function readStream(body, callbacks) { event = null; } if (event) { - ({ streaming, done: finished } = processEvent(event, streaming, callbacks)); + const result = processEvent(event, streaming, callbacks); + ({ streaming, done: finished } = result); + if (result.finishReason !== undefined) finishReason = result.finishReason; } } } } - if (streaming) callbacks.onText(streaming); + if (streaming) callbacks.onText?.(streaming); + callbacks.onFinish?.(finishReason); } diff --git a/nx2/blocks/chat/utils/tools.js b/nx2/blocks/chat/utils/tools.js new file mode 100644 index 000000000..01102e2b8 --- /dev/null +++ b/nx2/blocks/chat/utils/tools.js @@ -0,0 +1,17 @@ +import { TOOL_NAME } from '../constants.js'; + +// Derives the parent folders affected by a tool call so the file browser can refresh. +export function affectedFolders(toolName, input) { + const { org, repo } = input ?? {}; + if (!org || !repo) return []; + const toParent = (path) => { + const parts = (path ?? '').replace(/^\//, '').split('/').filter(Boolean); + parts.pop(); + return `/${org}/${repo}${parts.length ? `/${parts.join('/')}` : ''}`; + }; + if (toolName === TOOL_NAME.CONTENT_MOVE) { + return [...new Set([toParent(input.sourcePath), toParent(input.destinationPath)])]; + } + if (toolName === TOOL_NAME.CONTENT_COPY) return [toParent(input.destinationPath)]; + return input.path ? [toParent(input.path)] : []; +} diff --git a/nx2/blocks/shared/menu/menu.css b/nx2/blocks/shared/menu/menu.css index dc9eef434..e3891f5c0 100644 --- a/nx2/blocks/shared/menu/menu.css +++ b/nx2/blocks/shared/menu/menu.css @@ -13,8 +13,8 @@ display: block; padding: var(--s2-spacing-75) var(--s2-spacing-100); color: light-dark(var(--s2-gray-600), var(--s2-gray-700)); - font-size: var(--s2-body-size-xs); - font-weight: var(--s2-heading-font-weight); + font-size: var(--s2-body-size-xxs); + line-height: var(--s2-component-xs-regular-line-height); cursor: default; } @@ -29,7 +29,8 @@ background: none; color: var(--s2-gray-900); font-family: inherit; - font-size: var(--s2-body-size-s); + font-size: var(--s2-body-size-xs); + line-height: var(--s2-component-s-regular-line-height); margin-bottom: 2px; cursor: pointer; box-sizing: border-box; diff --git a/test/nx2/blocks/chat/chat-controller.test.js b/test/nx2/blocks/chat/chat-controller.test.js deleted file mode 100644 index 40d488e38..000000000 --- a/test/nx2/blocks/chat/chat-controller.test.js +++ /dev/null @@ -1,105 +0,0 @@ -import { expect } from '@esm-bundle/chai'; -import ChatController from '../../../../nx2/blocks/chat/chat-controller.js'; - -const TURN = 'turn-current'; -const OTHER_TURN = 'turn-previous'; - -// Build a controller with a known message history and current turn, then read back -// what would actually be POSTed to the (stateless) agent. -function agentMessages(messages, currentTurnId = TURN) { - const controller = new ChatController({ onUpdate() {}, onToolDone() {} }); - controller._messages = messages; - controller._currentTurnId = currentTurnId; - return controller._messagesForAgent(); -} - -// A completed non-approval tool call (e.g. content_read) as the UI stores it. -const virtualRead = (toolCallId, turnId, output) => ({ - role: 'assistant', - virtual: true, - turnId, - toolResult: { output }, - content: [{ type: 'tool-call', toolCallId, toolName: 'content_read', input: { path: '/x' } }], -}); - -describe('chat-controller _messagesForAgent', () => { - it('passes non-virtual messages through unchanged', () => { - const msgs = [ - { role: 'user', content: 'hello' }, - { role: 'assistant', content: 'hi there' }, - ]; - expect(agentMessages(msgs)).to.deep.equal(msgs); - }); - - it('replays a current-turn content_read as a paired tool-call + tool-result', () => { - const output = { content: '

surf

', blocks: [{ locator: 'abc' }] }; - const result = agentMessages([ - { role: 'user', content: 'add fishing para' }, - virtualRead('r1', TURN, output), - ]); - - expect(result).to.have.lengthOf(3); - expect(result[0]).to.deep.equal({ role: 'user', content: 'add fishing para' }); - expect(result[1]).to.deep.equal({ - role: 'assistant', - content: [{ type: 'tool-call', toolCallId: 'r1', toolName: 'content_read', input: { path: '/x' } }], - }); - expect(result[2]).to.deep.equal({ - role: 'tool', - content: [{ type: 'tool-result', toolCallId: 'r1', toolName: 'content_read', output: { type: 'json', value: output } }], - }); - }); - - it('wraps a string tool output as a text part', () => { - const result = agentMessages([virtualRead('r1', TURN, 'plain text')]); - expect(result[1].content[0].output).to.deep.equal({ type: 'text', value: 'plain text' }); - }); - - it('drops tool I/O from previous turns to keep the payload bounded', () => { - const result = agentMessages([ - virtualRead('old', OTHER_TURN, { content: 'stale' }), - { role: 'user', content: 'new question' }, - virtualRead('new', TURN, { content: 'fresh' }), - ]); - const readIds = result - .flatMap((m) => (Array.isArray(m.content) ? m.content : [])) - .filter((p) => p.type === 'tool-call') - .map((p) => p.toolCallId); - expect(readIds).to.deep.equal(['new']); // 'old' dropped - }); - - it('drops the virtual twin of an approval tool already represented by a real tool-call', () => { - const result = agentMessages([ - // Real (non-virtual) approval message for content_replace. - { - role: 'assistant', - content: [ - { type: 'tool-call', toolCallId: 'w1', toolName: 'content_replace', input: {} }, - { type: 'tool-approval-request', approvalId: 'a1', toolCallId: 'w1' }, - ], - }, - { role: 'tool', content: [{ type: 'tool-approval-response', approvalId: 'a1', approved: true }] }, - // Virtual DONE twin for the same call — must NOT be re-sent (would duplicate w1). - { - role: 'assistant', - virtual: true, - turnId: TURN, - toolResult: { output: { updated: true } }, - content: [{ type: 'tool-call', toolCallId: 'w1', toolName: 'content_replace', input: {} }], - }, - ]); - - const w1Calls = result - .filter((m) => m.role === 'assistant' && Array.isArray(m.content)) - .flatMap((m) => m.content) - .filter((p) => p.type === 'tool-call' && p.toolCallId === 'w1'); - expect(w1Calls).to.have.lengthOf(1); // exactly one, from the real approval message - }); - - it('skips a current-turn virtual message that has no stored output', () => { - const result = agentMessages([ - { role: 'assistant', virtual: true, turnId: TURN, content: [{ type: 'tool-call', toolCallId: 'r1', toolName: 'content_read', input: {} }] }, - ]); - expect(result).to.deep.equal([]); // no orphan tool-call emitted - }); -}); diff --git a/test/nx2/blocks/chat/controllers/chat-controller.test.js b/test/nx2/blocks/chat/controllers/chat-controller.test.js new file mode 100644 index 000000000..85003d0df --- /dev/null +++ b/test/nx2/blocks/chat/controllers/chat-controller.test.js @@ -0,0 +1,666 @@ +import { expect } from '@esm-bundle/chai'; +import ChatController from '../../../../../nx2/blocks/chat/controllers/chat-controller.js'; +import { TOOL_STATE, AGENT_EVENT, ROLE, FINISH_REASON } from '../../../../../nx2/blocks/chat/constants.js'; + +function makeController() { + return new ChatController({ onUpdate() { }, onToolDone() { } }); +} + +describe('ChatController — _onApprovalRequest', () => { + it('does nothing when card is already in a settled state', () => { + const ctrl = makeController(); + ctrl._turn.begin(); + const settled = [ + TOOL_STATE.APPROVAL_REQUESTED, TOOL_STATE.APPROVED, + TOOL_STATE.REJECTED, TOOL_STATE.DONE, TOOL_STATE.ERROR, + ]; + for (const state of settled) { + ctrl._messages = [{ role: ROLE.USER, content: 'go' }]; + ctrl._toolCards = new Map([['tc-1', { toolName: 'my-tool', input: {}, state }]]); + ctrl._pendingBatch = { toolCalls: [], approvalRequests: [] }; + ctrl._onApprovalRequest('tc-1', 'my-tool', 'ap-1'); + expect(ctrl._pendingBatch.approvalRequests).to.have.lengthOf(0); + expect(ctrl._messages).to.have.lengthOf(1); // no message committed + } + }); + + it('commits assistant message and updates card to APPROVAL_REQUESTED for manual approval', () => { + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._messages = [{ role: ROLE.USER, content: 'go' }]; + ctrl._pendingBatch = { + toolCalls: [{ toolCallId: 'tc-1', toolName: 'my-tool', input: { path: '/a.md' } }], + approvalRequests: [], + }; + ctrl._toolCards = new Map([['tc-1', { toolName: 'my-tool', input: { path: '/a.md' }, state: TOOL_STATE.RUNNING }]]); + + ctrl._onApprovalRequest('tc-1', 'my-tool', 'ap-1'); + + expect(ctrl._toolCards.get('tc-1').state).to.equal(TOOL_STATE.APPROVAL_REQUESTED); + expect(ctrl._toolCards.get('tc-1').approvalId).to.equal('ap-1'); + expect(ctrl._pendingBatch.approvalRequests).to.deep.equal([{ approvalId: 'ap-1', toolCallId: 'tc-1' }]); + // tool-call removed from batch (already committed in the message) + expect(ctrl._pendingBatch.toolCalls).to.have.lengthOf(0); + expect(ctrl._turn.isActive).to.be.true; + + // Assistant message committed immediately + expect(ctrl._messages).to.have.lengthOf(2); + expect(ctrl._messages[1]).to.deep.equal({ + role: ROLE.ASSISTANT, + content: [ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'my-tool', input: { path: '/a.md' } }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ], + }); + }); + + it('merges a preceding text-only assistant message into the approval message', () => { + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { role: ROLE.ASSISTANT, content: 'Sure, creating those pages now.' }, + ]; + ctrl._pendingBatch = { + toolCalls: [{ toolCallId: 'tc-1', toolName: 'content_create', input: { path: '/a.md' } }], + approvalRequests: [], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'content_create', input: { path: '/a.md' }, state: TOOL_STATE.RUNNING }], + ]); + + ctrl._onApprovalRequest('tc-1', 'content_create', 'ap-1'); + + // Text merged in — only 2 messages total, not 3 + expect(ctrl._messages).to.have.lengthOf(2); + expect(ctrl._messages[1]).to.deep.equal({ + role: ROLE.ASSISTANT, + content: [ + { type: 'text', text: 'Sure, creating those pages now.' }, + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'content_create', input: { path: '/a.md' } }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ], + }); + }); + + it('sets card to APPROVED and commits message when tool name is in autoApprovedTools', () => { + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._autoApprovedTools = new Set(['my-tool']); + ctrl._messages = [{ role: ROLE.USER, content: 'go' }]; + ctrl._pendingBatch = { + toolCalls: [{ toolCallId: 'tc-1', toolName: 'my-tool', input: {} }], + approvalRequests: [], + }; + ctrl._toolCards = new Map([['tc-1', { toolName: 'my-tool', input: {}, state: TOOL_STATE.RUNNING }]]); + + ctrl._onApprovalRequest('tc-1', 'my-tool', 'ap-1'); + + expect(ctrl._toolCards.get('tc-1').state).to.equal(TOOL_STATE.APPROVED); + expect(ctrl._pendingBatch.approvalRequests).to.deep.equal([{ approvalId: 'ap-1', toolCallId: 'tc-1' }]); + // Message still committed immediately even for auto-approved + expect(ctrl._messages).to.have.lengthOf(2); + expect(ctrl._messages[1].content[1].type).to.equal(AGENT_EVENT.TOOL_APPROVAL_REQUEST); + }); + + it('each parallel approval tool gets its own assistant message (no batching)', () => { + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._messages = [{ role: ROLE.USER, content: 'go' }]; + ctrl._pendingBatch = { + toolCalls: [ + { toolCallId: 'tc-1', toolName: 'tool-a', input: { a: 1 } }, + { toolCallId: 'tc-2', toolName: 'tool-b', input: { b: 2 } }, + ], + approvalRequests: [], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'tool-a', input: { a: 1 }, state: TOOL_STATE.RUNNING }], + ['tc-2', { toolName: 'tool-b', input: { b: 2 }, state: TOOL_STATE.RUNNING }], + ]); + + ctrl._onApprovalRequest('tc-1', 'tool-a', 'ap-1'); + ctrl._onApprovalRequest('tc-2', 'tool-b', 'ap-2'); + + // Two separate approval messages + expect(ctrl._messages).to.have.lengthOf(3); + expect(ctrl._messages[1].content).to.deep.equal([ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'tool-a', input: { a: 1 } }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ]); + expect(ctrl._messages[2].content).to.deep.equal([ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-2', toolName: 'tool-b', input: { b: 2 } }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-2', toolCallId: 'tc-2' }, + ]); + expect(ctrl._pendingBatch.toolCalls).to.have.lengthOf(0); + }); +}); + +describe('ChatController — _onStreamFinish', () => { + it('does not modify messages or set continuation when batch is empty (text-only response)', () => { + const ctrl = makeController(); + const messages = [ + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hi there' }, + ]; + ctrl._messages = [...messages]; + + ctrl._onStreamFinish('room-1'); + + expect(ctrl._messages).to.deep.equal(messages); + expect(ctrl._pendingContinuation).to.be.undefined; + }); + + it('adds auto-approval responses and sets continuation when all cards are approved', () => { + const ctrl = makeController(); + // Approval messages were already committed by _onApprovalRequest + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { + role: ROLE.ASSISTANT, + content: [ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'my-tool', input: { x: 1 } }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ], + }, + ]; + ctrl._pendingBatch = { + toolCalls: [], + approvalRequests: [{ approvalId: 'ap-1', toolCallId: 'tc-1' }], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: { x: 1 }, state: TOOL_STATE.APPROVED, approvalId: 'ap-1' }], + ]); + + ctrl._onStreamFinish('room-1'); + + // Auto-approval response appended after the already-committed assistant message + expect(ctrl._messages).to.have.lengthOf(3); + expect(ctrl._messages[2]).to.deep.equal({ + role: ROLE.TOOL, + content: [{ type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId: 'ap-1', approved: true }], + }); + expect(ctrl._pendingContinuation).to.be.true; + expect(ctrl._pendingBatch.approvalRequests).to.have.lengthOf(0); + }); + + it('does not set continuation when approval is pending (manual) — just clears the batch', () => { + const ctrl = makeController(); + // Approval message was already committed by _onApprovalRequest + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { + role: ROLE.ASSISTANT, + content: [ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'my-tool', input: {} }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ], + }, + ]; + ctrl._pendingBatch = { + toolCalls: [], + approvalRequests: [{ approvalId: 'ap-1', toolCallId: 'tc-1' }], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVAL_REQUESTED, approvalId: 'ap-1' }], + ]); + + ctrl._onStreamFinish('room-1'); + + expect(ctrl._messages).to.have.lengthOf(2); // no new messages added + expect(ctrl._pendingContinuation).to.not.be.true; + expect(ctrl._pendingBatch.approvalRequests).to.have.lengthOf(0); + }); + + it('adds responses for all parallel auto-approved tools', () => { + const ctrl = makeController(); + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { + role: ROLE.ASSISTANT, + content: [ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'tool-a', input: {} }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ], + }, + { + role: ROLE.ASSISTANT, + content: [ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-2', toolName: 'tool-b', input: {} }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-2', toolCallId: 'tc-2' }, + ], + }, + ]; + ctrl._pendingBatch = { + toolCalls: [], + approvalRequests: [ + { approvalId: 'ap-1', toolCallId: 'tc-1' }, + { approvalId: 'ap-2', toolCallId: 'tc-2' }, + ], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'tool-a', input: {}, state: TOOL_STATE.APPROVED, approvalId: 'ap-1' }], + ['tc-2', { toolName: 'tool-b', input: {}, state: TOOL_STATE.APPROVED, approvalId: 'ap-2' }], + ]); + + ctrl._onStreamFinish('room-1'); + + expect(ctrl._messages[3].content[0].approvalId).to.equal('ap-1'); + expect(ctrl._messages[4].content[0].approvalId).to.equal('ap-2'); + expect(ctrl._messages).to.have.lengthOf(5); + expect(ctrl._pendingContinuation).to.be.true; + }); + + it('skips duplicate responses and does not set continuation when user approved before stream ended (race condition)', () => { + // Scenario: user clicked "approve" before FINISH_MESSAGE arrived, so approveToolCall + // already added the tool-approval-response. _onStreamFinish must not add it again and + // must not set _pendingContinuation (approveToolCall's stream is already in flight). + const ctrl = makeController(); + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { + role: ROLE.ASSISTANT, + content: [ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'my-tool', input: {} }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ], + }, + // approveToolCall already added this response + { role: ROLE.TOOL, content: [{ type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId: 'ap-1', approved: true }] }, + ]; + ctrl._pendingBatch = { + toolCalls: [], + approvalRequests: [{ approvalId: 'ap-1', toolCallId: 'tc-1' }], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVED, approvalId: 'ap-1' }], + ]); + + ctrl._onStreamFinish('room-1'); + + expect(ctrl._messages).to.have.lengthOf(3); // no duplicate added + expect(ctrl._pendingContinuation).to.not.be.true; // no spurious Stream C + expect(ctrl._pendingBatch.approvalRequests).to.have.lengthOf(0); // batch still cleared + }); +}); + +describe('ChatController — approveToolCall', () => { + function makeControllerWithCard(state = TOOL_STATE.APPROVAL_REQUESTED) { + const ctrl = makeController(); + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { + role: ROLE.ASSISTANT, + content: [ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'my-tool', input: {} }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ], + }, + ]; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: {}, state, approvalId: 'ap-1' }], + ]); + ctrl._turn.begin(); + ctrl._turn.pause(); + return ctrl; + } + + it('does nothing when the card has no approvalId', async () => { + const ctrl = makeController(); + ctrl._messages = [{ role: ROLE.USER, content: 'go' }]; + ctrl._toolCards = new Map([['tc-1', { toolName: 'my-tool', state: TOOL_STATE.APPROVAL_REQUESTED }]]); + await ctrl.approveToolCall('tc-1', true); + expect(ctrl._messages).to.have.lengthOf(1); + }); + + it('appends an approval response and calls stream', async () => { + const ctrl = makeControllerWithCard(); + let streamCalled = false; + ctrl._stream = () => { + streamCalled = true; + return Promise.resolve(); + }; + + await ctrl.approveToolCall('tc-1', true); + + // Approval response is committed; mock stream returns no tool-result so the justApproved + // close-out adds a synthetic tool-result — the response is not the last message. + const response = ctrl._messages + .filter((m) => m.role === ROLE.TOOL) + .flatMap((m) => m.content) + .find((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_RESPONSE); + expect(response).to.deep.equal({ type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId: 'ap-1', approved: true }); + expect(streamCalled).to.be.true; + expect(ctrl._turn.isActive).to.be.false; + }); + + it('bulk-approves siblings with the same tool name when always=true', async () => { + const ctrl = makeControllerWithCard(); + ctrl._toolCards.set('tc-2', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVAL_REQUESTED, approvalId: 'ap-2' }); + ctrl._toolCards.set('tc-3', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVAL_REQUESTED, approvalId: 'ap-3' }); + ctrl._stream = () => Promise.resolve(); + + await ctrl.approveToolCall('tc-1', true, true); + + // All three responses committed in one go + const responses = ctrl._messages + .filter((m) => m.role === ROLE.TOOL) + .flatMap((m) => m.content) + .filter((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_RESPONSE); + + expect(responses).to.have.lengthOf(3); + expect(responses.map((r) => r.approvalId)).to.include.members(['ap-1', 'ap-2', 'ap-3']); + // Mock stream returns no tool-results → justApproved close-out sets all to DONE. + expect(ctrl._toolCards.get('tc-2').state).to.equal(TOOL_STATE.DONE); + expect(ctrl._toolCards.get('tc-3').state).to.equal(TOOL_STATE.DONE); + }); + + it('appends a rejection response and calls stream', async () => { + const ctrl = makeControllerWithCard(); + let streamCalled = false; + ctrl._stream = () => { + streamCalled = true; + return Promise.resolve(); + }; + + await ctrl.approveToolCall('tc-1', false); + + expect(ctrl._messages.at(-1)).to.deep.equal({ + role: ROLE.TOOL, + content: [{ type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId: 'ap-1', approved: false }], + }); + // Rejection streams immediately so da-agent can respond (e.g. "I won't do that"). + expect(streamCalled).to.be.true; + expect(ctrl._turn.isActive).to.be.false; + }); + + it('streams immediately for the first approval even when siblings are still pending', async () => { + // Per-tool streaming: each approval fires its own _stream() right away so da-agent + // can use buildApprovalContinuationResponse while other tools still have unresolved + // tool-approval-request parts — this keeps hasPendingApprovals=true and ensures + // results are streamed back rather than absorbed internally by streamText. + const ctrl = makeControllerWithCard(); + ctrl._toolCards.set('tc-2', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVAL_REQUESTED, approvalId: 'ap-2' }); + ctrl._toolCards.set('tc-3', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVAL_REQUESTED, approvalId: 'ap-3' }); + let streamCount = 0; + ctrl._stream = async () => { streamCount += 1; }; + + await ctrl.approveToolCall('tc-1', true); + + // Stream fired immediately for tc-1, not deferred until tc-2/tc-3 are resolved. + expect(streamCount).to.equal(1); + // Response for tc-1 is committed. + const responses = ctrl._messages + .filter((m) => m.role === ROLE.TOOL) + .flatMap((m) => m.content) + .filter((p) => p.type === AGENT_EVENT.TOOL_APPROVAL_RESPONSE); + expect(responses).to.have.lengthOf(1); + expect(responses[0].approvalId).to.equal('ap-1'); + }); +}); + +describe('ChatController — _onToolResult', () => { + it('commits call+result pair and removes tool from batch (non-approval)', () => { + const ctrl = makeController(); + ctrl._messages = [{ role: ROLE.USER, content: 'go' }]; + ctrl._pendingBatch = { + toolCalls: [{ toolCallId: 'tc-1', toolName: 'my-tool', input: { path: '/a.md' } }], + approvalRequests: [], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: { path: '/a.md' }, state: TOOL_STATE.RUNNING }], + ]); + + ctrl._onToolResult('tc-1', 'my-tool', 'done', false, null); + + expect(ctrl._messages).to.have.lengthOf(3); + expect(ctrl._messages[1]).to.deep.equal({ + role: ROLE.ASSISTANT, + content: [{ type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'my-tool', input: { path: '/a.md' } }], + }); + expect(ctrl._messages[2]).to.deep.equal({ + role: ROLE.TOOL, + content: [{ type: AGENT_EVENT.TOOL_RESULT, toolCallId: 'tc-1', toolName: 'my-tool', output: { type: 'text', value: 'done' } }], + }); + expect(ctrl._pendingBatch.toolCalls).to.have.lengthOf(0); + }); + + it('commits result only in continuation stream (approval tool, not in batch)', () => { + const ctrl = makeController(); + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { + role: ROLE.ASSISTANT, + content: [ + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'my-tool', input: {} }, + { type: AGENT_EVENT.TOOL_APPROVAL_REQUEST, approvalId: 'ap-1', toolCallId: 'tc-1' }, + ], + }, + { role: ROLE.TOOL, content: [{ type: AGENT_EVENT.TOOL_APPROVAL_RESPONSE, approvalId: 'ap-1', approved: true }] }, + ]; + ctrl._pendingBatch = { toolCalls: [], approvalRequests: [] }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVED, approvalId: 'ap-1' }], + ]); + + ctrl._onToolResult('tc-1', 'my-tool', 'done', false, null); + + expect(ctrl._messages).to.have.lengthOf(4); + expect(ctrl._messages[3]).to.deep.equal({ + role: ROLE.TOOL, + content: [{ type: AGENT_EVENT.TOOL_RESULT, toolCallId: 'tc-1', toolName: 'my-tool', output: { type: 'text', value: 'done' } }], + }); + }); + + it('sets card state to error, commits no messages, and evicts from batch when isError is true', () => { + const ctrl = makeController(); + ctrl._messages = [{ role: ROLE.USER, content: 'go' }]; + ctrl._pendingBatch = { + toolCalls: [{ toolCallId: 'tc-1', toolName: 'my-tool', input: {} }], + approvalRequests: [], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: {}, state: TOOL_STATE.RUNNING }], + ]); + + ctrl._onToolResult('tc-1', 'my-tool', 'something went wrong', true, null); + + expect(ctrl._messages).to.have.lengthOf(1); + expect(ctrl._toolCards.get('tc-1').state).to.equal(TOOL_STATE.ERROR); + // Must be evicted so it cannot contaminate a later approval batch commit. + expect(ctrl._pendingBatch.toolCalls).to.have.lengthOf(0); + }); + + it('merges a preceding text-only assistant message into the tool-call message', () => { + const ctrl = makeController(); + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { role: ROLE.ASSISTANT, content: 'Now let me read the existing pages:' }, + ]; + ctrl._pendingBatch = { + toolCalls: [{ toolCallId: 'tc-1', toolName: 'content_list', input: { path: '/a' } }], + approvalRequests: [], + }; + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'content_list', input: { path: '/a' }, state: TOOL_STATE.RUNNING }], + ]); + + ctrl._onToolResult('tc-1', 'content_list', 'result', false, null); + + // 3 messages: user + merged-assistant + tool-result (not 4 with separate text and tool-call) + expect(ctrl._messages).to.have.lengthOf(3); + expect(ctrl._messages[1]).to.deep.equal({ + role: ROLE.ASSISTANT, + content: [ + { type: 'text', text: 'Now let me read the existing pages:' }, + { type: AGENT_EVENT.TOOL_CALL, toolCallId: 'tc-1', toolName: 'content_list', input: { path: '/a' } }, + ], + }); + expect(ctrl._messages[2].role).to.equal(ROLE.TOOL); + }); +}); + +describe('ChatController — _onStreamFinish (finishReason)', () => { + it('sets _pendingContinuation when finishReason is tool-calls and batch is empty', () => { + const ctrl = makeController(); + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { role: ROLE.TOOL, content: [{ type: AGENT_EVENT.TOOL_RESULT, toolCallId: 'tc-1', output: 'ok' }] }, + ]; + + ctrl._onStreamFinish('room-1', FINISH_REASON.TOOL_CALLS); + + expect(ctrl._pendingContinuation).to.be.true; + }); + + it('does not set _pendingContinuation when last message is role:assistant (prefill guard)', () => { + const ctrl = makeController(); + ctrl._messages = [ + { role: ROLE.USER, content: 'go' }, + { role: ROLE.ASSISTANT, content: 'I will now create the files.' }, + ]; + + ctrl._onStreamFinish('room-1', FINISH_REASON.TOOL_CALLS); + + expect(ctrl._pendingContinuation).to.not.be.true; + }); +}); + +describe('ChatController — _done (batch cleanup)', () => { + it('does not end the turn when _activeApprovalStreams > 0', () => { + // Scenario: sendMessage's stream ended (FINISH_MESSAGE received) while approveToolCall's + // stream is still in progress. sendMessage's _done() must not set turn to IDLE — that + // would incorrectly hide the thinking indicator and corrupt turn state. + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._turn.pause(); + ctrl._turn.resume(); // RESUMING — approval stream is in progress + ctrl._activeApprovalStreams = 1; + + ctrl._done(); + + expect(ctrl._turn.isActive).to.be.true; // turn still active + }); + + it('ends the turn normally when _activeApprovalStreams is 0', () => { + const ctrl = makeController(); + ctrl._turn.begin(); + + ctrl._done(); + + expect(ctrl._turn.isActive).to.be.false; + }); + + it('does not end the turn when a second approval stream is still running (counter > 0 after first decrements)', () => { + // Race: user approves tc-2 while stream C (tc-1's approval stream) is still open. + // Stream C's finally decrements the counter but stream D (tc-2's) already incremented it. + // Net counter = 1 — _done() from stream C must not end the turn. + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._turn.pause(); + ctrl._turn.resume(); // RESUMING + ctrl._activeApprovalStreams = 1; // stream D incremented before stream C's finally ran + + ctrl._done(); // stream C's _done() + + expect(ctrl._turn.isActive).to.be.true; + }); + + it('does not end the turn when tool cards are still APPROVAL_REQUESTED', () => { + // Per-tool streaming: after tc-1's stream finishes, tc-2 and tc-3 are still pending. + // _done() must not end the turn — the turn should stay active so the UI keeps + // showing the spinner and sendMessage remains blocked. + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._turn.pause(); + ctrl._turn.resume(); // RESUMING — approval streams ongoing + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: {}, state: TOOL_STATE.DONE, approvalId: 'ap-1' }], + ['tc-2', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVAL_REQUESTED, approvalId: 'ap-2' }], + ['tc-3', { toolName: 'my-tool', input: {}, state: TOOL_STATE.APPROVAL_REQUESTED, approvalId: 'ap-3' }], + ]); + + ctrl._done(); // called after tc-1's stream finishes + + expect(ctrl._turn.isActive).to.be.true; + }); + + it('ends the turn in _done when no pending cards remain and no approval stream is active', () => { + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._turn.pause(); + ctrl._turn.resume(); + ctrl._toolCards = new Map([ + ['tc-1', { toolName: 'my-tool', input: {}, state: TOOL_STATE.DONE, approvalId: 'ap-1' }], + ['tc-2', { toolName: 'my-tool', input: {}, state: TOOL_STATE.DONE, approvalId: 'ap-2' }], + ]); + + ctrl._done(); + + expect(ctrl._turn.isActive).to.be.false; + }); + + it('clears _pendingBatch before triggering continuation stream', async () => { + const ctrl = makeController(); + ctrl._turn.begin(); + ctrl._pendingContinuation = true; + ctrl._pendingBatch = { + toolCalls: [{ toolCallId: 'stale', toolName: 'my-tool', input: {} }], + approvalRequests: [], + }; + + let batchAtStreamTime; + const started = new Promise((resolve) => { + ctrl._stream = () => { + batchAtStreamTime = { toolCalls: [...ctrl._pendingBatch.toolCalls] }; + resolve(); + return Promise.resolve(); + }; + }); + + ctrl._done(); + await started; + + expect(batchAtStreamTime.toolCalls).to.have.lengthOf(0); + }); +}); + +describe('ChatController — sendMessage', () => { + describe('guards', () => { + it('does nothing when turn is already active', async () => { + const ctrl = makeController(); + ctrl._isConnected = true; + ctrl._turn.begin(); + await ctrl.sendMessage('hi', [], {}); + expect(ctrl._messages).to.be.undefined; + }); + + it('does nothing when not connected', async () => { + const ctrl = makeController(); + // _isConnected is undefined by default + await ctrl.sendMessage('hi', [], {}); + expect(ctrl._messages).to.be.undefined; + }); + }); + + describe('error handling', () => { + it('appends an error message when stream throws', async () => { + const ctrl = makeController(); + ctrl._isConnected = true; + ctrl._stream = () => Promise.reject(new Error('network failure')); + await ctrl.sendMessage('hi', [], {}); + expect(ctrl._messages.at(-1)).to.deep.equal({ + role: 'assistant', + content: 'Error: network failure', + }); + }); + + it('does not append an error message when stream is aborted', async () => { + const ctrl = makeController(); + ctrl._isConnected = true; + ctrl._stream = () => Promise.reject(Object.assign(new Error('aborted'), { name: 'AbortError' })); + await ctrl.sendMessage('hi', [], {}); + expect(ctrl._messages.at(-1).role).to.equal('user'); + }); + }); +}); diff --git a/test/nx2/blocks/chat/controllers/turn.test.js b/test/nx2/blocks/chat/controllers/turn.test.js new file mode 100644 index 000000000..e89653d3a --- /dev/null +++ b/test/nx2/blocks/chat/controllers/turn.test.js @@ -0,0 +1,146 @@ +import { expect } from '@esm-bundle/chai'; +import Turn from '../../../../../nx2/blocks/chat/controllers/turn.js'; + +describe('Turn', () => { + let turn; + + beforeEach(() => { + turn = new Turn(); + }); + + describe('initial state', () => { + it('starts idle and inactive', () => { + expect(turn.isActive).to.be.false; + }); + }); + + describe('begin()', () => { + it('idle → streaming (isActive becomes true)', () => { + turn.begin(); + expect(turn.isActive).to.be.true; + }); + }); + + describe('end()', () => { + it('streaming → idle', () => { + turn.begin(); + turn.end(); + expect(turn.isActive).to.be.false; + }); + + it('resuming → idle', () => { + turn.begin(); + turn.pause(); + turn.resume(); + turn.end(); + expect(turn.isActive).to.be.false; + }); + + it('no-op from approval-pending — stays active', () => { + turn.begin(); + turn.pause(); + turn.end(); // must not go to idle + expect(turn.isActive).to.be.true; + }); + }); + + describe('pause()', () => { + it('streaming → approval-pending (end becomes no-op)', () => { + turn.begin(); + turn.pause(); + turn.end(); // no-op from approval-pending + expect(turn.isActive).to.be.true; + }); + + it('resuming → approval-pending (nested approval)', () => { + turn.begin(); + turn.pause(); + turn.resume(); + turn.pause(); // nested — back to approval-pending + turn.end(); // no-op from approval-pending + expect(turn.isActive).to.be.true; + }); + + it('no-op from idle', () => { + turn.pause(); + expect(turn.isActive).to.be.false; + }); + + it('no-op from approval-pending', () => { + turn.begin(); + turn.pause(); + turn.pause(); // second pause — no-op + // resume still works, confirming still in approval-pending + turn.resume(); + turn.end(); + expect(turn.isActive).to.be.false; + }); + }); + + describe('resume()', () => { + it('approval-pending → resuming (end then works)', () => { + turn.begin(); + turn.pause(); + turn.resume(); + turn.end(); + expect(turn.isActive).to.be.false; + }); + + it('no-op from streaming (end still works)', () => { + turn.begin(); + turn.resume(); // no-op — not in approval-pending + turn.end(); + expect(turn.isActive).to.be.false; + }); + + it('no-op from resuming (end still works)', () => { + turn.begin(); + turn.pause(); + turn.resume(); + turn.resume(); // no-op — already resuming + turn.end(); + expect(turn.isActive).to.be.false; + }); + }); + + describe('cancel()', () => { + it('streaming → idle', () => { + turn.begin(); + turn.cancel(); + expect(turn.isActive).to.be.false; + }); + + it('approval-pending → idle', () => { + turn.begin(); + turn.pause(); + turn.cancel(); + expect(turn.isActive).to.be.false; + }); + + it('resuming → idle', () => { + turn.begin(); + turn.pause(); + turn.resume(); + turn.cancel(); + expect(turn.isActive).to.be.false; + }); + }); + + describe('isActive', () => { + it('is false only in idle — true for streaming, approval-pending, resuming', () => { + expect(turn.isActive).to.be.false; // idle + + turn.begin(); + expect(turn.isActive).to.be.true; // streaming + + turn.pause(); + expect(turn.isActive).to.be.true; // approval-pending + + turn.resume(); + expect(turn.isActive).to.be.true; // resuming + + turn.end(); + expect(turn.isActive).to.be.false; // idle + }); + }); +}); diff --git a/test/nx2/blocks/chat/utils/messages.test.js b/test/nx2/blocks/chat/utils/messages.test.js new file mode 100644 index 000000000..16ef14401 --- /dev/null +++ b/test/nx2/blocks/chat/utils/messages.test.js @@ -0,0 +1,367 @@ +import { expect } from '@esm-bundle/chai'; +import { stripOrphanedToolCallMessages, buildUserMessage } from '../../../../../nx2/blocks/chat/utils/messages.js'; + +// Helpers to build message shapes without repeating boilerplate. +const user = (content = 'hello') => ({ role: 'user', content }); +const text = (content = 'ok') => ({ role: 'assistant', content }); +const toolCall = (toolCallId, toolName = 'do-thing', input = {}) => ({ + role: 'assistant', + content: [{ type: 'tool-call', toolCallId, toolName, input }], +}); +const toolResult = (toolCallId, toolName = 'do-thing', output = 'done') => ({ + role: 'tool', + content: [{ type: 'tool-result', toolCallId, toolName, output }], +}); +const approvalBatch = (calls, requests) => ({ + role: 'assistant', + content: [ + ...calls.map(({ toolCallId, toolName = 'do-thing', input = {} }) => ({ + type: 'tool-call', toolCallId, toolName, input, + })), + ...requests.map(({ approvalId, toolCallId }) => ({ + type: 'tool-approval-request', approvalId, toolCallId, + })), + ], +}); +const approvalResponse = (approvalId, approved = true) => ({ + role: 'tool', + content: [{ type: 'tool-approval-response', approvalId, approved }], +}); + +describe('stripOrphanedToolCallMessages', () => { + describe('no-op cases — nothing to strip', () => { + it('returns empty array unchanged', () => { + expect(stripOrphanedToolCallMessages([])).to.deep.equal([]); + }); + + it('passes through user and text-only assistant messages', () => { + const msgs = [user('hi'), text('hello back')]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal(msgs); + }); + + it('keeps a non-approval tool-call that has a result', () => { + const msgs = [user(), toolCall('id-1'), toolResult('id-1'), text()]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal(msgs); + }); + + it('keeps a complete approval batch (request + response) while tool result is absent and tool is live', () => { + // Response exists and tool is actively executing — keep both so da-agent can execute. + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + approvalResponse('ap-1'), + ]; + expect(stripOrphanedToolCallMessages(msgs, { + liveToolCallIds: new Set(['id-1']), + })).to.deep.equal(msgs); + }); + + it('strips the approval-response and approval-request once the tool result is present', () => { + // When a tool-result exists, both the approval-response and approval-request are stale. + // The approval-response is dropped to avoid "unknown approvalId" in da-agent's streamText. + // The approval-request is also stripped from the assistant message so da-agent sees a + // plain tool_call → tool_result pair rather than an unresolved approval. + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + approvalResponse('ap-1'), + toolResult('id-1'), + text(), + ]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal([ + user(), + toolCall('id-1'), // approval_request stripped — only tool_call remains + toolResult('id-1'), + text(), + ]); + }); + + it('strips approval-responses and approval-requests for a parallel batch once tool results exist', () => { + const msgs = [ + user(), + approvalBatch( + [{ toolCallId: 'id-1' }, { toolCallId: 'id-2' }], + [{ approvalId: 'ap-1', toolCallId: 'id-1' }, { approvalId: 'ap-2', toolCallId: 'id-2' }], + ), + approvalResponse('ap-1'), + approvalResponse('ap-2'), + toolResult('id-1'), + toolResult('id-2'), + text(), + ]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal([ + user(), + // Both approval_requests stripped — assistant message has only the two tool_calls + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'id-1', toolName: 'do-thing', input: {} }, + { type: 'tool-call', toolCallId: 'id-2', toolName: 'do-thing', input: {} }, + ], + }, + toolResult('id-1'), + toolResult('id-2'), + text(), + ]); + }); + }); + + describe('stripping cases', () => { + it('strips an assistant message whose tool-call has no result', () => { + const msgs = [user(), toolCall('id-1')]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal([user()]); + }); + + it('strips an approval batch where the request has no response', () => { + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + ]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal([user()]); + }); + + it('strips an orphaned approval response that has no matching request', () => { + // Response references approvalId that was never in an assistant message. + const msgs = [user(), approvalResponse('ap-ghost')]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal([user()]); + }); + + it('strips an entire parallel batch when one tool-call has no result', () => { + // Both id-1 and id-2 are in the same assistant message. + // id-2 has no result — the whole message must be stripped. + const msgs = [ + user(), + { + role: 'assistant', + content: [ + { type: 'tool-call', toolCallId: 'id-1', toolName: 'do-thing', input: {} }, + { type: 'tool-call', toolCallId: 'id-2', toolName: 'do-thing', input: {} }, + ], + }, + toolResult('id-1'), + ]; + const result = stripOrphanedToolCallMessages(msgs); + // Assistant message stripped; tool result for id-1 kept (it's not an approval-response) + expect(result).to.deep.equal([user(), toolResult('id-1')]); + }); + + it('strips a parallel approval batch whose assistant message is incomplete', () => { + // ap-2 has no response → batch stripped. ap-1 has a complete approval but no tool result + // and is not live → its response is also stripped (would cause orphaned tool_use). + const msgs = [ + user(), + approvalBatch( + [{ toolCallId: 'id-1' }, { toolCallId: 'id-2' }], + [{ approvalId: 'ap-1', toolCallId: 'id-1' }, { approvalId: 'ap-2', toolCallId: 'id-2' }], + ), + approvalResponse('ap-1'), + // ap-2 never responded + ]; + const result = stripOrphanedToolCallMessages(msgs); + expect(result).to.deep.equal([user()]); + }); + }); + + describe('mixed sequences', () => { + it('strips only the unresolved tool, keeps the resolved one', () => { + const msgs = [ + user(), + toolCall('id-1'), + toolResult('id-1'), + text('after first'), + user('second ask'), + toolCall('id-2'), + // id-2 has no result + ]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal([ + user(), + toolCall('id-1'), + toolResult('id-1'), + text('after first'), + user('second ask'), + ]); + }); + + it('strips an approval batch where response exists but tool result is missing (not live)', () => { + // Approval complete but tool result never arrived and tool is not in liveToolCallIds + // (e.g. session loaded from persistence after an interrupted stream). Keeping this + // would cause da-agent to forward an unresolved tool_use to Anthropic. + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + approvalResponse('ap-1'), + // tool result never arrived + ]; + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal([user()]); + }); + + it('keeps an approval batch with no tool result when tool is live', () => { + // Tool is actively executing — approval given, result not yet back. + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + approvalResponse('ap-1'), + ]; + const result = stripOrphanedToolCallMessages(msgs, { + liveToolCallIds: new Set(['id-1']), + }); + expect(result).to.deep.equal(msgs); + }); + + it('strips only the incomplete approval, keeps the complete one (stale response and request also stripped)', () => { + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + approvalResponse('ap-1'), + toolResult('id-1'), + text('first done'), + user('second ask'), + approvalBatch([{ toolCallId: 'id-2' }], [{ approvalId: 'ap-2', toolCallId: 'id-2' }]), + // ap-2 never responded + ]; + // ap-1 resolved: response stripped (stale), approval-request stripped from assistant message. + // ap-2 stripped (no response). + expect(stripOrphanedToolCallMessages(msgs)).to.deep.equal([ + user(), + toolCall('id-1'), // approval-request stripped — only tool_call remains + toolResult('id-1'), + text('first done'), + user('second ask'), + ]); + }); + }); + + describe('liveToolCallIds option', () => { + it('keeps a sibling approval message when its tool is in liveToolCallIds', () => { + // tc-2 has no response yet but is actively pending — keep so da-agent sees it + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + approvalBatch([{ toolCallId: 'id-2' }], [{ approvalId: 'ap-2', toolCallId: 'id-2' }]), + approvalResponse('ap-1'), + ]; + const result = stripOrphanedToolCallMessages(msgs, { + liveToolCallIds: new Set(['id-1', 'id-2']), + }); + expect(result).to.deep.equal(msgs); + }); + + it('without liveToolCallIds: strips all approval sequences that have no tool result', () => { + // Neither id-1 nor id-2 has a tool result and neither is live → both stripped entirely. + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + approvalBatch([{ toolCallId: 'id-2' }], [{ approvalId: 'ap-2', toolCallId: 'id-2' }]), + approvalResponse('ap-1'), + ]; + const result = stripOrphanedToolCallMessages(msgs); + expect(result).to.deep.equal([user()]); + }); + + it('per-tool streaming: keeps siblings live, strips stale approval-response after resolution', () => { + // Stream C scenario: tc-1 already done (tr-1 present), tc-2 just approved (tar-resp-2), + // tc-3 still pending (APPROVAL_REQUESTED). tar-resp-1 must be stripped (stale). + // Both tc-2 and tc-3 assistant messages must be kept (live). + const msgs = [ + user(), + approvalBatch([{ toolCallId: 'id-1' }], [{ approvalId: 'ap-1', toolCallId: 'id-1' }]), + approvalBatch([{ toolCallId: 'id-2' }], [{ approvalId: 'ap-2', toolCallId: 'id-2' }]), + approvalBatch([{ toolCallId: 'id-3' }], [{ approvalId: 'ap-3', toolCallId: 'id-3' }]), + approvalResponse('ap-1'), + toolResult('id-1'), + approvalResponse('ap-2'), + ]; + const result = stripOrphanedToolCallMessages(msgs, { + liveToolCallIds: new Set(['id-2', 'id-3']), + }); + expect(result).to.deep.equal([ + user(), + toolCall('id-1'), // approval-request stripped (id-1 resolved) — only tool_call remains + approvalBatch([{ toolCallId: 'id-2' }], [{ approvalId: 'ap-2', toolCallId: 'id-2' }]), + approvalBatch([{ toolCallId: 'id-3' }], [{ approvalId: 'ap-3', toolCallId: 'id-3' }]), + toolResult('id-1'), + approvalResponse('ap-2'), + ]); + }); + }); +}); + +describe('buildUserMessage', () => { + describe('base shape', () => { + it('returns role:user with content when context and attachments are empty', () => { + const result = buildUserMessage('hello', [], []); + expect(result).to.deep.equal({ role: 'user', content: 'hello' }); + }); + }); + + describe('selectionContext', () => { + it('includes items with proseIndex', () => { + const result = buildUserMessage('hi', [{ proseIndex: 2, innerText: 'some text' }], []); + expect(result.selectionContext).to.deep.equal([{ proseIndex: 2, innerText: 'some text' }]); + }); + + it('includes items with blockName', () => { + const result = buildUserMessage('hi', [{ blockName: 'hero', innerText: 'hi' }], []); + expect(result.selectionContext).to.deep.equal([{ blockName: 'hero', innerText: 'hi' }]); + }); + + it('includes items with both proseIndex and blockName', () => { + const result = buildUserMessage('hi', [{ proseIndex: 0, blockName: 'columns', innerText: 'x' }], []); + expect(result.selectionContext).to.deep.equal([{ proseIndex: 0, blockName: 'columns', innerText: 'x' }]); + }); + + it('omits innerText when not present', () => { + const result = buildUserMessage('hi', [{ proseIndex: 1 }], []); + expect(result.selectionContext).to.deep.equal([{ proseIndex: 1 }]); + }); + + it('filters out items with neither proseIndex nor blockName', () => { + const context = [ + { innerText: 'no index or name' }, + { proseIndex: 3, innerText: 'valid' }, + ]; + const result = buildUserMessage('hi', context, []); + expect(result.selectionContext).to.deep.equal([{ proseIndex: 3, innerText: 'valid' }]); + }); + + it('omits selectionContext entirely when all items are filtered out', () => { + const result = buildUserMessage('hi', [{ innerText: 'no index or name' }], []); + expect(result).to.not.have.property('selectionContext'); + }); + + it('treats proseIndex: 0 as valid (falsy but is a number)', () => { + const result = buildUserMessage('hi', [{ proseIndex: 0 }], []); + expect(result.selectionContext).to.deep.equal([{ proseIndex: 0 }]); + }); + }); + + describe('attachmentsMeta', () => { + it('maps attachments to id, fileName, mediaType, sizeBytes', () => { + const attachments = [{ id: 'a1', fileName: 'img.png', mediaType: 'image/png', sizeBytes: 1024, dataBase64: 'abc' }]; + const result = buildUserMessage('hi', [], attachments); + expect(result.attachmentsMeta).to.deep.equal([ + { id: 'a1', fileName: 'img.png', mediaType: 'image/png', sizeBytes: 1024 }, + ]); + }); + + it('omits sizeBytes when undefined', () => { + const attachments = [{ id: 'a1', fileName: 'doc.pdf', mediaType: 'application/pdf' }]; + const result = buildUserMessage('hi', [], attachments); + expect(result.attachmentsMeta[0]).to.not.have.property('sizeBytes'); + }); + + it('keeps sizeBytes when 0', () => { + const attachments = [{ id: 'a1', fileName: 'empty.txt', mediaType: 'text/plain', sizeBytes: 0 }]; + const result = buildUserMessage('hi', [], attachments); + expect(result.attachmentsMeta[0].sizeBytes).to.equal(0); + }); + + it('strips extra fields like dataBase64 and contentUrl', () => { + const attachments = [{ id: 'a1', fileName: 'f.png', mediaType: 'image/png', dataBase64: 'xyz', contentUrl: 'https://...' }]; + const result = buildUserMessage('hi', [], attachments); + const meta = result.attachmentsMeta[0]; + expect(meta).to.not.have.property('dataBase64'); + expect(meta).to.not.have.property('contentUrl'); + }); + }); +}); diff --git a/test/nx2/blocks/chat/utils/stream.test.js b/test/nx2/blocks/chat/utils/stream.test.js new file mode 100644 index 000000000..6e5e9137a --- /dev/null +++ b/test/nx2/blocks/chat/utils/stream.test.js @@ -0,0 +1,47 @@ +import { expect } from '@esm-bundle/chai'; +import { readStream } from '../../../../../nx2/blocks/chat/utils/stream.js'; + +async function* makeBody(...chunks) { + const enc = new TextEncoder(); + for (const chunk of chunks) { + yield enc.encode(chunk); + } +} + +describe('readStream', () => { + it('reassembles a JSON event split across two chunks', async () => { + // The buffer management splits on \n and holds the tail — a line broken + // at a chunk boundary must be completed by the next chunk before parsing. + const body = makeBody( + 'data: {"type":"text-delta","delta":"hel', + 'lo"}\n\n', + ); + const deltas = []; + await readStream(body, { onDelta: (t) => deltas.push(t) }); + expect(deltas).to.deep.equal(['hello']); + }); + + it('does not process events from chunks that arrive after a finish event', async () => { + // Once finished=true the outer for-await breaks on its next iteration, + // so a delta in a subsequent chunk must never reach onDelta. + const body = makeBody( + 'data: {"type":"finish","finishReason":"stop"}\n\n', + 'data: {"type":"text-delta","delta":"ghost"}\n\n', + ); + const deltas = []; + await readStream(body, { onDelta: (t) => deltas.push(t) }); + expect(deltas).to.have.lengthOf(0); + }); + + it('flushes uncommitted streaming text via onText when the stream ends without TEXT_END', async () => { + // If the connection drops after text-delta events but before text-end, + // readStream must still commit what it accumulated rather than silently dropping it. + const body = makeBody('data: {"type":"text-delta","delta":"partial"}\n\n'); + let committed = null; + await readStream(body, { + onDelta: () => {}, + onText: (t) => { committed = t; }, + }); + expect(committed).to.equal('partial'); + }); +}); diff --git a/test/nx2/blocks/chat/utils/tools.test.js b/test/nx2/blocks/chat/utils/tools.test.js new file mode 100644 index 000000000..ccc3a7899 --- /dev/null +++ b/test/nx2/blocks/chat/utils/tools.test.js @@ -0,0 +1,114 @@ +import { expect } from '@esm-bundle/chai'; +import { affectedFolders } from '../../../../../nx2/blocks/chat/utils/tools.js'; + +const ORG = 'adobe'; +const REPO = 'mysite'; +const base = { org: ORG, repo: REPO }; + +describe('affectedFolders', () => { + describe('guard — missing org/repo', () => { + it('returns [] when input is null', () => { + expect(affectedFolders('content_create', null)).to.deep.equal([]); + }); + + it('returns [] when input is undefined', () => { + expect(affectedFolders('content_create', undefined)).to.deep.equal([]); + }); + + it('returns [] when org is missing', () => { + expect(affectedFolders('content_create', { repo: REPO, path: '/a/b.md' })).to.deep.equal([]); + }); + + it('returns [] when repo is missing', () => { + expect(affectedFolders('content_create', { org: ORG, path: '/a/b.md' })).to.deep.equal([]); + }); + }); + + describe('toParent — path resolution', () => { + it('returns the immediate parent of a deep path', () => { + const result = affectedFolders('content_create', { ...base, path: '/folder/sub/file.md' }); + expect(result).to.deep.equal([`/${ORG}/${REPO}/folder/sub`]); + }); + + it('returns /org/repo for a file at the repo root', () => { + const result = affectedFolders('content_create', { ...base, path: '/file.md' }); + expect(result).to.deep.equal([`/${ORG}/${REPO}`]); + }); + + it('handles paths without a leading slash', () => { + const result = affectedFolders('content_create', { ...base, path: 'folder/file.md' }); + expect(result).to.deep.equal([`/${ORG}/${REPO}/folder`]); + }); + + it('returns [] when path is absent', () => { + expect(affectedFolders('content_create', { ...base })).to.deep.equal([]); + }); + }); + + describe('content_move', () => { + it('returns both source and destination parent folders', () => { + const input = { + ...base, + sourcePath: '/src/file.md', + destinationPath: '/dst/file.md', + }; + const result = affectedFolders('content_move', input); + expect(result).to.deep.equal([`/${ORG}/${REPO}/src`, `/${ORG}/${REPO}/dst`]); + }); + + it('deduplicates when source and destination are in the same folder', () => { + const input = { + ...base, + sourcePath: '/shared/a.md', + destinationPath: '/shared/b.md', + }; + const result = affectedFolders('content_move', input); + expect(result).to.deep.equal([`/${ORG}/${REPO}/shared`]); + }); + + it('returns single entry when both paths are at repo root', () => { + const input = { + ...base, + sourcePath: '/a.md', + destinationPath: '/b.md', + }; + const result = affectedFolders('content_move', input); + expect(result).to.deep.equal([`/${ORG}/${REPO}`]); + }); + }); + + describe('content_copy', () => { + it('returns only the destination parent', () => { + const input = { + ...base, + sourcePath: '/src/file.md', + destinationPath: '/dst/sub/file.md', + }; + const result = affectedFolders('content_copy', input); + expect(result).to.deep.equal([`/${ORG}/${REPO}/dst/sub`]); + }); + + it('does not include the source parent', () => { + const input = { + ...base, + sourcePath: '/original/file.md', + destinationPath: '/copy/file.md', + }; + const result = affectedFolders('content_copy', input); + expect(result).to.not.include(`/${ORG}/${REPO}/original`); + }); + }); + + describe('other tools (content_create, content_delete, content_update, content_upload)', () => { + for (const toolName of ['content_create', 'content_delete', 'content_update', 'content_upload']) { + it(`${toolName} — returns parent of input.path`, () => { + const result = affectedFolders(toolName, { ...base, path: '/docs/page.md' }); + expect(result).to.deep.equal([`/${ORG}/${REPO}/docs`]); + }); + } + + it('returns [] when path is absent', () => { + expect(affectedFolders('content_delete', { ...base })).to.deep.equal([]); + }); + }); +});