diff --git a/AGENTS.md b/AGENTS.md index b165e37..7592168 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,41 @@ npm test # Run tests ./cli/bin/mew.js space up ``` +## Quick Testing Guide +```bash +# Create test space (from repo root) +cd tests && mkdir test-feature && cd test-feature +../../cli/bin/mew.js space init --template coder-agent . +cd ../.. && npm install # Link local packages + +# Start space +cd tests/test-feature +../../cli/bin/mew.js space up # Default port 8080 + +# Send messages via HTTP API (replace test-feature with your folder name) +curl -X POST 'http://localhost:8080/participants/human/messages?space=test-feature' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer human-token' \ + -d '{"type": "chat", "content": "Hello"}' + +# View logs (non-blocking for coding agents) +pm2 logs --nostream --lines 50 # Last 50 lines, exits immediately +pm2 logs gateway --nostream # Gateway logs only, no streaming +pm2 logs coder --nostream --err # Agent errors only +tail -n 100 ~/.pm2/logs/gateway-out.log # Direct file access + +# Check responses in curl (add -v for verbose) +curl -v -X POST ... # Shows request/response headers + +# Interactive mode (better for development) +../../cli/bin/mew.js space connect +# Paste JSON directly to send protocol messages +# Type normally for chat messages + +# Clean up +../../cli/bin/mew.js space down +``` + ## Development Workflow 1. Read existing patterns before implementing 2. Update types first, then implementations @@ -58,6 +93,7 @@ npm test # Run tests - DO follow TypeScript strict mode - DO run relevant tests after changes - DO update specs if changing protocol +- DO send stream/close when done with a stream (agents must clean up) ## Making Changes 1. **Protocol changes**: Update spec/draft → types → implementations → tests diff --git a/CLAUDE.md b/CLAUDE.md index b165e37..7592168 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,41 @@ npm test # Run tests ./cli/bin/mew.js space up ``` +## Quick Testing Guide +```bash +# Create test space (from repo root) +cd tests && mkdir test-feature && cd test-feature +../../cli/bin/mew.js space init --template coder-agent . +cd ../.. && npm install # Link local packages + +# Start space +cd tests/test-feature +../../cli/bin/mew.js space up # Default port 8080 + +# Send messages via HTTP API (replace test-feature with your folder name) +curl -X POST 'http://localhost:8080/participants/human/messages?space=test-feature' \ + -H 'Content-Type: application/json' \ + -H 'Authorization: Bearer human-token' \ + -d '{"type": "chat", "content": "Hello"}' + +# View logs (non-blocking for coding agents) +pm2 logs --nostream --lines 50 # Last 50 lines, exits immediately +pm2 logs gateway --nostream # Gateway logs only, no streaming +pm2 logs coder --nostream --err # Agent errors only +tail -n 100 ~/.pm2/logs/gateway-out.log # Direct file access + +# Check responses in curl (add -v for verbose) +curl -v -X POST ... # Shows request/response headers + +# Interactive mode (better for development) +../../cli/bin/mew.js space connect +# Paste JSON directly to send protocol messages +# Type normally for chat messages + +# Clean up +../../cli/bin/mew.js space down +``` + ## Development Workflow 1. Read existing patterns before implementing 2. Update types first, then implementations @@ -58,6 +93,7 @@ npm test # Run tests - DO follow TypeScript strict mode - DO run relevant tests after changes - DO update specs if changing protocol +- DO send stream/close when done with a stream (agents must clean up) ## Making Changes 1. **Protocol changes**: Update spec/draft → types → implementations → tests diff --git a/cli/spec/draft/SPEC.md b/cli/spec/draft/SPEC.md index ba0be63..3eece41 100644 --- a/cli/spec/draft/SPEC.md +++ b/cli/spec/draft/SPEC.md @@ -1316,7 +1316,8 @@ The default interactive mode uses Ink (React for CLI) to provide a modern termin - Chat acknowledgement and cancellation shortcuts (`/ack`, `/cancel`) to manage UI queues - Participant control shortcuts (`/status`, `/pause`, `/resume`, `/forget`, `/clear`, `/restart`, `/shutdown`) - Stream negotiation helpers (`/stream request`, `/stream close`, `/streams`) with live frame previews -- Reasoning status card with `/reason-cancel` hint and automatic stream mirroring for thoughts +- Reasoning bar with token streaming, action display, and `/reason-cancel` control +- Fixed-height UI components to prevent jitter during real-time updates **Architecture:** - Message history uses Ink's `Static` component for native scrolling @@ -1330,6 +1331,90 @@ The default interactive mode uses Ink (React for CLI) to provide a modern termin - **Participant Status**: Displays most recent `participant/status` payloads (tokens, context occupancy, latency) per sender - **Pause State**: Highlights active pauses applied to the CLI participant with time remaining indicators - **Stream Monitor**: Lists current `stream/open` sessions and the latest raw frames decoded from `#streamId#` WebSocket traffic +- **Collapsed Summary**: When the board is hidden, the status bar surfaces a compact `acks`, `status`, and `paused` summary so operators can monitor high-level state without losing transcript space. The board remains collapsed unless toggled with `/ui board open`; `/ui board close` re-collapses it and `/ui board auto` re-enables auto-collapse when no activity is present. + +#### Reasoning Bar Display + +The advanced interactive mode includes a dedicated reasoning bar that appears when agents engage in reasoning sessions. This component provides real-time feedback about the agent's thought process, even when the underlying model doesn't stream the actual reasoning content. + +**Visual Design:** +- Cyan rounded border distinguishes it from regular messages +- Fixed height (8 lines) to prevent UI jitter during streaming +- Animated spinner indicates active processing +- Horizontal layout spreads information across the width + +**Information Display:** +``` +╭─────────────────────────────────────────────────────────────╮ +│ ⠋ mew is thinking 90s 32 tokens │ +│ Using tool mcp-fs-bridge /read_text_file │ +│ ...preview of reasoning text (max 3 lines)... │ +│ │ +│ Use /reason-cancel to interrupt reasoning. │ +╰─────────────────────────────────────────────────────────────╯ +``` + +**Content Elements:** +1. **Status Line**: + - Animated spinner (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) for visual activity + - Participant name ("mew is thinking") + - Elapsed time in seconds + - Token count or thought count depending on available data + +2. **Action Line** (when available): + - Shows current tool or action being executed + - Extracted from `reasoning/thought` payload's `action` field + - Example: "Using tool mcp-fs-bridge /read_text_file" + +3. **Text Preview**: + - Limited to 3 lines and 400 characters to prevent height changes + - Shows latest reasoning text or message + - Italicized gray text for visual distinction + - Ellipsis prefix when truncated + +4. **Control Hint**: + - Always displayed at bottom: "Use /reason-cancel to interrupt reasoning." + - Provides user with clear action to stop long-running reasoning + +**Streaming and Token Counting:** + +The reasoning bar handles two types of progress indication: + +1. **Token Streaming** (OpenAI-style models): + - Receives token count updates via `stream/data` frames + - Displays running total as "X tokens" + - Updates in real-time as tokens are generated + - No actual reasoning content streamed (model limitation) + +2. **Thought Counting** (legacy/fallback): + - Counts number of `reasoning/thought` messages + - Displays as "X thoughts" + - Used when token information not available + +**Implementation Details:** + +- **Fixed Layout**: All elements use fixed heights to prevent layout shifts: + - Main container: `height: 8` + - Action line: `height: 1` + - Text preview: `height: 3` (matches `maxLines`) + - Always renders elements with space character fallback to avoid Ink rendering errors + +- **State Management**: + - Tracks `activeReasoning` in UI state + - Updates on `reasoning/start` messages + - Clears on `reasoning/conclusion` or `/reason-cancel` + - Merges token metrics from multiple sources (thoughts and streams) + +- **Stream Integration**: + - Automatically subscribes to reasoning streams via `stream/request` + - Processes `stream/open` responses to track stream IDs + - Parses `#streamId#data` frames for token updates + - Handles `stream/close` for cleanup + +**User Interactions:** +- `/reason-cancel [reason]` - Sends cancellation request to stop reasoning +- Escape key can be used in some contexts to cancel +- Visual feedback shows cancellation was sent ### Debug Mode (--debug flag) @@ -1519,6 +1604,7 @@ Verbose mode (`/verbose`) shows full JSON messages. /help Show available commands and shortcuts /verbose Toggle verbose output (show full JSON) /ui-clear Clear local UI buffers (history, status board) +/ui board [open|close|auto] Control Signal Board docking behaviour /exit Disconnect and exit /ack [selector] [status] Acknowledge pending chat message(s) /cancel [selector] [reason] Cancel pending chat message(s) @@ -1628,4 +1714,4 @@ This CLI successfully implements the test plan when: 2. Gateway starts and accepts connections 3. Agents respond appropriately 4. FIFO mode enables test automation -5. Messages flow correctly between participants \ No newline at end of file +5. Messages flow correctly between participants diff --git a/cli/src/commands/gateway.js b/cli/src/commands/gateway.js index dc5b6c0..61e3ea9 100644 --- a/cli/src/commands/gateway.js +++ b/cli/src/commands/gateway.js @@ -204,7 +204,7 @@ gateway const wss = new WebSocket.Server({ server }); // Track spaces and participants - const spaces = new Map(); // spaceId -> { participants: Map(participantId -> ws) } + const spaces = new Map(); // spaceId -> { participants: Map(participantId -> ws), streamCounter: number, activeStreams: Map } // Track participant info const participantTokens = new Map(); // participantId -> token @@ -563,7 +563,34 @@ gateway ws.on('message', async (data) => { try { - const message = JSON.parse(data.toString()); + const dataStr = data.toString(); + + // Check if this is a stream data frame (format: #streamID#data) + if (dataStr.startsWith('#') && dataStr.indexOf('#', 1) > 0) { + const secondHash = dataStr.indexOf('#', 1); + const streamId = dataStr.substring(1, secondHash); + + // Forward stream data to all participants in the space + if (spaceId && spaces.has(spaceId)) { + const space = spaces.get(spaceId); + + // Verify stream exists and belongs to this participant + const streamInfo = space.activeStreams.get(streamId); + if (streamInfo && streamInfo.participantId === participantId) { + // Forward to all participants + for (const [pid, pws] of space.participants.entries()) { + if (pws.readyState === WebSocket.OPEN) { + pws.send(data); // Send raw data frame + } + } + } else { + console.log(`[GATEWAY WARNING] Invalid stream ID ${streamId} from ${participantId}`); + } + } + return; // Don't process as JSON message + } + + const message = JSON.parse(dataStr); // Validate message const validationError = validateMessage(message); @@ -593,7 +620,11 @@ gateway // Create space if it doesn't exist if (!spaces.has(spaceId)) { - spaces.set(spaceId, { participants: new Map() }); + spaces.set(spaceId, { + participants: new Map(), + streamCounter: 0, + activeStreams: new Map() + }); } // Add participant to space @@ -942,6 +973,65 @@ gateway envelope.correlation_id = [envelope.correlation_id]; } + // Handle stream/request - gateway must respond with stream/open + if (envelope.kind === 'stream/request' && spaceId && spaces.has(spaceId)) { + const space = spaces.get(spaceId); + + // Generate unique stream ID + space.streamCounter++; + const streamId = `stream-${space.streamCounter}`; + + // Track the stream + space.activeStreams.set(streamId, { + requestId: envelope.id, + participantId: participantId, + direction: envelope.payload?.direction || 'unknown', + created: new Date().toISOString() + }); + + // Send stream/open response + const streamOpenResponse = { + protocol: 'mew/v0.3', + id: `stream-open-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + ts: new Date().toISOString(), + from: 'gateway', + to: [participantId], + kind: 'stream/open', + correlation_id: [envelope.id], + payload: { + stream_id: streamId, + encoding: 'text' + } + }; + + // Send stream/open to ALL participants (MEW Protocol visibility) + for (const [pid, pws] of space.participants.entries()) { + if (pws.readyState === WebSocket.OPEN) { + pws.send(JSON.stringify(streamOpenResponse)); + } + } + + if (options.logLevel === 'debug') { + console.log(`[GATEWAY DEBUG] Assigned stream ID ${streamId} for request from ${participantId}`); + } + } + + // Handle stream/close - clean up active streams + if (envelope.kind === 'stream/close' && spaceId && spaces.has(spaceId)) { + const space = spaces.get(spaceId); + + // Find and remove the stream (may be referenced by correlation_id) + if (envelope.payload?.stream_id) { + const streamId = envelope.payload.stream_id; + if (space.activeStreams.has(streamId)) { + space.activeStreams.delete(streamId); + if (options.logLevel === 'debug') { + console.log(`[GATEWAY DEBUG] Closed stream ${streamId}`); + } + } + } + } + // ALWAYS broadcast to ALL participants - MEW Protocol requires all messages visible to all if (spaceId && spaces.has(spaceId)) { const space = spaces.get(spaceId); diff --git a/cli/src/utils/advanced-interactive-ui.js b/cli/src/utils/advanced-interactive-ui.js index 1a7b26f..45d3a7c 100644 --- a/cli/src/utils/advanced-interactive-ui.js +++ b/cli/src/utils/advanced-interactive-ui.js @@ -8,11 +8,37 @@ */ const React = require('react'); -const { render, Box, Text, Static, useInput, useApp, useFocus } = require('ink'); -const { useState, useEffect, useRef, useMemo } = React; +const { render, Box, Text, Static, useInput, useApp, useFocus, useStdout } = require('ink'); +const { useState, useEffect, useRef, useMemo, useCallback } = React; const { v4: uuidv4 } = require('uuid'); const EnhancedInput = require('../ui/components/EnhancedInput'); +const DECORATIVE_SYSTEM_KINDS = new Set([ + 'system/welcome', + 'system/heartbeat' +]); + +function isDecorativeSystemMessage(message) { + if (!message || !message.kind) { + return false; + } + return DECORATIVE_SYSTEM_KINDS.has(message.kind); +} + +function createSignalBoardSummary(ackCount, statusCount, pauseState, includeAck = true) { + const parts = []; + if (includeAck && ackCount > 0) { + parts.push(`acks:${ackCount}`); + } + if (statusCount > 0) { + parts.push(`status:${statusCount}`); + } + if (pauseState) { + parts.push('paused'); + } + return parts.length > 0 ? parts.join(' · ') : null; +} + /** * Main Advanced Interactive UI Component */ @@ -20,8 +46,8 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { const [messages, setMessages] = useState([]); const [commandHistory, setCommandHistory] = useState([]); const [pendingOperation, setPendingOperation] = useState(null); - const [showHelp, setShowHelp] = useState(false); const [verbose, setVerbose] = useState(false); + const [showStreams, setShowStreams] = useState(false); const [activeReasoning, setActiveReasoning] = useState(null); // Track active reasoning sessions const [grantedCapabilities, setGrantedCapabilities] = useState(new Map()); // Track granted capabilities by participant const [pendingAcknowledgements, setPendingAcknowledgements] = useState([]); @@ -30,14 +56,119 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { const [pauseState, setPauseState] = useState(null); const [activeStreams, setActiveStreams] = useState(new Map()); const [streamFrames, setStreamFrames] = useState([]); + const reasoningStreamRequestsRef = useRef(new Map()); + const reasoningStreamsRef = useRef(new Map()); + const reasoningUpdateTimerRef = useRef(null); + const pendingReasoningUpdateRef = useRef(null); const contextUsageEntries = useMemo(() => buildContextUsageEntries(participantStatuses), [participantStatuses]); + const [signalBoardExpanded, setSignalBoardExpanded] = useState(false); + const [signalBoardOverride, setSignalBoardOverride] = useState(null); const { exit } = useApp(); + const { stdout } = useStdout(); + const [terminalWidth, setTerminalWidth] = useState(() => { + if (stdout && typeof stdout.columns === 'number') { + return stdout.columns; + } + if (typeof process?.stdout?.columns === 'number') { + return process.stdout.columns; + } + return null; + }); + + useEffect(() => { + if (!stdout || typeof stdout.on !== 'function') { + return undefined; + } + + const handleResize = () => { + if (typeof stdout.columns === 'number') { + setTerminalWidth(stdout.columns); + } + }; + + stdout.on('resize', handleResize); + return () => { + stdout.off('resize', handleResize); + }; + }, [stdout]); + + const meaningfulMessageCount = useMemo(() => { + return messages.reduce((count, entry) => { + return count + (isDecorativeSystemMessage(entry.message) ? 0 : 1); + }, 0); + }, [messages]); + const signalBoardHasActivity = myPendingAcknowledgements.length > 0 || + participantStatuses.size > 0 || + Boolean(pauseState) || + activeStreams.size > 0 || + streamFrames.length > 0; + const wideEnoughForDockedLayout = terminalWidth === null || terminalWidth >= 110; + const desiredLayout = wideEnoughForDockedLayout && (signalBoardHasActivity || meaningfulMessageCount >= 2) + ? 'docked' + : 'stacked'; + const [layoutMode, setLayoutMode] = useState(desiredLayout); + + useEffect(() => { + setLayoutMode(prev => { + if (desiredLayout !== prev) { + return desiredLayout; + } + return prev; + }); + }, [desiredLayout]); + const showEmptyStateCard = !signalBoardHasActivity && meaningfulMessageCount === 0; + + useEffect(() => { + if (signalBoardOverride === 'open' && !signalBoardExpanded) { + setSignalBoardExpanded(true); + } + if (signalBoardOverride === 'closed' && signalBoardExpanded) { + setSignalBoardExpanded(false); + } + }, [signalBoardOverride, signalBoardExpanded]); + + useEffect(() => { + if (signalBoardOverride === 'open') { + return; + } + if (!signalBoardHasActivity && signalBoardExpanded) { + setSignalBoardExpanded(false); + } + }, [signalBoardHasActivity, signalBoardExpanded, signalBoardOverride]); + + // Throttled reasoning update to prevent performance issues during streaming + const updateReasoningThrottled = useCallback((updateFn) => { + // Store the latest update function + pendingReasoningUpdateRef.current = updateFn; + + // If no timer is running, start one + if (!reasoningUpdateTimerRef.current) { + reasoningUpdateTimerRef.current = setTimeout(() => { + // Apply the latest pending update + if (pendingReasoningUpdateRef.current) { + setActiveReasoning(pendingReasoningUpdateRef.current); + pendingReasoningUpdateRef.current = null; + } + reasoningUpdateTimerRef.current = null; + }, 100); // Update at most every 100ms + } + }, []); // Environment configuration const showHeartbeat = process.env.MEW_INTERACTIVE_SHOW_HEARTBEAT === 'true'; const showSystem = process.env.MEW_INTERACTIVE_SHOW_SYSTEM !== 'false'; const useColor = process.env.MEW_INTERACTIVE_COLOR !== 'false'; + // Cleanup throttle timer on unmount + useEffect(() => { + return () => { + if (reasoningUpdateTimerRef.current) { + clearTimeout(reasoningUpdateTimerRef.current); + reasoningUpdateTimerRef.current = null; + } + }; + }, []); + // Setup WebSocket handlers useEffect(() => { if (!ws) return; @@ -45,10 +176,18 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { const handleMessage = (data) => { const raw = typeof data === 'string' ? data : data.toString(); + // Check if this is a stream data frame (format: #streamID#data) if (raw.startsWith('#')) { const match = raw.match(/^#([^#]+)#([\s\S]*)$/); if (match) { - handleStreamFrame(match[1], match[2]); + const streamId = match[1]; + const streamData = match[2]; + + // Store frame for display + handleStreamFrame(streamId, streamData); + + // Process stream data for reasoning + handleStreamData(streamId, streamData); return; } } @@ -91,10 +230,11 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { }; const handleStreamFrame = (streamId, payload) => { + const timestamp = new Date(); const frame = { streamId, payload, - timestamp: new Date() + timestamp }; setStreamFrames(prev => { @@ -105,25 +245,135 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { setActiveStreams(prev => { const next = new Map(prev); const existing = next.get(streamId); - const now = new Date(); if (existing) { - next.set(streamId, { ...existing, lastActivityAt: now }); + next.set(streamId, { ...existing, lastActivityAt: timestamp }); } else { next.set(streamId, { streamId, openedBy: 'unknown', - openedAt: now, - lastActivityAt: now, + openedAt: timestamp, + lastActivityAt: timestamp, }); } return next; }); - addMessage({ - kind: 'stream/data', - from: streamId, - payload: { data: payload } - }, false); + let shouldAddMessage = true; + let parsed = null; + try { + parsed = JSON.parse(payload); + } catch (error) { + parsed = null; + } + + let reasoningStream = reasoningStreamsRef.current.get(streamId); + if (!reasoningStream && parsed && parsed.context) { + reasoningStreamsRef.current.set(streamId, { contextId: parsed.context, from: parsed.from || 'unknown' }); + reasoningStream = reasoningStreamsRef.current.get(streamId); + } + + if (reasoningStream && parsed && parsed.context === reasoningStream.contextId) { + const chunkType = parsed.type || 'token'; + if (chunkType === 'token' && typeof parsed.value === 'string') { + const chunkText = parsed.value; + shouldAddMessage = false; + setActiveReasoning(prev => { + if (!prev || prev.id !== parsed.context) { + return prev; + } + const combined = `${prev.streamText || ''}${chunkText}`; + const trimmed = combined.length > 4000 ? combined.slice(-4000) : combined; + return { + ...prev, + streamText: trimmed, + lastTokenUpdate: new Date() + }; + }); + } else if (chunkType === 'thought') { + shouldAddMessage = false; + const reasoningText = parsed.value?.reasoning || parsed.value?.message; + setActiveReasoning(prev => { + if (!prev || prev.id !== parsed.context) { + return prev; + } + const nextStreamText = reasoningText || prev.streamText; + return { + ...prev, + message: reasoningText || prev.message, + streamText: nextStreamText, + lastTokenUpdate: new Date() + }; + }); + } else if (chunkType === 'status') { + shouldAddMessage = false; + if (parsed.event === 'conclusion' || parsed.event === 'cancelled') { + reasoningStreamsRef.current.delete(streamId); + } + } + } + + // Don't add stream/data messages to the message list + // They're already displayed in the Signal Board's stream monitor + }; + + const handleStreamData = (streamId, data) => { + // Show stream data if streams mode is enabled + if (showStreams) { + addSystemMessage(`[STREAM ${streamId}] ${data.substring(0, 200)}${data.length > 200 ? '...' : ''}`, 'stream'); + } + + try { + const parsed = JSON.parse(data); + + // Check if this is reasoning stream data + const streamInfo = reasoningStreamsRef.current.get(streamId); + if (streamInfo) { + const { contextId } = streamInfo; + + if (parsed.type === 'progress' && parsed.tokenCount) { + // Update token count to show progress in the active reasoning bar (throttled) + updateReasoningThrottled(prev => { + if (prev && prev.id === contextId) { + return { + ...prev, + tokenCount: parsed.tokenCount, + updateCount: (prev.updateCount || 0) + 1 + }; + } + return prev; + }); + } else if (parsed.type === 'token' && parsed.value) { + // Update reasoning session with actual token content (if any) + setActiveReasoning(prev => { + if (prev && prev.id === contextId) { + return { + ...prev, + message: (prev.message || '') + parsed.value, + updateCount: (prev.updateCount || 0) + 1 + }; + } + return prev; + }); + } else if (parsed.type === 'thought' && parsed.value) { + // Handle complete thoughts + const thoughtText = parsed.value.reasoning || parsed.value.message || parsed.value; + setActiveReasoning(prev => { + if (prev && prev.id === contextId) { + return { + ...prev, + message: thoughtText, + thoughtCount: (prev.thoughtCount || 0) + 1, + updateCount: (prev.updateCount || 0) + 1 + }; + } + return prev; + }); + } + } + } catch (err) { + // Not JSON, might be raw text stream data + console.error('[Stream] Raw data on stream', streamId, ':', data); + } }; const handleIncomingMessage = (message) => { @@ -170,10 +420,16 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { const entry = prev[index]; const recipients = Array.isArray(entry.to) ? entry.to : []; + // For broadcast messages (no recipients), any acknowledgment removes it if (recipients.length === 0) { - return prev.filter(item => item.id !== target); + if (message.from) { + // Someone acknowledged our broadcast message + return prev.filter(item => item.id !== target); + } + return prev; } + // For targeted messages, track individual acknowledgments if (message.from && recipients.includes(message.from)) { const acked = new Set(entry.acked || []); acked.add(message.from); @@ -238,6 +494,7 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { return next; }); } + // Continue processing for reasoning-specific logic below } if (message.kind === 'stream/close') { @@ -283,10 +540,18 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { // Handle reasoning messages if (message.kind === 'reasoning/start') { const tokenMetrics = extractReasoningTokenMetrics(message.payload); + for (const [id, info] of reasoningStreamsRef.current.entries()) { + if (info.contextId === message.id) { + reasoningStreamsRef.current.delete(id); + } + } setActiveReasoning({ id: message.id, from: message.from, - message: message.payload?.message || 'Thinking...', + message: '', + streamText: '', + streamId: null, + streamRequestId: null, startTime: new Date(), thoughtCount: 0, tokenMetrics, @@ -294,21 +559,67 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { }); } else if (message.kind === 'reasoning/thought') { setActiveReasoning(prev => { - if (!prev) return null; + if (!prev) return prev; + if (message.context && message.context !== prev.id) { + return prev; + } const tokenUpdate = extractReasoningTokenMetrics(message.payload); const { metrics, changed } = mergeReasoningTokenMetrics(prev.tokenMetrics, tokenUpdate); + const reasoningText = message.payload?.reasoning || message.payload?.message; + const nextStreamText = prev.streamText || reasoningText || ''; + const action = message.payload?.action; return { ...prev, - message: message.payload?.message || prev.message, + message: reasoningText || prev.message, + streamText: nextStreamText, + action: action || prev.action, thoughtCount: prev.thoughtCount + 1, tokenMetrics: metrics, lastTokenUpdate: changed ? new Date() : prev.lastTokenUpdate }; }); } else if (message.kind === 'reasoning/conclusion') { - setActiveReasoning(null); + const contextId = message.context || message.id; + if (contextId) { + for (const [id, info] of reasoningStreamsRef.current.entries()) { + if (info.contextId === contextId) { + reasoningStreamsRef.current.delete(id); + } + } + for (const [requestId, info] of reasoningStreamRequestsRef.current.entries()) { + if (info?.contextId === contextId) { + reasoningStreamRequestsRef.current.delete(requestId); + } + } + } + setActiveReasoning(prev => { + if (!prev) return null; + if (contextId && prev.id !== contextId) { + return prev; + } + return null; + }); } else if (message.kind === 'reasoning/cancel') { - setActiveReasoning(null); + const contextId = message.context || message.id; + if (contextId) { + for (const [id, info] of reasoningStreamsRef.current.entries()) { + if (info.contextId === contextId) { + reasoningStreamsRef.current.delete(id); + } + } + for (const [requestId, info] of reasoningStreamRequestsRef.current.entries()) { + if (info?.contextId === contextId) { + reasoningStreamRequestsRef.current.delete(requestId); + } + } + } + setActiveReasoning(prev => { + if (!prev) return prev; + if (contextId && prev.id !== contextId) { + return prev; + } + return null; + }); if (message.payload?.reason) { addMessage({ kind: 'system/info', @@ -318,6 +629,59 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { } } + if (message.kind === 'stream/request') { + const description = message.payload?.description; + if (typeof description === 'string' && description.startsWith('reasoning:')) { + const contextId = description.slice('reasoning:'.length); + reasoningStreamRequestsRef.current.set(message.id, { contextId, from: message.from }); + setActiveReasoning(prev => { + if (!prev || prev.id !== contextId) { + return prev; + } + return { ...prev, streamRequestId: message.id }; + }); + } + } + + // Handle reasoning-specific stream logic (this is a second handler for the same event) + if (message.kind === 'stream/open') { + const streamId = message.payload?.stream_id; + let contextId = null; + const requestId = message.correlation_id?.[0]; + if (requestId && reasoningStreamRequestsRef.current.has(requestId)) { + const info = reasoningStreamRequestsRef.current.get(requestId); + reasoningStreamRequestsRef.current.delete(requestId); + contextId = info?.contextId || null; + } + const description = message.payload?.description; + if (!contextId && typeof description === 'string' && description.startsWith('reasoning:')) { + contextId = description.slice('reasoning:'.length); + } + if (contextId && streamId) { + reasoningStreamsRef.current.set(streamId, { contextId, from: message.from }); + setActiveReasoning(prev => { + if (!prev || prev.id !== contextId) { + return prev; + } + return { ...prev, streamId }; + }); + } + } + + if (message.kind === 'stream/close') { + const streamId = message.payload?.stream_id; + const correlationId = message.correlation_id?.[0]; + if (streamId && reasoningStreamsRef.current.has(streamId)) { + reasoningStreamsRef.current.delete(streamId); + } + if (correlationId) { + reasoningStreamRequestsRef.current.delete(correlationId); + if (reasoningStreamsRef.current.has(correlationId)) { + reasoningStreamsRef.current.delete(correlationId); + } + } + } + // Handle capability grant acknowledgments if (message.kind === 'capability/grant-ack') { console.error('[DEBUG] Received grant acknowledgment:', JSON.stringify(message.payload)); @@ -423,7 +787,11 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { } } - addMessage(message, false); + // Don't add stream/data messages to the message list - they're handled separately + // and displayed in the Signal Board's stream monitor + if (message.kind !== 'stream/data') { + addMessage(message, false); + } }; const sendMessage = (message) => { @@ -482,15 +850,55 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { }); }; - const addSystemMessage = (text) => { + const addSystemMessage = (text, type = 'info') => { setMessages(prev => [...prev, { id: uuidv4(), - message: { kind: 'system/info', payload: { text } }, + message: { kind: `system/${type}`, payload: { text } }, sent: false, timestamp: new Date(), }]); }; + const addHelpMessage = () => { + const lines = [ + 'Available Commands', + '/help Show this help', + '/verbose Toggle verbose output', + '/streams Toggle stream data display', + '/ui board [open|close|auto] Control Signal Board', + '/ui-clear Clear local UI buffers', + '/exit Exit application', + '', + 'Chat Queue', + '/ack [selector] [status] Acknowledge chat messages', + '/cancel [selector] [reason] Cancel reasoning or pending chats', + '', + 'Participant Controls', + '/status [fields...] Request status', + '/pause [timeout] [reason]', + '/resume [reason]', + '/forget [oldest|newest] [count]', + '/clear [reason]', + '/restart [mode] [reason]', + '/shutdown [reason]', + '', + 'Streams', + '/stream request [description] [size=bytes]', + '/stream close [reason]', + '/streams List active streams' + ]; + + setMessages(prev => [...prev, { + id: uuidv4(), + message: { + kind: 'system/help', + payload: { lines } + }, + sent: false, + timestamp: new Date() + }]); + }; + const pickAcknowledgementEntries = (args) => { if (pendingAcknowledgements.length === 0) { return { entries: [], rest: [] }; @@ -590,12 +998,16 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { switch (cmd) { case '/help': - setShowHelp(true); + addHelpMessage(); break; case '/verbose': setVerbose(prev => !prev); addSystemMessage(`Verbose mode ${!verbose ? 'enabled' : 'disabled'}.`); break; + case '/streams': + setShowStreams(prev => !prev); + addSystemMessage(`Stream data display ${!showStreams ? 'enabled' : 'disabled'}.`); + break; case '/ui-clear': setMessages([]); setPendingAcknowledgements([]); @@ -620,6 +1032,20 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { break; } case '/cancel': { + // First check for active reasoning session + if (activeReasoning) { + const reason = args.length > 0 ? args.join(' ') : undefined; + sendMessage({ + kind: 'reasoning/cancel', + context: activeReasoning.id, + payload: reason ? { reason } : {} + }); + setActiveReasoning(null); + addSystemMessage(`Sent reasoning cancel${reason ? ` (${reason})` : ''}.`); + break; + } + + // Otherwise handle chat acknowledgement cancellation const { entries, rest } = pickAcknowledgementEntries(args); if (entries.length === 0) { addSystemMessage('No chat messages matched for cancellation.'); @@ -630,21 +1056,6 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { addSystemMessage(`Cancelled ${entries.length} chat message${entries.length === 1 ? '' : 's'}${reason ? ` (${reason})` : ''}.`); break; } - case '/reason-cancel': { - if (!activeReasoning) { - addSystemMessage('No active reasoning session to cancel.'); - break; - } - const reason = args.length > 0 ? args.join(' ') : undefined; - sendMessage({ - kind: 'reasoning/cancel', - context: activeReasoning.id, - payload: reason ? { reason } : {} - }); - setActiveReasoning(null); - addSystemMessage(`Sent reasoning cancel${reason ? ` (${reason})` : ''}.`); - break; - } case '/status': { if (args.length === 0) { addSystemMessage('Usage: /status [field ...]'); @@ -882,6 +1293,44 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { addSystemMessage(`Active streams: ${summaries.join('; ')}`); break; } + case '/ui': { + if (args.length === 0) { + addSystemMessage('Usage: /ui board [open|close]'); + break; + } + const sub = args[0]; + if (sub !== 'board') { + addSystemMessage(`Unknown /ui subcommand: ${sub}`); + break; + } + let nextState; + if (args[1] === 'open') { + nextState = true; + setSignalBoardOverride('open'); + } else if (args[1] === 'close') { + nextState = false; + setSignalBoardOverride('closed'); + } else if (args[1] === 'auto') { + setSignalBoardOverride(null); + const autoState = signalBoardHasActivity; + setSignalBoardExpanded(autoState); + addSystemMessage(`Signal Board set to auto (currently ${autoState ? 'expanded' : 'collapsed'}).`); + break; + } else { + nextState = !signalBoardExpanded; + setSignalBoardOverride(nextState ? 'open' : 'closed'); + } + setSignalBoardExpanded(nextState); + addSystemMessage(`Signal Board ${nextState ? 'expanded' : 'collapsed'}.`); + break; + } + case '/ui-board': { + const nextState = !signalBoardExpanded; + setSignalBoardOverride(nextState ? 'open' : 'closed'); + setSignalBoardExpanded(nextState); + addSystemMessage(`Signal Board ${nextState ? 'expanded' : 'collapsed'}.`); + break; + } default: addSystemMessage(`Unknown command: ${cmd}`); } @@ -903,9 +1352,23 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { return obj && obj.protocol === 'mew/v0.3' && obj.id && obj.ts && obj.kind; }; + const isDockedLayout = layoutMode === 'docked'; + return React.createElement(Box, { flexDirection: "column", height: "100%" }, - React.createElement(Box, { flexDirection: "row", flexGrow: 1, marginTop: 1 }, - React.createElement(Box, { flexGrow: 1, flexDirection: "column", marginRight: 1 }, + React.createElement(Box, { + flexDirection: isDockedLayout ? "row" : "column", + flexGrow: 1, + marginTop: 1 + }, + React.createElement(Box, { + flexGrow: 1, + flexDirection: "column", + marginRight: signalBoardExpanded && isDockedLayout ? 1 : 0 + }, + showEmptyStateCard && React.createElement(EmptyStateCard, { + spaceId, + participantId + }), React.createElement(Static, { items: messages }, (item) => React.createElement(MessageDisplay, { key: item.id, @@ -915,13 +1378,29 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { }) ) ), + signalBoardExpanded && isDockedLayout && React.createElement(SidePanel, { + participantId, + myPendingAcknowledgements, + participantStatuses, + pauseState, + activeStreams, + streamFrames, + variant: 'docked' + }) + ), + signalBoardExpanded && !isDockedLayout && React.createElement(Box, { + flexDirection: "row", + justifyContent: "flex-start", + marginTop: 1 + }, React.createElement(SidePanel, { participantId, myPendingAcknowledgements, participantStatuses, pauseState, activeStreams, - streamFrames + streamFrames, + variant: 'stacked' }) ), @@ -1043,23 +1522,17 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { setPendingOperation(null); } }), - - // Help Modal - showHelp && React.createElement(HelpModal, { - onClose: () => setShowHelp(false) - }), - // Reasoning Status activeReasoning && React.createElement(ReasoningStatus, { reasoning: activeReasoning }), - // Enhanced Input Component (disabled when dialog is shown) + // Enhanced Input Component React.createElement(EnhancedInput, { onSubmit: processInput, placeholder: 'Type a message or /help for commands...', multiline: true, // Enable multi-line for Shift+Enter support - disabled: pendingOperation !== null || showHelp, + disabled: pendingOperation !== null, history: commandHistory, onHistoryChange: setCommandHistory, prompt: '> ', @@ -1077,7 +1550,8 @@ function AdvancedInteractiveUI({ ws, participantId, spaceId }) { awaitingAckCount: myPendingAcknowledgements.length, pauseState: pauseState, activeStreamCount: activeStreams.size, - contextUsage: contextUsageEntries + contextUsage: contextUsageEntries, + participantStatusCount: participantStatuses.size }) ); } @@ -1148,6 +1622,27 @@ function ReasoningDisplay({ payload, kind, contextPrefix }) { ); } + if (kind === 'system/help' && Array.isArray(payload.lines)) { + const sectionTitles = new Set(['Available Commands', 'Chat Queue', 'Participant Controls', 'Streams']); + const basePrefix = contextPrefix || ''; + const firstLinePrefix = `${basePrefix}└─ `; + const baseSpaces = basePrefix.replace(/[^\s]/g, ' '); + const subsequentPrefix = `${baseSpaces} `; + return React.createElement(Box, { flexDirection: "column" }, + payload.lines.map((line, index) => { + if (!line) { + return React.createElement(Text, { key: index, color: "gray" }, ' '); + } + const color = sectionTitles.has(line) ? 'cyan' : 'gray'; + return React.createElement(Text, { + key: index, + color, + wrap: "wrap" + }, `${index === 0 ? firstLinePrefix : subsequentPrefix}${line}`); + }) + ); + } + // Special formatting for chat messages if (kind === 'chat') { return React.createElement(Text, { @@ -1296,65 +1791,6 @@ function OperationConfirmation({ operation, onApprove, onDeny, onGrant }) { ); } -/** - * Help Modal - */ -function HelpModal({ onClose }) { - useInput((input, key) => { - if (key.escape || input === 'q') { - onClose(); - } - }); - - return React.createElement(Box, { - borderStyle: "round", - borderColor: "blue", - paddingX: 2, - paddingY: 1, - position: "absolute", - top: 2, - left: 2, - width: "50%" - }, - React.createElement(Box, { flexDirection: "column" }, - React.createElement(Text, { color: "blue", bold: true }, "Available Commands"), - React.createElement(Text, null, "/help Show this help"), - React.createElement(Text, null, "/verbose Toggle verbose output"), - React.createElement(Text, null, "/ui-clear Clear local UI buffers"), - React.createElement(Text, null, "/exit Exit application"), - - React.createElement(Box, { marginTop: 1 }), - React.createElement(Text, { color: "yellow", bold: true }, "Chat Queue"), - React.createElement(Text, null, "/ack [selector] [status] Acknowledge chat messages"), - React.createElement(Text, null, "/cancel [selector] [reason] Cancel pending chat entries"), - - React.createElement(Box, { marginTop: 1 }), - React.createElement(Text, { color: "magenta", bold: true }, "Reasoning"), - React.createElement(Text, null, "/reason-cancel [reason] Cancel active reasoning"), - - React.createElement(Box, { marginTop: 1 }), - React.createElement(Text, { color: "green", bold: true }, "Participant Controls"), - React.createElement(Text, null, "/status [fields...] Request status"), - React.createElement(Text, null, "/pause [timeout] [reason]"), - React.createElement(Text, null, "/resume [reason]"), - React.createElement(Text, null, "/forget [oldest|newest] [count]"), - React.createElement(Text, null, "/clear [reason]"), - React.createElement(Text, null, "/restart [mode] [reason]"), - React.createElement(Text, null, "/shutdown [reason]"), - - React.createElement(Box, { marginTop: 1 }), - React.createElement(Text, { color: "cyan", bold: true }, "Streams"), - React.createElement(Text, null, "/stream request [description] [size=bytes]"), - React.createElement(Text, null, "/stream close [reason]"), - React.createElement(Text, null, "/streams List active streams"), - - React.createElement(Box, { marginTop: 1 }, - React.createElement(Text, { color: "gray" }, "Press Esc or 'q' to close") - ) - ) - ); -} - /** * Reasoning Status Component */ @@ -1373,43 +1809,65 @@ function ReasoningStatus({ reasoning }) { const elapsedTime = Math.floor((Date.now() - reasoning.startTime) / 1000); const tokenSummary = formatReasoningTokenSummary(reasoning.tokenMetrics); const tokenUpdatedAgo = reasoning.lastTokenUpdate ? formatRelativeTime(reasoning.lastTokenUpdate) : null; + const displayText = reasoning.streamText && reasoning.streamText.trim().length > 0 + ? reasoning.streamText + : reasoning.message; + const textLimit = 400; + const maxLines = 3; // Limit to 3 lines to prevent height changes + let textPreview = null; + if (displayText) { + // First apply character limit + let preview = displayText.length > textLimit + ? `…${displayText.slice(displayText.length - textLimit)}` + : displayText; + // Then limit line count + const lines = preview.split('\n'); + if (lines.length > maxLines) { + preview = '…' + lines.slice(-maxLines).join('\n'); + } + textPreview = preview; + } return React.createElement(Box, { borderStyle: "round", borderColor: "cyan", paddingX: 1, - marginBottom: 1 + marginBottom: 1, + height: 8 // Fixed height to completely prevent jitter }, - React.createElement(Box, { justifyContent: "space-between", width: "100%" }, - React.createElement(Box, null, + React.createElement(Box, { flexDirection: "column", height: "100%" }, + React.createElement(Box, { width: "100%" }, React.createElement(Text, { color: "cyan", bold: true }, spinnerChars[spinnerIndex] + " "), - React.createElement(Text, { color: "cyan" }, `${reasoning.from} is thinking`) + React.createElement(Text, { color: "cyan" }, `${reasoning.from} is thinking`), + React.createElement(Text, { color: "gray", marginLeft: 4 }, ` ${elapsedTime}s `), + reasoning.tokenCount || reasoning.thoughtCount > 0 ? React.createElement(Text, { color: "gray", marginLeft: 3 }, + reasoning.tokenCount ? `${reasoning.tokenCount} tokens` : `${reasoning.thoughtCount} thoughts` + ) : null ), - React.createElement(Box, null, - React.createElement(Text, { color: "gray" }, - `${elapsedTime}s | ${reasoning.thoughtCount} thoughts` + React.createElement(Box, { height: 1 }, // Fixed height for action line + React.createElement(Text, { color: "gray" }, reasoning.action || ' ') // Use space instead of empty string + ), + React.createElement(Box, { marginTop: 0, height: maxLines }, // Fixed height for text preview - always rendered + React.createElement(Text, { color: "gray", italic: true, wrap: "wrap" }, + textPreview || ' ' // Use space to avoid empty string error ) + ), + tokenSummary?.summary ? React.createElement(Box, { marginTop: 0 }, + React.createElement(Text, { color: "cyan" }, `Tokens: ${tokenSummary.summary}`) + ) : null, + tokenSummary?.deltas ? React.createElement(Box, { marginTop: 0 }, + React.createElement(Text, { color: "cyan" }, `Δ ${tokenSummary.deltas}`) + ) : null, + tokenSummary?.details ? React.createElement(Box, { marginTop: 0 }, + React.createElement(Text, { color: "gray" }, tokenSummary.details) + ) : null, + tokenSummary && tokenUpdatedAgo ? React.createElement(Box, { marginTop: 0 }, + React.createElement(Text, { color: "gray" }, `Updated ${tokenUpdatedAgo}`) + ) : null, + React.createElement(Box, { marginTop: 0, flexGrow: 1 }), // Spacer to push cancel hint to bottom + React.createElement(Box, null, + React.createElement(Text, { color: "gray" }, 'Use /cancel to interrupt reasoning.') ) - ), - reasoning.message && React.createElement(Box, { marginTop: 0 }, - React.createElement(Text, { color: "gray", italic: true, wrap: "wrap" }, - `"${reasoning.message.slice(0, 300)}${reasoning.message.length > 300 ? '...' : ''}"` - ) - ), - tokenSummary?.summary && React.createElement(Box, { marginTop: 1 }, - React.createElement(Text, { color: "cyan" }, `Tokens: ${tokenSummary.summary}`) - ), - tokenSummary?.deltas && React.createElement(Box, { marginTop: 0 }, - React.createElement(Text, { color: "cyan" }, `Δ ${tokenSummary.deltas}`) - ), - tokenSummary?.details && React.createElement(Box, { marginTop: 0 }, - React.createElement(Text, { color: "gray" }, tokenSummary.details) - ), - tokenSummary && tokenUpdatedAgo && React.createElement(Box, { marginTop: 0 }, - React.createElement(Text, { color: "gray" }, `Updated ${tokenUpdatedAgo}`) - ), - React.createElement(Box, { marginTop: 1 }, - React.createElement(Text, { color: "gray" }, 'Use /reason-cancel to interrupt reasoning.') ) ); } @@ -1422,7 +1880,7 @@ function ReasoningStatus({ reasoning }) { /** * Status Bar Component */ -function StatusBar({ connected, messageCount, verbose, pendingOperation, spaceId, participantId, awaitingAckCount = 0, pauseState, activeStreamCount = 0, contextUsage = [] }) { +function StatusBar({ connected, messageCount, verbose, pendingOperation, spaceId, participantId, awaitingAckCount = 0, pauseState, activeStreamCount = 0, contextUsage = [], participantStatusCount = 0 }) { const status = connected ? 'Connected' : 'Disconnected'; const statusColor = connected ? 'green' : 'red'; @@ -1452,6 +1910,11 @@ function StatusBar({ connected, messageCount, verbose, pendingOperation, spaceId } } + const boardSummary = createSignalBoardSummary(awaitingAckCount, participantStatusCount, pauseState, awaitingAckCount === 0); + if (boardSummary) { + extras.push(boardSummary); + } + const extrasText = extras.length > 0 ? ` | ${extras.join(' | ')}` : ''; return React.createElement(Box, { justifyContent: "space-between", paddingX: 1 }, @@ -1479,6 +1942,10 @@ function getColorForKind(kind) { } function getPayloadPreview(payload, kind) { + if (kind === 'system/help') { + return 'help overview'; + } + if (kind === 'chat' && payload.text) { return `"${payload.text}"`; } @@ -2007,7 +2474,22 @@ function formatAckRecipients(recipients, participantId) { .join(', '); } -function SidePanel({ participantId, myPendingAcknowledgements, participantStatuses, pauseState, activeStreams, streamFrames }) { +function EmptyStateCard({ spaceId, participantId }) { + return React.createElement(Box, { + borderStyle: "round", + borderColor: "gray", + paddingX: 2, + paddingY: 1, + marginBottom: 1, + flexDirection: "column" + }, + React.createElement(Text, { color: "gray" }, `Connected to ${spaceId} as ${participantId}.`), + React.createElement(Text, { color: "gray" }, "Type a message or use /help to get started."), + React.createElement(Text, { color: "gray" }, "Signal Board docks once there is activity.") + ); +} + +function SidePanel({ participantId, myPendingAcknowledgements, participantStatuses, pauseState, activeStreams, streamFrames, variant = 'docked' }) { const ackEntries = myPendingAcknowledgements .slice() .sort((a, b) => { @@ -2026,14 +2508,20 @@ function SidePanel({ participantId, myPendingAcknowledgements, participantStatus const streamEntries = Array.from(activeStreams.values()); const latestFrames = streamFrames.slice(-3); - return React.createElement(Box, { - width: 42, + const panelProps = { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, - paddingY: 0 - }, + paddingY: 0, + width: variant === 'docked' ? 42 : undefined + }; + + if (variant === 'stacked') { + panelProps.alignSelf = 'flex-start'; + } + + return React.createElement(Box, panelProps, React.createElement(Text, { color: "cyan", bold: true }, "Signal Board"), React.createElement(Box, { marginTop: 1 }), @@ -2077,6 +2565,9 @@ function SidePanel({ participantId, myPendingAcknowledgements, participantStatus `${frame.streamId}: ${truncateText(frame.payload, 30)}` )) ) + , + React.createElement(Box, { marginTop: 1 }), + React.createElement(Text, { color: "gray" }, 'Use /ui board close or /ui board auto') ); } diff --git a/cli/templates/coder-agent/space.yaml b/cli/templates/coder-agent/space.yaml index 9cc4303..9d4d0b6 100644 --- a/cli/templates/coder-agent/space.yaml +++ b/cli/templates/coder-agent/space.yaml @@ -21,7 +21,12 @@ participants: # No tokens field - generated at runtime in .mew/tokens/ capabilities: - kind: "chat" + - kind: "chat/acknowledge" + - kind: "chat/cancel" - kind: "reasoning/*" + - kind: "stream/request" + - kind: "stream/open" + - kind: "stream/close" - kind: "mcp/proposal" - kind: "mcp/response" - kind: "mcp/request" diff --git a/cli/templates/note-taker/space.yaml b/cli/templates/note-taker/space.yaml index eb04e47..d610e56 100644 --- a/cli/templates/note-taker/space.yaml +++ b/cli/templates/note-taker/space.yaml @@ -28,4 +28,6 @@ participants: AGENT_PROMPT: "{{AGENT_PROMPT}}" capabilities: - kind: "mcp/request" - - kind: "chat" \ No newline at end of file + - kind: "chat" + - kind: "chat/acknowledge" + - kind: "chat/cancel" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e6f1c85..ce48ba8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,9 +16,7 @@ "sdk/typescript-sdk/capability-matcher", "sdk/typescript-sdk/gateway", "bridge", - "cli", - "tests/*/.mew", - "spaces/*/.mew" + "cli" ], "dependencies": { "@types/uuid": "^9.0.8", @@ -1329,6 +1327,7 @@ "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, "license": "ISC", "dependencies": { "string-width": "^5.1.2", @@ -1346,6 +1345,7 @@ "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1358,6 +1358,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1370,12 +1371,14 @@ "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, "license": "MIT", "dependencies": { "eastasianwidth": "^0.2.0", @@ -1393,6 +1396,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.0.1" @@ -1408,6 +1412,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.1.0", @@ -1999,62 +2004,6 @@ "resolved": "sdk/typescript-sdk/types", "link": true }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.0.1.tgz", - "integrity": "sha512-slLdFaxQJ9AlRg+hw28iiTtGvShAOgOKXcD0F91nUcRYiOMuS9ZBYjcdNZRXW9G5JQ511GRTdUy1zQVZDpJ+4w==", - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "raw-body": "^3.0.0", - "zod": "^3.23.8" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/@modelcontextprotocol/sdk/node_modules/raw-body": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", - "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.7.0", - "unpipe": "1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/@modelcontextprotocol/server-filesystem": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server-filesystem/-/server-filesystem-0.6.2.tgz", - "integrity": "sha512-qBrhLY524WEFmIg+s2O6bPIFBK8Dy0l20yjQ0reYN1moWYNy28kNyYgWVgTiSj4QvpMq2LFZs6foDHrG1Kgt2w==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/sdk": "1.0.1", - "glob": "^10.3.10", - "zod-to-json-schema": "^3.23.5" - }, - "bin": { - "mcp-server-filesystem": "dist/index.js" - } - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -2097,6 +2046,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -3523,6 +3473,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/baseline-browser-mapping": { @@ -3578,6 +3529,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -4097,6 +4049,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4235,6 +4188,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, "license": "MIT" }, "node_modules/ecdsa-sig-formatter": { @@ -4986,6 +4940,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -5002,6 +4957,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, "license": "ISC", "engines": { "node": ">=14" @@ -5156,6 +5112,7 @@ "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", @@ -5611,6 +5568,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -5688,6 +5646,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -6726,6 +6685,7 @@ "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, "license": "ISC" }, "node_modules/magic-string": { @@ -6836,30 +6796,6 @@ "node": ">= 0.6" } }, - "node_modules/mew-space-cat-app": { - "resolved": "spaces/cat-app/.mew", - "link": true - }, - "node_modules/mew-space-test1": { - "resolved": "spaces/test1/.mew", - "link": true - }, - "node_modules/mew-space-test2": { - "resolved": "spaces/test2/.mew", - "link": true - }, - "node_modules/mew-space-test3": { - "resolved": "spaces/test3/.mew", - "link": true - }, - "node_modules/mew-space-test4": { - "resolved": "spaces/test4/.mew", - "link": true - }, - "node_modules/mew-space-test5": { - "resolved": "spaces/test5/.mew", - "link": true - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -6919,6 +6855,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -6944,6 +6881,7 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" @@ -7219,6 +7157,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, "license": "BlueOak-1.0.0" }, "node_modules/parent-module": { @@ -7295,6 +7234,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7311,6 +7251,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^10.2.0", @@ -10439,6 +10380,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -10451,6 +10393,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10728,6 +10671,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -10755,6 +10699,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -12009,6 +11954,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -12085,6 +12031,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -12211,24 +12158,6 @@ "node": ">=8" } }, - "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.24.6", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", - "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", - "license": "ISC", - "peerDependencies": { - "zod": "^3.24.1" - } - }, "sdk/typescript-sdk/agent": { "name": "@mew-protocol/agent", "version": "0.4.1", @@ -12435,6 +12364,7 @@ "spaces/cat-app/.mew": { "name": "mew-space-cat-app", "version": "1.0.0", + "extraneous": true, "dependencies": { "@mew-protocol/agent": "^0.4.1", "@mew-protocol/bridge": "^0.1.1", @@ -12472,6 +12402,7 @@ "spaces/test1/.mew": { "name": "mew-space-test1", "version": "1.0.0", + "extraneous": true, "dependencies": { "@mew-protocol/agent": "^0.4.1", "@mew-protocol/bridge": "^0.1.1", @@ -12490,6 +12421,7 @@ "spaces/test2/.mew": { "name": "mew-space-test2", "version": "1.0.0", + "extraneous": true, "dependencies": { "@mew-protocol/agent": "^0.4.1", "@mew-protocol/bridge": "^0.1.1", @@ -12508,6 +12440,7 @@ "spaces/test3/.mew": { "name": "mew-space-test3", "version": "1.0.0", + "extraneous": true, "dependencies": { "@mew-protocol/agent": "^0.4.1", "@mew-protocol/bridge": "^0.1.1", @@ -12526,6 +12459,7 @@ "spaces/test4/.mew": { "name": "mew-space-test4", "version": "1.0.0", + "extraneous": true, "dependencies": { "@mew-protocol/agent": "^0.4.1", "@mew-protocol/bridge": "^0.1.1", @@ -12544,6 +12478,7 @@ "spaces/test5/.mew": { "name": "mew-space-test5", "version": "1.0.0", + "extraneous": true, "dependencies": { "@mew-protocol/agent": "^0.4.1", "@mew-protocol/bridge": "^0.1.1", diff --git a/package.json b/package.json index 9afa3cc..ffdffea 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,9 @@ "test": "./tests/run-all-tests.sh --no-llm", "test:all": "./tests/run-all-tests.sh", "test:llm": "./tests/run-all-tests.sh", - "build": "tsc -b", - "build:force": "tsc -b --force", + "build": "tsc -b && npm run postbuild", + "postbuild": "chmod +x node_modules/.bin/mew-agent 2>/dev/null || true", + "build:force": "tsc -b --force && npm run postbuild", "build:watch": "tsc -b --watch", "clean": "tsc -b --clean", "lint": "npm run lint --workspaces --if-present", diff --git a/sdk/typescript-sdk/agent/src/MEWAgent.ts b/sdk/typescript-sdk/agent/src/MEWAgent.ts index e4a8540..2cc30d0 100644 --- a/sdk/typescript-sdk/agent/src/MEWAgent.ts +++ b/sdk/typescript-sdk/agent/src/MEWAgent.ts @@ -78,7 +78,7 @@ export class MEWAgent extends MEWParticipant { private reasoningContextId?: string; private reasoningCancelled = false; private reasoningCancelReason?: string; - private reasoningStreamInfo?: { description: string; requestId?: string; streamId?: string }; + private reasoningStreamInfo?: { description: string; requestId?: string; streamId?: string; pending: string[] }; private compacting = false; // Message queueing properties @@ -347,6 +347,20 @@ export class MEWAgent extends MEWParticipant { return; } + // Send acknowledgment that we're handling this message + this.log('info', `About to acknowledge message ${envelope.id} from ${envelope.from}`); + if (envelope.id) { + try { + // Send acknowledgment TO the sender (like the CLI does) + this.acknowledgeChat(envelope.id, envelope.from, 'processing'); + this.log('info', `Successfully sent acknowledgment for chat message ${envelope.id} from ${envelope.from}`); + } catch (error) { + this.log('error', `Failed to acknowledge chat message ${envelope.id}: ${error}`); + } + } else { + this.log('warn', `Cannot acknowledge message - no ID present`); + } + // Mark as processing this.isProcessing = true; if (this.config.messageQueue?.notifyQueueing) { @@ -557,10 +571,18 @@ Return a JSON object: this.ensureContextHeadroom(); // Act phase + if (thought.action === 'cancelled') { + // Reasoning was cancelled during stream + this.emitReasoning('reasoning/conclusion', { cancelled: true, reason: thought.actionInput?.reason || 'cancelled by user' }); + this.reasoningCancelled = false; + this.reasoningCancelReason = undefined; + return `Reasoning cancelled: ${thought.actionInput?.reason || 'cancelled by user'}`; + } + if (thought.action === 'respond') { return thought.actionInput; } - + // Track tool call in conversation history if (thought.action === 'tool') { this.conversationHistory.push({ @@ -615,6 +637,179 @@ Return a JSON object: return 'I exceeded the maximum number of reasoning iterations.'; } + private async createStandardReasoning(messages: any[], tools: LLMTool[]): Promise { + if (!this.openai) { + throw new Error('OpenAI client not initialized - this should never happen!'); + } + + const params: any = { + model: this.config.model!, + messages + }; + + if (tools.length > 0) { + params.tools = this.convertToOpenAITools(tools); + params.tool_choice = 'auto'; + } + + const response = await this.openai.chat.completions.create(params); + return response.choices[0].message; + } + + private async createStreamingReasoning(messages: any[], tools: LLMTool[]): Promise { + if (!this.openai) { + throw new Error('OpenAI client not initialized - this should never happen!'); + } + + const params: any = { + model: this.config.model!, + messages, + stream: true, + stream_options: { include_usage: true } // Request token usage in stream + }; + + if (tools.length > 0) { + params.tools = this.convertToOpenAITools(tools); + params.tool_choice = 'auto'; + } + + const stream = await this.openai.chat.completions.create(params); + const aggregated: any = { content: '' }; + const toolCalls = new Map(); + let tokenCount = 0; + + for await (const part of stream as any) { + // Check if reasoning was cancelled + if (this.reasoningCancelled) { + this.log('info', '🛑 Reasoning cancelled - exiting stream loop'); + // We can't actually abort the underlying HTTP request with OpenAI SDK + // but we can stop processing and exit early + throw new Error(`Reasoning cancelled: ${this.reasoningCancelReason || 'cancelled by user'}`); + } + + const choice = part?.choices?.[0]; + + // Track progress even when no content is streaming + tokenCount++; + this.pushReasoningStreamChunk({ type: 'progress', tokenCount }); + + if (!choice) { + continue; + } + + const delta = (choice as any).delta || {}; + const textDelta = this.extractTextFromDelta(delta); + if (textDelta) { + aggregated.content += textDelta; + this.pushReasoningStreamChunk({ type: 'token', value: textDelta }); + } + + if (Array.isArray(delta.tool_calls)) { + for (const call of delta.tool_calls) { + const index = typeof call.index === 'number' ? call.index : 0; + const existing = toolCalls.get(index) || { + id: call.id, + type: call.type, + function: { name: '', arguments: '' } + }; + + if (call.id) { + existing.id = call.id; + } + if (call.type) { + existing.type = call.type; + } + if (call.function?.name) { + existing.function.name += call.function.name; + } + if (call.function?.arguments) { + existing.function.arguments += call.function.arguments; + } + + toolCalls.set(index, existing); + } + } + } + + if (aggregated.content === '') { + aggregated.content = null; + } + + if (toolCalls.size > 0) { + aggregated.tool_calls = Array.from(toolCalls.values()).map(call => ({ + id: call.id, + type: call.type || 'function', + function: { + name: call.function.name, + arguments: call.function.arguments + } + })); + } + + return aggregated; + } + + private extractTextFromDelta(delta: any): string { + if (!delta) { + return ''; + } + + const content = delta.content ?? delta.text ?? delta.output_text ?? delta.reasoning; + if (!content) { + return ''; + } + + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + return content.map((part: any) => { + if (typeof part === 'string') { + return part; + } + if (!part) { + return ''; + } + if (typeof part === 'object') { + if (typeof part.text === 'string') { + return part.text; + } + if (typeof part.output_text === 'string') { + return part.output_text; + } + if (typeof part.reasoning === 'string') { + return part.reasoning; + } + if (typeof part.content === 'string') { + return part.content; + } + if (typeof part.value === 'string') { + return part.value; + } + } + return ''; + }).join(''); + } + + if (typeof content === 'object') { + if (typeof content.text === 'string') { + return content.text; + } + if (typeof content.output_text === 'string') { + return content.output_text; + } + if (typeof content.reasoning === 'string') { + return content.reasoning; + } + if (typeof content.content === 'string') { + return content.content; + } + } + + return ''; + } + /** * Reason phase (ReAct: Reason) */ @@ -691,17 +886,42 @@ Return a JSON object: } // Use function calling to let the model decide whether to use tools - const response = await this.openai.chat.completions.create({ - model: this.config.model!, - messages, - tools: tools.length > 0 ? this.convertToOpenAITools(tools) : undefined, - tool_choice: tools.length > 0 ? 'auto' : undefined - }); + let message: any; + let streamed = false; + try { + message = await this.createStreamingReasoning(messages, tools); + streamed = true; + } catch (error) { + // Check if this was a cancellation + if (this.reasoningCancelled) { + this.log('info', `Reasoning cancelled: ${this.reasoningCancelReason || 'cancelled by user'}`); + // Return a special thought indicating cancellation + return { + reasoning: 'Reasoning cancelled by user request', + action: 'cancelled', + actionInput: { reason: this.reasoningCancelReason || 'cancelled by user' } + }; + } + + this.log('warn', `Streaming reasoning failed: ${error instanceof Error ? error.message : error}`); + try { + message = await this.createStandardReasoning(messages, tools); + } catch (fallbackError) { + this.log('error', `Failed to generate reasoning response: ${fallbackError instanceof Error ? fallbackError.message : fallbackError}`); + throw fallbackError; + } + } + + if (!streamed && message?.content) { + this.pushReasoningStreamChunk({ type: 'token', value: message.content, final: true }); + } + + if (!message) { + throw new Error('Model did not return a reasoning message.'); + } - const message = response.choices[0].message; - // Check if the model wants to use a tool - if (message.tool_calls && message.tool_calls.length > 0) { + if (message?.tool_calls && message.tool_calls.length > 0) { const toolCall = message.tool_calls[0] as any; // Convert back from OpenAI format: replace underscore separator with slash // Note: Participant IDs must not contain underscores for this to work correctly @@ -828,9 +1048,14 @@ Return a JSON object: * Act phase (ReAct: Act) */ protected async act(action: string, input: any): Promise { + if (action === 'cancelled') { + // Handle cancellation - no further action needed + return `Cancelled: ${input.reason || 'cancelled by user'}`; + } + if (action === 'tool') { const result = await this.executeLLMToolCall(input.tool, input.arguments); - + // Format the result as a string response if (typeof result === 'object' && result.content) { // MCP response format @@ -844,7 +1069,7 @@ Return a JSON object: return JSON.stringify(result); } } - + return 'Action completed'; } @@ -1045,7 +1270,16 @@ Return a JSON object: if (!this.reasoningStreamInfo) return; const correlationId = envelope.correlation_id?.[0]; if (correlationId && correlationId === this.reasoningStreamInfo.requestId) { - this.reasoningStreamInfo.streamId = envelope.payload?.stream_id; + const streamId = envelope.payload?.stream_id; + if (streamId) { + this.reasoningStreamInfo.streamId = streamId; + if (this.reasoningStreamInfo.pending.length > 0) { + for (const chunk of this.reasoningStreamInfo.pending) { + this.sendStreamData(streamId, chunk); + } + this.reasoningStreamInfo.pending = []; + } + } } } @@ -1063,7 +1297,26 @@ Return a JSON object: if (this.matchesReasoningContext(envelope.context)) { this.reasoningCancelled = true; this.reasoningCancelReason = envelope.payload?.reason || 'cancelled'; + + // Note: We can't actually abort the OpenAI HTTP stream + // The loop will check this flag and exit on next iteration + + this.pushReasoningStreamChunk({ + type: 'status', + event: 'cancelled', + reason: this.reasoningCancelReason + }); this.closeReasoningStream('cancelled'); + + // Immediately send reasoning/conclusion to confirm cancellation + this.emitReasoning('reasoning/conclusion', { + cancelled: true, + reason: this.reasoningCancelReason, + message: `Reasoning cancelled: ${this.reasoningCancelReason}` + }); + + // Clear reasoning context + this.reasoningContextId = undefined; } } @@ -1154,7 +1407,39 @@ Return a JSON object: await super.onRestart(payload); } - private closeReasoningStream(_reason: string): void { + private pushReasoningStreamChunk(payload: Record): void { + if (!this.reasoningStreamInfo || !this.reasoningContextId) { + return; + } + + const chunk = JSON.stringify({ + context: this.reasoningContextId, + ...payload + }); + + if (this.reasoningStreamInfo.streamId) { + this.sendStreamData(this.reasoningStreamInfo.streamId, chunk); + return; + } + + if (!Array.isArray(this.reasoningStreamInfo.pending)) { + this.reasoningStreamInfo.pending = []; + } + + this.reasoningStreamInfo.pending.push(chunk); + } + + private closeReasoningStream(reason: string): void { + if (this.reasoningStreamInfo?.streamId) { + // Send stream/close message to properly clean up the stream + this.send({ + kind: 'stream/close', + payload: { + stream_id: this.reasoningStreamInfo.streamId, + reason + } + }); + } this.reasoningStreamInfo = undefined; } @@ -1180,9 +1465,10 @@ Return a JSON object: if (this.canSend({ kind: 'stream/request', payload: { direction: 'upload' } })) { const description = `reasoning:${id}`; - this.reasoningStreamInfo = { description }; - this.requestStream({ direction: 'upload', description }); + const requestId = this.requestStream({ direction: 'upload', description }); + this.reasoningStreamInfo = { description, requestId, pending: [] }; } + this.pushReasoningStreamChunk({ type: 'status', event: 'start', input: data?.input }); } else if (this.reasoningContextId) { envelope.context = this.reasoningContextId; } @@ -1193,11 +1479,17 @@ Return a JSON object: this.send(envelope); - if (event === 'reasoning/thought' && this.reasoningStreamInfo?.streamId) { - this.sendStreamData(this.reasoningStreamInfo.streamId, JSON.stringify(data)); + if (event === 'reasoning/thought') { + this.pushReasoningStreamChunk({ type: 'thought', value: data }); } if (event === 'reasoning/conclusion') { + this.pushReasoningStreamChunk({ + type: 'status', + event: 'conclusion', + cancelled: !!data?.cancelled, + reason: data?.reason + }); this.closeReasoningStream('complete'); this.reasoningContextId = undefined; } diff --git a/sdk/typescript-sdk/participant/src/MEWParticipant.ts b/sdk/typescript-sdk/participant/src/MEWParticipant.ts index 16cbd0a..a7e6874 100644 --- a/sdk/typescript-sdk/participant/src/MEWParticipant.ts +++ b/sdk/typescript-sdk/participant/src/MEWParticipant.ts @@ -286,14 +286,21 @@ export class MEWParticipant extends MEWClient { this.recordContextUsage({ tokens: this.estimateTokens(text), messages: 1 }); } - acknowledgeChat(messageId: string, target: string | string[], status: string = 'received'): void { - const to = Array.isArray(target) ? target : [target]; - this.send({ + acknowledgeChat(messageId: string, target: string | string[] | null = null, status: string = 'received'): void { + const envelope: Partial = { kind: 'chat/acknowledge', - to, correlation_id: [messageId], payload: status ? { status } : {} - }); + }; + + // Only add 'to' field if target is explicitly provided + if (target) { + envelope.to = Array.isArray(target) ? target : [target]; + } + + console.log(`[MEWParticipant] Sending chat acknowledgment for message ${messageId} with status ${status} to ${target || 'broadcast'}`); + this.send(envelope); + console.log(`[MEWParticipant] Chat acknowledgment sent`); } cancelChat(messageId: string, target?: string | string[], reason: string = 'cancelled'): void { @@ -324,13 +331,30 @@ export class MEWParticipant extends MEWClient { }); } - requestStream(payload: StreamRequestPayload, target: string | string[] = 'gateway'): void { + requestStream(payload: StreamRequestPayload, target: string | string[] = 'gateway'): string { const to = Array.isArray(target) ? target : [target]; + const id = `stream-req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; this.send({ + id, kind: 'stream/request', to, payload }); + return id; + } + + /** + * Send data over a stream using the #streamID#data frame format + */ + sendStreamData(streamId: string, data: string): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + this.log('warn', `Cannot send stream data: WebSocket not connected`); + return; + } + + // Send as raw frame with stream ID prefix per spec + const frame = `#${streamId}#${data}`; + this.ws.send(frame); } /** diff --git a/spec/draft/SPEC.md b/spec/draft/SPEC.md index ac32d88..cf5325e 100644 --- a/spec/draft/SPEC.md +++ b/spec/draft/SPEC.md @@ -1000,7 +1000,8 @@ Sent by whichever entity initiates stream negotiation (typically a participant a - `direction` clarifies whether the sender will upload or download data - `expected_size_bytes` is advisory and MAY be omitted - `correlation_id` (not shown) MAY reference the message that motivated the stream (e.g., a proposal or request) -- The recipient SHOULD reply with `stream/open` once it has allocated resources +- The gateway MUST intercept all `stream/request` messages and reply with `stream/open` containing a unique `stream_id` +- The gateway is responsible for stream ID allocation and management, regardless of the request's `to` field - Agents that expose long-form reasoning SHOULD consider issuing a `stream/request` alongside `reasoning/start` so subscribers can follow high-volume traces without bloating the shared envelope log. diff --git a/tests/scenario-11-chat-controls/space.yaml b/tests/scenario-11-chat-controls/space.yaml index f148f5e..76d3e1f 100644 --- a/tests/scenario-11-chat-controls/space.yaml +++ b/tests/scenario-11-chat-controls/space.yaml @@ -30,6 +30,8 @@ participants: tokens: ["test-token"] capabilities: - kind: chat + - kind: chat/acknowledge + - kind: chat/cancel - kind: reasoning/* - kind: participant/* - kind: system/*