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; updatedAt: string;
cliSessionFile: string | null; cliSessionFile: string | null;
inFlightGeneration: string | null; inFlightGeneration: string | null;
cliExecutorAdapterId: string | null;
} }
/** Database row shape for chat_messages. */ /** Database row shape for chat_messages. */
@@ -161,6 +162,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
cliSessionFile: row.cliSessionFile ?? null, cliSessionFile: row.cliSessionFile ?? null,
inFlightGeneration: fromJson<ChatInFlightGenerationState>(row.inFlightGeneration) ?? null, inFlightGeneration: fromJson<ChatInFlightGenerationState>(row.inFlightGeneration) ?? null,
cliExecutorAdapterId: row.cliExecutorAdapterId ?? null,
}; };
} }
@@ -254,11 +256,12 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
updatedAt: now, updatedAt: now,
cliSessionFile: null, cliSessionFile: null,
inFlightGeneration: null, inFlightGeneration: null,
cliExecutorAdapterId: input.cliExecutorAdapterId ?? null,
}; };
this.db.prepare(` this.db.prepare(`
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration) INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
session.id, session.id,
session.agentId, session.agentId,
@@ -270,6 +273,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
session.createdAt, session.createdAt,
session.updatedAt, session.updatedAt,
null, null,
session.cliExecutorAdapterId,
); );
this.db.bumpLastModified(); this.db.bumpLastModified();
@@ -466,6 +470,27 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
this.db.bumpLastModified(); 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 { setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined {
const existing = this.getSession(id); const existing = this.getSession(id);
if (!existing) return undefined; if (!existing) return undefined;

View File

@@ -68,6 +68,13 @@ export interface ChatSession {
* for sessions that have never produced an assistant reply. * for sessions that have never produced an assistant reply.
*/ */
cliSessionFile: string | null; 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. */ /** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */
inFlightGeneration: ChatInFlightGenerationState | null; inFlightGeneration: ChatInFlightGenerationState | null;
} }
@@ -160,6 +167,8 @@ export interface ChatSessionCreateInput {
modelProvider?: string | null; modelProvider?: string | null;
/** Optional model ID override */ /** Optional model ID override */
modelId?: string | null; 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);
}
}