Merge U12: cli-agent hybrid chat transcript with raw-terminal toggle
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;
|
||||
cliSessionFile: string | null;
|
||||
inFlightGeneration: string | null;
|
||||
cliExecutorAdapterId: string | null;
|
||||
}
|
||||
|
||||
/** Database row shape for chat_messages. */
|
||||
@@ -161,6 +162,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
updatedAt: row.updatedAt,
|
||||
cliSessionFile: row.cliSessionFile ?? null,
|
||||
inFlightGeneration: fromJson<ChatInFlightGenerationState>(row.inFlightGeneration) ?? null,
|
||||
cliExecutorAdapterId: row.cliExecutorAdapterId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,11 +256,12 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
updatedAt: now,
|
||||
cliSessionFile: null,
|
||||
inFlightGeneration: null,
|
||||
cliExecutorAdapterId: input.cliExecutorAdapterId ?? null,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO chat_sessions (id, agentId, title, status, projectId, modelProvider, modelId, createdAt, updatedAt, inFlightGeneration, cliExecutorAdapterId)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
session.id,
|
||||
session.agentId,
|
||||
@@ -270,6 +273,7 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
session.createdAt,
|
||||
session.updatedAt,
|
||||
null,
|
||||
session.cliExecutorAdapterId,
|
||||
);
|
||||
|
||||
this.db.bumpLastModified();
|
||||
@@ -466,6 +470,27 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
|
||||
this.db.bumpLastModified();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear) the cli-agent adapter that backs this chat session (U12).
|
||||
* When set, the chat is CLI-backed: composer sends route through the inject
|
||||
* path and adapter transcript events map to chat_messages rows. Emits a
|
||||
* session update so the client can switch to the CLI-backed rendering path.
|
||||
*
|
||||
* @param id - Session ID
|
||||
* @param adapterId - cli-agent adapter id, or null to revert to the provider path
|
||||
*/
|
||||
setCliExecutorAdapterId(id: string, adapterId: string | null): ChatSession | undefined {
|
||||
const existing = this.getSession(id);
|
||||
if (!existing) return undefined;
|
||||
this.db
|
||||
.prepare("UPDATE chat_sessions SET cliExecutorAdapterId = ?, updatedAt = ? WHERE id = ?")
|
||||
.run(adapterId, new Date().toISOString(), id);
|
||||
this.db.bumpLastModified();
|
||||
const updated = this.getSession(id)!;
|
||||
this.emit("chat:session:updated", updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
setInFlightGeneration(id: string, inFlightGeneration: ChatInFlightGenerationState | null): ChatSession | undefined {
|
||||
const existing = this.getSession(id);
|
||||
if (!existing) return undefined;
|
||||
|
||||
@@ -68,6 +68,13 @@ export interface ChatSession {
|
||||
* for sessions that have never produced an assistant reply.
|
||||
*/
|
||||
cliSessionFile: string | null;
|
||||
/**
|
||||
* cli-agent adapter id backing this chat session (CLI Agent Executor, U12).
|
||||
* When non-null the chat is CLI-backed: composer sends inject into a live
|
||||
* CLI session and adapter transcript events map to chat_messages rows. Null
|
||||
* means the chat uses the standard model-provider path.
|
||||
*/
|
||||
cliExecutorAdapterId: string | null;
|
||||
/** Durable in-flight assistant snapshot used to recover streaming UI after refresh. */
|
||||
inFlightGeneration: ChatInFlightGenerationState | null;
|
||||
}
|
||||
@@ -160,6 +167,8 @@ export interface ChatSessionCreateInput {
|
||||
modelProvider?: string | null;
|
||||
/** Optional model ID override */
|
||||
modelId?: string | null;
|
||||
/** Optional cli-agent adapter id; when set the chat is CLI-backed (U12) */
|
||||
cliExecutorAdapterId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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