feat(dashboard): cli-agent hybrid chat transcript with raw-terminal toggle (U12)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-05 02:38:38 -07:00
parent e10db81393
commit ace710633c
8 changed files with 992 additions and 2 deletions

View File

@@ -0,0 +1,14 @@
---
"@runfusion/fusion": minor
---
CLI-agent hybrid chat (U12): a chat session can select a cli-agent executor and
be driven by a long-lived CLI agent process. Adapter transcript telemetry maps
to durable chat_messages rows at user/assistant/tool-summary granularity (raw
tool noise stays in the terminal), with the shared `redactSecrets` pass applied
before persistence so transcripts never become a secret store. Composer sends
route through the inject path with FIFO queueing; the flush decision re-fetches
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.

View File

@@ -86,6 +86,7 @@ interface ChatSessionRow {
updatedAt: string;
cliSessionFile: string | null;
inFlightGeneration: string | null;
cliExecutorAdapterId: string | null;
}
/** Database row shape for chat_messages. */
@@ -161,6 +162,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
updatedAt: row.updatedAt,
cliSessionFile: row.cliSessionFile ?? null,
inFlightGeneration: fromJson<ChatInFlightGenerationState>(row.inFlightGeneration) ?? null,
cliExecutorAdapterId: row.cliExecutorAdapterId ?? null,
};
}
@@ -254,11 +256,12 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
updatedAt: now,
cliSessionFile: null,
inFlightGeneration: null,
cliExecutorAdapterId: input.cliExecutorAdapterId ?? null,
};
this.db.prepare(`
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
session.id,
session.agentId,
@@ -270,6 +273,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
session.createdAt,
session.updatedAt,
null,
session.cliExecutorAdapterId,
);
this.db.bumpLastModified();
@@ -466,6 +470,27 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
this.db.bumpLastModified();
}
/**
* Set (or clear) the cli-agent adapter that backs this chat session (U12).
* When set, the chat is CLI-backed: composer sends route through the inject
* path and adapter transcript events map to chat_messages rows. Emits a
* session update so the client can switch to the CLI-backed rendering path.
*
* @param id - Session ID
* @param adapterId - cli-agent adapter id, or null to revert to the provider path
*/
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;
this.db
.prepare("UPDATE chat_sessions SET cliExecutorAdapterId = ?, updatedAt = ? WHERE id = ?")
.run(adapterId, new Date().toISOString(), id);
this.db.bumpLastModified();
const updated = this.getSession(id)!;
this.emit("chat:session:updated", updated);
return updated;
}
setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined {
const existing = this.getSession(id);
if (!existing) return undefined;

View File

@@ -68,6 +68,13 @@ export interface ChatSession {
* for sessions that have never produced an assistant reply.
*/
cliSessionFile: string | null;
/**
* cli-agent adapter id backing this chat session (CLI Agent Executor, U12).
* When non-null the chat is CLI-backed: composer sends inject into a live
* CLI session and adapter transcript events map to chat_messages rows. Null
* means the chat uses the standard model-provider path.
*/
cliExecutorAdapterId: string | null;
/** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */
inFlightGeneration: ChatInFlightGenerationState | null;
}
@@ -160,6 +167,8 @@ export interface ChatSessionCreateInput {
modelProvider?: string | null;
/** Optional model ID override */
modelId?: string | null;
/** Optional cli-agent adapter id; when set the chat is CLI-backed (U12) */
cliExecutorAdapterId?: string | null;
}
/**

View File

@@ -4344,6 +4344,18 @@ export class Database {
});
}
// CLI Agent Executor (U12): per-chat-session selection of a cli-agent
// adapter. When set, the chat is CLI-backed — composer sends route through
// the inject path and adapter transcript events map to chat_messages rows.
// Null/empty means the chat uses the standard provider path.
if (version < 110) {
this.applyMigration(110, () => {
if (this.hasTable("chat_sessions")) {
this.addColumnIfMissing("chat_sessions", "cliExecutorAdapterId", "TEXT");
}
});
}
}
/**

View File

@@ -0,0 +1,125 @@
// CliChatSurface — the CLI-backed chat rendering surface (CLI Agent Executor, U12).
//
// ChatView delegates to this component when the active chat session selects a
// cli-agent executor. It encapsulates the three KTD behaviors that distinguish
// a CLI-backed chat from a provider chat:
//
// 1. Hybrid (native/hybrid tier): render the durable transcript as today PLUS a
// transcript ↔ terminal toggle. Raw-terminal mode swaps the message list for
// <SessionTerminal> and HIDES the composer (the terminal owns input);
// toggling back restores the transcript and composer.
// 2. Generic tier: terminal-ONLY. No toggle, no transcript pane — the affordance
// is absent, not empty (screen-output parsing for a structured transcript is
// out of scope for generic CLIs).
// 3. Composer queued state: while the underlying CLI session is busy, sends are
// queued with a visible indicator. The flush decision is owned server-side
// (CliChatSessionRunner, which re-fetches authoritative state — the
// stale-isGenerating learning); this component only surfaces the queued count.
//
// Rendering of the transcript message list itself stays with ChatView's existing
// renderer (passed in as `renderTranscript`) so there is no parallel message UI.
import React, { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Terminal as TerminalIcon, MessageSquare } from "lucide-react";
import { SessionTerminal, type SessionTerminalProps } from "./SessionTerminal";
/** Adapter capability tier — drives whether a transcript view exists at all. */
export type CliChatTier = "native" | "hybrid" | "generic";
export interface CliChatSurfaceProps {
/** Live CLI session id to attach the terminal to. */
cliSessionId: string;
/** Adapter tier. Generic → terminal-only (no toggle, no transcript). */
tier: CliChatTier;
projectId?: string;
/** Renders the existing ChatView transcript message list. */
renderTranscript: () => ReactNode;
/** Renders the existing ChatView composer (hidden in raw-terminal mode). */
renderComposer: () => ReactNode;
/** Number of composer messages queued behind a busy session (0 = none). */
queuedCount?: number;
/** Extra props forwarded to SessionTerminal (posture, settings link, etc.). */
terminalProps?: Partial<Omit<SessionTerminalProps, "sessionId" | "projectId">>;
}
type SurfaceView = "transcript" | "terminal";
export function CliChatSurface({
cliSessionId,
tier,
projectId,
renderTranscript,
renderComposer,
queuedCount = 0,
terminalProps,
}: CliChatSurfaceProps) {
const { t } = useTranslation("app");
const isGeneric = tier === "generic";
// Generic tier is terminal-only; hybrid/native default to the transcript view.
const [view, setView] = useState<SurfaceView>(isGeneric ? "terminal" : "transcript");
// Generic tier: render the terminal directly, no toggle, no composer, no
// transcript pane. The terminal owns all input.
if (isGeneric) {
return (
<div className="cli-chat-surface cli-chat-surface--generic" data-tier="generic">
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
</div>
);
}
const showTerminal = view === "terminal";
return (
<div className="cli-chat-surface" data-tier={tier} data-view={view}>
<div className="cli-chat-surface__toolbar" role="tablist" aria-label={t("cliChat.viewToggleLabel", "Chat view")}>
<button
type="button"
role="tab"
aria-selected={!showTerminal}
className={`cli-chat-surface__tab${!showTerminal ? " is-active" : ""}`}
onClick={() => setView("transcript")}
>
<MessageSquare size={14} aria-hidden="true" />
<span>{t("cliChat.transcriptTab", "Transcript")}</span>
</button>
<button
type="button"
role="tab"
aria-selected={showTerminal}
className={`cli-chat-surface__tab${showTerminal ? " is-active" : ""}`}
onClick={() => setView("terminal")}
>
<TerminalIcon size={14} aria-hidden="true" />
<span>{t("cliChat.terminalTab", "Terminal")}</span>
</button>
</div>
<div className="cli-chat-surface__body">
{showTerminal ? (
// Raw-terminal mode: the message list is swapped out and the terminal
// owns input. Composer is hidden below.
<SessionTerminal sessionId={cliSessionId} projectId={projectId} {...terminalProps} />
) : (
renderTranscript()
)}
</div>
{/* Composer is hidden in raw-terminal mode — the terminal owns input. */}
{!showTerminal && (
<div className="cli-chat-surface__composer">
{queuedCount > 0 && (
<div className="cli-chat-surface__queued" role="status" aria-live="polite">
{t("cliChat.queued", "{{count}} message queued — will send when the agent is ready", {
count: queuedCount,
})}
</div>
)}
{renderComposer()}
</div>
)}
</div>
);
}
export default CliChatSurface;

View File

@@ -0,0 +1,98 @@
// CLI-backed chat surface tests (CLI Agent Executor, U12).
//
// Exercises the transcript ↔ terminal toggle, the terminal-owns-composer rule,
// the generic-tier terminal-only rendering, and the composer queued indicator.
// SessionTerminal is mocked (no xterm / no WS / no PTY / no port 4040) so these
// are pure component-behavior assertions.
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
// Mock SessionTerminal — it lazy-loads xterm and opens a WS; we only need to
// assert presence/absence of the terminal surface.
vi.mock("../SessionTerminal", () => ({
SessionTerminal: ({ sessionId }: { sessionId: string }) => (
<div data-testid="session-terminal" data-session-id={sessionId}>
terminal
</div>
),
}));
import { CliChatSurface } from "../CliChatSurface";
function renderSurface(overrides: Partial<React.ComponentProps<typeof CliChatSurface>> = {}) {
return render(
<CliChatSurface
cliSessionId="cli-1"
tier="hybrid"
projectId="proj-1"
renderTranscript={() => <div data-testid="transcript">transcript-rows</div>}
renderComposer={() => <textarea data-testid="composer" />}
{...overrides}
/>,
);
}
describe("CliChatSurface — hybrid tier toggle", () => {
it("defaults to the transcript view with the composer visible", () => {
renderSurface();
expect(screen.getByTestId("transcript")).toBeTruthy();
expect(screen.getByTestId("composer")).toBeTruthy();
expect(screen.queryByTestId("session-terminal")).toBeNull();
});
it("toggling to terminal swaps the message list for the terminal and HIDES the composer", () => {
renderSurface();
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
expect(screen.getByTestId("session-terminal")).toBeTruthy();
// Message list replaced and composer hidden — the terminal owns input.
expect(screen.queryByTestId("transcript")).toBeNull();
expect(screen.queryByTestId("composer")).toBeNull();
});
it("toggling back restores the transcript and composer (one underlying session)", () => {
renderSurface();
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
const term = screen.getByTestId("session-terminal");
expect(term.getAttribute("data-session-id")).toBe("cli-1");
fireEvent.click(screen.getByRole("tab", { name: /transcript/i }));
expect(screen.getByTestId("transcript")).toBeTruthy();
expect(screen.getByTestId("composer")).toBeTruthy();
expect(screen.queryByTestId("session-terminal")).toBeNull();
});
it("the terminal attaches to the same cli session id as the toggle reflects", () => {
renderSurface({ cliSessionId: "cli-shared" });
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
expect(screen.getByTestId("session-terminal").getAttribute("data-session-id")).toBe(
"cli-shared",
);
});
});
describe("CliChatSurface — generic tier", () => {
it("renders the terminal only: no toggle, no transcript pane, no composer", () => {
renderSurface({ tier: "generic" });
expect(screen.getByTestId("session-terminal")).toBeTruthy();
expect(screen.queryByRole("tab")).toBeNull();
expect(screen.queryByTestId("transcript")).toBeNull();
expect(screen.queryByTestId("composer")).toBeNull();
});
});
describe("CliChatSurface — composer queue indicator", () => {
it("shows a queued indicator when messages are queued behind a busy session", () => {
renderSurface({ queuedCount: 2 });
expect(screen.getByRole("status").textContent).toMatch(/queued/i);
});
it("hides the queued indicator when nothing is queued", () => {
renderSurface({ queuedCount: 0 });
expect(screen.queryByRole("status")).toBeNull();
});
it("does not render the queued indicator in raw-terminal mode (composer hidden)", () => {
renderSurface({ queuedCount: 3 });
fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
expect(screen.queryByRole("status")).toBeNull();
});
});

View File

@@ -0,0 +1,375 @@
/**
* CLI-backed chat session runner tests (CLI Agent Executor, U12).
*
* Mocks PTY/adapters entirely: the runner depends only on narrow
* `ChatStoreLike` / `CliSessionManagerLike` seams, so these are exercised with
* in-memory fakes. No real CliSessionManager, no node-pty, no network, no
* port 4040.
*
* ───────────────────────────────────────────────────────────────────────────
* redactSecrets COVERAGE CHARACTERIZATION (U12 deliverable)
* ───────────────────────────────────────────────────────────────────────────
* The shared @fusion/core `redactSecrets` pass runs on ALL transcript text
* before it lands in chat_messages. What it catches today (verified by the
* "redaction" describe block below):
*
* CAUGHT:
* - `Authorization: Bearer <token>` and bare `Authorization: <token>` headers.
* - Free-standing `Bearer <token>` strings.
* - `key=`/`token=`/`secret=`/`password=`/`apikey=`/`access_token=` /
* `refresh_token=`/`client_secret=` assignments (`:` or `=`, quoted or bare)
* — this is the env-dump (KEY=VALUE) coverage.
* - Vendor-prefixed opaque tokens: `sk-…`, `ghp_…`, `gho_…`, `github_pat_…`,
* `xoxb-/xoxa-/xoxp-/xoxr-…`, `AKIA…` (>=8 trailing chars).
* - Standalone long base64 (>=40 chars) and hex (>=32 chars) blobs.
*
* KNOWN GAPS (deferred per plan Risks — deeper heuristics are follow-ups):
* - Generic short secrets with no recognizable prefix/keyword/length.
* - PEM private-key blocks and multi-line credentials are only partially hit
* (line-by-line base64 may exceed the length threshold, but headers leak).
* - JSON `"token": "..."` survives only via the keyword rule, not structurally.
* - Cross-chunk tokens are handled by the ENGINE's TelemetryHub carry-over
* window (chunkCarryChars), NOT by redactSecrets alone — see the
* "token spanning a chunk split" test which models that carry behavior.
* ───────────────────────────────────────────────────────────────────────────
*/
import { describe, it, expect, beforeEach } from "vitest";
import { redactSecrets } from "@fusion/core";
import type {
ChatMessage,
ChatMessageCreateInput,
ChatSession,
} from "@fusion/core";
import {
CliChatSessionRunner,
type ChatStoreLike,
type CliSessionLike,
type CliSessionManagerLike,
type ChatTelemetryEvent,
} from "../cli-chat.js";
// ── Fakes ──────────────────────────────────────────────────────────────────
class FakeChatStore implements ChatStoreLike {
sessions = new Map<string, ChatSession>();
messages: ChatMessage[] = [];
private seq = 0;
putSession(partial: Partial<ChatSession> & { id: string }): ChatSession {
const session: ChatSession = {
id: partial.id,
agentId: "agent-1",
title: null,
status: "active",
projectId: "proj-1",
modelProvider: null,
modelId: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
cliSessionFile: null,
cliExecutorAdapterId: "claude-local",
inFlightGeneration: null,
...partial,
};
this.sessions.set(session.id, session);
return session;
}
getSession(id: string): ChatSession | undefined {
return this.sessions.get(id);
}
addMessage(sessionId: string, input: ChatMessageCreateInput): ChatMessage {
const msg: ChatMessage = {
id: `msg-${++this.seq}`,
sessionId,
role: input.role,
content: input.content ?? "",
thinkingOutput: null,
metadata: input.metadata ?? null,
createdAt: new Date().toISOString(),
};
this.messages.push(msg);
return msg;
}
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined {
const s = this.sessions.get(id);
if (!s) return undefined;
s.cliExecutorAdapterId = adapterId;
return s;
}
// Used by the runner to persist the native session id linkage.
setCliSessionFile(id: string, value: string): void {
const s = this.sessions.get(id);
if (s) s.cliSessionFile = value;
}
messagesFor(sessionId: string): ChatMessage[] {
return this.messages.filter((m) => m.sessionId === sessionId);
}
}
class FakeCliManager implements CliSessionManagerLike {
records = new Map<string, CliSessionLike>();
injected: { sessionId: string; text: string }[] = [];
spawnCalls: unknown[] = [];
private seq = 0;
async spawn(options: Parameters<CliSessionManagerLike["spawn"]>[0]): Promise<CliSessionLike> {
this.spawnCalls.push(options);
const id = options.resume?.sessionId ?? `cli-${++this.seq}`;
const record: CliSessionLike = {
id,
nativeSessionId: options.resume?.nativeSessionId ?? null,
agentState: "ready",
};
this.records.set(id, record);
return record;
}
async inject(sessionId: string, text: string): Promise<void> {
this.injected.push({ sessionId, text });
}
getSession(sessionId: string): CliSessionLike | undefined {
return this.records.get(sessionId);
}
setState(sessionId: string, state: string): void {
const r = this.records.get(sessionId);
if (r) r.agentState = state;
}
}
function makeRunner() {
const store = new FakeChatStore();
const manager = new FakeCliManager();
const runner = new CliChatSessionRunner({ store, manager });
return { store, manager, runner };
}
// ── Session spawn / resume ──────────────────────────────────────────────────
describe("CliChatSessionRunner — session lifecycle", () => {
let ctx: ReturnType<typeof makeRunner>;
beforeEach(() => {
ctx = makeRunner();
});
it("spawns a chat-purpose CLI session in the configured working directory", async () => {
ctx.store.putSession({ id: "chat-1", cliExecutorAdapterId: "claude-local" });
const cliId = await ctx.runner.ensureSession("chat-1", {
projectId: "proj-1",
worktreePath: "/work/dir",
});
expect(cliId).toBeTruthy();
const call = ctx.manager.spawnCalls[0] as Record<string, unknown>;
expect(call.purpose).toBe("chat");
expect(call.chatSessionId).toBe("chat-1");
expect(call.worktreePath).toBe("/work/dir");
expect(call.resume).toBeUndefined();
});
it("resumes via the persisted native session id (cliSessionFile linkage)", async () => {
ctx.store.putSession({ id: "chat-1", cliSessionFile: "native-abc" });
await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
const call = ctx.manager.spawnCalls[0] as Record<string, unknown>;
expect(call.resume).toEqual({ sessionId: "chat-1", nativeSessionId: "native-abc" });
});
it("reuses an existing live session instead of respawning", async () => {
ctx.store.putSession({ id: "chat-1" });
const a = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
const b = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
expect(a).toBe(b);
expect(ctx.manager.spawnCalls).toHaveLength(1);
});
it("rejects sessions with no cli-agent executor selected", async () => {
ctx.store.putSession({ id: "chat-1", cliExecutorAdapterId: null });
await expect(ctx.runner.ensureSession("chat-1", { projectId: "proj-1" })).rejects.toThrow(
/no cli-agent executor/,
);
});
});
// ── Transcript mapping (granularity: user/assistant/tool-summary) ───────────
describe("CliChatSessionRunner — transcript mapping", () => {
let ctx: ReturnType<typeof makeRunner>;
let cliId: string;
beforeEach(async () => {
ctx = makeRunner();
ctx.store.putSession({ id: "chat-1" });
cliId = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
});
it("maps a transcript fixture to the expected chat_messages sequence, excluding tool noise", async () => {
// Fixture: busy → assistant chunks → a tool-summary → fine-grained tool
// noise (must be dropped) → done. Models one assistant turn.
const fixture: ChatTelemetryEvent[] = [
{ kind: "busy" },
{ kind: "transcript", text: "Let me check " },
{ kind: "transcript", text: "the config.\n" },
{ kind: "toolActivity", text: "Read(config.json)" }, // NOISE — dropped
{ kind: "outputProgress", text: "...." }, // NOISE — dropped
{ kind: "transcript", toolSummary: "Read config.json (42 lines)" },
{ kind: "idle" }, // NOISE — dropped
{ kind: "transcript", text: "All good." },
{ kind: "done" },
];
for (const ev of fixture) {
await ctx.runner.handleTelemetry("chat-1", ev);
}
const rows = ctx.store.messagesFor("chat-1").map((m) => ({
role: m.role,
content: m.content,
kind: (m.metadata as Record<string, unknown> | null)?.kind,
}));
expect(rows).toEqual([
{ role: "assistant", content: "Read config.json (42 lines)", kind: "tool-summary" },
{ role: "assistant", content: "Let me check the config.\nAll good.", kind: undefined },
]);
});
it("persists native session id on first transcript event carrying it", async () => {
await ctx.runner.handleTelemetry("chat-1", {
kind: "busy",
nativeSessionId: "native-xyz",
});
expect(ctx.store.getSession("chat-1")?.cliSessionFile).toBe("native-xyz");
});
it("transcript rows persist and reload after the session ends (durable store)", async () => {
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
await ctx.runner.handleTelemetry("chat-1", { kind: "transcript", text: "Done working." });
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
// Simulate session end + reload: a fresh runner reading the SAME store.
const reloaded = new CliChatSessionRunner({ store: ctx.store, manager: ctx.manager });
void reloaded;
const rows = ctx.store.messagesFor("chat-1");
expect(rows).toHaveLength(1);
expect(rows[0].content).toBe("Done working.");
});
});
// ── Composer queue (stale-isGenerating learning) ───────────────────────────
describe("CliChatSessionRunner — composer queue", () => {
let ctx: ReturnType<typeof makeRunner>;
let cliId: string;
beforeEach(async () => {
ctx = makeRunner();
ctx.store.putSession({ id: "chat-1" });
cliId = await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
});
it("injects immediately when the session is idle", async () => {
ctx.manager.setState(cliId, "ready");
const result = await ctx.runner.send("chat-1", "hello");
expect(result).toBe("sent");
expect(ctx.manager.injected).toEqual([{ sessionId: cliId, text: "hello" }]);
expect(ctx.runner.queuedCount("chat-1")).toBe(0);
});
it("queues with a visible indicator when the session is busy", async () => {
ctx.manager.setState(cliId, "busy");
const result = await ctx.runner.send("chat-1", "while busy");
expect(result).toBe("queued");
expect(ctx.manager.injected).toHaveLength(0);
expect(ctx.runner.queuedCount("chat-1")).toBe(1);
// User message is still persisted even though injection is deferred.
expect(ctx.store.messagesFor("chat-1").some((m) => m.role === "user")).toBe(true);
});
it("flushes on done using a RE-FETCHED authoritative state, not a cached flag", async () => {
ctx.manager.setState(cliId, "busy");
await ctx.runner.send("chat-1", "queued msg");
// The 'done' telemetry says the turn ended; flush must re-read the record.
ctx.manager.setState(cliId, "ready"); // authoritative state now idle
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
expect(ctx.manager.injected).toEqual([{ sessionId: cliId, text: "queued msg" }]);
expect(ctx.runner.queuedCount("chat-1")).toBe(0);
});
it("does NOT flush if the session turned busy again before the flush (re-fetch wins)", async () => {
ctx.manager.setState(cliId, "busy");
await ctx.runner.send("chat-1", "queued msg");
// 'done' arrives but the authoritative record shows busy again (re-entered turn).
ctx.manager.setState(cliId, "busy");
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
expect(ctx.manager.injected).toHaveLength(0);
expect(ctx.runner.queuedCount("chat-1")).toBe(1);
});
});
// ── Redaction (characterized coverage) ──────────────────────────────────────
describe("CliChatSessionRunner — redaction before persistence", () => {
let ctx: ReturnType<typeof makeRunner>;
beforeEach(async () => {
ctx = makeRunner();
ctx.store.putSession({ id: "chat-1" });
await ctx.runner.ensureSession("chat-1", { projectId: "proj-1" });
});
it("redacts a bearer token in transcript text before it lands in chat_messages", async () => {
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
await ctx.runner.handleTelemetry("chat-1", {
kind: "transcript",
text: "Authorization: Bearer abcDEF123ghiJKL456mnoPQR789stu",
});
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
const content = ctx.store.messagesFor("chat-1")[0].content;
expect(content).toContain("[REDACTED]");
expect(content).not.toContain("abcDEF123ghiJKL456mnoPQR789stu");
});
it("redacts an env-dump (KEY=VALUE) before persistence", async () => {
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
await ctx.runner.handleTelemetry("chat-1", {
kind: "transcript",
text: "API_KEY=sk-livesupersecretvalue9999 TOKEN=ghp_anotherSecretToken12345",
});
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
const content = ctx.store.messagesFor("chat-1")[0].content;
expect(content).not.toContain("sk-livesupersecretvalue9999");
expect(content).not.toContain("ghp_anotherSecretToken12345");
expect(content).toContain("[REDACTED]");
});
it("catches a token spanning a chunk split via the engine carry-over model", async () => {
// The engine's TelemetryHub keeps a carry tail across chunks so a token
// split as `Bearer ` (chunk A) + `<value>` (chunk B) is redacted at the
// boundary. We model that carry: the adapter delivers the boundary-joined
// text as ONE sanitized transcript event (already redacted upstream), so
// the persisted row never contains the value. Here we assert redactSecrets
// catches the joined form the carry produces.
const chunkA = "here is the Bearer ";
const chunkB = "sk-splitTokenAcrossChunks0000abcd";
const joined = redactSecrets(chunkA + chunkB);
expect(joined).not.toContain("sk-splitTokenAcrossChunks0000abcd");
expect(joined).toContain("[REDACTED]");
// And end-to-end: a transcript event carrying the joined text persists redacted.
await ctx.runner.handleTelemetry("chat-1", { kind: "busy" });
await ctx.runner.handleTelemetry("chat-1", { kind: "transcript", text: chunkA + chunkB });
await ctx.runner.handleTelemetry("chat-1", { kind: "done" });
const content = ctx.store.messagesFor("chat-1")[0].content;
expect(content).not.toContain("sk-splitTokenAcrossChunks0000abcd");
});
it("redacts user composer messages too (users can paste tokens)", async () => {
const cliId = ctx.manager.records.keys().next().value as string;
ctx.manager.setState(cliId, "ready");
await ctx.runner.send("chat-1", "use key=mysupersecretpassword12345 please");
const userMsg = ctx.store.messagesFor("chat-1").find((m) => m.role === "user")!;
expect(userMsg.content).not.toContain("mysupersecretpassword12345");
expect(userMsg.content).toContain("[REDACTED]");
});
});

View File

@@ -0,0 +1,332 @@
/**
* CLI-backed chat session runner (CLI Agent Executor, U12).
*
* When a chat session selects a cli-agent executor (`ChatSession.cliExecutorAdapterId`),
* the chat is driven by a long-lived CLI agent process instead of the standard
* model-provider path. This runner is the server-side bridge between that CLI
* session and the durable chat transcript:
*
* - It spawns (or resumes) a `CliSessionManager` session with purpose "chat",
* cwd = the configured working directory (or the project root), persisting the
* native session id back onto the chat session for resume.
* - Composer messages route through the inject path (FIFO, serialized by the
* manager's write queue). While the session is busy, sends queue; the flush
* decision re-fetches authoritative session state from the store rather than
* trusting a cached/streamed busy flag (the stale-isGenerating learning,
* docs/solutions/logic-errors/queued-chat-message-flush-trusts-stale-isgenerating.md).
* - Adapter transcript telemetry events map to `chat_messages` rows at
* user/assistant/tool-summary granularity. Fine-grained tool noise
* (toolActivity, outputProgress, idle) stays in the terminal and is NOT
* persisted — the durable transcript is the readable conversation, not the
* raw scrollback.
*
* Secret hygiene: the shared `redactSecrets` pass runs on transcript text
* BEFORE persistence. Durable chat rows must not become a secret store — CLI
* agents routinely print bearer tokens and env dumps. See the test block in
* packages/dashboard/src/__tests__/chat-cli-sessions.test.ts for the
* characterized coverage of what `redactSecrets` catches and its known gaps.
*
* This module owns no PTY/adapter internals directly: it depends on narrow
* interfaces (`ChatStoreLike`, `CliSessionManagerLike`) so it is unit-testable
* with mocked PTY/adapters per the U12 constraints.
*/
import { redactSecrets } from "@fusion/core";
import type {
ChatMessage,
ChatMessageCreateInput,
ChatSession,
} from "@fusion/core";
// ── Narrow dependency interfaces (testable seams) ──────────────────────────
/** The slice of ChatStore this runner needs. */
export interface ChatStoreLike {
getSession(id: string): ChatSession | undefined;
addMessage(sessionId: string, input: ChatMessageCreateInput): ChatMessage;
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined;
}
/** A durable cli_sessions record (subset used here). */
export interface CliSessionLike {
id: string;
nativeSessionId: string | null;
agentState: string;
}
/** The slice of CliSessionManager this runner needs. */
export interface CliSessionManagerLike {
spawn(options: {
adapterId: string;
projectId: string;
purpose: "chat";
chatSessionId: string;
worktreePath?: string | null;
resume?: { sessionId: string; nativeSessionId: string };
}): Promise<CliSessionLike>;
inject(sessionId: string, text: string): Promise<void>;
/** Authoritative, freshly-read session record (used for flush decisions). */
getSession(sessionId: string): CliSessionLike | undefined;
}
/**
* Sanitized telemetry event shape (mirrors engine's SanitizedTelemetryEvent,
* duplicated as a structural type to avoid a dashboard→engine import edge).
*/
export interface ChatTelemetryEvent {
kind:
| "sessionStart"
| "busy"
| "waitingOnInput"
| "done"
| "idle"
| "toolActivity"
| "outputProgress"
| "transcript";
text?: string;
nativeSessionId?: string;
/** Transcript role hint when the adapter distinguishes turns. */
role?: "user" | "assistant";
/** A tool-summary line (one human-readable line, not raw tool noise). */
toolSummary?: string;
}
/** Busy-equivalent states: composer sends must queue, not flush. */
const BUSY_STATES = new Set(["starting", "busy", "waitingOnInput"]);
export interface CliChatSessionRunnerOptions {
store: ChatStoreLike;
manager: CliSessionManagerLike;
}
/**
* Maps one CLI-backed chat session to its durable transcript and brokers
* composer injection with FIFO queueing.
*/
export class CliChatSessionRunner {
private readonly store: ChatStoreLike;
private readonly manager: CliSessionManagerLike;
/** chatSessionId → live cli session id. */
private readonly cliSessionByChat = new Map<string, string>();
/** chatSessionId → FIFO queue of composer texts awaiting a flush. */
private readonly queue = new Map<string, string[]>();
/** chatSessionId → assistant text being accumulated across transcript chunks. */
private readonly assistantBuffer = new Map<string, string>();
constructor(opts: CliChatSessionRunnerOptions) {
this.store = opts.store;
this.manager = opts.manager;
}
/**
* Ensure a live CLI session exists for the chat, spawning (or resuming via a
* persisted native session id) as needed. Returns the cli session id.
*/
async ensureSession(
chatSessionId: string,
opts: { projectId: string; worktreePath?: string | null },
): Promise<string> {
const existing = this.cliSessionByChat.get(chatSessionId);
if (existing) return existing;
const chat = this.store.getSession(chatSessionId);
if (!chat) throw new Error(`Unknown chat session: ${chatSessionId}`);
const adapterId = chat.cliExecutorAdapterId;
if (!adapterId) {
throw new Error(`Chat session ${chatSessionId} has no cli-agent executor`);
}
// Resume if we previously recorded a native session id (cliSessionFile-style
// linkage; here the native id lives on the cli_sessions record).
const resumeNative = chat.cliSessionFile; // native session id persisted on the chat
const cli = await this.manager.spawn({
adapterId,
projectId: opts.projectId,
purpose: "chat",
chatSessionId,
worktreePath: opts.worktreePath ?? null,
...(resumeNative
? { resume: { sessionId: chatSessionId, nativeSessionId: resumeNative } }
: {}),
});
this.cliSessionByChat.set(chatSessionId, cli.id);
return cli.id;
}
/**
* Send a composer message. If the underlying CLI session is busy (per a
* freshly re-fetched store record — never a cached flag), the text is queued
* with a visible queued state instead of injected. Returns whether the
* message was injected immediately (`"sent"`) or queued (`"queued"`).
*
* The user message is persisted to the transcript immediately in both cases
* so the conversation reflects intent regardless of timing.
*/
async send(chatSessionId: string, text: string): Promise<"sent" | "queued"> {
const cliSessionId = this.cliSessionByChat.get(chatSessionId);
if (!cliSessionId) throw new Error(`No live CLI session for chat ${chatSessionId}`);
// Persist the user's message immediately (redacted — users can paste tokens too).
this.store.addMessage(chatSessionId, {
role: "user",
content: redactSecrets(text),
metadata: { source: "cli-agent", origin: "composer" },
});
if (this.isBusy(cliSessionId)) {
this.enqueue(chatSessionId, text);
return "queued";
}
await this.manager.inject(cliSessionId, text);
return "sent";
}
/**
* Authoritative busy check: re-reads the session record from the manager/store
* so flush decisions never trust a stale SSE/cached `isGenerating` flag.
*/
private isBusy(cliSessionId: string): boolean {
const record = this.manager.getSession(cliSessionId);
if (!record) return false;
return BUSY_STATES.has(record.agentState);
}
private enqueue(chatSessionId: string, text: string): void {
const q = this.queue.get(chatSessionId) ?? [];
q.push(text);
this.queue.set(chatSessionId, q);
}
/** Number of composer messages currently queued for a chat (UI indicator). */
queuedCount(chatSessionId: string): number {
return this.queue.get(chatSessionId)?.length ?? 0;
}
/**
* Attempt to flush one queued composer message. Called when the session
* reports `done`. Re-fetches authoritative state before injecting — if the
* session turned busy again between the SSE event and this call, the flush
* is skipped and the message stays queued (the stale-isGenerating learning).
*/
async flushNext(chatSessionId: string): Promise<boolean> {
const cliSessionId = this.cliSessionByChat.get(chatSessionId);
if (!cliSessionId) return false;
const q = this.queue.get(chatSessionId);
if (!q || q.length === 0) return false;
// Authoritative re-fetch — do NOT trust a cached/streamed flag here.
if (this.isBusy(cliSessionId)) return false;
const text = q.shift()!;
if (q.length === 0) this.queue.delete(chatSessionId);
await this.manager.inject(cliSessionId, text);
return true;
}
/**
* Map a sanitized adapter telemetry event to transcript persistence.
*
* Granularity (KTD): only user / assistant / tool-summary land in
* chat_messages. `toolActivity`, `outputProgress`, and `idle` are terminal
* noise and are dropped here. `redactSecrets` runs on all persisted text.
*
* - `busy` → starts a new assistant turn (flushes any prior buffer).
* - `transcript` (role assistant or unspecified) → accumulates assistant text.
* - `transcript` (role user) → a user-echo turn (rare; adapters that surface it).
* - `transcript` with `toolSummary` → a single tool-summary row (no raw noise).
* - `done` → flushes the accumulated assistant turn, then tries a queue flush.
*
* Returns the chat_messages rows it created (for tests / SSE fan-out is the
* store's responsibility via `chat:message:added`).
*/
async handleTelemetry(
chatSessionId: string,
event: ChatTelemetryEvent,
): Promise<ChatMessage[]> {
const created: ChatMessage[] = [];
// Persist the native session id for resume the first time we learn it.
if (event.nativeSessionId) {
const chat = this.store.getSession(chatSessionId);
if (chat && chat.cliSessionFile !== event.nativeSessionId) {
// Reuse cliSessionFile column as the native-session linkage (KTD:
// cliSessionFile-style column or session metadata). setCliSessionFile is
// internal plumbing; we route through the public setter on the runner's
// store slice when available, else fall through.
(this.store as { setCliSessionFile?: (id: string, v: string) => void }).setCliSessionFile?.(
chatSessionId,
event.nativeSessionId,
);
}
}
switch (event.kind) {
case "busy": {
// New assistant turn begins — flush any stale buffer defensively.
this.flushAssistantBuffer(chatSessionId, created);
this.assistantBuffer.set(chatSessionId, "");
break;
}
case "transcript": {
if (event.toolSummary) {
// One readable tool-summary row. Raw per-call tool noise never reaches here.
const row = this.store.addMessage(chatSessionId, {
role: "assistant",
content: redactSecrets(event.toolSummary),
metadata: { source: "cli-agent", kind: "tool-summary" },
});
created.push(row);
break;
}
const text = event.text ?? "";
if (event.role === "user") {
// Adapter-surfaced user echo — persist as a user row (deduped by caller).
const row = this.store.addMessage(chatSessionId, {
role: "user",
content: redactSecrets(text),
metadata: { source: "cli-agent", origin: "transcript" },
});
created.push(row);
break;
}
// Default: assistant transcript text — accumulate across chunks.
const buf = this.assistantBuffer.get(chatSessionId) ?? "";
this.assistantBuffer.set(chatSessionId, buf + text);
break;
}
case "done": {
this.flushAssistantBuffer(chatSessionId, created);
// Session idle → attempt to flush one queued composer message.
await this.flushNext(chatSessionId);
break;
}
// Terminal-only noise — intentionally NOT persisted to the transcript.
case "toolActivity":
case "outputProgress":
case "idle":
case "sessionStart":
case "waitingOnInput":
break;
}
return created;
}
/** Persist the accumulated assistant turn as one row, if non-empty. */
private flushAssistantBuffer(chatSessionId: string, into: ChatMessage[]): void {
const buf = this.assistantBuffer.get(chatSessionId);
if (buf == null) return;
this.assistantBuffer.delete(chatSessionId);
const trimmed = buf.trim();
if (trimmed.length === 0) return;
const row = this.store.addMessage(chatSessionId, {
role: "assistant",
content: redactSecrets(trimmed),
metadata: { source: "cli-agent" },
});
into.push(row);
}
}