/** * 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, EffectivePermissions, PermissionSet, Project, ProjectPermissions, ProjectWorkState, ProfileAvailability, ResumableAgent, Skill, ReplyChunk, SkillScope, Sprint, Template, TerminalSession, Ticket, TicketCarnet, TicketChat, TicketLink, TicketLinkKind, TicketList, TurnPage, TurnView, Unsubscribe, } from "@/domain"; import type { AgentGateway, ConversationGateway, ConversationPageRequest, ConversationDetails, CreateAgentInput, CreateMemoryInput, CreateSkillInput, EmbedderGateway, CreateTemplateInput, Gateways, InputGateway, LiveAgent, MemoryGateway, GitGateway, LayoutGateway, OpenTerminalOptions, ProfileGateway, ProjectGateway, PermissionGateway, ReattachResult, RemoteGateway, SkillGateway, StoppedLiveAgent, SystemGateway, TemplateGateway, TerminalGateway, TerminalHandle, TicketGateway, TicketListQuery, CreateTicketInput, UpdateTicketInput, WorkStateGateway, } from "@/ports"; import { normalizeProjectWorkState } from "../workStateNormalization"; import { applyOperation, singleLeafTree } from "@/features/layout/layout"; export class MockSystemGateway implements SystemGateway { private listeners = new Set<(e: DomainEvent) => void>(); async health(note?: string): Promise { 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 { 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 { 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(); /** Context content indexed by `${projectId}::${agentId}`. */ private contexts = new Map(); /** 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(); /** * 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(); /** Live PTY session id per agent (`agentId → sessionId`). */ private liveSessionByAgent = new Map(); 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 { return structuredClone(this.getAgents(projectId)); } async listLiveAgents(projectId: string): Promise { // 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)!, kind: "pty" as const, })); } /** * Resumable agents per project, seeded by tests via * {@link _setResumableAgents}. Empty by default (no panel on open). */ private resumable = new Map(); /** Seeds the resumable inventory returned for a project (deterministic tests). */ _setResumableAgents(projectId: string, list: ResumableAgent[]): void { this.resumable.set(projectId, list); } async listResumableAgents(projectId: string): Promise { return structuredClone(this.resumable.get(projectId) ?? []); } async attachLiveAgent( projectId: string, agentId: string, nodeId: string, ): Promise { 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, kind: "pty" }; } async stopLiveAgent(projectId: string, agentId: string): Promise { 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) { const err: GatewayError = { code: "NOT_FOUND", message: `agent ${agentId} has no live session`, }; throw err; } const session = this.sessions.get(sessionId); if (session) session.closed = true; this.sessions.delete(sessionId); this.liveSessionByAgent.delete(agentId); this.liveByAgent.delete(agentId); return { agentId, sessionId, kind: "pty" }; } async createAgent(projectId: string, input: CreateAgentInput): Promise { const list = this.getAgents(projectId); const slug = slugify(input.name) || "agent"; // Derive a unique agents/.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 { 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 { 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 { 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>, 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 { 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; // 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); } const clearLive = () => { if (this.liveByAgent.get(agentId) === options.nodeId) { this.liveByAgent.delete(agentId); this.liveSessionByAgent.delete(agentId); } }; // Every agent cell is a raw PTY (Option 1 — Terminal + MCP). The former // structured "chat" cell routing is retired. const enc = new TextEncoder(); const session = new MockPtySession(sessionId, onData); this.sessions.set(sessionId, session); // Greet so something is visible immediately (mirrors MockTerminalGateway). queueMicrotask(() => session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)), ); const handle: TerminalHandle = makeMockHandle(session, () => { this.sessions.delete(sessionId); clearLive(); }); // 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 { 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(); /** 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 { // 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 { 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 {}, detach(): void { session.detach(); }, async close(): Promise { 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(); async openTerminal( options: OpenTerminalOptions, onData: (bytes: Uint8Array) => void, ): Promise { 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 { 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 { // 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(); async listProjects(): Promise { return [...this.projects]; } async createProject(name: string, root: string): Promise { 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 { 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 {} async readProjectContext(projectId: string): Promise { 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 { 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(); 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 { const tree = this.getTree(projectId, layoutId); return structuredClone(tree); } async mutateLayout( projectId: string, operation: LayoutOperation, layoutId?: string, ): Promise { const current = this.getTree(projectId, layoutId); const next = applyOperation(current, operation); this.setTree(projectId, next, layoutId); return structuredClone(next); } async listLayouts(projectId: string): Promise { 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 { 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<{ activeId: string }> { const ps = this.getProjectLayouts(projectId); // Self-heal (I4): only adopt the requested id when it exists; otherwise keep // the current active id. Either way the returned id is authoritative. if (ps.layouts.some((l) => l.id === layoutId)) { ps.activeId = layoutId; } return { activeId: ps.activeId }; } } /** Per-project git state kept in the mock. */ interface MockGitProjectState { files: Map; // 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(); 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(); 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 { if (!this.projects.has(projectId)) { this.projects.set(projectId, this._seedState()); } } async status(projectId: string): Promise { const state = this.getState(projectId); return Array.from(state.files.entries()).map(([path, staged]) => ({ path, staged, })); } async stage(projectId: string, path: string): Promise { const state = this.getState(projectId); if (state.files.has(path)) { state.files.set(path, true); } } async unstage(projectId: string, path: string): Promise { const state = this.getState(projectId); if (state.files.has(path)) { state.files.set(path, false); } } async commit(projectId: string, message: string): Promise { 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 { const state = this.getState(projectId); return { branches: [...state.branches], current: state.current }; } async checkout(projectId: string, branch: string): Promise { const state = this.getState(projectId); if (!state.branches.includes(branch)) { state.branches.push(branch); } state.current = branch; } async log(projectId: string, limit: number): Promise { const state = this.getState(projectId); return state.log.slice(0, limit); } async graph(_projectId: string, limit: number): Promise { return DEMO_GRAPH_COMMITS.slice(0, limit); } } class MockRemoteGateway implements RemoteGateway { async connect(): Promise {} } /** * The pre-filled reference catalogue the mock serves — mirror of the backend's * **selectable** catalogue (§17.3/D7): only profiles drivable in structured mode * (Claude + Codex) are offered to selection/creation. Gemini/Aider stay in the * backend's raw catalogue data but are filtered out of the selection path, so the * mock must not serve them either. */ 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}", }, ]; /** * 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 { return { isFirstRun: !this.configured, referenceProfiles: structuredClone(MOCK_REFERENCE_PROFILES), }; } async referenceProfiles(): Promise { return structuredClone(MOCK_REFERENCE_PROFILES); } async detectProfiles( candidates: AgentProfile[], ): Promise { // Pretend only `claude` is installed, so the wizard shows a mix of ✓/✗. return candidates.map((profile) => ({ profile, available: profile.command === "claude", })); } async listProfiles(): Promise { return structuredClone(this.profiles); } async saveProfile(profile: AgentProfile): Promise { 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 { this.profiles = this.profiles.filter((p) => p.id !== profileId); } async configureProfiles(profiles: AgentProfile[]): Promise { 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 { return structuredClone(this.templates); } async createTemplate(input: CreateTemplateInput): Promise