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:
gsxdsm
2026-06-05 03:02:27 -07:00
parent d30e29fe6a
commit 85f70148a1
28 changed files with 597 additions and 292 deletions

View File

@@ -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.

View File

@@ -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>
)}

View File

@@ -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();
});
});

View File

@@ -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"`)

View File

@@ -5,6 +5,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -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,
}));

View File

@@ -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),
}));

View File

@@ -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,

View File

@@ -7,6 +7,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -7,6 +7,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -14,6 +14,7 @@ vi.mock("node:child_process", () => ({
}));
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
createFnAgent: vi.fn(async () => ({
session: {
prompt: promptMock,

View File

@@ -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 }> },

View File

@@ -55,6 +55,7 @@ vi.mock("@fusion/core", async () => {
});
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
executeApprovedAgentProvisioning: vi.fn(async () => undefined),
}));

View File

@@ -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>) => {

View File

@@ -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,
}));

View File

@@ -26,6 +26,7 @@ class MockApprovalRequestStore {
}
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
WORKTRUNK_INSTALL_PATH: "~/.fusion/bin/wt",
WORKTRUNK_PINNED_RELEASE: {
source: "upstream-pending-verification",

View File

@@ -45,6 +45,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -38,6 +38,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -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() },

View File

@@ -38,6 +38,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -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 }> },

View File

@@ -9,6 +9,7 @@ const { mockCreateFnAgent } = vi.hoisted(() => ({
}));
vi.mock("@fusion/engine", () => ({
listCliAdapterDescriptors: () => [],
createFnAgent: mockCreateFnAgent,
}));

View File

@@ -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() },

View File

@@ -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() },

View File

@@ -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() },

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -657,6 +657,7 @@ export {
type TelemetryEventKind,
type SanitizedTelemetryEvent,
type NotificationDispatch,
type TelemetryEventListener,
} from "./cli-agent/telemetry-hub.js";
export {
CliSessionStateMachine,