feat(dashboard): wire cli-agent chat surface and runner glue; fix stale engine mocks (U12 completion)
Mount CliChatSurface in ChatView for cli-backed chat sessions (sessions carrying cliExecutorAdapterId): the message-pane + composer region is delegated to the surface (transcript/raw-terminal toggle for hybrid/native adapters, terminal-only for the generic adapter), while regular sessions keep the standard composer. The existing message list and composer JSX are captured once as render thunks and passed through, so there is no parallel message/composer UI. Add a narrow telemetry seam: TelemetryHub gains an optional onEvent tap (also settable post-construction via setEventListener) invoked with each sanitized event after routing — best-effort, a throwing listener never breaks ingest. This is the seam the CliChatSessionRunner uses to build the durable transcript from the same sanitized events the hook route already feeds the hub, without the hub becoming a general subscriber bus. Fix the stale @fusion/engine vi.mocks across dashboard tests: object-literal mocks that fully replace the module now also return listCliAdapterDescriptors (added by U15's cli-agent-settings route, evaluated at module load). Mocks that spread importOriginal/importActual already pick it up. Tests: new ChatView.cli-mount.test.tsx (cli session → CliChatSurface, regular session → normal composer, generic → terminal-only); telemetry-hub onEvent tap coverage. chat-attachment-routes, chat-cli-sessions, cli-agent-hooks-route, ChatView.cli-toggle all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,3 +12,9 @@ authoritative session state rather than trusting a cached busy flag. The chat
|
||||
surface gains a transcript ↔ raw-terminal toggle (terminal owns input, composer
|
||||
hidden in terminal mode); generic-tier sessions render terminal-only with no
|
||||
toggle. New per-session `cliExecutorAdapterId` linkage on chat_sessions.
|
||||
|
||||
ChatView now mounts `CliChatSurface` for cli-backed sessions (the message-pane +
|
||||
composer region is delegated to it; regular sessions keep the standard composer),
|
||||
and the engine `TelemetryHub` gains a narrow optional `onEvent` tap (settable via
|
||||
`setEventListener`) so the chat transcript runner can observe the same sanitized
|
||||
events the hook route already feeds, without the hub becoming a subscriber bus.
|
||||
|
||||
@@ -37,6 +37,7 @@ import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { FileMentionPopup } from "./FileMentionPopup";
|
||||
import { CreateRoomModal } from "./CreateRoomModal";
|
||||
import { CliChatSurface, type CliChatTier } from "./CliChatSurface";
|
||||
import { useFileMention } from "../hooks/useFileMention";
|
||||
import { useModelsCache } from "../hooks/useModelsCache";
|
||||
import { useDiscoveredSkillsCache } from "../hooks/useDiscoveredSkillsCache";
|
||||
@@ -2623,6 +2624,300 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
containerEl.scrollTo({ top, behavior: prefersReducedMotion ? "auto" : "smooth" });
|
||||
}, []);
|
||||
|
||||
// ── CLI-backed chat mount (U12) ──────────────────────────────────────────
|
||||
// When the active chat session selects a cli-agent executor, the message-pane
|
||||
// + composer region is delegated to <CliChatSurface> (transcript + raw-terminal
|
||||
// toggle for hybrid/native adapters, terminal-only for the generic adapter).
|
||||
// The transcript renderer and composer renderer are the EXISTING ChatView JSX
|
||||
// passed through as thunks so there is no parallel message/composer UI.
|
||||
const cliAdapterId = activeSession?.cliExecutorAdapterId ?? null;
|
||||
const cliChatActive = Boolean(cliAdapterId);
|
||||
// Generic adapter has no structured transcript → terminal-only; every other
|
||||
// bundled adapter exposes a transcript and gets the toggle (the authoritative
|
||||
// tier is resolved server-side; this only needs the generic vs. non-generic
|
||||
// split that drives the toggle's presence).
|
||||
const cliChatTier: CliChatTier = cliAdapterId === "generic" ? "generic" : "hybrid";
|
||||
// Terminal attach id: the native session linkage when known, else the chat id.
|
||||
const cliTerminalSessionId = activeSession?.cliSessionFile || activeSession?.id || "";
|
||||
|
||||
// The session message pane and composer, captured once so both the normal
|
||||
// provider path and the CLI-backed path (CliChatSurface thunks) render the
|
||||
// exact same JSX — no parallel message/composer UI.
|
||||
const renderSessionMessagesPane = () => (
|
||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||||
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
|
||||
{hasMoreMessages && messagesLoading && (
|
||||
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
{!hideAssistantIdentity && (
|
||||
<div className="chat-message-avatar">
|
||||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
|
||||
<span>{agentName}</span>
|
||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
)}
|
||||
{streamingText ? (
|
||||
renderAssistantContent(streamingText, showAllAsPlain)
|
||||
) : (
|
||||
<div className="chat-message-content chat-message-content--waiting">
|
||||
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
|
||||
</div>
|
||||
)}
|
||||
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
|
||||
{renderToolCalls(streamingToolCalls, t)}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="chat-typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : messagesLoading ? (
|
||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||
) : messages.length === 0 && !activeSession ? (
|
||||
renderEmptyState()
|
||||
) : messages.length === 0 && activeSession ? (
|
||||
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSessionComposerPane = () => (
|
||||
<div className="chat-input-area">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
handleAttachmentFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{showSkillMenu && (
|
||||
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
|
||||
{skillsLoading ? (
|
||||
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="chat-skill-menu-empty">
|
||||
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill, index) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedSkillIndex}
|
||||
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedSkillIndex(index)}
|
||||
onClick={() => handleSkillSelect(skill)}
|
||||
>
|
||||
<span className="chat-skill-menu-item-name">{skill.name}</span>
|
||||
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
|
||||
{skill.relativePath}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pendingAttachments.length > 0 && (
|
||||
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
|
||||
{pendingAttachments.map((attachment, index) => (
|
||||
<div
|
||||
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
|
||||
className="chat-attachment-preview"
|
||||
data-testid={`chat-attachment-preview-${index}`}
|
||||
>
|
||||
{attachment.previewUrl ? (
|
||||
<img src={attachment.previewUrl} alt={attachment.file.name} />
|
||||
) : (
|
||||
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="chat-attachment-remove"
|
||||
onClick={() => removeAttachment(index)}
|
||||
data-testid={`chat-attachment-remove-${index}`}
|
||||
aria-label={`Remove ${attachment.file.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon chat-attach-btn"
|
||||
data-testid="chat-attach-btn"
|
||||
aria-label={t("chat.attachFiles", "Attach files")}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
</button>
|
||||
<div
|
||||
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
handleAttachmentFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="chat-input-textarea"
|
||||
placeholder={t("chat.typeMessage", "Type a message...")}
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyUp={handleInputKeyUp}
|
||||
onClick={handleInputSelectionChange}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={handleInputFocus}
|
||||
onPaste={handlePaste}
|
||||
onTouchStart={(event) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.innerWidth > 768) return;
|
||||
if (!isIOS()) return;
|
||||
if (document.activeElement === event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
}}
|
||||
rows={1}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<AgentMentionPopup
|
||||
agents={mentionAgents}
|
||||
filter={mentionFilter}
|
||||
highlightedIndex={mentionHighlightIndex}
|
||||
visible={mentionPopupVisible}
|
||||
onSelect={handleMentionSelect}
|
||||
position="below"
|
||||
roomMemberIds={roomContext?.memberIds}
|
||||
roomName={roomContext?.roomName}
|
||||
/>
|
||||
<FileMentionPopup
|
||||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||||
position={fileMentionPosition}
|
||||
tasks={fileMention.tasks}
|
||||
files={fileMention.files}
|
||||
selectedIndex={fileMention.selectedIndex}
|
||||
onSelectTask={(task) => {
|
||||
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
|
||||
}}
|
||||
onSelectFile={(file) => {
|
||||
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
|
||||
}}
|
||||
loading={fileMention.loading}
|
||||
/>
|
||||
{pendingMessage && (
|
||||
<div className="chat-pending-message" data-testid="chat-pending-indicator">
|
||||
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-pending-message-dismiss"
|
||||
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
|
||||
data-testid="chat-pending-dismiss"
|
||||
onClick={clearPendingMessage}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
className="chat-input-stop"
|
||||
onClick={stopStreaming}
|
||||
aria-label={t("chat.stopGeneration", "Stop generation")}
|
||||
data-testid="chat-stop-btn"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-input-send"
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType && event.pointerType !== "mouse") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
void handleSend();
|
||||
}}
|
||||
disabled={!messageInput.trim() && pendingAttachments.length === 0}
|
||||
data-testid="chat-send-btn"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="chat-view">
|
||||
{/* Sidebar */}
|
||||
@@ -3216,301 +3511,33 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="chat-messages" ref={messagesContainerRef} onScroll={updateScrollState}>
|
||||
<div ref={loadMoreSentinelRef} className="chat-load-more-sentinel">
|
||||
{hasMoreMessages && messagesLoading && (
|
||||
<div className="chat-loading-older">{t("chat.loadingOlderMessages", "Loading older messages…")}</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
{!hideAssistantIdentity && (
|
||||
<div className="chat-message-avatar">
|
||||
{activeModelProvider ? <ProviderIcon provider={activeModelProvider} size="sm" /> : <Bot size={14} />}
|
||||
<span>{agentName}</span>
|
||||
{showAssistantModelTag && <span className="chat-model-tag">{activeModelTag}</span>}
|
||||
</div>
|
||||
)}
|
||||
{streamingText ? (
|
||||
renderAssistantContent(streamingText, showAllAsPlain)
|
||||
) : (
|
||||
<div className="chat-message-content chat-message-content--waiting">
|
||||
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
|
||||
</div>
|
||||
)}
|
||||
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
|
||||
{renderToolCalls(streamingToolCalls, t)}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
<pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="chat-typing-indicator">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : messagesLoading ? (
|
||||
<div className="chat-empty-state">{t("chat.loadingMessages", "Loading messages...")}</div>
|
||||
) : messages.length === 0 && !activeSession ? (
|
||||
renderEmptyState()
|
||||
) : messages.length === 0 && activeSession ? (
|
||||
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
forcePlain={showAllAsPlain}
|
||||
agentName={agentName}
|
||||
hideAssistantIdentity={hideAssistantIdentity}
|
||||
showAssistantModelTag={showAssistantModelTag}
|
||||
activeModelTag={activeModelTag}
|
||||
activeModelProvider={activeModelProvider}
|
||||
activeSessionId={activeSession?.id ?? null}
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
{isUserScrolling && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm chat-jump-to-latest"
|
||||
data-testid="chat-jump-to-latest"
|
||||
onClick={() => scrollToBottom("fab-click")}
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
{t("chat.latest", "Latest")}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
{activeSession && (
|
||||
<div className="chat-input-area">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.txt,.json,.yaml,.yml,.log,.csv,.xml,.md"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(event) => {
|
||||
handleAttachmentFiles(event.target.files);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
{showSkillMenu && (
|
||||
<div className="chat-skill-menu" data-testid="chat-skill-menu" role="listbox" aria-label={t("chat.skillSuggestions", "Skill suggestions")}>
|
||||
{skillsLoading ? (
|
||||
<div className="chat-skill-menu-empty">{t("chat.loadingSkills", "Loading skills…")}</div>
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="chat-skill-menu-empty">
|
||||
{skillFilter ? t("chat.noSkillsFound", "No skills found") : t("chat.noSkillsAvailable", "No skills available")}
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill, index) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === highlightedSkillIndex}
|
||||
className={`chat-skill-menu-item${index === highlightedSkillIndex ? " chat-skill-menu-item--highlighted" : ""}`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onMouseEnter={() => setHighlightedSkillIndex(index)}
|
||||
onClick={() => handleSkillSelect(skill)}
|
||||
>
|
||||
<span className="chat-skill-menu-item-name">{skill.name}</span>
|
||||
<span className="chat-skill-menu-item-description" title={skill.relativePath}>
|
||||
{skill.relativePath}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pendingAttachments.length > 0 && (
|
||||
<div className="chat-attachment-previews" data-testid="chat-attachment-previews">
|
||||
{pendingAttachments.map((attachment, index) => (
|
||||
<div
|
||||
key={attachment.previewUrl || `${attachment.file.name}-${index}`}
|
||||
className="chat-attachment-preview"
|
||||
data-testid={`chat-attachment-preview-${index}`}
|
||||
>
|
||||
{attachment.previewUrl ? (
|
||||
<img src={attachment.previewUrl} alt={attachment.file.name} />
|
||||
) : (
|
||||
<span className="chat-attachment-preview-name">{attachment.file.name}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="chat-attachment-remove"
|
||||
onClick={() => removeAttachment(index)}
|
||||
data-testid={`chat-attachment-remove-${index}`}
|
||||
aria-label={`Remove ${attachment.file.name}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input-row">
|
||||
{/* Messages + composer. CLI-backed chat sessions delegate this
|
||||
region to <CliChatSurface> (transcript/raw-terminal toggle +
|
||||
queued composer); generic-tier adapters render terminal-only. */}
|
||||
{cliChatActive ? (
|
||||
<CliChatSurface
|
||||
cliSessionId={cliTerminalSessionId}
|
||||
tier={cliChatTier}
|
||||
projectId={projectId}
|
||||
renderTranscript={renderSessionMessagesPane}
|
||||
renderComposer={() => (activeSession ? renderSessionComposerPane() : null)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{renderSessionMessagesPane()}
|
||||
{isUserScrolling && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon chat-attach-btn"
|
||||
data-testid="chat-attach-btn"
|
||||
aria-label={t("chat.attachFiles", "Attach files")}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn btn-sm chat-jump-to-latest"
|
||||
data-testid="chat-jump-to-latest"
|
||||
onClick={() => scrollToBottom("fab-click")}
|
||||
>
|
||||
<Paperclip size={16} />
|
||||
<ChevronDown size={14} />
|
||||
{t("chat.latest", "Latest")}
|
||||
</button>
|
||||
<div
|
||||
className={`chat-input-wrapper${isDragOver ? " chat-input-wrapper--dragover" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
handleAttachmentFiles(event.dataTransfer.files);
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
ref={handleComposerRef}
|
||||
className="chat-input-textarea"
|
||||
placeholder={t("chat.typeMessage", "Type a message...")}
|
||||
value={messageInput}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onKeyUp={handleInputKeyUp}
|
||||
onClick={handleInputSelectionChange}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={handleInputFocus}
|
||||
onPaste={handlePaste}
|
||||
onTouchStart={(event) => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.innerWidth > 768) return;
|
||||
// iOS-only: see comment on the other chat-input touchstart
|
||||
// handler above. On Android, preventDefault blocks the
|
||||
// soft keyboard from opening.
|
||||
if (!isIOS()) return;
|
||||
if (document.activeElement === event.currentTarget) return;
|
||||
event.preventDefault();
|
||||
event.currentTarget.focus({ preventScroll: true });
|
||||
}}
|
||||
rows={1}
|
||||
data-testid="chat-input"
|
||||
/>
|
||||
<AgentMentionPopup
|
||||
agents={mentionAgents}
|
||||
filter={mentionFilter}
|
||||
highlightedIndex={mentionHighlightIndex}
|
||||
visible={mentionPopupVisible}
|
||||
onSelect={handleMentionSelect}
|
||||
position="below"
|
||||
roomMemberIds={roomContext?.memberIds}
|
||||
roomName={roomContext?.roomName}
|
||||
/>
|
||||
<FileMentionPopup
|
||||
visible={fileMention.mentionActive && !mentionPopupVisible}
|
||||
position={fileMentionPosition}
|
||||
tasks={fileMention.tasks}
|
||||
files={fileMention.files}
|
||||
selectedIndex={fileMention.selectedIndex}
|
||||
onSelectTask={(task) => {
|
||||
insertHashMention(fileMention.selectTask(task, messageInput), `#${task.id}`);
|
||||
}}
|
||||
onSelectFile={(file) => {
|
||||
insertHashMention(fileMention.selectFile(file, messageInput), `#${file.path}`);
|
||||
}}
|
||||
loading={fileMention.loading}
|
||||
/>
|
||||
{pendingMessage && (
|
||||
<div className="chat-pending-message" data-testid="chat-pending-indicator">
|
||||
<span>{t("chat.queuedMessage", "Queued: {{preview}}", { preview: pendingPreview })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-pending-message-dismiss"
|
||||
aria-label={t("chat.dismissQueuedMessage", "Dismiss queued message")}
|
||||
data-testid="chat-pending-dismiss"
|
||||
onClick={clearPendingMessage}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
className="chat-input-stop"
|
||||
onClick={stopStreaming}
|
||||
aria-label={t("chat.stopGeneration", "Stop generation")}
|
||||
data-testid="chat-stop-btn"
|
||||
>
|
||||
<Square size={14} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="chat-input-send"
|
||||
// Keep keyboard up when sending. preventDefault fires on
|
||||
// pointerdown for touch pointers (BEFORE iOS blurs the
|
||||
// textarea — the synthesized mousedown is too late on
|
||||
// iOS), and on mousedown for desktop. Crucially we do NOT
|
||||
// call preventDefault on touchstart and we do NOT run the
|
||||
// action here — both of those broke quick taps. Click
|
||||
// still fires from the iOS touch sequence and runs the
|
||||
// action reliably.
|
||||
onPointerDown={(event) => {
|
||||
if (event.pointerType && event.pointerType !== "mouse") {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
void handleSend();
|
||||
}}
|
||||
disabled={!messageInput.trim() && pendingAttachments.length === 0}
|
||||
data-testid="chat-send-btn"
|
||||
style={{ touchAction: "manipulation" }}
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{activeSession && renderSessionComposerPane()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// ChatView CLI-backed mount test (CLI Agent Executor, U12 completion).
|
||||
//
|
||||
// Asserts ChatView delegates the message-pane + composer region to
|
||||
// <CliChatSurface> when the active chat session carries a `cliExecutorAdapterId`,
|
||||
// and falls back to the normal provider composer for a regular session.
|
||||
//
|
||||
// SessionTerminal is mocked (no xterm / no WS / no PTY / no port 4040) because
|
||||
// CliChatSurface renders it under the hood.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { ChatView } from "../ChatView";
|
||||
import * as useChatModule from "../../hooks/useChat";
|
||||
import * as useChatRoomsModule from "../../hooks/useChatRooms";
|
||||
import type { ChatSessionInfo, UseChatReturn } from "../../hooks/useChat";
|
||||
import type { UseChatRoomsResult } from "../../hooks/useChatRooms";
|
||||
import { _resetInitialViewportHeight } from "../../hooks/useMobileKeyboard";
|
||||
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
vi.mock("../SessionTerminal", () => ({
|
||||
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
|
||||
<div data-testid="session-terminal" data-session-id={sessionId}>
|
||||
terminal
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useChat");
|
||||
vi.mock("../../hooks/useChatRooms");
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../api")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
fetchTasks: vi.fn().mockResolvedValue([]),
|
||||
searchFiles: vi.fn().mockResolvedValue({ files: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
const mockUseChat = vi.mocked(useChatModule.useChat);
|
||||
const mockUseChatRooms = vi.mocked(useChatRoomsModule.useChatRooms);
|
||||
|
||||
function makeSession(overrides: Partial<ChatSessionInfo> = {}): ChatSessionInfo {
|
||||
return {
|
||||
id: "sess-1",
|
||||
agentId: "agent-1",
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function chatState(session: ChatSessionInfo): UseChatReturn {
|
||||
return {
|
||||
sessions: [session],
|
||||
activeSession: session,
|
||||
sessionsLoading: false,
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
isStreaming: false,
|
||||
streamingText: "",
|
||||
streamingThinking: "",
|
||||
streamingToolCalls: [],
|
||||
selectSession: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
archiveSession: vi.fn(),
|
||||
deleteSession: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
stopStreaming: vi.fn(),
|
||||
pendingMessage: "",
|
||||
clearPendingMessage: vi.fn(),
|
||||
loadMoreMessages: vi.fn(),
|
||||
hasMoreMessages: false,
|
||||
searchQuery: "",
|
||||
setSearchQuery: vi.fn(),
|
||||
filteredSessions: [session],
|
||||
refreshSessions: vi.fn(),
|
||||
agentsMap: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
const defaultRoomsState: UseChatRoomsResult = {
|
||||
rooms: [],
|
||||
roomsLoading: false,
|
||||
roomsError: null,
|
||||
activeRoom: null,
|
||||
activeRoomMembers: [],
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
selectRoom: vi.fn(),
|
||||
createRoom: vi.fn(),
|
||||
deleteRoom: vi.fn(),
|
||||
sendRoomMessage: vi.fn().mockResolvedValue(undefined),
|
||||
refreshRooms: vi.fn(),
|
||||
};
|
||||
|
||||
describe("ChatView CLI-backed session mount", () => {
|
||||
beforeEach(() => {
|
||||
_resetInitialViewportHeight();
|
||||
vi.clearAllMocks();
|
||||
mockUseChatRooms.mockReturnValue(defaultRoomsState);
|
||||
});
|
||||
|
||||
it("renders CliChatSurface (transcript/terminal toggle) for a cli-backed session", () => {
|
||||
mockUseChat.mockReturnValue(
|
||||
chatState(makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" })),
|
||||
);
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
// CliChatSurface renders the transcript/terminal toggle tablist.
|
||||
expect(screen.getByRole("tab", { name: /transcript/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /terminal/i })).toBeInTheDocument();
|
||||
// The standard provider send button is NOT rendered as a top-level composer
|
||||
// affordance for the cli surface's default (transcript) view it wraps the
|
||||
// existing composer, but the distinguishing CLI toggle is present.
|
||||
});
|
||||
|
||||
it("attaches the terminal to the native cli session id linkage", () => {
|
||||
mockUseChat.mockReturnValue(
|
||||
chatState(makeSession({ cliExecutorAdapterId: "claude-code", cliSessionFile: "cli-native-1" })),
|
||||
);
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
// Switch to the terminal tab to mount SessionTerminal.
|
||||
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
|
||||
expect(screen.getByTestId("session-terminal").getAttribute("data-session-id")).toBe("cli-native-1");
|
||||
});
|
||||
|
||||
it("generic-tier cli session renders terminal-only (no toggle)", () => {
|
||||
mockUseChat.mockReturnValue(chatState(makeSession({ cliExecutorAdapterId: "generic" })));
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
expect(screen.getByTestId("session-terminal")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /transcript/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the normal provider composer for a regular (non-cli) session", () => {
|
||||
mockUseChat.mockReturnValue(chatState(makeSession({ cliExecutorAdapterId: null })));
|
||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
// Normal composer present, CLI toggle absent.
|
||||
expect(screen.getByPlaceholderText("Type a message...")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: /transcript/i })).toBeNull();
|
||||
expect(screen.queryByTestId("session-terminal")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,14 @@ export interface ChatSessionInfo {
|
||||
lastMessageAt?: string;
|
||||
isGenerating?: boolean;
|
||||
inFlightGeneration?: ChatInFlightGenerationState | null;
|
||||
/**
|
||||
* When set, this chat session is driven by a cli-agent executor (U12). The
|
||||
* message-pane + composer region is delegated to <CliChatSurface> instead of
|
||||
* the standard provider transcript/composer.
|
||||
*/
|
||||
cliExecutorAdapterId?: string | null;
|
||||
/** Native CLI session id linkage (used as the terminal attach id for resume). */
|
||||
cliSessionFile?: string | null;
|
||||
}
|
||||
|
||||
// Re-export shared chat types so existing consumers (`import { ChatMessageInfo } from "../hooks/useChat"`)
|
||||
|
||||
@@ -5,6 +5,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
|
||||
// Mock the engine module to avoid dynamic import issues in tests
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ const { mockChatStreamManager, mockSendMessage, mockCancelGeneration, mockBeginG
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({ createFnAgent: vi.fn() }));
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [], createFnAgent: vi.fn() }));
|
||||
vi.mock("../planning.js", () => ({
|
||||
getSession: vi.fn(), cleanupSession: vi.fn(), __setCreateFnAgent: vi.fn(), __resetPlanningState: vi.fn(), setAiSessionStore: vi.fn(), rehydrateFromStore: vi.fn().mockReturnValue(0),
|
||||
}));
|
||||
|
||||
@@ -21,6 +21,7 @@ const mockErrors = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
defaultGitOps: vi.fn(() => ({})),
|
||||
ExperimentFinalizeService: vi.fn(() => ({ previewPlan: previewPlanMock, finalize: finalizeMock })),
|
||||
ExperimentFinalizeStateError: mockErrors.StateError,
|
||||
|
||||
@@ -7,6 +7,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ vi.mock("node:child_process", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({
|
||||
session: {
|
||||
prompt: promptMock,
|
||||
|
||||
@@ -33,6 +33,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({
|
||||
session: {
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
|
||||
@@ -55,6 +55,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
executeApprovedAgentProvisioning: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class MockApprovalRequestStore {
|
||||
vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@fusion/core")>()), ApprovalRequestStore: MockApprovalRequestStore, AgentStore: class { async init() {} async getAgent() { return null; } } }));
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
executeApprovedAgentProvisioning: vi.fn(),
|
||||
executeApprovedWorktrunkInstall: vi.fn(),
|
||||
assertNoSecretPlaintext: (metadata?: Record<string, unknown>) => {
|
||||
|
||||
@@ -86,6 +86,7 @@ vi.mock("@fusion/core", async (importOriginal) => ({
|
||||
const executeApprovedWorktrunkInstall = vi.fn(async () => ({ binaryPath: "~/.fusion/bin/wt", source: "installed-release" }));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
executeApprovedAgentProvisioning,
|
||||
executeApprovedWorktrunkInstall,
|
||||
}));
|
||||
|
||||
@@ -26,6 +26,7 @@ class MockApprovalRequestStore {
|
||||
}
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
WORKTRUNK_INSTALL_PATH: "~/.fusion/bin/wt",
|
||||
WORKTRUNK_PINNED_RELEASE: {
|
||||
source: "upstream-pending-verification",
|
||||
|
||||
@@ -45,6 +45,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
createResolvedAgentSession: vi.fn(async () => ({
|
||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||
|
||||
@@ -38,6 +38,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({
|
||||
session: {
|
||||
state: { messages: [] as Array<{ role: string; content: string }> },
|
||||
|
||||
@@ -9,6 +9,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
|
||||
}));
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: mockCreateFnAgent,
|
||||
}));
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
|
||||
createResolvedAgentSession: vi.fn(async () => ({
|
||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||
|
||||
@@ -28,6 +28,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
|
||||
createResolvedAgentSession: vi.fn(async () => ({
|
||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||
|
||||
@@ -34,6 +34,7 @@ vi.mock("@fusion/core", async () => {
|
||||
});
|
||||
|
||||
vi.mock("@fusion/engine", () => ({
|
||||
listCliAdapterDescriptors: () => [],
|
||||
createFnAgent: vi.fn(async () => ({ session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() } })),
|
||||
createResolvedAgentSession: vi.fn(async () => ({
|
||||
session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() },
|
||||
|
||||
@@ -230,4 +230,53 @@ describe("TelemetryHub", () => {
|
||||
const flushed = hub.flush(a) ?? "";
|
||||
expect(flushed).not.toContain("sk-zzzz0123456789abcd0123");
|
||||
});
|
||||
|
||||
// ── Sanitized-event tap (U12 chat transcript seam) ──────────────────────────
|
||||
|
||||
it("onEvent tap receives sanitized events after routing (constructor option)", () => {
|
||||
const a = seed({ agentState: "busy" });
|
||||
const seen: Array<{ sessionId: string; kind: string; text?: string }> = [];
|
||||
const hub = new TelemetryHub({
|
||||
store,
|
||||
// No carry window so transcript text emits in the same event (the carry
|
||||
// behavior is exercised by the redaction tests above).
|
||||
chunkCarryChars: 0,
|
||||
onEvent: (sessionId, event) => seen.push({ sessionId, kind: event.kind, text: event.text }),
|
||||
});
|
||||
hub.issueToken(a);
|
||||
hub.ingest(a, { kind: "busy", payload: {} });
|
||||
hub.ingest(a, { kind: "transcript", payload: { text: "hello world" } });
|
||||
hub.ingest(a, { kind: "done", payload: {} });
|
||||
expect(seen.map((e) => e.kind)).toEqual(["busy", "transcript", "done"]);
|
||||
expect(seen.every((e) => e.sessionId === a)).toBe(true);
|
||||
// Tap sees the SANITIZED text, not the raw payload.
|
||||
expect(seen[1].text).toContain("hello world");
|
||||
});
|
||||
|
||||
it("onEvent tap is settable post-construction and clearable", () => {
|
||||
const a = seed({ agentState: "busy" });
|
||||
const hub = new TelemetryHub({ store });
|
||||
hub.issueToken(a);
|
||||
const seen: string[] = [];
|
||||
hub.setEventListener((_sessionId, event) => seen.push(event.kind));
|
||||
hub.ingest(a, { kind: "busy", payload: {} });
|
||||
hub.setEventListener(undefined);
|
||||
hub.ingest(a, { kind: "done", payload: {} });
|
||||
expect(seen).toEqual(["busy"]); // only the event ingested while the listener was set
|
||||
});
|
||||
|
||||
it("a throwing onEvent listener never breaks ingest", () => {
|
||||
const a = seed({ agentState: "busy" });
|
||||
const hub = new TelemetryHub({
|
||||
store,
|
||||
chunkCarryChars: 0,
|
||||
onEvent: () => {
|
||||
throw new Error("listener boom");
|
||||
},
|
||||
});
|
||||
hub.issueToken(a);
|
||||
// ingest still returns the sanitized event despite the throwing tap.
|
||||
const out = hub.ingest(a, { kind: "transcript", payload: { text: "still here" } });
|
||||
expect(out?.text).toContain("still here");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,10 +94,29 @@ export type NotificationDispatch = (info: {
|
||||
notification: Record<string, unknown> | undefined;
|
||||
}) => void;
|
||||
|
||||
/**
|
||||
* Narrow tap invoked synchronously for every successfully-ingested (sanitized)
|
||||
* event, AFTER state-machine routing. This is the seam that lets a downstream
|
||||
* consumer — e.g. the CLI-backed chat runner (U12) building a durable transcript
|
||||
* — observe the same sanitized events the hub already produced, without the hub
|
||||
* growing a full subscriber bus. It is best-effort: a throwing listener never
|
||||
* breaks ingest (the listener error is swallowed; ingest still returns).
|
||||
*/
|
||||
export type TelemetryEventListener = (
|
||||
sessionId: string,
|
||||
event: SanitizedTelemetryEvent,
|
||||
) => void;
|
||||
|
||||
export interface TelemetryHubOptions {
|
||||
store: CliSessionStore;
|
||||
/** Notification dispatch for waiting-on-input events (per node config). */
|
||||
onNotification?: NotificationDispatch;
|
||||
/**
|
||||
* Optional sanitized-event tap (e.g. the CLI-backed chat transcript runner).
|
||||
* Invoked once per ingested event after routing; best-effort (never throws
|
||||
* into ingest). May also be set later via {@link TelemetryHub.setEventListener}.
|
||||
*/
|
||||
onEvent?: TelemetryEventListener;
|
||||
/** Per-event text cap. */
|
||||
maxEventChars?: number;
|
||||
/** Per-turn event count cap. */
|
||||
@@ -143,6 +162,8 @@ interface SessionTelemetry {
|
||||
export class TelemetryHub {
|
||||
private readonly store: CliSessionStore;
|
||||
private readonly onNotification?: NotificationDispatch;
|
||||
/** Sanitized-event tap; settable post-construction (narrow seam, not a bus). */
|
||||
private onEvent?: TelemetryEventListener;
|
||||
private readonly maxEventChars: number;
|
||||
private readonly maxEventsPerTurn: number;
|
||||
private readonly chunkCarryChars: number;
|
||||
@@ -156,6 +177,7 @@ export class TelemetryHub {
|
||||
constructor(opts: TelemetryHubOptions) {
|
||||
this.store = opts.store;
|
||||
this.onNotification = opts.onNotification;
|
||||
this.onEvent = opts.onEvent;
|
||||
this.maxEventChars = opts.maxEventChars ?? DEFAULT_MAX_EVENT_CHARS;
|
||||
this.maxEventsPerTurn = opts.maxEventsPerTurn ?? DEFAULT_MAX_EVENTS_PER_TURN;
|
||||
this.chunkCarryChars = opts.chunkCarryChars ?? DEFAULT_CHUNK_CARRY_CHARS;
|
||||
@@ -193,6 +215,15 @@ export class TelemetryHub {
|
||||
return this.sessions.get(sessionId)?.machine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear) the sanitized-event tap after construction. Used to wire the
|
||||
* CLI-backed chat transcript runner (U12) onto the same hub the hook route
|
||||
* already feeds, without the hub becoming a general subscriber bus.
|
||||
*/
|
||||
setEventListener(listener: TelemetryEventListener | undefined): void {
|
||||
this.onEvent = listener;
|
||||
}
|
||||
|
||||
// ── Token registry ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -281,6 +312,16 @@ export class TelemetryHub {
|
||||
|
||||
const sanitized = this.sanitize(entry, event);
|
||||
this.route(entry, sanitized);
|
||||
// Narrow tap: feed the sanitized event to a downstream observer (e.g. the
|
||||
// chat transcript runner). Best-effort — a throwing listener must not break
|
||||
// ingest or the authoritative state transition that already happened.
|
||||
if (this.onEvent) {
|
||||
try {
|
||||
this.onEvent(sessionId, sanitized);
|
||||
} catch {
|
||||
// Swallow: telemetry observers are best-effort.
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
|
||||
@@ -657,6 +657,7 @@ export {
|
||||
type TelemetryEventKind,
|
||||
type SanitizedTelemetryEvent,
|
||||
type NotificationDispatch,
|
||||
type TelemetryEventListener,
|
||||
} from "./cli-agent/telemetry-hub.js";
|
||||
export {
|
||||
CliSessionStateMachine,
|
||||
|
||||
Reference in New Issue
Block a user