Files
IdeA/frontend/src/adapters/mock/index.ts
Blomios fbaca9d5cc feat(plugins): seam slash-commands contribuées par plugin via callback — #165 (QA verte)
Permet à un plugin de contribuer des commandes slash adossées à une callback,
transitant par le registry/contrat unifié (#162) :

- sdk/IdeaSDK: manifeste contributes.slashCommands (déclaratif) — pointer bumpé.
- domain: source Plugin étendue + effet PluginCallback (la commande ne
  s'exécute pas directement : elle renvoie une référence à la callback du plugin).
- application: registration des commandes plugin dans le registry + exécution
  renvoyant l'effet PluginCallback ; métadonnées UI suffisantes pour l'autocomplete.
- backend + app-tauri: DTO + commandes Tauri listant/invoquant les commandes plugin.
- frontend (contrat): types adapters/domain/ports/mock pour pluginCallback et
  l'invocation avec arguments.

L'exécution effective de la callback côté runtime UI est livrée par #166.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:46:52 +02:00

4455 lines
138 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,
AppExitWorkGuardState,
AgentProfile,
DiagnosticWarning,
DomainEvent,
EffortSelection,
EmbedderEngines,
EmbedderProfile,
EmbeddedServerStatus,
FirstRunState,
GatewayError,
GitBranches,
GitCommit,
GitFileStatus,
GraphCommit,
HealthReport,
LayoutInfo,
LayoutKind,
LayoutList,
LayoutOperation,
LayoutTree,
PluginLayoutOrigin,
LocalModelServerConfig,
ModelServerCommandPreview,
Memory,
MemoryIndexEntry,
MemoryLink,
MemoryType,
McpToolCatalogue,
McpToolPolicy,
OpenCodeProviderCatalogEntry,
ProfileModelCatalog,
ProfileModelCatalogEntry,
PairedDevice,
PairingCode,
PermissionSet,
PluginAdmin,
PluginCommandTask,
PluginConfigDocument,
PluginConfigDocumentWriteResult,
PluginContributionSummary,
PluginEventBatch,
PluginEventSubscription,
PluginInstallResult,
PluginLifecycleState,
PluginProjectConvention,
PluginProjectModule,
PluginProjectStructure,
PluginProjectStructureEntry,
PluginReview,
PluginRuntimeContributionCatalog,
PluginToolchainDiagnostic,
PluginUninstallResult,
PluginPublicEvent,
PluginWorkspaceBinaryFile,
PluginWorkspaceDirectoryListing,
PluginWorkspaceDirEntry,
PluginWorkspaceStat,
PluginWorkspaceTextFile,
Project,
JsonValue,
ProjectMcpToolPermissions,
ProjectPermissions,
ProjectWorkState,
ProjectSystemPermissions,
ProfileAvailability,
ResumableAgent,
ResolvedAgentPermissions,
ResolvedAgentSystemPermissions,
ServerExposurePreview,
ServerExposureSettings,
Skill,
ReplyChunk,
ExecuteSlashCommandResult,
SlashCommand,
SkillScope,
Sprint,
Template,
SystemPermissionSet,
TerminalSession,
Ticket,
TicketAttachmentContent,
TicketBulkResult,
TicketCarnet,
TicketChat,
TicketLink,
TicketLinkKind,
TicketList,
TicketPriority,
TicketStatus,
TurnPage,
TurnView,
Unsubscribe,
} from "@/domain";
import type {
AgentChatHandle,
AgentGateway,
ConversationGateway,
ConversationPageRequest,
ConversationDetails,
CloneProfileFromSeedInput,
CloneOpenCodeProfileFromSeedInput,
CreateAgentInput,
CreateMemoryInput,
CreateSkillInput,
DesktopServerGateway,
DeviceGateway,
EmbedderGateway,
CreateTemplateInput,
Gateways,
InputGateway,
LiveAgent,
MemoryGateway,
GitGateway,
LayoutGateway,
ModelServerGateway,
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
PermissionGateway,
PluginConfigDocumentReadInput,
PluginConfigDocumentUpdateInput,
PluginConfigGateway,
PluginEventGateway,
PluginEventPollInput,
PluginEventSubscribeInput,
PluginEventUnsubscribeInput,
PluginGateway,
PluginStorageGateway,
PluginStorageGetInput,
PluginStorageSetInput,
PluginProjectStructureQuery,
PluginRunCommandInput,
PluginTaskGateway,
PluginTaskStatusInput,
PluginToolchainDiagnosticRequest,
PluginToolchainGateway,
PluginWorkspaceGateway,
PluginWorkspacePathInput,
PluginWorkspaceWriteBinaryInput,
PluginWorkspaceWriteTextInput,
ReattachResult,
ReattachAgentChatResult,
RemoteGateway,
ReviewPluginPackageInput,
SaveOpenCodeProviderProfileInput,
SendAgentChatOptions,
SkillGateway,
StoppedLiveAgent,
SystemGateway,
TemplateGateway,
TerminalGateway,
TerminalHandle,
TicketGateway,
TicketListQuery,
CreateTicketInput,
UpdateTicketInput,
UiPreferencesGateway,
ViewWindowClosed,
ViewWindowSnapshot,
WindowGateway,
PluginLayoutWindowOpenInput,
PluginLayoutWindowOpenResult,
FocusedProject,
FocusedProjectGateway,
WorkStateGateway,
BackgroundTaskAttachment,
} from "@/ports";
import { normalizeProjectWorkState } from "../workStateNormalization";
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
const TICKET_PRIORITY_RANK: Record<TicketPriority, number> = {
low: 0,
medium: 1,
high: 2,
critical: 3,
};
const TICKET_STATUS_RANK: Record<TicketStatus, number> = {
open: 0,
inProgress: 1,
QA: 2,
closed: 3,
};
function sortTickets(
rows: Ticket[],
sort: NonNullable<TicketListQuery["sort"]>,
): Ticket[] {
return rows.sort((a, b) => {
let fieldOrder = 0;
if (sort.field === "number") {
fieldOrder = a.number - b.number;
} else if (sort.field === "priority") {
fieldOrder =
TICKET_PRIORITY_RANK[a.priority] - TICKET_PRIORITY_RANK[b.priority];
} else if (sort.field === "status") {
fieldOrder = TICKET_STATUS_RANK[a.status] - TICKET_STATUS_RANK[b.status];
} else {
fieldOrder =
a.title.localeCompare(b.title, undefined, { sensitivity: "base" }) ||
a.title.localeCompare(b.title);
}
const directed = sort.direction === "desc" ? -fieldOrder : fieldOrder;
return directed || a.number - b.number;
});
}
function sameTicketCreator(
actor: Ticket["createdBy"],
filter: NonNullable<TicketListQuery["createdBy"]>,
): boolean {
if (actor.kind !== filter.kind) return false;
if (actor.kind === "agent" && filter.kind === "agent") {
return actor.agentId === filter.agentId;
}
return true;
}
function filenameFromPath(path: string): string {
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? "attachment";
}
function inferAttachmentMime(filename: string, supplied?: string | null): string {
if (supplied?.trim()) return supplied.trim();
const lower = filename.toLowerCase();
if (lower.endsWith(".md")) return "text/markdown";
if (lower.endsWith(".txt")) return "text/plain";
if (lower.endsWith(".json")) return "application/json";
if (lower.endsWith(".png")) return "image/png";
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
if (lower.endsWith(".pdf")) return "application/pdf";
return "application/octet-stream";
}
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";
}
/** Returns a deterministic fake path — never opens a native dialog. */
async pickArchiveFile(): Promise<string | null> {
return "/home/user/mock-plugin.ideaplug";
}
/** Returns a deterministic fake path — never opens a native dialog. */
async pickFile(): Promise<string | null> {
return "/home/user/mock-attachment.txt";
}
private exitGuardListeners = new Set<(state: AppExitWorkGuardState) => void>();
/** Count of `confirmAppExit()` calls, for test assertions. */
confirmAppExitCallCount = 0;
async onAppExitWorkGuard(
handler: (state: AppExitWorkGuardState) => void,
): Promise<Unsubscribe> {
this.exitGuardListeners.add(handler);
return () => {
this.exitGuardListeners.delete(handler);
};
}
/** Test/dev helper to push an app-exit work guard state to all subscribers. */
emitAppExitWorkGuard(state: AppExitWorkGuardState): void {
for (const l of this.exitGuardListeners) l(state);
}
async confirmAppExit(): Promise<void> {
this.confirmAppExitCallCount += 1;
}
}
/**
* 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>();
/** Runtime kind per live agent (`agentId → kind`). */
private liveKindByAgent = new Map<string, "pty" | "structured">();
/** Retained structured reply chunks per live chat session. */
private chatScrollback = new Map<string, ReplyChunk[]>();
private slashCommands: SlashCommand[] = [
{
name: "/help",
shortDescription: "Afficher les commandes disponibles",
requiresConfirmation: false,
availability: { status: "available" },
source: "native",
native: "help",
},
{
name: "/clean",
shortDescription: "Nettoyer la conversation courante",
requiresConfirmation: false,
availability: { status: "available" },
source: "native",
native: "clean",
},
{
name: "/profile",
shortDescription: "Changer le profil de l'agent",
requiresConfirmation: true,
availability: { status: "available" },
source: "native",
native: "profile",
},
];
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)!,
kind: this.liveKindByAgent.get(agentId) ?? "pty",
}));
}
/**
* Resumable agents per project, seeded by tests via
* {@link _setResumableAgents}. Empty by default (no panel on open).
*/
private resumable = new Map<string, ResumableAgent[]>();
/** 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<ResumableAgent[]> {
return structuredClone(this.resumable.get(projectId) ?? []);
}
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.liveKindByAgent.get(agentId) !== "structured" &&
!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: this.liveKindByAgent.get(agentId) ?? "pty",
};
}
async stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
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.chatScrollback.delete(sessionId);
this.liveSessionByAgent.delete(agentId);
this.liveByAgent.delete(agentId);
const kind = this.liveKindByAgent.get(agentId) ?? "pty";
this.liveKindByAgent.delete(agentId);
return { agentId, sessionId, kind };
}
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,
},
};
}
async updateAgentEffort(
projectId: string,
agentId: string,
effort: EffortSelection | null,
): Promise<Agent> {
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;
}
const next =
effort === null
? (({ effort: _dropped, ...rest }) => rest)(list[idx])
: { ...list[idx], effort };
list[idx] = next;
return structuredClone(next);
}
// ── 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;
// 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);
this.liveKindByAgent.set(agentId, "pty");
}
const clearLive = () => {
if (this.liveByAgent.get(agentId) === options.nodeId) {
this.liveByAgent.delete(agentId);
this.liveSessionByAgent.delete(agentId);
this.liveKindByAgent.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 launchAgentChat(
projectId: string,
agentId: string,
options: OpenTerminalOptions,
): Promise<AgentChatHandle> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
throw {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
} as GatewayError;
}
const liveNode = this.liveByAgent.get(agentId);
if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
throw {
code: "AGENT_ALREADY_RUNNING",
message: `agent ${agentId} is already running in cell ${liveNode}`,
} as GatewayError;
}
this.sessionSeq += 1;
const sessionId = `mock-agent-chat-${this.sessionSeq}`;
this.chatScrollback.set(sessionId, []);
if (options.nodeId) {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
this.liveKindByAgent.set(agentId, "structured");
}
return {
sessionId,
cellKind: "chat",
...(!options.conversationId
? { assignedConversationId: `mock-conversation-${sessionId}` }
: {}),
};
}
async reattachAgentChat(
sessionId: string,
_onChunk: (chunk: ReplyChunk) => void,
): Promise<ReattachAgentChatResult> {
const chunks = this.chatScrollback.get(sessionId);
if (!chunks) {
throw {
code: "NOT_FOUND",
message: `agent chat session ${sessionId} is not alive`,
} as GatewayError;
}
return { sessionId, scrollback: structuredClone(chunks) };
}
async sendAgentChat(
sessionId: string,
prompt: string,
onChunk: (chunk: ReplyChunk) => void,
_options: SendAgentChatOptions = {},
): Promise<void> {
const chunks = this.chatScrollback.get(sessionId);
if (!chunks) {
throw {
code: "NOT_FOUND",
message: `agent chat session ${sessionId} not found`,
} as GatewayError;
}
const content = `Agent: reçu « ${prompt} ».`;
const streamed: ReplyChunk[] = [
{
kind: "progress",
progress: {
source: "providerNative",
kind: "message",
stage: "delta",
label: "Analyse",
text: "Traitement du message en cours.",
provider: "mock",
nativeEvent: "mock.progress",
},
},
{ kind: "toolActivity", label: "Analyse du prompt" },
{ kind: "textDelta", text: "Agent: reçu " },
{ kind: "textDelta", text: `« ${prompt} ».` },
{ kind: "final", content },
];
for (const chunk of streamed) {
await Promise.resolve();
chunks.push(structuredClone(chunk));
onChunk(chunk);
}
}
async listSlashCommands(prefix?: string): Promise<SlashCommand[]> {
const normalized = prefix?.trim();
const query = normalized
? normalized.startsWith("/")
? normalized
: `/${normalized}`
: "";
return structuredClone(
this.slashCommands.filter((command) =>
query ? command.name.startsWith(query) : true,
),
);
}
async executeSlashCommand(
name: string,
options: { sessionId?: string | null; arguments?: unknown[] } = {},
): Promise<ExecuteSlashCommandResult> {
const normalized = name.trim().startsWith("/") ? name.trim() : `/${name.trim()}`;
const command = this.slashCommands.find((item) => item.name === normalized);
if (!command) {
throw { code: "NOT_FOUND", message: `slash command ${normalized}` } as GatewayError;
}
if (command.availability.status === "unavailable") {
throw {
code: "INVALID",
message: `slash command ${normalized} unavailable: ${command.availability.reason}`,
} as GatewayError;
}
if (command.native === "clean") {
if (!options.sessionId) {
throw {
code: "INVALID",
message: "/clean requires a current session id",
} as GatewayError;
}
this.chatScrollback.set(options.sessionId, []);
return {
command: structuredClone(command),
effect: {
kind: "cleanConversation",
sessionId: options.sessionId,
clearedChunks: 0,
},
};
}
if (command.native === "profile") {
if (!options.sessionId) {
throw {
code: "INVALID",
message: "/profile requires a current session id",
} as GatewayError;
}
return {
command: structuredClone(command),
effect: {
kind: "profileSwitch",
sessionId: options.sessionId,
},
};
}
if (command.plugin) {
return {
command: structuredClone(command),
effect: {
kind: "pluginCallback",
pluginId: command.plugin.pluginId,
commandId: command.plugin.commandId,
sessionId: options.sessionId ?? undefined,
arguments: structuredClone(options.arguments ?? []),
},
};
}
return {
command: structuredClone(command),
effect: {
kind: "help",
commands: structuredClone(this.slashCommands),
},
};
}
async closeAgentChat(sessionId: string): Promise<void> {
this.chatScrollback.delete(sessionId);
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
if (liveSessionId === sessionId) {
this.liveSessionByAgent.delete(agentId);
this.liveByAgent.delete(agentId);
this.liveKindByAgent.delete(agentId);
}
}
}
async cancelAgentChat(sessionId: string): Promise<void> {
if (!this.chatScrollback.has(sessionId)) {
throw {
code: "NOT_FOUND",
message: `agent chat session ${sessionId} not found`,
} as GatewayError;
}
}
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);
this.liveKindByAgent.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;
pluginOrigin?: PluginLayoutOrigin | null;
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,
pluginOrigin: l.pluginOrigin,
}));
return { layouts, activeId: ps.activeId };
}
async createLayout(
projectId: string,
name: string,
kind: LayoutKind = "terminal",
pluginOrigin?: PluginLayoutOrigin,
): Promise<{ layoutId: string }> {
const ps = this.getProjectLayouts(projectId);
const layoutId = `layout-${Math.random().toString(36).slice(2, 10)}`;
const tree =
kind === "plugin" && pluginOrigin
? {
root: {
type: "customPluginLayout" as const,
node: {
id: `plugin-cell-${Math.random().toString(36).slice(2, 10)}`,
pluginId: pluginOrigin.pluginId,
layoutType: pluginOrigin.layoutType,
state: null,
},
},
}
: singleLeafTree();
ps.layouts.push({ id: layoutId, name, kind, pluginOrigin: pluginOrigin ?? null, tree });
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<{ 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<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> {}
}
/**
* In-memory {@link WindowGateway} for tests/dev (#23, panel-only in #47). Tracks
* which views are "detached" in a set keyed by `panel` alone (a panel-only
* window follows the focused project, so no project id is part of the key), and
* lets tests simulate the OS closing a window via {@link simulateOsClose}.
* `closeViewWindow` also emits the close event, mirroring the backend (a
* programmatic close still notifies).
*/
export class MockWindowGateway implements WindowGateway {
/** Currently-open detached windows, keyed by `panel`. */
readonly open = new Set<string>();
readonly pluginLayoutWindows = new Map<string, PluginLayoutWindowOpenResult>();
private readonly listeners = new Set<(e: ViewWindowClosed) => void>();
async openViewWindow(panel: string): Promise<void> {
this.open.add(panel);
}
async openPluginLayoutWindow(
input: PluginLayoutWindowOpenInput,
): Promise<PluginLayoutWindowOpenResult> {
const label = `view-plugin-layout-${input.pluginId}.${input.layoutType}`;
const alreadyOpen = this.pluginLayoutWindows.has(label);
const result: PluginLayoutWindowOpenResult = {
label,
url: `index.html?pluginLayout=1&pluginId=${encodeURIComponent(input.pluginId)}&layoutType=${encodeURIComponent(input.layoutType)}`,
alreadyOpen,
providerPluginDisplayName: input.pluginId,
layoutLabel: input.layoutType,
surface: {
pluginId: input.pluginId,
layoutType: input.layoutType,
state: input.state ?? null,
},
};
this.pluginLayoutWindows.set(label, result);
return result;
}
async closeViewWindow(panel: string): Promise<void> {
if (this.open.delete(panel)) {
this.emit({ panel });
}
}
async listOpenViewWindows(): Promise<ViewWindowSnapshot[]> {
// Mirror the backend enumeration: every currently-open detached window is
// reported visible, with a stable label derived from its panel id.
return [...this.open].map((panel) => ({
panel,
label: panel,
visible: true,
}));
}
async onViewWindowClosed(
handler: (event: ViewWindowClosed) => void,
): Promise<Unsubscribe> {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
/** Test hook: simulate the user closing the OS window (fires the event). */
simulateOsClose(panel: string): void {
this.open.delete(panel);
this.emit({ panel });
}
private emit(event: ViewWindowClosed): void {
for (const l of this.listeners) l(event);
}
}
/**
* In-memory {@link FocusedProjectGateway} for tests/dev (#47). Holds the current
* focus in memory and fans changes out to subscribers — the mock counterpart to
* the backend's shared focused-project state + `focused-project://changed`
* event. `setFocusedProject` always notifies (mirrors the backend emit), so a
* detached-window test can drive a panel window's focus transitions.
*/
export class MockFocusedProjectGateway implements FocusedProjectGateway {
private focused: FocusedProject | null = null;
private readonly listeners = new Set<
(project: FocusedProject | null) => void
>();
async setFocusedProject(project: FocusedProject | null): Promise<void> {
this.focused = project;
for (const l of this.listeners) l(project);
}
async getFocusedProject(): Promise<FocusedProject | null> {
return this.focused;
}
async onFocusedProjectChanged(
handler: (project: FocusedProject | null) => void,
): Promise<Unsubscribe> {
this.listeners.add(handler);
return () => {
this.listeners.delete(handler);
};
}
}
/**
* 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}",
structuredAdapter: "claude",
},
{
id: "mock-codex",
name: "OpenAI Codex CLI",
command: "codex",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "codex --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "codex",
},
{
id: "mock-opencode",
name: "OpenCode + llama.cpp",
command: "opencode",
args: [],
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
detect: "opencode --version",
cwdTemplate: "{projectRoot}",
structuredAdapter: "openCode",
opencode: {
baseURL: "http://localhost:8080/v1",
apiKey: "sk-no-key",
model: "qwen3-coder-30b",
},
},
];
/** Static mock catalogue mirroring the backend OpenCode cloud-provider list. */
const MOCK_OPENCODE_PROVIDERS: OpenCodeProviderCatalogEntry[] = [
{
providerId: "anthropic",
displayName: "Anthropic",
models: ["claude-sonnet-5", "claude-opus-4-8", "claude-haiku-4-5"],
},
{
providerId: "openrouter",
displayName: "OpenRouter",
models: ["openrouter/auto", "qwen/qwen3-coder"],
},
];
const MOCK_CLAUDE_MODELS: ProfileModelCatalogEntry[] = [
{
adapter: "claude",
modelId: "claude-sonnet-5",
displayName: "Claude Sonnet 5",
aliases: ["sonnet"],
recommended: true,
compatibility: "compatible",
source: "catalogue",
},
{
adapter: "claude",
modelId: "claude-opus-4-8",
displayName: "Claude Opus 4.8",
aliases: ["opus"],
recommended: false,
compatibility: "unknown",
source: "catalogue",
},
{
adapter: "claude",
modelId: "claude-haiku-4-5-20251001",
displayName: "Claude Haiku 4.5",
aliases: ["haiku"],
recommended: false,
compatibility: "likelyTooRecent",
source: "provider",
},
];
const MOCK_CODEX_MODELS: ProfileModelCatalogEntry[] = [
{
adapter: "codex",
modelId: "gpt-5-codex",
displayName: "GPT-5 Codex",
aliases: ["codex"],
recommended: true,
compatibility: "compatible",
source: "catalogue",
},
{
adapter: "codex",
modelId: "gpt-5",
displayName: "GPT-5",
aliases: ["general"],
recommended: false,
compatibility: "unknown",
source: "catalogue",
},
{
adapter: "codex",
modelId: "gpt-5-mini",
displayName: "GPT-5 mini",
aliases: ["mini", "fast"],
recommended: false,
compatibility: "likelyTooRecent",
source: "provider",
},
];
/**
* 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;
/** Monotonic suffix so cloned OpenCode profiles get distinct ids/names. */
private cloneCounter = 0;
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);
}
async cloneProfileFromSeed(
input: CloneProfileFromSeedInput,
): Promise<AgentProfile> {
const seed = [...this.profiles, ...MOCK_REFERENCE_PROFILES].find(
(p) => p.id === input.seedProfileId,
);
if (!seed) throw new Error(`unknown profile seed: ${input.seedProfileId}`);
this.cloneCounter += 1;
const cloned: AgentProfile = {
...structuredClone(seed),
id: `mock-profile-clone-${this.cloneCounter}`,
name: input.name ?? `${seed.name} copy`,
model:
input.model !== undefined && input.model.trim() !== ""
? input.model
: seed.model,
};
this.profiles.push(cloned);
this.configured = true;
return structuredClone(cloned);
}
async listClaudeModels(): Promise<ProfileModelCatalog> {
return {
models: structuredClone(MOCK_CLAUDE_MODELS),
cliVersion: "2.1.220",
warnings: [],
};
}
async listCodexModels(): Promise<ProfileModelCatalog> {
return {
models: structuredClone(MOCK_CODEX_MODELS),
cliVersion: "0.145.0",
warnings: ["Catalogue provider partiellement estime depuis les donnees locales."],
};
}
async cloneOpenCodeProfileFromSeed(
input: CloneOpenCodeProfileFromSeedInput = {},
): Promise<AgentProfile> {
const seed = MOCK_REFERENCE_PROFILES.find(
(p) => p.structuredAdapter === "openCode",
);
if (!seed) throw new Error("no OpenCode seed profile");
this.cloneCounter += 1;
return structuredClone({
...seed,
id: `mock-opencode-clone-${this.cloneCounter}`,
name: input.name ?? `${seed.name} (copy ${this.cloneCounter})`,
opencode: input.opencode ?? seed.opencode,
});
}
async listOpenCodeProviders(): Promise<OpenCodeProviderCatalogEntry[]> {
return structuredClone(MOCK_OPENCODE_PROVIDERS);
}
async saveOpenCodeProviderProfile(
input: SaveOpenCodeProviderProfileInput,
): Promise<AgentProfile> {
const saved: AgentProfile = {
...structuredClone(input.profile),
opencode: undefined,
opencodeProvider: {
providerId: input.providerId,
model: input.model,
// The mock never seals a real secret; the ref is opaque either way.
apiKeyRef: `mock-secret-${input.profile.id}`,
custom: input.custom,
},
};
const i = this.profiles.findIndex((p) => p.id === saved.id);
if (i >= 0) this.profiles[i] = saved;
else this.profiles.push(saved);
this.configured = true;
return structuredClone(saved);
}
}
/**
* In-memory local model server registry (F35). Tracks declared servers and can
* be told which ids are still referenced by a profile, so `deleteModelServer`
* reproduces the backend `model_server_in_use` rejection offline.
*/
export class MockModelServerGateway implements ModelServerGateway {
private servers: LocalModelServerConfig[] = [];
private readonly inUse = new Set<string>();
async listModelServers(): Promise<LocalModelServerConfig[]> {
return structuredClone(this.servers);
}
async saveModelServer(
config: LocalModelServerConfig,
): Promise<LocalModelServerConfig> {
const i = this.servers.findIndex((s) => s.id === config.id);
if (i >= 0) this.servers[i] = structuredClone(config);
else this.servers.push(structuredClone(config));
return structuredClone(config);
}
async deleteModelServer(serverId: string): Promise<void> {
if (this.inUse.has(serverId)) {
const err: GatewayError = {
code: "model_server_in_use",
message: "A profile still references this server.",
};
throw err;
}
this.servers = this.servers.filter((s) => s.id !== serverId);
}
async deleteModelArtifact(serverId: string): Promise<void> {
const i = this.servers.findIndex((s) => s.id === serverId);
if (i < 0) {
const err: GatewayError = {
code: "not_configured",
message: "model server is not configured",
};
throw err;
}
const server = this.servers[i];
if (server.modelSource?.type !== "huggingFace") {
const err: GatewayError = {
code: "invalid",
message: "only managed Hugging Face model artifacts can be deleted",
};
throw err;
}
if (server.artifact?.state === "downloading" || this.inUse.has(serverId)) {
const err: GatewayError = {
code: "model_server_in_use",
message: "model artifact cannot be deleted while in use",
};
throw err;
}
this.servers[i] = {
...server,
artifact: { state: "missing" },
};
}
async previewModelServerCommand(
config: LocalModelServerConfig,
): Promise<ModelServerCommandPreview> {
// Mirror of the backend `LlamaCppRuntime::build_argv` (order matters), kept
// deliberately simple: it exists so the UI preview flow is testable, never
// as the source of truth (production uses the real backend command).
if (!config.modelSource) {
const err: GatewayError = {
code: "INVALID",
message: "model.source missing",
};
throw err;
}
const command =
config.binaryPath && config.binaryPath.trim().length > 0
? config.binaryPath
: "llama-server";
const args: string[] = [];
if (config.modelSource.type === "localPath") {
args.push("--model", config.modelSource.path);
} else {
args.push("-hf", config.modelSource.repo);
}
args.push("--port", String(config.port), "--host", config.host);
if (config.gpuLayers !== undefined) {
args.push("-ngl", String(config.gpuLayers));
}
if (config.contextSize !== undefined) {
args.push("-c", String(config.contextSize));
}
if (config.jinja) args.push("--jinja");
args.push(...config.args);
const display = [command, ...args]
.map((part) => (/\s/.test(part) ? `'${part}'` : part))
.join(" ");
return { command, args, display };
}
/** Test hook: mark a server id as still referenced (delete then rejects). */
markInUse(serverId: string): void {
this.inUse.add(serverId);
}
}
/** Mirror of the backend `default_settings()` (ticket #68). */
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
mode: "localOnly",
autoStart: false,
port: 17373,
trustedProxies: [],
};
/** Fixed LAN candidates so the offline UI has something to pick from. */
const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"];
function invalid(message: string): never {
const err: GatewayError = { code: "INVALID", message };
throw err;
}
/**
* In-memory embedded-server gateway (ticket #68).
*
* Mirrors the backend `validate_settings` / `preview_settings` closely enough
* that the Deployment UI — including its rejection paths — is testable offline.
* It is never the source of truth: production runs the real Tauri commands.
* `candidateLanAddresses` is fixed data here precisely because the UI must take
* addresses from the gateway rather than deriving them.
*/
export class MockDesktopServerGateway implements DesktopServerGateway {
private settings: ServerExposureSettings = structuredClone(
DEFAULT_EXPOSURE_SETTINGS,
);
private current: EmbeddedServerStatus = { state: "stopped" };
private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>();
async getExposureSettings(): Promise<ServerExposureSettings> {
return structuredClone(this.settings);
}
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
this.validate(settings);
this.settings = structuredClone(settings);
}
async previewExposure(
settings: ServerExposureSettings,
): Promise<ServerExposurePreview> {
// The real `preview_server_exposure_settings` validates *before* previewing,
// so an incomplete draft is rejected rather than previewed. Mirrored here —
// the UI relies on it as its validation authority.
this.validate(settings);
const warnings: DiagnosticWarning[] = [];
if (
settings.mode === "remoteProxyOtherMachine" &&
settings.trustedProxies.length === 0
) {
// Mirrors the backend's `missingTrustedProxy` warning — which the backend
// itself cannot currently reach, since `validate_settings` rejects an
// empty `trustedProxies` for this mode first. Kept aligned on purpose: if
// the backend reorders, the mock already matches.
warnings.push({
code: "missingTrustedProxy",
message:
"Add the IP address or CIDR of the reverse proxy that connects to this server.",
});
}
const bindAddress =
settings.mode === "remoteProxyOtherMachine"
? settings.lanBindAddress
: "127.0.0.1";
const upstreamUrl =
settings.mode !== "localOnly" && bindAddress
? `http://${bindAddress}:${settings.port}`
: undefined;
return {
candidateLanAddresses: [...MOCK_LAN_ADDRESSES],
upstreamUrl,
warnings,
};
}
async status(): Promise<EmbeddedServerStatus> {
return structuredClone(this.current);
}
async start(): Promise<EmbeddedServerStatus> {
// The backend re-validates on start; a bad config fails there, not silently.
this.validate(this.settings);
const preview = await this.previewExposure(this.settings);
this.emit({
state: "running",
localUrl: `http://127.0.0.1:${this.settings.port}`,
publicUrl:
this.settings.mode === "localOnly"
? undefined
: this.settings.publicOrigin,
upstreamUrl: preview.upstreamUrl,
});
return structuredClone(this.current);
}
async stop(): Promise<EmbeddedServerStatus> {
this.emit({ state: "stopped" });
return structuredClone(this.current);
}
async onStatusChanged(
handler: (status: EmbeddedServerStatus) => void,
): Promise<Unsubscribe> {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
/** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */
setStatus(status: EmbeddedServerStatus): void {
this.emit(status);
}
private emit(status: EmbeddedServerStatus): void {
this.current = status;
for (const handler of this.handlers) handler(structuredClone(status));
}
private validate(settings: ServerExposureSettings): void {
if (settings.mode !== "localOnly") {
const origin = settings.publicOrigin;
if (!origin) invalid("remote exposure requires publicOrigin");
if (!origin.startsWith("https://")) {
invalid("remote exposure requires publicOrigin to start with https://");
}
if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard");
if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'");
}
if (settings.mode === "remoteProxyOtherMachine") {
if (!settings.lanBindAddress) {
invalid("remoteProxyOtherMachine requires lanBindAddress");
}
if (settings.trustedProxies.length === 0) {
invalid("remoteProxyOtherMachine requires at least one trustedProxies entry");
}
}
}
}
/**
* 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,
description: input.description ?? null,
kind: input.kind ?? "workflow",
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);
}
}
/** One recorded agent-input control call (for test assertions). */
export interface MockInputCall {
projectId: string;
agentId: string;
/** Present for `delegationDelivered`, absent for `interrupt`. */
ticket?: string;
}
/**
* In-memory {@link InputGateway} (ARCHITECTURE §20.3). Records every
* `interrupt`/`delegationDelivered` so tests can assert the component routed
* through the port with the right args — no backend.
*
* Exported so tests can instantiate it directly (same pattern as the other mocks).
*/
export class MockInputGateway implements InputGateway {
/** All recorded interrupt calls, in order. */
readonly interrupts: MockInputCall[] = [];
/** All recorded delegation-delivered acks, in order. */
readonly delivered: MockInputCall[] = [];
/** All recorded front-attached reports, in order. */
readonly frontAttached: { agentId: string; attached: boolean }[] = [];
/** All recorded cancel-resume calls, in order. */
readonly cancelledResumes: string[] = [];
/** All recorded set-resume-at armings (human net, level 3), in order. */
readonly resumeArmings: { agentId: string; resetsAtMs: number }[] = [];
/**
* What {@link cancelResume} resolves to (mirrors the backend `bool`: whether a
* pending resume was disarmed). Tests can flip it to exercise the "already
* fired / none armed" path.
*/
cancelResumeResult = true;
async interrupt(projectId: string, agentId: string): Promise<void> {
this.interrupts.push({ projectId, agentId });
}
async delegationDelivered(
projectId: string,
agentId: string,
ticket: string,
): Promise<void> {
this.delivered.push({ projectId, agentId, ticket });
}
async setFrontAttached(agentId: string, attached: boolean): Promise<void> {
this.frontAttached.push({ agentId, attached });
}
async cancelResume(agentId: string): Promise<boolean> {
this.cancelledResumes.push(agentId);
return this.cancelResumeResult;
}
async setResumeAt(agentId: string, resetsAtMs: number): Promise<void> {
this.resumeArmings.push({ agentId, resetsAtMs });
}
}
/**
* In-memory paired-device gateway (ticket #77) — stands in for B1/B2/B3 while
* the backend lands, and drives the `VITE_USE_MOCK` dev surface.
*
* Models the parts of the frozen contract the UI can actually observe: a device
* list with exactly one `isCurrentDevice`, single-use codes with a 600 s TTL
* where generating invalidates the previous one, and revocation. It does **not**
* model the wire (no HTTP, no cookie): that is the HTTP gateway's job.
*/
export class MockDeviceGateway implements DeviceGateway {
private devices: PairedDevice[] = [];
private issued = 0;
/** Seeds the device list (tests/dev). */
_setDevices(devices: PairedDevice[]): void {
this.devices = structuredClone(devices);
}
/** Every code handed out so far, newest last — the previous ones are dead. */
_issuedCodes: string[] = [];
async listDevices(): Promise<PairedDevice[]> {
return structuredClone(this.devices);
}
async createPairingCode(): Promise<PairingCode> {
// Deterministic 8-char uppercase hex, same shape as the server's.
const code = (0x10000000 + this.issued++)
.toString(16)
.toUpperCase()
.slice(0, 8);
this._issuedCodes.push(code);
const ttlSeconds = 600;
return {
code,
// Epoch ms, exactly as the server sends it — a mock that invents a
// friendlier encoding is how the ISO/epoch mismatch stayed green (#77).
expiresAtMs: Date.now() + ttlSeconds * 1000,
ttlSeconds,
};
}
async renameDevice(deviceId: string, name: string): Promise<void> {
const device = this.devices.find((d) => d.deviceId === deviceId);
if (!device) {
const err: GatewayError = { code: "NOT_FOUND", message: `unknown device ${deviceId}` };
throw err;
}
device.name = name;
}
async revokeDevice(deviceId: string): Promise<void> {
this.devices = this.devices.filter((d) => d.deviceId !== deviceId);
}
async revokeAllDevices(): Promise<void> {
this.devices = [];
}
}
/** In-memory permissions gateway. */
export class MockPermissionGateway implements PermissionGateway {
private docs = new Map<string, ProjectPermissions>();
private systemDocs = new Map<string, ProjectSystemPermissions>();
private systemRuntime = new Map<string, ResolvedAgentSystemPermissions>();
private doc(projectId: string): ProjectPermissions {
if (!this.docs.has(projectId)) {
this.docs.set(projectId, { version: 1, agents: [] });
}
return this.docs.get(projectId)!;
}
async getProjectPermissions(projectId: string): Promise<ProjectPermissions> {
return structuredClone(this.doc(projectId));
}
async updateProjectPermissions(
projectId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
const doc = this.doc(projectId);
if (permissions) doc.projectDefaults = structuredClone(permissions);
else delete doc.projectDefaults;
return structuredClone(doc);
}
async updateAgentPermissions(
projectId: string,
agentId: string,
permissions: PermissionSet | null,
): Promise<ProjectPermissions> {
const doc = this.doc(projectId);
const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId);
if (permissions) agents.push({ agentId, permissions: structuredClone(permissions) });
doc.agents = agents;
return structuredClone(doc);
}
async resolveAgentPermissions(
projectId: string,
agentId: string,
): Promise<ResolvedAgentPermissions> {
const doc = this.doc(projectId);
const project = doc.projectDefaults;
const agent = doc.agents?.find((entry) => entry.agentId === agentId)?.permissions;
const shadowed = permissionShadowReport(project, agent);
if (!project && !agent) return { effective: null, shadowed };
return {
effective: {
rules: [...(project?.rules ?? []), ...(agent?.rules ?? [])],
fallback: mostRestrictive(project?.fallback, agent?.fallback),
},
shadowed,
};
}
private systemDoc(projectId: string): ProjectSystemPermissions {
if (!this.systemDocs.has(projectId)) {
this.systemDocs.set(projectId, { version: 1, agents: [] });
}
return this.systemDocs.get(projectId)!;
}
async getProjectSystemPermissions(
projectId: string,
): Promise<ProjectSystemPermissions> {
return structuredClone(this.systemDoc(projectId));
}
async updateProjectSystemPermissions(
projectId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions> {
const doc = this.systemDoc(projectId);
if (permissions && Object.keys(permissions).length > 0) {
doc.projectDefault = structuredClone(permissions);
} else {
delete doc.projectDefault;
}
return structuredClone(doc);
}
async updateAgentSystemPermissions(
projectId: string,
agentId: string,
permissions: SystemPermissionSet | null,
): Promise<ProjectSystemPermissions> {
const doc = this.systemDoc(projectId);
const agents = (doc.agents ?? []).filter((entry) => entry.agentId !== agentId);
if (permissions && Object.keys(permissions).length > 0) {
agents.push({ agentId, permissions: structuredClone(permissions) });
}
doc.agents = agents;
return structuredClone(doc);
}
async resolveAgentSystemPermissions(
projectId: string,
agentId: string,
): Promise<ResolvedAgentSystemPermissions> {
const runtime = this.systemRuntime.get(agentId);
if (runtime) return structuredClone(runtime);
const doc = this.systemDoc(projectId);
const wanted =
doc.agents?.find((entry) => entry.agentId === agentId)?.permissions.network ??
doc.projectDefault?.network ??
null;
return {
wanted,
effective: wanted ?? "ask",
runtimeLock: { state: "none" },
control: { mode: "editable" },
runtimeControl: {
mode: "readOnly",
reason: "No active runtime network permission state is currently observed.",
},
};
}
setResolvedAgentSystemPermissions(
agentId: string,
resolved: ResolvedAgentSystemPermissions,
): void {
this.systemRuntime.set(agentId, structuredClone(resolved));
}
// ── MCP tool permissions (ticket #82) — mirrors the backend catalogue in
// `crates/infrastructure/src/orchestrator/mcp/tools.rs`, a separate durable
// document from the file/command permissions above. ─────────────────────
private mcpCatalogue: McpToolCatalogue = {
readOnlyTools: [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
],
writeActionTools: [
"idea_ask_agent",
"idea_ask_agents",
"idea_run_in_background",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
],
};
private mcpDocs = new Map<string, ProjectMcpToolPermissions>();
private mcpDoc(projectId: string): ProjectMcpToolPermissions {
if (!this.mcpDocs.has(projectId)) {
this.mcpDocs.set(projectId, {
version: 1,
catalogue: this.mcpCatalogue,
projectDefault: null,
agents: [],
});
}
return this.mcpDocs.get(projectId)!;
}
async getMcpToolPermissions(projectId: string): Promise<ProjectMcpToolPermissions> {
return structuredClone(this.mcpDoc(projectId));
}
async updateProjectMcpToolPermissions(
projectId: string,
policy: McpToolPolicy | null,
): Promise<ProjectMcpToolPermissions> {
const doc = this.mcpDoc(projectId);
doc.projectDefault = policy ? structuredClone(policy) : null;
return structuredClone(doc);
}
async updateAgentMcpToolPermissions(
projectId: string,
agentId: string,
policy: McpToolPolicy | null,
): Promise<ProjectMcpToolPermissions> {
const doc = this.mcpDoc(projectId);
doc.agents = doc.agents.filter((entry) => entry.agentId !== agentId);
if (policy) doc.agents.push({ agentId, policy: structuredClone(policy) });
return structuredClone(doc);
}
}
export class MockWorkStateGateway implements WorkStateGateway {
private states = new Map<string, ProjectWorkState>();
private backgroundTaskStreams = new Map<
string,
{
scrollback: Uint8Array;
live: boolean;
sinks: Set<(bytes: Uint8Array) => void>;
}
>();
/** Seeds the read-model returned for a project (deterministic tests/dev). */
_setProjectWorkState(projectId: string, state: unknown): void {
this.states.set(projectId, normalizeProjectWorkState(state));
}
/** Seeds attach output for a background task (deterministic tests/dev). */
_setBackgroundTaskAttachment(
taskId: string,
attachment: { scrollback?: string | Uint8Array; live?: boolean },
): void {
const encoder = new TextEncoder();
const scrollback =
typeof attachment.scrollback === "string"
? encoder.encode(attachment.scrollback)
: attachment.scrollback ?? new Uint8Array();
this.backgroundTaskStreams.set(taskId, {
scrollback,
live: attachment.live ?? false,
sinks: new Set(),
});
}
/** Emits a live output chunk to current subscribers (deterministic tests/dev). */
_emitBackgroundTaskOutput(taskId: string, bytes: string | Uint8Array): void {
const stream = this.backgroundTaskStreams.get(taskId);
if (!stream) return;
const encoder = new TextEncoder();
const chunk = typeof bytes === "string" ? encoder.encode(bytes) : bytes;
for (const sink of stream.sinks) sink(chunk);
}
_backgroundTaskSubscriberCount(taskId: string): number {
return this.backgroundTaskStreams.get(taskId)?.sinks.size ?? 0;
}
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
return structuredClone(
this.states.get(projectId) ?? { agents: [], conversations: [] },
);
}
async attachBackgroundTask(
taskId: string,
onData: (bytes: Uint8Array) => void,
): Promise<BackgroundTaskAttachment> {
const stream = this.backgroundTaskStreams.get(taskId) ?? {
scrollback: new Uint8Array(),
live: false,
sinks: new Set<(bytes: Uint8Array) => void>(),
};
if (stream.live) stream.sinks.add(onData);
return {
taskId,
scrollback: Uint8Array.from(stream.scrollback),
live: stream.live,
detach: () => {
stream.sinks.delete(onData);
},
};
}
async cancelBackgroundTask(_taskId: string): Promise<void> {
// No-op in the mock; real refresh is driven by domain events.
}
async retryBackgroundTask(_taskId: string): Promise<void> {
// No-op in the mock; real refresh is driven by domain events.
}
}
/**
* In-memory {@link ConversationGateway} with **real** pagination, mirroring the
* LS6 backend slicing (`crates/infrastructure/.../conversation_log/mod.rs::page`
* + `domain::clamp_page_limit`): turns stored oldest→newest; anchor pagination
* is **strict** (turns strictly before/after the anchor); `limit` defaults to 50
* and is clamped to `[1, 200]`; `hasMore` is true when more turns exist in the
* travel direction; `nextAnchor` is the **last** (most recent) turn of the page.
* Full turn text is preserved (never truncated).
*/
export class MockConversationGateway implements ConversationGateway {
private threads = new Map<string, TurnView[]>();
private key(projectId: string, conversationId: string): string {
return `${projectId}::${conversationId}`;
}
/** Seeds the turns of a conversation (oldest→newest) for deterministic tests. */
_setTurns(
projectId: string,
conversationId: string,
turns: TurnView[],
): void {
this.threads.set(
this.key(projectId, conversationId),
turns.map((t) => structuredClone(t)),
);
}
async readPage(
projectId: string,
conversationId: string,
request?: ConversationPageRequest,
): Promise<TurnPage> {
const all = this.threads.get(this.key(projectId, conversationId)) ?? [];
const len = all.length;
const raw = request?.limit;
const limit = Math.min(Math.max(raw && raw > 0 ? raw : 50, 1), 200);
const direction = request?.direction ?? "backward";
const anchor = request?.anchor;
let start: number;
let end: number;
let hasMore: boolean;
if (anchor === undefined) {
if (direction === "forward") {
start = 0;
end = Math.min(limit, len);
hasMore = end < len;
} else {
start = Math.max(len - limit, 0);
end = len;
hasMore = start > 0;
}
} else {
const pos = all.findIndex((t) => t.id === anchor);
if (pos === -1) {
start = 0;
end = 0;
hasMore = false;
} else if (direction === "forward") {
start = pos + 1;
end = Math.min(start + limit, len);
hasMore = end < len;
} else {
end = pos;
start = Math.max(end - limit, 0);
hasMore = start > 0;
}
}
const turns = all.slice(start, end).map((t) => structuredClone(t));
const last = turns.at(-1);
return {
turns,
hasMore,
...(last ? { nextAnchor: last.id } : {}),
};
}
}
/**
* In-memory {@link TicketGateway} with real optimistic-concurrency semantics:
* every mutation checks `expectedVersion` against the stored version and rejects
* a stale write with an `INVALID` {@link GatewayError} whose message contains
* `"version conflict"` (mirroring the backend `IssueStoreError::VersionConflict`
* → `AppError::Invalid` mapping). Mutations emit the matching `Issue*` domain
* event through the injected {@link MockSystemGateway} so event-driven refresh
* can be exercised offline.
*/
export class MockTicketGateway implements TicketGateway {
private tickets = new Map<string, Map<string, Ticket>>();
private carnets = new Map<string, Map<string, string>>();
private counters = new Map<string, number>();
private sprints = new Map<string, Map<string, Sprint>>();
private sprintCounters = new Map<string, number>();
/** Open assistant chats, keyed by `issueRef` → sessionId (ticket #8). */
private chatSessions = new Map<string, string>();
private chatCounter = 0;
private attachmentCounter = 0;
constructor(private readonly system?: MockSystemGateway) {}
/** Seeds a ticket for deterministic tests; returns the stored clone. */
_seedTicket(projectId: string, ticket: Ticket): Ticket {
const stored = structuredClone(ticket);
if (stored.sprintId === undefined) stored.sprintId = null;
stored.attachments ??= [];
this.projectTickets(projectId).set(stored.ref, stored);
this.counters.set(
projectId,
Math.max(this.counters.get(projectId) ?? 0, stored.number),
);
return structuredClone(stored);
}
/** Seeds a sprint for deterministic tests; returns the stored clone. */
_seedSprint(
projectId: string,
sprint: Pick<Sprint, "id" | "order" | "name"> &
Partial<Pick<Sprint, "status" | "ticketCount" | "version">>,
): Sprint {
const stored: Sprint = {
status: "planned",
ticketCount: 0,
version: 1,
...sprint,
};
this.projectSprints(projectId).set(stored.id, stored);
return structuredClone(stored);
}
private projectSprints(projectId: string): Map<string, Sprint> {
let map = this.sprints.get(projectId);
if (!map) {
map = new Map();
this.sprints.set(projectId, map);
}
return map;
}
/** Recomputes `ticketCount` for every sprint from the current tickets. */
private recountSprints(projectId: string): void {
const sprints = this.projectSprints(projectId);
const counts = new Map<string, number>();
for (const t of this.projectTickets(projectId).values()) {
if (t.sprintId) counts.set(t.sprintId, (counts.get(t.sprintId) ?? 0) + 1);
}
for (const s of sprints.values()) s.ticketCount = counts.get(s.id) ?? 0;
}
private projectTickets(projectId: string): Map<string, Ticket> {
let map = this.tickets.get(projectId);
if (!map) {
map = new Map();
this.tickets.set(projectId, map);
}
return map;
}
private projectCarnets(projectId: string): Map<string, string> {
let map = this.carnets.get(projectId);
if (!map) {
map = new Map();
this.carnets.set(projectId, map);
}
return map;
}
private require(projectId: string, ref: string): Ticket {
const ticket = this.projectTickets(projectId).get(ref);
if (!ticket) {
throw { code: "NOT_FOUND", message: `ticket ${ref} not found` } as GatewayError;
}
return ticket;
}
private guard(ticket: Ticket, expectedVersion: number): void {
if (ticket.version !== expectedVersion) {
throw {
code: "INVALID",
message: `issue version conflict: expected ${expectedVersion}, actual ${ticket.version}`,
} as GatewayError;
}
}
async create(projectId: string, input: CreateTicketInput): Promise<Ticket> {
const number = (this.counters.get(projectId) ?? 0) + 1;
this.counters.set(projectId, number);
const now = Date.now();
const ticket: Ticket = {
id: `mock-ticket-${projectId}-${number}`,
ref: `#${number}`,
number,
title: input.title,
description: input.description ?? "",
status: input.status ?? "open",
priority: input.priority ?? "medium",
sprintId: null,
links: [],
assignedAgentIds: [...(input.assignedAgentIds ?? [])],
attachments: [],
createdBy: { kind: "user" },
updatedBy: { kind: "user" },
createdAt: now,
updatedAt: now,
version: 1,
};
this.projectTickets(projectId).set(ticket.ref, ticket);
this.system?.emit({
type: "issueCreated",
issueId: ticket.id,
issueRef: ticket.ref,
});
return structuredClone(ticket);
}
async read(
projectId: string,
ref: string,
includeCarnet = false,
): Promise<Ticket> {
const ticket = structuredClone(this.require(projectId, ref));
if (includeCarnet) {
ticket.carnet = this.projectCarnets(projectId).get(ref) ?? "";
}
return ticket;
}
async list(projectId: string, query?: TicketListQuery): Promise<TicketList> {
const q = query ?? {};
const text = q.text?.trim().toLowerCase();
// Multi-select facets (ticket #12): OR within a facet, AND across facets; an
// empty/absent set means "no constraint" (all values pass).
const statuses = q.statuses ?? [];
const priorities = q.priorities ?? [];
let rows = [...this.projectTickets(projectId).values()]
.filter((t) => statuses.length === 0 || statuses.includes(t.status))
.filter((t) => priorities.length === 0 || priorities.includes(t.priority))
.filter(
(t) =>
!q.assignedAgentId || t.assignedAgentIds.includes(q.assignedAgentId),
)
.filter((t) => !q.createdBy || sameTicketCreator(t.createdBy, q.createdBy))
.filter(
(t) =>
!text ||
t.title.toLowerCase().includes(text) ||
t.description.toLowerCase().includes(text),
);
rows = q.sort
? sortTickets(rows, q.sort)
: rows.sort((a, b) => b.number - a.number);
const start = Number.parseInt(q.cursor ?? "0", 10) || 0;
const limit = Math.min(Math.max(q.limit ?? 100, 1), 500);
const end = Math.min(rows.length, start + limit);
return {
items: rows.slice(start, end).map((t) => ({
ref: t.ref,
path: `.ideai/tickets/${t.number}.md`,
title: t.title,
status: t.status,
priority: t.priority,
sprintId: t.sprintId ?? null,
assignedAgentIds: [...t.assignedAgentIds],
createdBy: structuredClone(t.createdBy),
updatedAt: t.updatedAt,
})),
...(end < rows.length ? { nextCursor: String(end) } : {}),
};
}
private bump(ticket: Ticket): void {
ticket.version += 1;
ticket.updatedAt = Date.now();
ticket.updatedBy = { kind: "user" };
}
async update(
projectId: string,
ref: string,
input: UpdateTicketInput,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, input.expectedVersion);
if (input.title !== undefined) ticket.title = input.title;
if (input.description !== undefined) ticket.description = input.description;
if (input.status !== undefined) ticket.status = input.status;
if (input.priority !== undefined) ticket.priority = input.priority;
if (input.assignedAgentIds !== undefined) {
ticket.assignedAgentIds = [...input.assignedAgentIds];
}
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async delete(projectId: string, ref: string): Promise<void> {
// NotFound if absent (mirrors the backend `AppError::NotFound`).
const ticket = this.require(projectId, ref);
const freedSprint = ticket.sprintId ?? null;
this.projectTickets(projectId).delete(ref);
this.projectCarnets(projectId).delete(ref);
// Releasing the ticket updates its former sprint's count.
if (freedSprint) this.recountSprints(projectId);
this.system?.emit({
type: "issueDeleted",
projectId,
issueRef: ref,
freedSprint,
});
}
async bulkUpdateStatus(
projectId: string,
refs: string[],
status: Ticket["status"],
): Promise<TicketBulkResult> {
const items = refs.map((ref) => {
try {
const ticket = this.require(projectId, ref);
ticket.status = status;
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return { ref, ok: true, ticket: structuredClone(ticket) };
} catch (error) {
return { ref, ok: false, error: error as GatewayError };
}
});
return { items };
}
async bulkUpdatePriority(
projectId: string,
refs: string[],
priority: Ticket["priority"],
): Promise<TicketBulkResult> {
const items = refs.map((ref) => {
try {
const ticket = this.require(projectId, ref);
ticket.priority = priority;
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return { ref, ok: true, ticket: structuredClone(ticket) };
} catch (error) {
return { ref, ok: false, error: error as GatewayError };
}
});
return { items };
}
async bulkDelete(projectId: string, refs: string[]): Promise<TicketBulkResult> {
const items = refs.map((ref) => {
try {
const ticket = this.require(projectId, ref);
const deleted = structuredClone(ticket);
const freedSprint = ticket.sprintId ?? null;
this.projectTickets(projectId).delete(ref);
this.projectCarnets(projectId).delete(ref);
if (freedSprint) this.recountSprints(projectId);
this.system?.emit({
type: "issueDeleted",
projectId,
issueRef: ref,
freedSprint,
});
return { ref, ok: true, ticket: deleted };
} catch (error) {
return { ref, ok: false, error: error as GatewayError };
}
});
return { items };
}
async readCarnet(projectId: string, ref: string): Promise<TicketCarnet> {
const ticket = this.require(projectId, ref);
return {
ref,
carnet: this.projectCarnets(projectId).get(ref) ?? "",
version: ticket.version,
};
}
async updateCarnet(
projectId: string,
ref: string,
carnet: string,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
this.projectCarnets(projectId).set(ref, carnet);
this.bump(ticket);
this.system?.emit({
type: "issueCarnetUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async addAttachment(
projectId: string,
ref: string,
path: string,
expectedVersion: number,
mime?: string | null,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const filename = filenameFromPath(path);
this.attachmentCounter += 1;
ticket.attachments.push({
id: `att-${this.attachmentCounter}`,
filename,
mime: inferAttachmentMime(filename, mime),
sizeBytes: Math.max(1, filename.length * 128),
addedBy: { kind: "user" },
addedAt: Date.now(),
summarizedInCarnet: false,
summarizedBy: null,
summarizedAt: null,
});
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async readAttachment(
projectId: string,
ref: string,
attachmentId: string,
): Promise<TicketAttachmentContent> {
const ticket = this.require(projectId, ref);
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
throw {
code: "NOT_FOUND",
message: `attachment ${attachmentId} not found`,
} as GatewayError;
}
return {
attachment: structuredClone(attachment),
contentBase64: btoa(`mock content for ${attachment.filename}`),
};
}
async markAttachmentSummarized(
projectId: string,
ref: string,
attachmentId: string,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const attachment = ticket.attachments.find((item) => item.id === attachmentId);
if (!attachment) {
throw {
code: "NOT_FOUND",
message: `attachment ${attachmentId} not found`,
} as GatewayError;
}
attachment.summarizedInCarnet = true;
attachment.summarizedBy = { kind: "user" };
attachment.summarizedAt = Date.now();
this.bump(ticket);
this.system?.emit({
type: "issueUpdated",
issueRef: ref,
version: ticket.version,
});
return structuredClone(ticket);
}
async link(
projectId: string,
ref: string,
targetRef: string,
kind: TicketLinkKind,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const exists = ticket.links.some(
(l: TicketLink) => l.targetRef === targetRef && l.kind === kind,
);
if (!exists) ticket.links.push({ targetRef, kind });
this.bump(ticket);
this.system?.emit({
type: "issueLinked",
issueRef: ref,
target: targetRef,
kind,
version: ticket.version,
});
return structuredClone(ticket);
}
async unlink(
projectId: string,
ref: string,
targetRef: string,
expectedVersion: number,
kind?: TicketLinkKind,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
ticket.links = ticket.links.filter(
(l: TicketLink) =>
l.targetRef !== targetRef || (kind !== undefined && l.kind !== kind),
);
this.bump(ticket);
this.system?.emit({
type: "issueUnlinked",
issueRef: ref,
target: targetRef,
kind: kind ?? "relatesTo",
version: ticket.version,
});
return structuredClone(ticket);
}
async assign(
projectId: string,
ref: string,
agentId: string,
assigned: boolean,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
const has = ticket.assignedAgentIds.includes(agentId);
if (assigned && !has) ticket.assignedAgentIds.push(agentId);
if (!assigned && has) {
ticket.assignedAgentIds = ticket.assignedAgentIds.filter(
(id) => id !== agentId,
);
}
this.bump(ticket);
this.system?.emit({
type: assigned ? "issueAgentAssigned" : "issueAgentUnassigned",
issueRef: ref,
agentId,
version: ticket.version,
});
return structuredClone(ticket);
}
async listSprints(projectId: string): Promise<Sprint[]> {
this.recountSprints(projectId);
return [...this.projectSprints(projectId).values()]
.sort((a, b) => a.order - b.order)
.map((s) => structuredClone(s));
}
async setTicketSprint(
projectId: string,
ref: string,
sprintId: string | null,
expectedVersion: number,
): Promise<Ticket> {
const ticket = this.require(projectId, ref);
this.guard(ticket, expectedVersion);
if (sprintId !== null && !this.projectSprints(projectId).has(sprintId)) {
throw {
code: "NOT_FOUND",
message: `sprint ${sprintId} not found`,
} as GatewayError;
}
const from = ticket.sprintId ?? null;
ticket.sprintId = sprintId;
this.bump(ticket);
this.recountSprints(projectId);
this.system?.emit({
type: "issueSprintChanged",
issueRef: ref,
from,
to: sprintId,
version: ticket.version,
});
return structuredClone(ticket);
}
async createSprint(projectId: string, name: string): Promise<Sprint> {
const sprints = this.projectSprints(projectId);
const seq = (this.sprintCounters.get(projectId) ?? 0) + 1;
this.sprintCounters.set(projectId, seq);
const order =
sprints.size === 0
? 1
: Math.max(...[...sprints.values()].map((s) => s.order)) + 1;
const sprint: Sprint = {
id: `mock-sprint-${projectId}-${seq}`,
order,
name,
status: "planned",
ticketCount: 0,
version: 1,
};
sprints.set(sprint.id, sprint);
this.system?.emit({
type: "sprintCreated",
sprintId: sprint.id,
order: sprint.order,
});
return structuredClone(sprint);
}
async renameSprint(
projectId: string,
sprintId: string,
name: string,
expectedVersion: number,
): Promise<Sprint> {
const sprint = this.requireSprint(projectId, sprintId);
this.guardSprint(sprint, expectedVersion);
sprint.name = name;
sprint.version += 1;
this.system?.emit({
type: "sprintRenamed",
sprintId,
name,
version: sprint.version,
});
return structuredClone(sprint);
}
async reorderSprints(
projectId: string,
orderedIds: string[],
): Promise<Sprint[]> {
const sprints = this.projectSprints(projectId);
for (const id of orderedIds) this.requireSprint(projectId, id);
// Assign 1-based order following the provided id sequence; ids omitted from
// `orderedIds` keep their relative order after the listed ones.
const rest = [...sprints.values()]
.filter((s) => !orderedIds.includes(s.id))
.sort((a, b) => a.order - b.order)
.map((s) => s.id);
[...orderedIds, ...rest].forEach((id, index) => {
const sprint = sprints.get(id);
if (sprint) {
sprint.order = index + 1;
this.system?.emit({
type: "sprintReordered",
sprintId: id,
order: sprint.order,
version: sprint.version,
});
}
});
return this.listSprints(projectId);
}
async deleteSprint(projectId: string, sprintId: string): Promise<void> {
this.requireSprint(projectId, sprintId);
// Delete unassigns the sprint's tickets (it does NOT delete them): they fall
// back to the "no sprint" bucket.
for (const ticket of this.projectTickets(projectId).values()) {
if (ticket.sprintId === sprintId) {
ticket.sprintId = null;
this.bump(ticket);
this.system?.emit({
type: "issueSprintChanged",
issueRef: ticket.ref,
from: sprintId,
to: null,
version: ticket.version,
});
}
}
this.projectSprints(projectId).delete(sprintId);
this.system?.emit({ type: "sprintDeleted", sprintId });
}
async openTicketChat(
projectId: string,
issueRef: string,
profileId: string,
): Promise<TicketChat> {
// Ensure the ticket exists (mirrors the backend resolving the ref).
this.require(projectId, issueRef);
const sessionId = `mock-ticket-chat-${++this.chatCounter}`;
this.chatSessions.set(issueRef, sessionId);
this.system?.emit({ type: "ticketAssistantOpened", issueRef, profileId });
return { sessionId, requester: "ticket-assistant", issueRef };
}
async closeTicketChat(_projectId: string, issueRef: string): Promise<void> {
if (this.chatSessions.delete(issueRef)) {
this.system?.emit({ type: "ticketAssistantClosed", issueRef });
}
}
async sendTicketChat(
sessionId: string,
message: string,
onChunk: (chunk: ReplyChunk) => void,
): Promise<void> {
if (![...this.chatSessions.values()].includes(sessionId)) {
throw {
code: "NOT_FOUND",
message: `chat session ${sessionId} not found`,
} as GatewayError;
}
// Simulate a streamed assistant turn: a couple of text deltas, then the
// deterministic `final` chunk. Delivered across microtasks (like a real
// Channel), so a consumer that awaits sees the whole turn.
const content = `Assistant: reçu « ${message} ».`;
const chunks: ReplyChunk[] = [
{ kind: "textDelta", text: "Assistant: reçu " },
{ kind: "textDelta", text: `« ${message} ».` },
{ kind: "final", content },
];
for (const chunk of chunks) {
await Promise.resolve();
onChunk(chunk);
}
}
private requireSprint(projectId: string, sprintId: string): Sprint {
const sprint = this.projectSprints(projectId).get(sprintId);
if (!sprint) {
throw {
code: "NOT_FOUND",
message: `sprint ${sprintId} not found`,
} as GatewayError;
}
return sprint;
}
private guardSprint(sprint: Sprint, expectedVersion: number): void {
if (sprint.version !== expectedVersion) {
throw {
code: "INVALID",
message: `sprint version conflict: expected ${expectedVersion}, actual ${sprint.version}`,
} as GatewayError;
}
}
}
/**
* In-memory {@link UiPreferencesGateway} for tests/dev (ticket #29). Mirrors the
* localStorage adapter's best-effort JSON round-trip without touching the DOM,
* so persisted-filter behaviour can be exercised offline.
*/
export class MockUiPreferencesGateway implements UiPreferencesGateway {
private readonly store = new Map<string, string>();
read(key: string): unknown {
const raw = this.store.get(key);
if (raw === undefined) return null;
try {
return JSON.parse(raw);
} catch {
this.store.delete(key);
return null;
}
}
write(key: string, value: unknown): void {
try {
this.store.set(key, JSON.stringify(value));
} catch {
/* best-effort */
}
}
remove(key: string): void {
this.store.delete(key);
}
}
function mostRestrictive(
project?: PermissionSet["fallback"],
agent?: PermissionSet["fallback"],
): PermissionSet["fallback"] {
const rank = { allow: 0, ask: 1, deny: 2 } as const;
const fallback = project ?? agent ?? "ask";
if (!agent) return fallback;
return rank[agent] >= rank[fallback] ? agent : fallback;
}
function permissionShadowReport(project?: PermissionSet, agent?: PermissionSet) {
const empty = {
read: false,
write: false,
delete: false,
executeBash: false,
fallback: false,
};
if (!agent) return empty;
const shadowed = (capability: "read" | "write" | "delete" | "executeBash") =>
blanketEffect(project, capability, "deny") === "deny" &&
blanketEffect(agent, capability, "allow") === "allow";
return {
read: shadowed("read"),
write: shadowed("write"),
delete: shadowed("delete"),
executeBash: shadowed("executeBash"),
fallback: agent.fallback !== mostRestrictive(project?.fallback, agent.fallback),
};
}
function blanketEffect(
set: PermissionSet | undefined,
capability: "read" | "write" | "delete" | "executeBash",
wins: "allow" | "deny",
) {
let found: "allow" | "deny" | undefined;
for (const rule of set?.rules ?? []) {
if (rule.capability !== capability || !isBlanketRule(rule)) continue;
if (rule.effect === wins) return rule.effect;
found = rule.effect;
}
return found;
}
function isBlanketRule(rule: PermissionSet["rules"][number]) {
if (rule.capability === "executeBash") return (rule.commands ?? []).length === 0;
const paths = rule.paths ?? [];
return paths.length === 1 && paths[0] === "**";
}
/**
* In-memory plugin store (ticket #43, F1). Mirrors the carnet contract closely
* enough to develop/test F1-F4 without the backend (B1-B4, landing in
* parallel): review before commit, install/enable/disable/uninstall, and a
* runtime catalog that only ever lists `enabled && !pendingUninstall` plugins.
*/
export class MockPluginGateway implements PluginGateway {
private plugins: PluginAdmin[] = [];
private seq = 0;
private summaryFor(_pluginId: string): PluginContributionSummary {
return { topLevelMenus: 0, menuItems: 0, layouts: 0, mcpServers: 0 };
}
/** Test/dev helper: seed a plugin directly, bypassing install. */
_seedPlugin(plugin: PluginAdmin): void {
this.plugins.push(plugin);
}
async listPlugins(): Promise<PluginAdmin[]> {
return structuredClone(this.plugins);
}
async reviewPackage(input: ReviewPluginPackageInput): Promise<PluginReview> {
this.seq += 1;
const label = input.path.split("/").pop() ?? input.path;
return {
id: `mock.plugin.${this.seq}`,
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
publisher: "Mock Publisher",
version: "0.1.0",
description: `Reviewed from ${input.sourceKind}: ${input.path}`,
trustLevel: "full",
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
issues: [],
installable: true,
};
}
private async install(
sourceKind: "archive" | "directory",
path: string,
): Promise<PluginInstallResult> {
this.seq += 1;
const label = path.split("/").pop() ?? path;
const plugin: PluginAdmin = {
id: `mock.plugin.${this.seq}`,
displayName: label.replace(/\.(ideaplug|zip|vsix)$/i, ""),
publisher: "Mock Publisher",
version: "0.1.0",
sourceKind,
sourceLabel: path,
lifecycleState: "enabled",
enabled: true,
pendingUninstall: false,
restartRequired: true,
trustLevel: "full",
contributionSummary: this.summaryFor(`mock.plugin.${this.seq}`),
};
this.plugins.push(plugin);
return { plugin: structuredClone(plugin), restartRequired: true };
}
installFromArchive(path: string): Promise<PluginInstallResult> {
return this.install("archive", path);
}
installFromDirectory(path: string): Promise<PluginInstallResult> {
return this.install("directory", path);
}
async setEnabled(pluginId: string, enabled: boolean): Promise<PluginAdmin> {
const plugin = this.plugins.find((p) => p.id === pluginId);
if (!plugin) {
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
throw err;
}
const state: PluginLifecycleState = enabled ? "enabled" : "disabled";
plugin.enabled = enabled;
plugin.lifecycleState = state;
plugin.restartRequired = true;
return structuredClone(plugin);
}
async uninstall(pluginId: string): Promise<PluginUninstallResult> {
const idx = this.plugins.findIndex((p) => p.id === pluginId);
if (idx === -1) {
const err: GatewayError = { code: "NOT_FOUND", message: `plugin ${pluginId} not found` };
throw err;
}
this.plugins.splice(idx, 1);
return { pluginId, removalOutcome: "removed", restartRequired: true };
}
async listRuntimeContributions(): Promise<PluginRuntimeContributionCatalog> {
// Only enabled, non-pending-uninstall plugins are loadable at bootstrap
// (carnet §1.3) — the mock has no bundle to actually import, so this
// starts empty; tests seed `plugins` on `PluginRuntimeContributionCatalog`
// directly via a `MockPluginGateway` subclass/test double when a loader
// round-trip is needed.
return { plugins: [] };
}
async openPluginsFolder(_pluginId?: string): Promise<void> {
// No filesystem in the mock — no-op.
}
}
const PROJECT_MARKERS: Record<string, string> = {
"package.json": "node-package",
"Cargo.toml": "rust-cargo",
"pyproject.toml": "python-project",
"go.mod": "go-module",
Makefile: "makefile",
makefile: "makefile",
".git": "git-repository",
};
function invalidWorkspacePath(path: string): GatewayError {
return {
code: "INVALID",
message: `workspace path must be relative to the project root: ${path}`,
};
}
function normalizeWorkspacePath(path: string): string {
const raw = path.trim();
if (raw === "" || raw === ".") return "";
if (raw.includes("\0") || raw.startsWith("/") || raw.startsWith("\\") || raw.includes(":")) {
throw invalidWorkspacePath(path);
}
const parts = raw.replace(/\\/g, "/").split("/");
if (parts.some((part) => part === "" || part === "." || part === "..")) {
throw invalidWorkspacePath(path);
}
return parts.join("/");
}
function basename(path: string): string {
return path.split("/").pop() ?? path;
}
/**
* In-memory plugin workspace gateway for offline plugin development/tests.
* It mirrors the public contract shape, not the host filesystem.
*/
export class MockPluginWorkspaceGateway implements PluginWorkspaceGateway {
private readonly files = new Map<string, Map<string, Uint8Array>>();
private bucket(projectId: string): Map<string, Uint8Array> {
let files = this.files.get(projectId);
if (!files) {
files = new Map();
this.files.set(projectId, files);
}
return files;
}
_seedText(projectId: string, path: string, content: string): void {
this.bucket(projectId).set(normalizeWorkspacePath(path), new TextEncoder().encode(content));
}
async readText(input: PluginWorkspacePathInput): Promise<PluginWorkspaceTextFile> {
const file = await this.readBinary(input);
return { path: file.path, content: new TextDecoder().decode(file.bytes) };
}
async readBinary(input: PluginWorkspacePathInput): Promise<PluginWorkspaceBinaryFile> {
const path = normalizeWorkspacePath(input.path);
const bytes = this.bucket(input.projectId).get(path);
if (!bytes) {
const err: GatewayError = { code: "NOT_FOUND", message: `workspace file ${path} not found` };
throw err;
}
return { path, bytes: new Uint8Array(bytes) };
}
async writeText(input: PluginWorkspaceWriteTextInput): Promise<void> {
const path = normalizeWorkspacePath(input.path);
this.bucket(input.projectId).set(path, new TextEncoder().encode(input.content));
}
async writeBinary(input: PluginWorkspaceWriteBinaryInput): Promise<void> {
const path = normalizeWorkspacePath(input.path);
this.bucket(input.projectId).set(path, new Uint8Array(input.bytes));
}
async listDir(input: PluginWorkspacePathInput): Promise<PluginWorkspaceDirectoryListing> {
const path = normalizeWorkspacePath(input.path);
const prefix = path === "" ? "" : `${path}/`;
const entries = new Map<string, PluginWorkspaceDirEntry>();
for (const filePath of this.bucket(input.projectId).keys()) {
if (!filePath.startsWith(prefix)) continue;
const rest = filePath.slice(prefix.length);
if (rest === "") continue;
const [name, ...tail] = rest.split("/");
const entryPath = path === "" ? name : `${path}/${name}`;
const existing = entries.get(name);
entries.set(name, {
name,
path: entryPath,
isDir: Boolean(existing?.isDir) || tail.length > 0,
});
}
return { path, entries: [...entries.values()].sort((a, b) => a.name.localeCompare(b.name)) };
}
async stat(input: PluginWorkspacePathInput): Promise<PluginWorkspaceStat> {
const path = normalizeWorkspacePath(input.path);
const files = this.bucket(input.projectId);
const bytes = files.get(path);
if (bytes) {
return { path, exists: true, isFile: true, isDir: false, len: bytes.byteLength };
}
const prefix = path === "" ? "" : `${path}/`;
const isDir = [...files.keys()].some((filePath) => filePath.startsWith(prefix));
return { path, exists: isDir, isFile: false, isDir, len: null };
}
async queryProjectStructure(
input: PluginProjectStructureQuery,
): Promise<PluginProjectStructure> {
const rootPath = normalizeWorkspacePath(input.path ?? "");
const maxDepth = Math.min(input.maxDepth ?? 3, 8);
const maxEntries = Math.min(input.maxEntries ?? 500, 5000);
const prefix = rootPath === "" ? "" : `${rootPath}/`;
const entries = new Map<string, PluginProjectStructureEntry>();
const conventions = new Map<string, PluginProjectConvention>();
const modules = new Map<string, PluginProjectModule>();
for (const filePath of this.bucket(input.projectId).keys()) {
if (!filePath.startsWith(prefix)) continue;
const rest = filePath.slice(prefix.length);
const parts = rest.split("/").filter(Boolean);
for (let i = 0; i < parts.length && i <= maxDepth; i += 1) {
const path = [rootPath, ...parts.slice(0, i + 1)].filter(Boolean).join("/");
const isLeaf = i === parts.length - 1;
entries.set(path, {
path,
name: parts[i],
kind: isLeaf ? "file" : "directory",
});
}
const marker = basename(filePath);
const conventionId = PROJECT_MARKERS[marker];
if (conventionId) {
conventions.set(`${conventionId}:${filePath}`, { id: conventionId, markerPath: filePath });
const modulePath = filePath.slice(0, Math.max(0, filePath.length - marker.length - 1));
modules.set(`${conventionId}:${modulePath}`, {
path: modulePath,
markerPath: filePath,
conventionId,
});
}
}
const sortedEntries = [...entries.values()].sort((a, b) => a.path.localeCompare(b.path));
return {
projectId: input.projectId,
rootPath,
entries: sortedEntries.slice(0, maxEntries),
conventions: [...conventions.values()].sort((a, b) =>
a.markerPath.localeCompare(b.markerPath),
),
modules: [...modules.values()].sort((a, b) => a.path.localeCompare(b.path)),
truncated: sortedEntries.length > maxEntries,
};
}
}
/**
* In-memory command-task gateway for plugin runtime tests/dev. It models host
* task creation and status reads; live output remains owned by WorkStateGateway.
*/
export class MockPluginTaskGateway implements PluginTaskGateway {
private readonly tasks = new Map<string, PluginCommandTask>();
private nextId = 1;
async runCommand(input: PluginRunCommandInput): Promise<PluginCommandTask> {
if (input.command.trim() === "") {
const err: GatewayError = { code: "INVALID", message: "command must not be empty" };
throw err;
}
const now = Date.now();
const task: PluginCommandTask = {
taskId: `mock-plugin-task-${this.nextId++}`,
ownerAgentId: input.ownerAgentId,
projectId: input.projectId,
kind: "command",
state: "running",
exitCode: null,
summary: input.label,
stdoutTail: null,
stderrTail: null,
createdAtMs: now,
updatedAtMs: now,
};
this.tasks.set(task.taskId, task);
return task;
}
async getStatus(input: PluginTaskStatusInput): Promise<PluginCommandTask | null> {
return this.tasks.get(input.taskId) ?? null;
}
}
/**
* In-memory generic toolchain diagnostics for plugin tests/dev. It is
* deterministic and does not inspect the real host environment.
*/
export class MockPluginToolchainGateway implements PluginToolchainGateway {
async diagnose(input: PluginToolchainDiagnosticRequest): Promise<PluginToolchainDiagnostic> {
const tools = (input.tools ?? []).map((tool) => {
const missing = tool.executable.includes("missing");
const ok = !missing;
return {
id: tool.id,
executable: tool.executable,
present: ok,
ok,
status: ok ? ("ok" as const) : ("missing" as const),
required: tool.required ?? false,
exitCode: ok ? 0 : null,
version: ok ? `${tool.executable} mock-version` : null,
stdout: ok ? `${tool.executable} mock-version\n` : null,
stderr: null,
error: ok ? null : `executable not found: ${tool.executable}`,
};
});
const env = (input.env ?? []).map((requirement) => {
const present = !requirement.name.includes("MISSING");
const value = present ? (requirement.equals ?? "mock") : null;
const ok = present && (requirement.equals === undefined || value === requirement.equals);
return {
name: requirement.name,
present,
ok,
required: requirement.required ?? false,
value,
status: ok ? ("ok" as const) : present ? ("mismatch" as const) : ("missing" as const),
};
});
const files = (input.files ?? []).map((requirement) => {
const exists = !requirement.path.includes("missing");
const kind = exists ? (requirement.kind === "directory" ? "directory" : "file") : "missing";
const ok =
exists &&
(requirement.kind === undefined || requirement.kind === "any" || requirement.kind === kind);
return {
path: requirement.path,
exists,
ok,
required: requirement.required ?? false,
kind: kind as "file" | "directory" | "missing",
expectedKind: requirement.kind ?? null,
len: exists && kind === "file" ? 12 : null,
};
});
const messages = [
...tools
.filter((tool) => tool.required && !tool.ok)
.map((tool) => ({ level: "error" as const, message: `${tool.id}: ${tool.error}` })),
...env
.filter((item) => item.required && !item.ok)
.map((item) => ({ level: "error" as const, message: `${item.name}: ${item.status}` })),
...files
.filter((file) => file.required && !file.ok)
.map((file) => ({ level: "error" as const, message: `${file.path}: ${file.kind}` })),
];
return {
projectId: input.projectId,
cwd: input.cwd ?? "",
ok: messages.length === 0,
tools,
env,
files,
messages,
};
}
}
interface MockPluginEventSubscriptionState {
subscription: PluginEventSubscription;
queue: PluginPublicEvent[];
dropped: number;
}
/**
* In-memory public plugin event gateway for offline runtime tests/dev.
*/
export class MockPluginEventGateway implements PluginEventGateway {
private readonly subscriptions = new Map<string, MockPluginEventSubscriptionState>();
private nextId = 1;
async subscribe(input: PluginEventSubscribeInput): Promise<PluginEventSubscription> {
const subscription: PluginEventSubscription = {
subscriptionId: `mock-plugin-events-${this.nextId++}`,
projectId: input.projectId,
eventTypes: input.eventTypes?.length
? input.eventTypes
: ["workspaceFileChanged", "backgroundTaskChanged"],
capacity: Math.min(Math.max(input.capacity ?? 100, 1), 1000),
retention: "bestEffortBounded",
};
this.subscriptions.set(subscription.subscriptionId, {
subscription,
queue: [],
dropped: 0,
});
return subscription;
}
async poll(input: PluginEventPollInput): Promise<PluginEventBatch> {
const state = this.subscriptions.get(input.subscriptionId);
if (!state) {
const err: GatewayError = {
code: "NOT_FOUND",
message: "plugin event subscription not found",
};
throw err;
}
const maxEvents = Math.min(Math.max(input.maxEvents ?? 100, 1), 1000);
const events = state.queue.splice(0, maxEvents);
const dropped = state.dropped;
state.dropped = 0;
return { subscriptionId: input.subscriptionId, events, dropped };
}
async unsubscribe(input: PluginEventUnsubscribeInput): Promise<PluginEventSubscription> {
const state = this.subscriptions.get(input.subscriptionId);
this.subscriptions.delete(input.subscriptionId);
return (
state?.subscription ?? {
subscriptionId: input.subscriptionId,
projectId: "",
eventTypes: [],
capacity: 0,
retention: "disposed",
}
);
}
_emit(event: PluginPublicEvent): void {
for (const state of this.subscriptions.values()) {
if (
state.subscription.projectId !== event.projectId ||
!state.subscription.eventTypes.includes(event.type)
) {
continue;
}
if (state.queue.length >= state.subscription.capacity) {
state.queue.shift();
state.dropped += 1;
}
state.queue.push(event);
}
}
}
function cloneJson<T extends JsonValue>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function applyJsonMergePatch(target: JsonValue, patch: JsonValue): JsonValue {
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) return cloneJson(patch);
const base =
target !== null && typeof target === "object" && !Array.isArray(target)
? { ...target }
: {};
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete base[key];
} else {
base[key] = applyJsonMergePatch(base[key] ?? null, value);
}
}
return base;
}
/**
* In-memory JSON config-document gateway for plugin tests/dev.
*/
export class MockPluginConfigGateway implements PluginConfigGateway {
private readonly documents = new Map<string, JsonValue>();
_seed(projectId: string, path: string, value: JsonValue): void {
this.documents.set(`${projectId}:${normalizeWorkspacePath(path)}`, cloneJson(value));
}
async readDocument(input: PluginConfigDocumentReadInput): Promise<PluginConfigDocument> {
const path = normalizeWorkspacePath(input.path);
const format = input.format ?? "json";
if (format !== "json") {
const err: GatewayError = {
code: "INVALID",
message: `unsupported structured config format: ${format}; supported formats: json`,
};
throw err;
}
const value = this.documents.get(`${input.projectId}:${path}`);
if (value === undefined) {
const err: GatewayError = { code: "NOT_FOUND", message: `config document ${path} not found` };
throw err;
}
return { projectId: input.projectId, path, format, value: cloneJson(value) };
}
async updateDocument(
input: PluginConfigDocumentUpdateInput,
): Promise<PluginConfigDocumentWriteResult> {
const path = normalizeWorkspacePath(input.path);
const format = input.format ?? "json";
const mode = input.mode ?? "mergePatch";
if (format !== "json") {
const err: GatewayError = {
code: "INVALID",
message: `unsupported structured config format: ${format}; supported formats: json`,
};
throw err;
}
if (mode !== "mergePatch" && mode !== "replace") {
const err: GatewayError = {
code: "INVALID",
message: `unsupported structured config update mode: ${mode}`,
};
throw err;
}
const key = `${input.projectId}:${path}`;
const current = this.documents.get(key);
if (mode === "mergePatch" && current === undefined) {
const err: GatewayError = { code: "NOT_FOUND", message: `config document ${path} not found` };
throw err;
}
const next = mode === "replace" ? cloneJson(input.value) : applyJsonMergePatch(current!, input.value);
this.documents.set(key, next);
return {
projectId: input.projectId,
path,
format,
mode,
bytesWritten: JSON.stringify(next, null, 2).length + 1,
};
}
}
/**
* In-memory plugin-owned storage gateway for plugin runtime tests/dev.
*/
export class MockPluginStorageGateway implements PluginStorageGateway {
private readonly values = new Map<string, JsonValue>();
private storageKey(input: PluginStorageGetInput): string {
if (!input.pluginId.trim()) {
const err: GatewayError = { code: "INVALID", message: "pluginId must not be empty" };
throw err;
}
if (!input.key.trim()) {
const err: GatewayError = { code: "INVALID", message: "key must not be empty" };
throw err;
}
return `${input.pluginId}:${input.key}`;
}
async get(input: PluginStorageGetInput): Promise<JsonValue | null> {
const value = this.values.get(this.storageKey(input));
return value === undefined ? null : cloneJson(value);
}
async set(input: PluginStorageSetInput): Promise<void> {
this.values.set(this.storageKey(input), cloneJson(input.value));
}
async delete(input: PluginStorageGetInput): Promise<boolean> {
return this.values.delete(this.storageKey(input));
}
}
/** Builds the full set of mock gateways. */
export function createMockGateways(): Gateways {
const agentGateway = new MockAgentGateway();
const systemGateway = new MockSystemGateway();
return {
system: systemGateway,
agent: agentGateway,
input: new MockInputGateway(),
terminal: new MockTerminalGateway(),
project: new MockProjectGateway(),
layout: new MockLayoutGateway(),
git: new MockGitGateway(),
remote: new MockRemoteGateway(),
profile: new MockProfileGateway(),
modelServer: new MockModelServerGateway(),
desktopServer: new MockDesktopServerGateway(),
template: new MockTemplateGateway(agentGateway),
skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(),
embedder: new MockEmbedderGateway(),
device: new MockDeviceGateway(),
permission: new MockPermissionGateway(),
workState: new MockWorkStateGateway(),
conversation: new MockConversationGateway(),
ticket: new MockTicketGateway(systemGateway),
window: new MockWindowGateway(),
focusedProject: new MockFocusedProjectGateway(),
uiPreferences: new MockUiPreferencesGateway(),
plugin: new MockPluginGateway(),
pluginWorkspace: new MockPluginWorkspaceGateway(),
pluginTask: new MockPluginTaskGateway(),
pluginToolchain: new MockPluginToolchainGateway(),
pluginEvents: new MockPluginEventGateway(),
pluginConfig: new MockPluginConfigGateway(),
pluginStorage: new MockPluginStorageGateway(),
};
}
// Re-export GatewayError so tests can reference the thrown shape.
export type { GatewayError };