feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -16,9 +16,6 @@ import { Channel, invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type {
|
||||
Agent,
|
||||
CellKind,
|
||||
ReattachChatDto,
|
||||
ReplyChunk,
|
||||
ResumableAgent,
|
||||
TerminalSession,
|
||||
} from "@/domain";
|
||||
@ -41,8 +38,6 @@ interface LaunchAgentResponse {
|
||||
cols: number;
|
||||
/** Conversation id minted by this launch (omitted when nothing was assigned). */
|
||||
assignedConversationId?: string;
|
||||
/** How the hosting cell should render (`"chat"` ⇒ structured AI cell, §17.6). */
|
||||
cellKind: CellKind;
|
||||
}
|
||||
|
||||
export class TauriAgentGateway implements AgentGateway {
|
||||
@ -155,11 +150,9 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
|
||||
const base = makeTerminalHandle(res.sessionId, channel);
|
||||
// Surface the id assigned by this launch (so the caller persists it on the
|
||||
// leaf and resumes next time) and the derived `cellKind` (so the leaf routes
|
||||
// to `AgentChatView` vs `TerminalView`, §17.6).
|
||||
// leaf and resumes next time).
|
||||
return {
|
||||
...base,
|
||||
cellKind: res.cellKind,
|
||||
...(res.assignedConversationId
|
||||
? { assignedConversationId: res.assignedConversationId }
|
||||
: {}),
|
||||
@ -197,40 +190,4 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
request: { projectId, agentId, conversationId },
|
||||
});
|
||||
}
|
||||
|
||||
async sendPrompt(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
onReply: (chunk: ReplyChunk) => void,
|
||||
): Promise<void> {
|
||||
// Per-turn reply channel. The backend streams typed `ReplyChunk`s (tagged on
|
||||
// `kind`); the pump runs to the `final` chunk then detaches its generation.
|
||||
const channel = new Channel<ReplyChunk>();
|
||||
channel.onmessage = (chunk) => onReply(chunk);
|
||||
|
||||
// `agent_send` takes top-level args (Tauri renames the snake_case command
|
||||
// params to camelCase): `sessionId`, `prompt`, `onReply`.
|
||||
await invoke("agent_send", { sessionId, prompt, onReply: channel });
|
||||
}
|
||||
|
||||
async reattachChat(
|
||||
sessionId: string,
|
||||
onReply: (chunk: ReplyChunk) => void,
|
||||
): Promise<ReplyChunk[]> {
|
||||
// Re-bind to a still-living structured session without re-spawning it. The
|
||||
// returned scrollback repaints prior turns; subsequent chunks arrive over the
|
||||
// freshly-registered channel.
|
||||
const channel = new Channel<ReplyChunk>();
|
||||
channel.onmessage = (chunk) => onReply(chunk);
|
||||
|
||||
const res = await invoke<ReattachChatDto>("reattach_agent_chat", {
|
||||
sessionId,
|
||||
onReply: channel,
|
||||
});
|
||||
return res.scrollback;
|
||||
}
|
||||
|
||||
async closeAgentSession(sessionId: string): Promise<void> {
|
||||
await invoke("close_agent_session", { sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import type {
|
||||
} from "@/ports";
|
||||
import { TauriSystemGateway } from "./system";
|
||||
import { TauriAgentGateway } from "./agent";
|
||||
import { TauriInputGateway } from "./input";
|
||||
import { TauriProjectGateway } from "./project";
|
||||
import { TauriTerminalGateway } from "./terminal";
|
||||
import { TauriLayoutGateway } from "./layout";
|
||||
@ -43,6 +44,7 @@ export function createTauriGateways(): Gateways {
|
||||
return {
|
||||
system: new TauriSystemGateway(),
|
||||
agent: new TauriAgentGateway(),
|
||||
input: new TauriInputGateway(),
|
||||
terminal: new TauriTerminalGateway(),
|
||||
project: new TauriProjectGateway(),
|
||||
layout: new TauriLayoutGateway(),
|
||||
@ -59,6 +61,7 @@ export function createTauriGateways(): Gateways {
|
||||
export {
|
||||
TauriSystemGateway,
|
||||
TauriAgentGateway,
|
||||
TauriInputGateway,
|
||||
TauriProjectGateway,
|
||||
TauriTerminalGateway,
|
||||
TauriLayoutGateway,
|
||||
|
||||
33
frontend/src/adapters/input.ts
Normal file
33
frontend/src/adapters/input.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Tauri adapter for {@link InputGateway} (lot F1, cadrage §4.2).
|
||||
*
|
||||
* `submit` → `submit_agent_input` (enqueue), `interrupt` → `interrupt_agent`
|
||||
* (preempt). These app-tauri commands land with lot C4; this adapter is complete
|
||||
* on the frontend side and the mock covers tests/offline dev meanwhile.
|
||||
*
|
||||
* Commands use snake_case (Tauri convention); payload keys are camelCase
|
||||
* (matching the backend DTO `#[serde(rename_all = "camelCase")]`), consistent
|
||||
* with the other adapters in this directory.
|
||||
*/
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { InputGateway } from "@/ports";
|
||||
|
||||
export class TauriInputGateway implements InputGateway {
|
||||
async submit(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
await invoke("submit_agent_input", {
|
||||
request: { projectId, agentId, text },
|
||||
});
|
||||
}
|
||||
|
||||
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||
await invoke("interrupt_agent", {
|
||||
request: { projectId, agentId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,112 +0,0 @@
|
||||
/**
|
||||
* D5 — the `MockAgentGateway` chat surface (`_setChatAgents`, `launchAgent` as a
|
||||
* chat cell, `sendPrompt`, `reattachChat`, `closeAgentSession`). Verifies the
|
||||
* mock scripts a realistic `ReplyChunk` sequence (deltas… toolActivity… final)
|
||||
* and that re-attach replays the retained scrollback WITHOUT a new turn — the
|
||||
* exact behaviours the feature tests rely on.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
import type { ReplyChunk } from "@/domain";
|
||||
import { MockAgentGateway } from "./index";
|
||||
|
||||
const CWD = "/home/me/proj";
|
||||
|
||||
/** Launches a chat agent and returns its session id. */
|
||||
async function launchChat(gw: MockAgentGateway, agentId = "a1", projectId = "p1") {
|
||||
await gw.createAgent(projectId, { name: agentId, profileId: "claude" });
|
||||
// createAgent mints its own id — re-fetch so we mark the real id as chat.
|
||||
const created = (await gw.listAgents(projectId)).find((a) => a.name === agentId)!;
|
||||
gw._setChatAgents([created.id]);
|
||||
const handle = await gw.launchAgent(
|
||||
projectId,
|
||||
created.id,
|
||||
{ cwd: CWD, rows: 0, cols: 0, nodeId: "node-1" },
|
||||
() => {},
|
||||
);
|
||||
return { handle, agentId: created.id, projectId };
|
||||
}
|
||||
|
||||
describe("MockAgentGateway chat surface", () => {
|
||||
it("launchAgent reports cellKind 'chat' for a structured agent", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const { handle } = await launchChat(gw);
|
||||
expect(handle.cellKind).toBe("chat");
|
||||
});
|
||||
|
||||
it("launchAgent stays 'pty' for a non-structured agent (no regression)", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
await gw.createAgent("p1", { name: "plain", profileId: "claude" });
|
||||
const plain = (await gw.listAgents("p1")).find((a) => a.name === "plain")!;
|
||||
const handle = await gw.launchAgent(
|
||||
"p1",
|
||||
plain.id,
|
||||
{ cwd: CWD, rows: 24, cols: 80, nodeId: "node-1" },
|
||||
() => {},
|
||||
);
|
||||
expect(handle.cellKind ?? "pty").toBe("pty");
|
||||
});
|
||||
|
||||
it("sendPrompt streams deltas… toolActivity… then a single final", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const { handle } = await launchChat(gw);
|
||||
const chunks: ReplyChunk[] = [];
|
||||
await gw.sendPrompt(handle.sessionId, "hi", (c) => chunks.push(c));
|
||||
|
||||
// The send pump runs on a microtask; flush it.
|
||||
await new Promise((r) => queueMicrotask(() => r(undefined)));
|
||||
|
||||
const kinds = chunks.map((c) => c.kind);
|
||||
expect(kinds.filter((k) => k === "textDelta").length).toBeGreaterThanOrEqual(1);
|
||||
expect(kinds).toContain("toolActivity");
|
||||
// Exactly one terminal final, and it is the LAST chunk.
|
||||
expect(kinds.filter((k) => k === "final")).toHaveLength(1);
|
||||
expect(kinds.at(-1)).toBe("final");
|
||||
|
||||
// The aggregated final content equals the concatenated deltas (no loss).
|
||||
const deltas = chunks
|
||||
.filter((c): c is { kind: "textDelta"; text: string } => c.kind === "textDelta")
|
||||
.map((c) => c.text)
|
||||
.join("");
|
||||
const final = chunks.find((c): c is { kind: "final"; content: string } => c.kind === "final")!;
|
||||
expect(final.content).toBe(deltas);
|
||||
expect(final.content).toBe("echo: hi");
|
||||
});
|
||||
|
||||
it("reattachChat replays the retained scrollback without re-sending", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const { handle } = await launchChat(gw);
|
||||
|
||||
// Run one full turn so the session retains a scrollback.
|
||||
await gw.sendPrompt(handle.sessionId, "remember me", () => {});
|
||||
await new Promise((r) => queueMicrotask(() => r(undefined)));
|
||||
|
||||
// Re-attach: returns the prior chunks; the live sink must NOT receive any
|
||||
// new chunk (no fresh turn is triggered by a re-attach).
|
||||
const liveSink = vi.fn();
|
||||
const scrollback = await gw.reattachChat(handle.sessionId, liveSink);
|
||||
|
||||
expect(scrollback.length).toBeGreaterThan(0);
|
||||
expect(scrollback.at(-1)?.kind).toBe("final");
|
||||
await new Promise((r) => queueMicrotask(() => r(undefined)));
|
||||
expect(liveSink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reattachChat rejects for a closed session", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const { handle } = await launchChat(gw);
|
||||
await gw.closeAgentSession(handle.sessionId);
|
||||
await expect(gw.reattachChat(handle.sessionId, () => {})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("sendPrompt rejects once the session is closed", async () => {
|
||||
const gw = new MockAgentGateway();
|
||||
const { handle } = await launchChat(gw);
|
||||
await gw.closeAgentSession(handle.sessionId);
|
||||
await expect(gw.sendPrompt(handle.sessionId, "x", () => {})).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -27,10 +27,8 @@ import type {
|
||||
MemoryIndexEntry,
|
||||
MemoryLink,
|
||||
MemoryType,
|
||||
CellKind,
|
||||
Project,
|
||||
ProfileAvailability,
|
||||
ReplyChunk,
|
||||
ResumableAgent,
|
||||
Skill,
|
||||
SkillScope,
|
||||
@ -47,6 +45,7 @@ import type {
|
||||
EmbedderGateway,
|
||||
CreateTemplateInput,
|
||||
Gateways,
|
||||
InputGateway,
|
||||
LiveAgent,
|
||||
MemoryGateway,
|
||||
GitGateway,
|
||||
@ -161,68 +160,6 @@ class MockPtySession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A live in-memory mock **chat** session (the structured-AI twin of
|
||||
* {@link MockPtySession}). It retains a conversation scrollback (every
|
||||
* {@link ReplyChunk} streamed) and tracks the current reply sink so a view can
|
||||
* detach (sink cleared, session stays alive) and later re-attach (new sink,
|
||||
* scrollback replayed by the caller). `sendPrompt` streams a scripted turn:
|
||||
* a couple of `textDelta`s, an optional `toolActivity`, then one `final`.
|
||||
*/
|
||||
class MockChatSession {
|
||||
/** Accumulated reply chunks (the conversation scrollback). */
|
||||
private scrollback: ReplyChunk[] = [];
|
||||
/** Current reply sink; `null` while detached. */
|
||||
private sink: ((chunk: ReplyChunk) => void) | null = null;
|
||||
/** Whether the session was explicitly closed (shut down). */
|
||||
closed = false;
|
||||
|
||||
constructor(readonly sessionId: string) {}
|
||||
|
||||
/** Records a chunk into the scrollback and forwards it to the current sink. */
|
||||
private emit(chunk: ReplyChunk): void {
|
||||
if (this.closed) return;
|
||||
this.scrollback.push(chunk);
|
||||
this.sink?.(chunk);
|
||||
}
|
||||
|
||||
/** Re-attaches a new reply sink, returning the retained scrollback to replay. */
|
||||
reattach(sink: (chunk: ReplyChunk) => void): ReplyChunk[] {
|
||||
this.sink = sink;
|
||||
return structuredClone(this.scrollback);
|
||||
}
|
||||
|
||||
/** Detaches the current view: stop delivering, keep the session alive. */
|
||||
detach(): void {
|
||||
this.sink = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams a scripted reply turn for `prompt`: two text deltas, a tool-activity
|
||||
* badge, then the deterministic `final` carrying the aggregated content. Each
|
||||
* chunk is recorded into the scrollback (so a re-attach replays the whole
|
||||
* turn) and forwarded live to the current sink.
|
||||
*/
|
||||
send(prompt: string, onReply: (chunk: ReplyChunk) => void): void {
|
||||
if (this.closed) return;
|
||||
this.sink = onReply;
|
||||
const content = `echo: ${prompt}`;
|
||||
const half = Math.ceil(content.length / 2);
|
||||
queueMicrotask(() => {
|
||||
this.emit({ kind: "textDelta", text: content.slice(0, half) });
|
||||
this.emit({ kind: "toolActivity", label: "thinking" });
|
||||
this.emit({ kind: "textDelta", text: content.slice(half) });
|
||||
this.emit({ kind: "final", content });
|
||||
});
|
||||
}
|
||||
|
||||
/** Shuts the session down (idempotent). */
|
||||
shutdown(): void {
|
||||
this.closed = true;
|
||||
this.sink = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful in-memory agent gateway — mirrors the backend `CreateAgentFromScratch`,
|
||||
* `ListAgents`, `ReadAgentContext`, `UpdateAgentContext`, `DeleteAgent`, and
|
||||
@ -249,19 +186,6 @@ export class MockAgentGateway implements AgentGateway {
|
||||
private liveByAgent = new Map<string, string>();
|
||||
/** Live PTY session id per agent (`agentId → sessionId`). */
|
||||
private liveSessionByAgent = new Map<string, string>();
|
||||
/** Live structured (chat) sessions, kept across detach so reattach finds them. */
|
||||
private chatSessions = new Map<string, MockChatSession>();
|
||||
/**
|
||||
* Agents that launch as a **chat** cell (`structured_adapter` present). Seeded
|
||||
* by tests via {@link _setChatAgents}; absent ⇒ the agent launches as a `pty`
|
||||
* cell (the historical default), so existing tests are unaffected.
|
||||
*/
|
||||
private chatAgents = new Set<string>();
|
||||
|
||||
/** Marks the given agents as structured (chat) cells for `launchAgent`. */
|
||||
_setChatAgents(agentIds: string[]): void {
|
||||
for (const id of agentIds) this.chatAgents.add(id);
|
||||
}
|
||||
|
||||
private getAgents(projectId: string): Agent[] {
|
||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||
@ -450,7 +374,6 @@ export class MockAgentGateway implements AgentGateway {
|
||||
cwd: `/home/user/mock-project`,
|
||||
rows,
|
||||
cols,
|
||||
cellKind: this.chatAgents.has(agentId) ? "chat" : "pty",
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -535,10 +458,9 @@ export class MockAgentGateway implements AgentGateway {
|
||||
this.sessionSeq += 1;
|
||||
const sessionId = `mock-agent-session-${this.sessionSeq}`;
|
||||
const cwd = options.cwd;
|
||||
const cellKind: CellKind = this.chatAgents.has(agentId) ? "chat" : "pty";
|
||||
|
||||
// Record liveness for `listLiveAgents` + the guard above (only when the
|
||||
// caller pins a node) — shared by both cell kinds.
|
||||
// caller pins a node).
|
||||
if (options.nodeId) {
|
||||
this.liveByAgent.set(agentId, options.nodeId);
|
||||
this.liveSessionByAgent.set(agentId, sessionId);
|
||||
@ -550,37 +472,19 @@ export class MockAgentGateway implements AgentGateway {
|
||||
}
|
||||
};
|
||||
|
||||
let handle: TerminalHandle;
|
||||
if (cellKind === "chat") {
|
||||
// Structured AI cell (§17.6): spawn a chat session (no PTY). The reply
|
||||
// stream is driven by `sendPrompt`; the returned handle is mostly inert
|
||||
// (no bytes), but its `close` shuts the structured session down.
|
||||
const chat = new MockChatSession(sessionId);
|
||||
this.chatSessions.set(sessionId, chat);
|
||||
handle = {
|
||||
...makeInertHandle(sessionId, () => {
|
||||
chat.shutdown();
|
||||
this.chatSessions.delete(sessionId);
|
||||
clearLive();
|
||||
}),
|
||||
cellKind,
|
||||
};
|
||||
} else {
|
||||
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`)),
|
||||
);
|
||||
handle = {
|
||||
...makeMockHandle(session, () => {
|
||||
this.sessions.delete(sessionId);
|
||||
clearLive();
|
||||
}),
|
||||
cellKind,
|
||||
};
|
||||
}
|
||||
// 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
|
||||
@ -594,58 +498,6 @@ export class MockAgentGateway implements AgentGateway {
|
||||
return handle;
|
||||
}
|
||||
|
||||
async sendPrompt(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
onReply: (chunk: ReplyChunk) => void,
|
||||
): Promise<void> {
|
||||
const session = this.chatSessions.get(sessionId);
|
||||
if (!session || session.closed) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `structured session ${sessionId} is not alive`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
session.send(prompt, onReply);
|
||||
}
|
||||
|
||||
async reattachChat(
|
||||
sessionId: string,
|
||||
onReply: (chunk: ReplyChunk) => void,
|
||||
): Promise<ReplyChunk[]> {
|
||||
const session = this.chatSessions.get(sessionId);
|
||||
if (!session || session.closed) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `structured session ${sessionId} is not alive`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
// Re-attach the reply sink and return the retained conversation scrollback —
|
||||
// no new turn is started here (mirrors the backend `reattach_agent_chat`).
|
||||
return session.reattach(onReply);
|
||||
}
|
||||
|
||||
async closeAgentSession(sessionId: string): Promise<void> {
|
||||
const session = this.chatSessions.get(sessionId);
|
||||
if (!session) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `structured session ${sessionId} is not alive`,
|
||||
};
|
||||
throw err;
|
||||
}
|
||||
session.shutdown();
|
||||
this.chatSessions.delete(sessionId);
|
||||
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
|
||||
if (liveSessionId === sessionId) {
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
this.liveByAgent.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
@ -730,28 +582,6 @@ function makeMockHandle(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an inert {@link TerminalHandle} for a structured **chat** session: it
|
||||
* carries no PTY, so `write`/`resize` are no-ops and `detach` does nothing
|
||||
* (the chat view manages its own reply subscription). Only `close` is live —
|
||||
* it shuts the structured session down. The chat cell never feeds bytes through
|
||||
* this handle; it drives the session via `sendPrompt`/`reattachChat`.
|
||||
*/
|
||||
function makeInertHandle(
|
||||
sessionId: string,
|
||||
shutdown: () => void,
|
||||
): TerminalHandle {
|
||||
return {
|
||||
sessionId,
|
||||
async write(): Promise<void> {},
|
||||
async resize(): Promise<void> {},
|
||||
detach(): void {},
|
||||
async close(): Promise<void> {
|
||||
shutdown();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@ -1703,12 +1533,48 @@ export class MockEmbedderGateway implements EmbedderGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/** One recorded mediated-input call (for test assertions). */
|
||||
export interface MockInputCall {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
/** Present for `submit`, absent for `interrupt`. */
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory {@link InputGateway} (lot F1). Records every `submit`/`interrupt`
|
||||
* so tests can assert the component routed through the port with the right
|
||||
* args — no backend. `submit` always resolves (the FIFO never refuses an
|
||||
* enqueue; the forward/fallback rule lives backend-side, §4.2/§6).
|
||||
*
|
||||
* Exported so tests can instantiate it directly (same pattern as the other mocks).
|
||||
*/
|
||||
export class MockInputGateway implements InputGateway {
|
||||
/** All recorded submit calls, in order. */
|
||||
readonly submits: MockInputCall[] = [];
|
||||
/** All recorded interrupt calls, in order. */
|
||||
readonly interrupts: MockInputCall[] = [];
|
||||
|
||||
async submit(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
this.submits.push({ projectId, agentId, text });
|
||||
}
|
||||
|
||||
async interrupt(projectId: string, agentId: string): Promise<void> {
|
||||
this.interrupts.push({ projectId, agentId });
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the full set of mock gateways. */
|
||||
export function createMockGateways(): Gateways {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
return {
|
||||
system: new MockSystemGateway(),
|
||||
agent: agentGateway,
|
||||
input: new MockInputGateway(),
|
||||
terminal: new MockTerminalGateway(),
|
||||
project: new MockProjectGateway(),
|
||||
layout: new MockLayoutGateway(),
|
||||
|
||||
@ -12,11 +12,12 @@ import { createMockGateways, MockSystemGateway } from "./index";
|
||||
const gateways: Gateways = createMockGateways();
|
||||
|
||||
describe("createMockGateways", () => {
|
||||
it("exposes all twelve gateways", () => {
|
||||
it("exposes all thirteen gateways", () => {
|
||||
expect(Object.keys(gateways).sort()).toEqual([
|
||||
"agent",
|
||||
"embedder",
|
||||
"git",
|
||||
"input",
|
||||
"layout",
|
||||
"memory",
|
||||
"profile",
|
||||
|
||||
Reference in New Issue
Block a user