Files
IdeA/frontend/src/adapters/mock/index.ts
Blomios b82e3e1a40 feat(agent): UI hot-swap de profil (A2) — commande Tauri + sélecteur + dialog
- Tauri : commande change_agent_profile + ChangeAgentProfileRequestDto/Dto
  (camelCase, relaunchedSession omis si None), câblage state.rs par composition.
- Front : gateway changeAgentProfile (adapters Tauri+mock), sélecteur de profil
  par agent, dialog de confirmation FR (« Changer le moteur abandonne l'historique
  de conversation… »), refresh sur event agentProfileChanged.

Tests : app-tauri dto 5/5 ; vitest 314/314 (mapping, payload, dialog gating,
libellé FR, refresh). 0 régression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 10:07:35 +02:00

1544 lines
46 KiB
TypeScript

/**
* Mock gateways implementing every UI port in-memory, with no backend. They let
* the frontend run and be tested fully offline (ARCHITECTURE §1.3, §11) and are
* selected by the DI provider when `VITE_USE_MOCK` is set.
*/
import type {
Agent,
AgentDrift,
AgentProfile,
DomainEvent,
EmbedderEngines,
EmbedderProfile,
FirstRunState,
GatewayError,
GitBranches,
GitCommit,
GitFileStatus,
GraphCommit,
HealthReport,
LayoutInfo,
LayoutKind,
LayoutList,
LayoutOperation,
LayoutTree,
Memory,
MemoryIndexEntry,
MemoryLink,
MemoryType,
Project,
ProfileAvailability,
Skill,
SkillScope,
Template,
TerminalSession,
Unsubscribe,
} from "@/domain";
import type {
AgentGateway,
ConversationDetails,
CreateAgentInput,
CreateMemoryInput,
CreateSkillInput,
EmbedderGateway,
CreateTemplateInput,
Gateways,
LiveAgent,
MemoryGateway,
GitGateway,
LayoutGateway,
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
ReattachResult,
RemoteGateway,
SkillGateway,
SystemGateway,
TemplateGateway,
TerminalGateway,
TerminalHandle,
} from "@/ports";
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
export class MockSystemGateway implements SystemGateway {
private listeners = new Set<(e: DomainEvent) => void>();
async health(note?: string): Promise<HealthReport> {
const report: HealthReport = {
version: "0.1.0-mock",
alive: true,
timeMillis: Date.now(),
correlationId: `mock-${Math.random().toString(36).slice(2, 10)}`,
note: note ?? null,
};
// Emit a smoke event so subscribers can be exercised offline too.
this.emit({ type: "projectCreated", projectId: report.correlationId });
return report;
}
async onDomainEvent(
handler: (event: DomainEvent) => void,
): Promise<Unsubscribe> {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
/** Test/dev helper to push an event to all subscribers. */
emit(event: DomainEvent): void {
for (const l of this.listeners) l(event);
}
/** Returns a deterministic fake path — never opens a native dialog. */
async pickFolder(): Promise<string | null> {
return "/home/user/mock-project";
}
}
/**
* Slugifies a display name into a safe file stem (`[a-z0-9-]`), collapsing
* runs of non-alphanumeric characters into a single dash, mirroring the
* backend `slugify` logic.
*/
function slugify(name: string): string {
let out = "";
let prevDash = false;
for (const ch of name.trim()) {
if (/[a-zA-Z0-9]/.test(ch)) {
out += ch.toLowerCase();
prevDash = false;
} else if (!prevDash) {
out += "-";
prevDash = true;
}
}
return out.replace(/^-+|-+$/g, "");
}
/**
* A live in-memory mock PTY session: it retains a scrollback (everything written
* to `onData`) and tracks the *current* output sink so a view can detach (sink
* cleared, session stays alive) and later re-attach (new sink, scrollback
* replayed). Only `close` ends the session — mirroring the backend's decoupling
* of PTY lifecycle from view lifecycle.
*/
class MockPtySession {
/** Accumulated output (the scrollback ring; unbounded in the mock — fine for tests). */
private scrollback: number[] = [];
/** Current view sink; `null` while detached. */
private sink: ((bytes: Uint8Array) => void) | null = null;
/** Whether the session was explicitly closed (PTY killed). */
closed = false;
constructor(
readonly sessionId: string,
sink: (bytes: Uint8Array) => void,
) {
this.sink = sink;
}
/** Records output into the scrollback and forwards it to the current sink. */
emit(bytes: Uint8Array): void {
if (this.closed) return;
for (const b of bytes) this.scrollback.push(b);
this.sink?.(bytes);
}
/** Detaches the current view: stop delivering, keep the session alive. */
detach(): void {
this.sink = null;
}
/** Re-attaches a new view, returning the retained scrollback to repaint. */
reattach(sink: (bytes: Uint8Array) => void): Uint8Array {
this.sink = sink;
return Uint8Array.from(this.scrollback);
}
}
/**
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
* `LaunchAgent` use cases, keyed per `projectId` (ARCHITECTURE §11).
*
* Exported so tests can instantiate it directly (same pattern as
* {@link MockProjectGateway}).
*/
export class MockAgentGateway implements AgentGateway {
/** Agents indexed by projectId. */
private agents = new Map<string, Agent[]>();
/** Context content indexed by `${projectId}::${agentId}`. */
private contexts = new Map<string, string>();
/** Monotonic session counter for deterministic session ids in tests. */
private sessionSeq = 0;
/** Live agent PTY sessions, kept across detach so reattach can find them. */
private sessions = new Map<string, MockPtySession>();
/**
* The cell each live agent currently runs in (`agentId → nodeId`). Mirrors the
* backend "one live session per agent" invariant: launching an agent already
* present here in a *different* node is refused; closing its session clears it.
* Only populated when the caller supplies a `nodeId`.
*/
private liveByAgent = new Map<string, string>();
/** Live PTY session id per agent (`agentId → sessionId`). */
private liveSessionByAgent = new Map<string, string>();
private getAgents(projectId: string): Agent[] {
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
return this.agents.get(projectId)!;
}
private contextKey(projectId: string, agentId: string): string {
return `${projectId}::${agentId}`;
}
async listAgents(projectId: string): Promise<Agent[]> {
return structuredClone(this.getAgents(projectId));
}
async listLiveAgents(projectId: string): Promise<LiveAgent[]> {
// Only report agents that belong to this project (ids are disjoint across
// projects in the mock, mirroring the backend's per-project agent ids).
const known = new Set(this.getAgents(projectId).map((a) => a.id));
return [...this.liveByAgent.entries()]
.filter(([agentId]) => known.has(agentId))
.map(([agentId, nodeId]) => ({
agentId,
nodeId,
sessionId: this.liveSessionByAgent.get(agentId),
}));
}
async attachLiveAgent(
projectId: string,
agentId: string,
nodeId: string,
): Promise<LiveAgent> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
const sessionId = this.liveSessionByAgent.get(agentId);
if (!sessionId || !this.sessions.has(sessionId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} has no live session`,
};
throw err;
}
this.liveByAgent.set(agentId, nodeId);
return { agentId, nodeId, sessionId };
}
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
const list = this.getAgents(projectId);
const slug = slugify(input.name) || "agent";
// Derive a unique agents/<slug>.md path, disambiguating with -2, -3, …
const existingPaths = new Set(list.map((a) => a.contextPath));
let candidate = `agents/${slug}.md`;
let n = 2;
while (existingPaths.has(candidate)) {
candidate = `agents/${slug}-${n}.md`;
n += 1;
}
const agent: Agent = {
id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`,
name: input.name,
contextPath: candidate,
profileId: input.profileId,
origin: { type: "scratch" },
synchronized: false,
skills: [],
};
list.push(agent);
this.contexts.set(
this.contextKey(projectId, agent.id),
input.initialContent ?? "",
);
return structuredClone(agent);
}
async readContext(projectId: string, agentId: string): Promise<string> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
return this.contexts.get(this.contextKey(projectId, agentId)) ?? "";
}
async updateContext(
projectId: string,
agentId: string,
content: string,
): Promise<void> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
this.contexts.set(this.contextKey(projectId, agentId), content);
}
async deleteAgent(projectId: string, agentId: string): Promise<void> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
list.splice(idx, 1);
this.contexts.delete(this.contextKey(projectId, agentId));
}
async changeAgentProfile(
projectId: string,
agentId: string,
profileId: string,
rows: number,
cols: number,
): Promise<{ agent: Agent; relaunchedSession?: TerminalSession }> {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
// Mutate the agent's runtime profile in place (the rest of the record —
// origin, synchronized, skills — is untouched).
list[idx] = { ...list[idx], profileId };
const agent = structuredClone(list[idx]);
// Hot-swap path: when the agent owns a live session, the engine restarts —
// the old PTY is killed (history abandoned, per the product decision) and a
// fresh session is minted in the same cell, surfaced for the caller to
// rebind. No live session ⇒ nothing to relaunch.
const nodeId = this.liveByAgent.get(agentId);
const oldSessionId = this.liveSessionByAgent.get(agentId);
if (!nodeId || !oldSessionId) {
return { agent };
}
// Kill the old session.
const old = this.sessions.get(oldSessionId);
if (old) old.closed = true;
this.sessions.delete(oldSessionId);
// Mint a fresh, detached session (no view sink yet; the cell reattaches).
this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`;
const session = new MockPtySession(sessionId, () => {});
session.detach();
this.sessions.set(sessionId, session);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveByAgent.set(agentId, nodeId);
return {
agent,
relaunchedSession: {
sessionId,
cwd: `/home/user/mock-project`,
rows,
cols,
},
};
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ──
/**
* Inserts a pre-built agent record into the registry.
* Used by `MockTemplateGateway.createAgentFromTemplate` so both gateways
* share the same in-memory store.
*/
_insertAgent(projectId: string, agent: Agent, context: string): void {
const list = this.getAgents(projectId);
list.push(agent);
this.contexts.set(this.contextKey(projectId, agent.id), context);
}
/**
* Updates an agent record in-place (origin + synchronized flag).
* Used by `MockTemplateGateway.syncAgent`.
*/
_updateAgent(
projectId: string,
agentId: string,
patch: Partial<Pick<Agent, "origin" | "synchronized">>,
newContext?: string,
): void {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) return;
list[idx] = { ...list[idx], ...patch };
if (newContext !== undefined) {
this.contexts.set(this.contextKey(projectId, agentId), newContext);
}
}
/**
* Returns a **live** (not cloned) reference to the agent list so
* `MockTemplateGateway` can read agent origins without triggering async overhead.
*/
_rawAgents(projectId: string): Agent[] {
return this.getAgents(projectId);
}
/**
* Replaces an agent's assigned skills in-place.
* Used by `MockSkillGateway.assignSkill` / `unassignSkill` so both gateways
* share the same in-memory store.
*/
_setSkills(projectId: string, agentId: string, skills: Agent["skills"]): void {
const list = this.getAgents(projectId);
const idx = list.findIndex((a) => a.id === agentId);
if (idx === -1) return;
list[idx] = { ...list[idx], skills };
}
async launchAgent(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
// Singleton invariant: refuse a launch when the agent is already live in a
// *different* cell (mirrors the backend `AGENT_ALREADY_RUNNING`). The same
// node is allowed (idempotent re-launch of the very same cell).
const liveNode = this.liveByAgent.get(agentId);
if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
const err: GatewayError = {
code: "AGENT_ALREADY_RUNNING",
message: `agent ${agentId} is already running in cell ${liveNode}`,
};
throw err;
}
this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd;
const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session);
// Record liveness for `listLiveAgents` + the guard above (only when the
// caller pins a node).
if (options.nodeId) {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
}
// Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() =>
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
);
const handle = makeMockHandle(session, () => {
this.sessions.delete(sessionId);
if (this.liveByAgent.get(agentId) === options.nodeId) {
this.liveByAgent.delete(agentId);
this.liveSessionByAgent.delete(agentId);
}
});
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
// newly-minted id surfaced on the handle so the caller persists it; a cell
// that already carries an id resumes it and nothing new is assigned.
if (!options.conversationId) {
return {
...handle,
assignedConversationId: `mock-conversation-${sessionId}`,
};
}
return handle;
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const session = this.sessions.get(sessionId);
if (!session || session.closed) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent session ${sessionId} is not alive`,
};
throw err;
}
const scrollback = session.reattach(onData);
return {
handle: makeMockHandle(session, () => {
this.sessions.delete(sessionId);
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
if (liveSessionId === sessionId) {
this.liveSessionByAgent.delete(agentId);
this.liveByAgent.delete(agentId);
}
}
}),
scrollback,
};
}
/**
* Best-effort conversation details, keyed by conversation id (T7). Empty by
* default (degraded mode); a test seeds enriched details via
* {@link _setConversationDetails}.
*/
private conversationDetails = new Map<string, ConversationDetails>();
/** Seeds the (best-effort) details returned for a given conversation id. */
_setConversationDetails(
conversationId: string,
details: ConversationDetails,
): void {
this.conversationDetails.set(conversationId, details);
}
async inspectConversation(
_projectId: string,
_agentId: string,
conversationId: string,
): Promise<ConversationDetails> {
// Best-effort: an unknown conversation yields empty details (degraded mode),
// never an error — mirroring the backend contract.
return structuredClone(this.conversationDetails.get(conversationId) ?? {});
}
}
/**
* Builds a {@link TerminalHandle} over a {@link MockPtySession}. `write` echoes
* (cooked-terminal CRLF translation) through the session so the scrollback
* records it; `detach` keeps the session alive; `close` ends it and unregisters.
*/
function makeMockHandle(
session: MockPtySession,
unregister: () => void,
): TerminalHandle {
return {
sessionId: session.sessionId,
async write(data: Uint8Array): Promise<void> {
if (session.closed) return;
const out: number[] = [];
for (const b of data) {
if (b === 0x0d) out.push(0x0d, 0x0a);
else out.push(b);
}
session.emit(Uint8Array.from(out));
},
async resize(): Promise<void> {},
detach(): void {
session.detach();
},
async close(): Promise<void> {
session.closed = true;
unregister();
},
};
}
/**
* In-memory fake terminal: a shell-less PTY that **echoes** whatever is written
* back to `onData` (so the xterm wrapper renders typed input) and greets on
* open. Lets the terminal feature run and be tested fully offline.
*/
export class MockTerminalGateway implements TerminalGateway {
private seq = 0;
/** Live sessions kept across detach so reattach can find them. */
private sessions = new Map<string, MockPtySession>();
async openTerminal(
options: OpenTerminalOptions,
onData: (bytes: Uint8Array) => void,
): Promise<TerminalHandle> {
this.seq += 1;
const sessionId = `mock-session-${this.seq}`;
const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session);
// Greet so something is visible immediately.
queueMicrotask(() =>
session.emit(enc.encode(`mock terminal @ ${options.cwd}\r\n`)),
);
return makeMockHandle(session, () => this.sessions.delete(sessionId));
}
async reattach(
sessionId: string,
onData: (bytes: Uint8Array) => void,
): Promise<ReattachResult> {
const session = this.sessions.get(sessionId);
if (!session || session.closed) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `terminal session ${sessionId} is not alive`,
};
throw err;
}
const scrollback = session.reattach(onData);
return {
handle: makeMockHandle(session, () => this.sessions.delete(sessionId)),
scrollback,
};
}
async closeTerminal(sessionId: string): Promise<void> {
// Best-effort / idempotent: kill the PTY by id (mirrors the handle's close)
// so a later reattach to it fails. No-op if the session is already gone.
const session = this.sessions.get(sessionId);
if (session) {
session.closed = true;
this.sessions.delete(sessionId);
}
}
}
export class MockProjectGateway implements ProjectGateway {
private projects: Project[] = [];
private contexts = new Map<string, string>();
async listProjects(): Promise<Project[]> {
return [...this.projects];
}
async createProject(name: string, root: string): Promise<Project> {
if (this.projects.some((p) => p.root === root)) {
const err: GatewayError = {
code: "INVALID",
message: `a project already exists at ${root} for this remote`,
};
throw err;
}
const project: Project = {
id: `mock-project-${Math.random().toString(36).slice(2, 10)}`,
name,
root,
remote: { kind: "local" },
createdAt: Date.now(),
};
this.projects.push(project);
return project;
}
async openProject(projectId: string): Promise<Project> {
const project = this.projects.find((p) => p.id === projectId);
if (!project) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `project ${projectId} not found`,
};
throw err;
}
return project;
}
async closeProject(): Promise<void> {}
async readProjectContext(projectId: string): Promise<string> {
if (!this.projects.some((p) => p.id === projectId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `project ${projectId} not found`,
};
throw err;
}
return this.contexts.get(projectId) ?? "";
}
async updateProjectContext(projectId: string, content: string): Promise<void> {
if (!this.projects.some((p) => p.id === projectId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `project ${projectId} not found`,
};
throw err;
}
this.contexts.set(projectId, content);
}
}
/** Internal per-project layout store entry. */
interface MockLayoutEntry {
id: string;
name: string;
kind: LayoutKind;
tree: LayoutTree;
}
/** Internal per-project layout state: list of named layouts + active id. */
interface MockProjectLayouts {
activeId: string;
layouts: MockLayoutEntry[];
}
/**
* In-memory layout store: keeps multiple named {@link LayoutTree}s per project,
* applying operations with the same pure logic as the backend (`applyOperation`).
* Lets the grid feature run and be tested fully offline.
*
* Each project starts with a single "Default" layout. The old `loadLayout` /
* `mutateLayout` signatures (no `layoutId`) default to the active layout,
* preserving backward-compat with existing tests.
*/
export class MockLayoutGateway implements LayoutGateway {
private store = new Map<string, MockProjectLayouts>();
private getProjectLayouts(projectId: string): MockProjectLayouts {
if (!this.store.has(projectId)) {
const defaultId = `layout-default-${Math.random().toString(36).slice(2, 8)}`;
this.store.set(projectId, {
activeId: defaultId,
layouts: [{ id: defaultId, name: "Default", kind: "terminal", tree: singleLeafTree() }],
});
}
return this.store.get(projectId)!;
}
private getActiveTree(projectId: string): LayoutTree {
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === ps.activeId);
return entry ? entry.tree : singleLeafTree();
}
private getTree(projectId: string, layoutId?: string): LayoutTree {
if (!layoutId) return this.getActiveTree(projectId);
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === layoutId);
return entry ? entry.tree : singleLeafTree();
}
private setTree(projectId: string, tree: LayoutTree, layoutId?: string): void {
const ps = this.getProjectLayouts(projectId);
const id = layoutId ?? ps.activeId;
const entry = ps.layouts.find((l) => l.id === id);
if (entry) entry.tree = tree;
}
async loadLayout(projectId: string, layoutId?: string): Promise<LayoutTree> {
const tree = this.getTree(projectId, layoutId);
return structuredClone(tree);
}
async mutateLayout(
projectId: string,
operation: LayoutOperation,
layoutId?: string,
): Promise<LayoutTree> {
const current = this.getTree(projectId, layoutId);
const next = applyOperation(current, operation);
this.setTree(projectId, next, layoutId);
return structuredClone(next);
}
async listLayouts(projectId: string): Promise<LayoutList> {
const ps = this.getProjectLayouts(projectId);
const layouts: LayoutInfo[] = ps.layouts.map((l) => ({ id: l.id, name: l.name, kind: l.kind }));
return { layouts, activeId: ps.activeId };
}
async createLayout(projectId: string, name: string, kind: LayoutKind = "terminal"): Promise<{ layoutId: string }> {
const ps = this.getProjectLayouts(projectId);
const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`;
ps.layouts.push({ id: layoutId, name, kind, tree: singleLeafTree() });
return { layoutId };
}
async renameLayout(projectId: string, layoutId: string, name: string): Promise<void> {
const ps = this.getProjectLayouts(projectId);
const entry = ps.layouts.find((l) => l.id === layoutId);
if (entry) entry.name = name;
}
async deleteLayout(
projectId: string,
layoutId: string,
): Promise<{ activeId: string }> {
const ps = this.getProjectLayouts(projectId);
const idx = ps.layouts.findIndex((l) => l.id === layoutId);
if (idx !== -1) ps.layouts.splice(idx, 1);
// If the deleted layout was active, switch to the first remaining layout.
if (ps.activeId === layoutId && ps.layouts.length > 0) {
ps.activeId = ps.layouts[0].id;
}
return { activeId: ps.activeId };
}
async setActiveLayout(projectId: string, layoutId: string): Promise<void> {
const ps = this.getProjectLayouts(projectId);
if (ps.layouts.some((l) => l.id === layoutId)) {
ps.activeId = layoutId;
}
}
}
/** Per-project git state kept in the mock. */
interface MockGitProjectState {
files: Map<string, boolean>; // path → staged
branches: string[];
current: string;
log: GitCommit[];
commitSeq: number;
}
/**
* A small demo DAG that exercises branches, a merge commit, and a tag.
*
* Topology (newest first, as git log returns):
*
* e (main, HEAD) — merge commit from feature
* ├─ d (feature)
* │ └─ c
* └─ b
* └─ a (tag: v1.0)
*
* In list form (parents reference earlier hashes):
* e parents=[b,d]
* d parents=[c]
* c parents=[a] (feature branch diverges from a)
* b parents=[a]
* a parents=[] (initial commit, tag v1.0)
*/
const DEMO_GRAPH_COMMITS: GraphCommit[] = [
{
hash: "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
summary: "Merge feature into main",
parents: [
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"dddddddddddddddddddddddddddddddddddddddd",
],
refs: ["main", "HEAD"],
author: "Alice",
timestamp: 1_717_200_000,
},
{
hash: "dddddddddddddddddddddddddddddddddddddddd",
summary: "Implement feature (step 2)",
parents: ["cccccccccccccccccccccccccccccccccccccccc"],
refs: ["feature"],
author: "Bob",
timestamp: 1_717_100_000,
},
{
hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
summary: "Hotfix on main",
parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
refs: [],
author: "Alice",
timestamp: 1_717_090_000,
},
{
hash: "cccccccccccccccccccccccccccccccccccccccc",
summary: "Implement feature (step 1)",
parents: ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"],
refs: [],
author: "Bob",
timestamp: 1_717_080_000,
},
{
hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
summary: "Initial commit",
parents: [],
refs: ["tag: v1.0"],
author: "Alice",
timestamp: 1_717_000_000,
},
];
/**
* Stateful in-memory git gateway — simulates a git repository per project
* (keyed by projectId). Seeded with demo files so the panel renders something
* on first render.
*
* Exported so tests can instantiate it directly.
*/
export class MockGitGateway implements GitGateway {
private projects = new Map<string, MockGitProjectState>();
private getState(projectId: string): MockGitProjectState {
if (!this.projects.has(projectId)) {
this.projects.set(projectId, this._seedState());
}
return this.projects.get(projectId)!;
}
private _seedState(): MockGitProjectState {
const files = new Map<string, boolean>();
files.set("src/main.rs", false);
files.set("README.md", false);
return {
files,
branches: ["main"],
current: "main",
log: [],
commitSeq: 0,
};
}
async init(projectId: string): Promise<void> {
if (!this.projects.has(projectId)) {
this.projects.set(projectId, this._seedState());
}
}
async status(projectId: string): Promise<GitFileStatus[]> {
const state = this.getState(projectId);
return Array.from(state.files.entries()).map(([path, staged]) => ({
path,
staged,
}));
}
async stage(projectId: string, path: string): Promise<void> {
const state = this.getState(projectId);
if (state.files.has(path)) {
state.files.set(path, true);
}
}
async unstage(projectId: string, path: string): Promise<void> {
const state = this.getState(projectId);
if (state.files.has(path)) {
state.files.set(path, false);
}
}
async commit(projectId: string, message: string): Promise<GitCommit> {
const state = this.getState(projectId);
state.commitSeq += 1;
const gitCommit: GitCommit = {
hash: `mock-${state.commitSeq}`,
summary: message.split("\n")[0],
};
// Remove staged files from the working tree status.
for (const [path, staged] of Array.from(state.files.entries())) {
if (staged) state.files.delete(path);
}
// Push commit to the top of the log.
state.log.unshift(gitCommit);
return gitCommit;
}
async branches(projectId: string): Promise<GitBranches> {
const state = this.getState(projectId);
return { branches: [...state.branches], current: state.current };
}
async checkout(projectId: string, branch: string): Promise<void> {
const state = this.getState(projectId);
if (!state.branches.includes(branch)) {
state.branches.push(branch);
}
state.current = branch;
}
async log(projectId: string, limit: number): Promise<GitCommit[]> {
const state = this.getState(projectId);
return state.log.slice(0, limit);
}
async graph(_projectId: string, limit: number): Promise<GraphCommit[]> {
return DEMO_GRAPH_COMMITS.slice(0, limit);
}
}
class MockRemoteGateway implements RemoteGateway {
async connect(): Promise<void> {}
}
/** The pre-filled reference catalogue the mock serves (mirror of the backend). */
export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
{
id: "mock-claude",
name: "Claude Code",
command: "claude",
args: [],
contextInjection: { strategy: "conventionFile", target: "CLAUDE.md" },
detect: "claude --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-codex",
name: "OpenAI Codex CLI",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "codex --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-gemini",
name: "Gemini CLI",
command: "gemini",
args: [],
contextInjection: { strategy: "conventionFile", target: "GEMINI.md" },
detect: "gemini --version",
cwdTemplate: "{projectRoot}",
},
{
id: "mock-aider",
name: "Aider",
command: "aider",
args: [],
contextInjection: { strategy: "flag", flag: "--message-file {path}" },
detect: "aider --version",
cwdTemplate: "{projectRoot}",
},
];
/**
* In-memory profiles gateway. Tracks configured profiles and a first-run flag so
* the wizard can be driven and tested fully offline. By default it reports the
* first run as *not done* until {@link configureProfiles} is called. Detection
* marks a fixed subset (claude) as installed so ✓/✗ rendering is exercised.
*/
export class MockProfileGateway implements ProfileGateway {
private profiles: AgentProfile[] = [];
private configured = false;
async firstRunState(): Promise<FirstRunState> {
return {
isFirstRun: !this.configured,
referenceProfiles: structuredClone(MOCK_REFERENCE_PROFILES),
};
}
async referenceProfiles(): Promise<AgentProfile[]> {
return structuredClone(MOCK_REFERENCE_PROFILES);
}
async detectProfiles(
candidates: AgentProfile[],
): Promise<ProfileAvailability[]> {
// Pretend only `claude` is installed, so the wizard shows a mix of ✓/✗.
return candidates.map((profile) => ({
profile,
available: profile.command === "claude",
}));
}
async listProfiles(): Promise<AgentProfile[]> {
return structuredClone(this.profiles);
}
async saveProfile(profile: AgentProfile): Promise<AgentProfile> {
const i = this.profiles.findIndex((p) => p.id === profile.id);
if (i >= 0) this.profiles[i] = profile;
else this.profiles.push(profile);
this.configured = true;
return structuredClone(profile);
}
async deleteProfile(profileId: string): Promise<void> {
this.profiles = this.profiles.filter((p) => p.id !== profileId);
}
async configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]> {
this.profiles = structuredClone(profiles);
this.configured = true;
return structuredClone(profiles);
}
}
/**
* Stateful in-memory template gateway.
*
* Shares the `MockAgentGateway` instance passed at construction time so that
* `createAgentFromTemplate` / `detectDrift` / `syncAgent` operate on the same
* agent registry as the rest of the UI (ARCHITECTURE §11).
*
* Exported so tests can instantiate it directly and inject a shared
* `MockAgentGateway`.
*/
export class MockTemplateGateway implements TemplateGateway {
private templates: Template[] = [];
private seq = 0;
constructor(private readonly agentGateway: MockAgentGateway) {}
async listTemplates(): Promise<Template[]> {
return structuredClone(this.templates);
}
async createTemplate(input: CreateTemplateInput): Promise<Template> {
this.seq += 1;
const template: Template = {
id: `mock-template-${this.seq}`,
name: input.name,
contentMd: input.content,
version: 1,
defaultProfileId: input.defaultProfileId,
};
this.templates.push(template);
return structuredClone(template);
}
async updateTemplate(templateId: string, content: string): Promise<Template> {
const idx = this.templates.findIndex((t) => t.id === templateId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
this.templates[idx] = {
...this.templates[idx],
contentMd: content,
version: this.templates[idx].version + 1,
};
return structuredClone(this.templates[idx]);
}
async deleteTemplate(templateId: string): Promise<void> {
const idx = this.templates.findIndex((t) => t.id === templateId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
this.templates.splice(idx, 1);
}
async createAgentFromTemplate(
projectId: string,
templateId: string,
opts?: { name?: string; synchronized?: boolean },
): Promise<Agent> {
const template = this.templates.find((t) => t.id === templateId);
if (!template) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `template ${templateId} not found`,
};
throw err;
}
const synchronized = opts?.synchronized ?? true;
const name = opts?.name ?? template.name;
const slug = slugify(name) || "agent";
// Derive unique contextPath (same logic as MockAgentGateway)
const existingPaths = new Set(
this.agentGateway._rawAgents(projectId).map((a) => a.contextPath),
);
let candidate = `agents/${slug}.md`;
let n = 2;
while (existingPaths.has(candidate)) {
candidate = `agents/${slug}-${n}.md`;
n += 1;
}
const agent: Agent = {
id: `mock-agent-${Math.random().toString(36).slice(2, 10)}`,
name,
contextPath: candidate,
profileId: template.defaultProfileId,
origin: {
type: "fromTemplate",
templateId,
syncedTemplateVersion: template.version,
},
synchronized,
skills: [],
};
this.agentGateway._insertAgent(projectId, agent, template.contentMd);
return structuredClone(agent);
}
async detectDrift(projectId: string): Promise<AgentDrift[]> {
const agents = this.agentGateway._rawAgents(projectId);
const drifts: AgentDrift[] = [];
for (const agent of agents) {
if (!agent.synchronized) continue;
if (agent.origin.type !== "fromTemplate") continue;
const { templateId, syncedTemplateVersion } = agent.origin;
const template = this.templates.find((t) => t.id === templateId);
if (!template) continue;
if (template.version > syncedTemplateVersion) {
drifts.push({
agentId: agent.id,
from: syncedTemplateVersion,
to: template.version,
});
}
}
return drifts;
}
async syncAgent(
projectId: string,
agentId: string,
): Promise<{ synced: boolean; version: number | null }> {
const agents = this.agentGateway._rawAgents(projectId);
const agent = agents.find((a) => a.id === agentId);
if (!agent || !agent.synchronized || agent.origin.type !== "fromTemplate") {
return { synced: false, version: null };
}
const { templateId } = agent.origin;
const template = this.templates.find((t) => t.id === templateId);
if (!template) {
return { synced: false, version: null };
}
this.agentGateway._updateAgent(
projectId,
agentId,
{
origin: {
type: "fromTemplate",
templateId,
syncedTemplateVersion: template.version,
},
},
template.contentMd,
);
return { synced: true, version: template.version };
}
}
/**
* Stateful in-memory skill gateway (L12).
*
* Skills are keyed by `${scope}` (project scope is further partitioned by
* `projectId`, mirroring the disjoint on-disk roots). Assignment mutates the
* agent record held by the injected {@link MockAgentGateway}, so both gateways
* share one store — exactly like {@link MockTemplateGateway}.
*/
export class MockSkillGateway implements SkillGateway {
// Global skills are project-independent; project skills live under their id.
private global: Skill[] = [];
private byProject = new Map<string, Skill[]>();
private seq = 0;
constructor(private readonly agentGateway: MockAgentGateway) {}
private bucket(projectId: string, scope: SkillScope): Skill[] {
if (scope === "global") return this.global;
let list = this.byProject.get(projectId);
if (!list) {
list = [];
this.byProject.set(projectId, list);
}
return list;
}
async listSkills(projectId: string, scope: SkillScope): Promise<Skill[]> {
return structuredClone(this.bucket(projectId, scope));
}
async createSkill(input: CreateSkillInput): Promise<Skill> {
this.seq += 1;
const skill: Skill = {
id: `mock-skill-${this.seq}`,
name: input.name,
contentMd: input.content,
scope: input.scope,
};
this.bucket(input.projectId, input.scope).push(skill);
return structuredClone(skill);
}
async updateSkill(
projectId: string,
scope: SkillScope,
skillId: string,
content: string,
): Promise<Skill> {
const list = this.bucket(projectId, scope);
const idx = list.findIndex((s) => s.id === skillId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `skill ${skillId} not found`,
};
throw err;
}
list[idx] = { ...list[idx], contentMd: content };
return structuredClone(list[idx]);
}
async deleteSkill(
projectId: string,
scope: SkillScope,
skillId: string,
): Promise<void> {
const list = this.bucket(projectId, scope);
const idx = list.findIndex((s) => s.id === skillId);
if (idx === -1) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `skill ${skillId} not found`,
};
throw err;
}
list.splice(idx, 1);
}
async assignSkill(
projectId: string,
agentId: string,
skillId: string,
scope: SkillScope,
): Promise<void> {
const agent = this.agentGateway
._rawAgents(projectId)
.find((a) => a.id === agentId);
if (!agent) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
if (agent.skills.some((s) => s.skillId === skillId)) return; // idempotent
this.agentGateway._setSkills(projectId, agentId, [
...agent.skills,
{ skillId, scope },
]);
}
async unassignSkill(
projectId: string,
agentId: string,
skillId: string,
): Promise<void> {
const agent = this.agentGateway
._rawAgents(projectId)
.find((a) => a.id === agentId);
if (!agent) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
this.agentGateway._setSkills(
projectId,
agentId,
agent.skills.filter((s) => s.skillId !== skillId),
);
}
}
/**
* Stateful in-memory memory gateway (L14).
*
* Notes are partitioned by `projectId` and keyed by their immutable **slug**
* (derived from the name on create). `readIndex` projects the notes into the
* recall index; `resolveLinks` scans `[[slug]]` wikilinks in a note's content
* and keeps only those targeting an existing note; `recall` returns a simple
* truncation of the index (no embeddings in the mock).
*/
export class MockMemoryGateway implements MemoryGateway {
private byProject = new Map<string, Map<string, Memory>>();
private bucket(projectId: string): Map<string, Memory> {
let store = this.byProject.get(projectId);
if (!store) {
store = new Map();
this.byProject.set(projectId, store);
}
return store;
}
private notFound(slug: string, projectId: string): GatewayError {
return {
code: "NOT_FOUND",
message: `memory ${slug} not found in project ${projectId}`,
};
}
async listMemories(projectId: string): Promise<Memory[]> {
return structuredClone([...this.bucket(projectId).values()]);
}
async getMemory(projectId: string, slug: string): Promise<Memory> {
const note = this.bucket(projectId).get(slug);
if (!note) throw this.notFound(slug, projectId);
return structuredClone(note);
}
async createMemory(input: CreateMemoryInput): Promise<Memory> {
const note: Memory = {
name: input.name,
description: input.description,
type: input.type,
content: input.content,
};
this.bucket(input.projectId).set(slugify(input.name), note);
return structuredClone(note);
}
async updateMemory(
projectId: string,
slug: string,
description: string,
type: MemoryType,
content: string,
): Promise<Memory> {
const store = this.bucket(projectId);
const note = store.get(slug);
if (!note) throw this.notFound(slug, projectId);
const updated: Memory = { ...note, description, type, content };
store.set(slug, updated);
return structuredClone(updated);
}
async deleteMemory(projectId: string, slug: string): Promise<void> {
const store = this.bucket(projectId);
if (!store.delete(slug)) throw this.notFound(slug, projectId);
}
async readIndex(projectId: string): Promise<MemoryIndexEntry[]> {
return [...this.bucket(projectId).entries()].map(([slug, note]) => ({
slug,
title: note.name,
hook: note.description,
type: note.type,
}));
}
async resolveLinks(projectId: string, slug: string): Promise<MemoryLink[]> {
const store = this.bucket(projectId);
const note = store.get(slug);
if (!note) throw this.notFound(slug, projectId);
const targets = new Set<MemoryLink>();
for (const match of note.content.matchAll(/\[\[([^\]]+)\]\]/g)) {
const target = match[1].trim();
if (target !== slug && store.has(target)) targets.add(target);
}
return [...targets];
}
async recall(
projectId: string,
_text: string,
tokenBudget: number,
): Promise<MemoryIndexEntry[]> {
const index = await this.readIndex(projectId);
const limit = Math.max(0, Math.floor(tokenBudget / 16));
return index.slice(0, limit);
}
}
/**
* In-memory {@link EmbedderGateway} (L14 / lot C2). Seeds a curated set of
* recommended ONNX engines (e5-small recommended) with both feature flags on by
* default; tests can pass overrides to simulate a build where a strategy is not
* compiled in.
*/
export class MockEmbedderGateway implements EmbedderGateway {
private profiles = new Map<string, EmbedderProfile>();
private engines: EmbedderEngines;
constructor(
engines?: Partial<EmbedderEngines>,
seedProfiles: EmbedderProfile[] = [],
) {
this.engines = {
recommendedOnnx: [
{
id: "e5-small",
displayName: "E5 Small (multilingual)",
dimension: 384,
approxSizeMb: 120,
recommended: true,
},
{
id: "bge-small",
displayName: "BGE Small",
dimension: 384,
approxSizeMb: 130,
recommended: false,
},
],
ollamaDetected: false,
onnxCachedModels: [],
vectorHttpEnabled: true,
vectorOnnxEnabled: true,
...engines,
};
for (const p of seedProfiles) this.profiles.set(p.id, p);
}
async listEmbedderProfiles(): Promise<EmbedderProfile[]> {
return structuredClone([...this.profiles.values()]);
}
async saveEmbedderProfile(
profile: EmbedderProfile,
): Promise<EmbedderProfile> {
if (!profile.id.trim() || !profile.name.trim()) {
const err: GatewayError = {
code: "INVALID",
message: "embedder profile id/name must not be empty",
};
throw err;
}
if (profile.dimension <= 0) {
const err: GatewayError = {
code: "INVALID",
message: "embedder profile dimension must be > 0",
};
throw err;
}
this.profiles.set(profile.id, structuredClone(profile));
return structuredClone(profile);
}
async deleteEmbedderProfile(embedderId: string): Promise<void> {
this.profiles.delete(embedderId);
}
async describeEmbedderEngines(): Promise<EmbedderEngines> {
return structuredClone(this.engines);
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
return {
system: new MockSystemGateway(),
agent: agentGateway,
terminal: new MockTerminalGateway(),
project: new MockProjectGateway(),
layout: new MockLayoutGateway(),
git: new MockGitGateway(),
remote: new MockRemoteGateway(),
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(),
embedder: new MockEmbedderGateway(),
};
}
// Re-export GatewayError so tests can reference the thrown shape.
export type { GatewayError };