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:
14
.changeset/cli-agent-hybrid-chat.md
Normal file
14
.changeset/cli-agent-hybrid-chat.md
Normal 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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
125
packages/dashboard/app/components/CliChatSurface.tsx
Normal file
125
packages/dashboard/app/components/CliChatSurface.tsx
Normal 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;
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
375
packages/dashboard/src/__tests__/chat-cli-sessions.test.ts
Normal file
375
packages/dashboard/src/__tests__/chat-cli-sessions.test.ts
Normal 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]");
|
||||||
|
});
|
||||||
|
});
|
||||||
332
packages/dashboard/src/cli-chat.ts
Normal file
332
packages/dashboard/src/cli-chat.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user