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:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

@ -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 });
}
}

View File

@ -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,

View 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 },
});
}
}

View File

@ -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",
});
});
});

View File

@ -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(),

View File

@ -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",

View File

@ -19,6 +19,7 @@ export type DomainEvent =
| { type: "agentLaunched"; agentId: string; sessionId: string }
| { type: "agentExited"; agentId: string; code: number }
| { type: "agentProfileChanged"; agentId: string; profileId: string }
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
| { type: "templateUpdated"; templateId: string; version: number }
| { type: "agentDriftDetected"; agentId: string; from: number; to: number }
| { type: "agentSynced"; agentId: string; to: number }
@ -283,49 +284,6 @@ export interface TerminalSession {
* session assignment and the hosting cell had none yet; absent otherwise.
*/
assignedConversationId?: string;
/**
* How the frontend should render the cell hosting this session (mirror of the
* backend `TerminalSessionDto.cellKind`, ARCHITECTURE §17.6). `"chat"` ⇒ a
* structured AI session driven by `agent_send`/`reattach_agent_chat` (rendered
* as an `AgentChatView`); `"pty"` ⇒ a raw terminal (xterm). **Derived**
* backend-side from the launch routing — a single source of truth.
*/
cellKind: CellKind;
}
/**
* Whether a session's hosting cell renders as a structured chat view or a raw
* terminal (mirror of the backend `CellKind`, ARCHITECTURE §17.6). The
* `LayoutGrid` switches `AgentChatView` vs `TerminalView` on this value.
*/
export type CellKind = "pty" | "chat";
/**
* One incremental event of an AI agent's reply turn (mirror of the backend
* `ReplyChunk`, ARCHITECTURE §17.7). Tagged on `kind` (camelCase on the wire),
* so the view branches without positional parsing:
*
* - `textDelta`: a text fragment of the current turn (accumulated live);
* - `toolActivity`: a human-readable tool-activity badge (best-effort);
* - `final`: the **deterministic** end-of-turn chunk carrying the aggregated
* final content — after it the turn is frozen and the stream ends.
*/
export type ReplyChunk =
| { kind: "textDelta"; text: string }
| { kind: "toolActivity"; label: string }
| { kind: "final"; content: string };
/**
* The retained **conversation scrollback** of a still-live structured session
* (mirror of the backend `ReattachChatDto`, ARCHITECTURE §17.7), returned by
* `reattachChat`. The frontend replays `scrollback` to rebuild the visible turns
* before subsequent chunks arrive over the freshly-registered channel.
*/
export interface ReattachChatDto {
/** The session that was re-attached (echoed back). */
sessionId: string;
/** The chunks already streamed for this conversation, in order. */
scrollback: ReplyChunk[];
}
/**

View File

@ -1,268 +0,0 @@
/**
* D5 — {@link AgentChatView}, the structured-AI chat twin of `TerminalView`.
*
* These are pure-component tests: the view takes its `launch`/`reattach`/`send`
* callbacks as props (the leaf wires them from the `AgentGateway` port), so we
* drive it with hand-rolled spies and assert the visible transcript + the exact
* lifecycle calls. The cardinal invariants under test:
*
* - deltas accumulate into the pending turn; `final` FREEZES it and REPLACES
* the deltas (no double rendering);
* - mounting with an existing session id RE-ATTACHES (replays scrollback) and
* NEVER launches / sends (re-attach ≠ re-spawn);
* - Enter submits the prompt via `send`; Shift+Enter does not.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { ReplyChunk } from "@/domain";
import { AgentChatView, type AgentChatViewProps } from "./AgentChatView";
/** A captured `onReply` sink, so a test can stream chunks live after attach. */
interface Wired {
props: AgentChatViewProps;
/** Last `onReply` handed to `reattach`/`send` (live stream sink). */
emit: (chunk: ReplyChunk) => void;
launch: ReturnType<typeof vi.fn>;
reattach: ReturnType<typeof vi.fn>;
send: ReturnType<typeof vi.fn>;
onSessionId: ReturnType<typeof vi.fn>;
}
/**
* Builds the prop set with spy callbacks. `scrollback` is returned by
* `reattach`; `launchId` is the id `launch` resolves with.
*/
function wire(opts: {
sessionId?: string | null;
scrollback?: ReplyChunk[];
launchId?: string;
}): Wired {
let lastSink: (chunk: ReplyChunk) => void = () => {};
const reattach = vi.fn(
async (_sid: string, onReply: (c: ReplyChunk) => void) => {
lastSink = onReply;
return opts.scrollback ?? [];
},
);
const launch = vi.fn(async () => opts.launchId ?? "launched-session");
const send = vi.fn(
async (_sid: string, _prompt: string, onReply: (c: ReplyChunk) => void) => {
lastSink = onReply;
},
);
const onSessionId = vi.fn();
const props: AgentChatViewProps = {
launch,
reattach,
send,
sessionId: opts.sessionId ?? null,
onSessionId,
};
return {
props,
emit: (chunk) => lastSink(chunk),
launch,
reattach,
send,
onSessionId,
};
}
describe("AgentChatView", () => {
it("mounts the chat view container", () => {
render(<AgentChatView {...wire({ sessionId: "s1" }).props} />);
expect(screen.getByTestId("agent-chat-view")).toBeTruthy();
});
// ── Zone 3: re-attach ≠ re-spawn ──────────────────────────────────────────
it("re-attaches to an existing session and NEVER launches or sends", async () => {
const w = wire({ sessionId: "live-1" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
// Re-attach used the persisted id.
expect(w.reattach.mock.calls[0][0]).toBe("live-1");
// The cardinal invariant: navigating back must NOT re-spawn nor re-send.
expect(w.launch).not.toHaveBeenCalled();
expect(w.send).not.toHaveBeenCalled();
// A reattach (not a launch) must NOT re-persist the session id.
expect(w.onSessionId).not.toHaveBeenCalled();
});
it("launches a fresh session only when the cell has no session yet", async () => {
const w = wire({ sessionId: null, launchId: "fresh-7" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.launch).toHaveBeenCalled());
// The launched id is persisted and then attached.
await waitFor(() => expect(w.onSessionId).toHaveBeenCalledWith("fresh-7"));
await waitFor(() => expect(w.reattach).toHaveBeenCalledWith("fresh-7", expect.any(Function)));
});
// ── Zone 3 (scrollback): re-attach repaints prior turns ───────────────────
it("repaints the conversation scrollback returned by re-attach", async () => {
const w = wire({
sessionId: "live-1",
scrollback: [
{ kind: "textDelta", text: "restored " },
{ kind: "textDelta", text: "answer" },
{ kind: "final", content: "restored answer" },
],
});
render(<AgentChatView {...w.props} />);
// The replayed final freezes into a (single) assistant turn.
await waitFor(() =>
expect(screen.getByTestId("chat-turn-assistant").textContent).toContain(
"restored answer",
),
);
// No prompt was sent to rebuild it.
expect(w.send).not.toHaveBeenCalled();
});
// ── Zone 2: accumulation + non-double-render ──────────────────────────────
it("accumulates textDelta into the pending turn, then final freezes it", async () => {
const w = wire({ sessionId: "s1" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
// Stream two deltas live → they accumulate in the pending bubble.
w.emit({ kind: "textDelta", text: "Hel" });
w.emit({ kind: "textDelta", text: "lo" });
await waitFor(() =>
expect(screen.getByTestId("chat-turn-pending").textContent).toContain("Hello"),
);
// Still pending (no assistant turn yet).
expect(screen.queryByTestId("chat-turn-assistant")).toBeNull();
// final freezes the turn into an assistant bubble; pending disappears.
w.emit({ kind: "final", content: "Hello" });
await waitFor(() =>
expect(screen.getByTestId("chat-turn-assistant").textContent).toContain("Hello"),
);
expect(screen.queryByTestId("chat-turn-pending")).toBeNull();
});
it("final content REPLACES the deltas — no double rendering", async () => {
const w = wire({ sessionId: "s1" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
w.emit({ kind: "textDelta", text: "abc" });
w.emit({ kind: "final", content: "abc" });
await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy());
// The frozen turn shows "abc" exactly ONCE — not "abcabc" (delta + final).
const text = screen.getByTestId("chat-turn-assistant").textContent ?? "";
const occurrences = text.split("abc").length - 1;
expect(occurrences).toBe(1);
// And there is exactly one assistant turn, not two.
expect(screen.getAllByTestId("chat-turn-assistant")).toHaveLength(1);
});
// Anti "always-green" guard: prove the non-double assertion would FAIL if the
// component had appended final on top of the deltas (a mutated reducer). Here
// we feed a final whose content DIFFERS from the deltas, then check the visible
// turn is the final alone — if deltas leaked through, "xx" would also appear.
it("the frozen turn is the final content alone (deltas discarded)", async () => {
const w = wire({ sessionId: "s1" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
w.emit({ kind: "textDelta", text: "DELTA" });
w.emit({ kind: "final", content: "FINAL" });
await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy());
const text = screen.getByTestId("chat-turn-assistant").textContent ?? "";
expect(text).toContain("FINAL");
expect(text).not.toContain("DELTA");
});
// ── Zone 5: toolActivity → badge ──────────────────────────────────────────
it("renders toolActivity as a tool badge on the turn", async () => {
const w = wire({ sessionId: "s1" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
w.emit({ kind: "textDelta", text: "working" });
w.emit({ kind: "toolActivity", label: "Read(file.ts)" });
await waitFor(() => {
const badges = screen.getAllByTestId("chat-tool-badge");
expect(badges).toHaveLength(1);
expect(badges[0].textContent).toBe("Read(file.ts)");
});
// The tools carry over onto the frozen turn after final.
w.emit({ kind: "final", content: "done" });
await waitFor(() => expect(screen.getByTestId("chat-turn-assistant")).toBeTruthy());
expect(screen.getByTestId("chat-tool-badge").textContent).toBe("Read(file.ts)");
});
// ── Zone 4: prompt submission ─────────────────────────────────────────────
it("Enter submits the prompt via send(sessionId, prompt, onReply)", async () => {
const w = wire({ sessionId: "live-9" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
const input = screen.getByTestId("agent-chat-input");
fireEvent.change(input, { target: { value: "what is 2+2?" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(w.send).toHaveBeenCalled());
expect(w.send.mock.calls[0][0]).toBe("live-9");
expect(w.send.mock.calls[0][1]).toBe("what is 2+2?");
expect(typeof w.send.mock.calls[0][2]).toBe("function");
// The user turn is echoed in the transcript and the input is cleared.
expect(screen.getByTestId("chat-turn-user").textContent).toContain("what is 2+2?");
expect((input as HTMLTextAreaElement).value).toBe("");
});
it("Shift+Enter does NOT submit (newline, not send)", async () => {
const w = wire({ sessionId: "live-9" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
const input = screen.getByTestId("agent-chat-input");
fireEvent.change(input, { target: { value: "line one" } });
fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
expect(w.send).not.toHaveBeenCalled();
});
it("the submit button sends and streams the reply turn back over onReply", async () => {
const w = wire({ sessionId: "live-9" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
fireEvent.change(screen.getByTestId("agent-chat-input"), {
target: { value: "ping" },
});
fireEvent.click(screen.getByLabelText("send"));
await waitFor(() => expect(w.send).toHaveBeenCalledWith("live-9", "ping", expect.any(Function)));
// The reply now streams over the same onReply captured by send().
w.emit({ kind: "final", content: "pong" });
await waitFor(() =>
expect(
screen.getAllByTestId("chat-turn-assistant").some((el) =>
(el.textContent ?? "").includes("pong"),
),
).toBe(true),
);
});
it("does not send an empty/whitespace prompt", async () => {
const w = wire({ sessionId: "live-9" });
render(<AgentChatView {...w.props} />);
await waitFor(() => expect(w.reattach).toHaveBeenCalled());
const input = screen.getByTestId("agent-chat-input");
fireEvent.change(input, { target: { value: " " } });
fireEvent.keyDown(input, { key: "Enter" });
expect(w.send).not.toHaveBeenCalled();
});
});

View File

@ -1,431 +0,0 @@
/**
* Structured AI chat view (§17.6) — the chat twin of {@link TerminalView}.
*
* Where `TerminalView` wires raw PTY bytes into xterm, this view consumes the
* **typed** {@link ReplyChunk} stream of an `AgentSession`:
*
* - `textDelta` → accumulated into the current (in-flight) assistant turn;
* - `toolActivity` → a tool-activity badge on the current turn;
* - `final` → freezes the turn (the aggregated final content is authoritative,
* so it replaces the accumulated deltas — no double rendering).
*
* Input (the prompt box) → {@link AgentChatViewProps.send} (`agent_send`), which
* opens a fresh assistant turn whose chunks stream in over the same `onReply`.
*
* **Session lifecycle is decoupled from the view lifecycle** (same guarantee as
* the PTY): navigating (layout/tab switch) tears the view down but must NOT kill
* the backend structured session. So on mount the view **re-attaches** to the
* still-living session (repainting its conversation scrollback) via
* {@link AgentChatViewProps.reattach}; if no session exists yet it **launches**
* one via {@link AgentChatViewProps.launch}. On unmount it only drops its local
* subscription — it never closes the session (an explicit user action elsewhere).
*
* Pure presentation: it depends only on the callbacks the leaf wires from the
* {@link AgentGateway} port — never on `invoke()`/`Channel` (ARCHITECTURE §1.3).
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { ReplyChunk } from "@/domain";
/** A frozen, completed assistant turn (its `final` content). */
interface AssistantTurn {
readonly kind: "assistant";
readonly text: string;
/** Tool-activity labels observed during the turn (in order). */
readonly tools: readonly string[];
}
/** A user prompt turn. */
interface UserTurn {
readonly kind: "user";
readonly text: string;
}
type Turn = AssistantTurn | UserTurn;
/**
* The live (in-flight) assistant turn being streamed: accumulated text deltas
* plus any tool-activity labels seen so far. `null` between turns.
*/
interface PendingTurn {
text: string;
tools: string[];
}
export interface AgentChatViewProps {
/**
* Launches a fresh structured session for this cell and returns its session id.
* Used at mount when the cell has no session yet. Mirrors `TerminalView.open`.
*/
launch: () => Promise<string>;
/**
* Re-attaches to an already-living structured session, replaying its retained
* conversation scrollback. `onReply` then receives subsequent chunks. Mirrors
* `TerminalView.reattach`. Rejects if the session is gone (caller falls back
* to {@link launch}).
*/
reattach: (
sessionId: string,
onReply: (chunk: ReplyChunk) => void,
) => Promise<ReplyChunk[]>;
/**
* Sends a prompt to the live session; its reply turn streams back over
* `onReply`. Mirrors a PTY `write`. Resolves once the turn stream is wired.
*/
send: (
sessionId: string,
prompt: string,
onReply: (chunk: ReplyChunk) => void,
) => Promise<void>;
/** Persisted session id for this cell, if a session is already running for it. */
sessionId?: string | null;
/**
* Called once a session is established (launched) so the caller persists its id
* for this cell and re-attaches on the next mount. Not called on reattach.
*/
onSessionId?: (sessionId: string) => void;
}
export function AgentChatView({
launch,
reattach,
send,
sessionId,
onSessionId,
}: AgentChatViewProps) {
/** Frozen turns (history), oldest first. */
const [turns, setTurns] = useState<Turn[]>([]);
/** The in-flight assistant turn, or `null` between turns. */
const [pending, setPending] = useState<PendingTurn | null>(null);
/** The current input value. */
const [input, setInput] = useState("");
/** A connection/attach error to surface (non-fatal). */
const [error, setError] = useState<string | null>(null);
// The live session id is held in a ref so the (stable) chunk reducer and the
// submit handler always read the current value without re-subscribing.
const liveSessionId = useRef<string | null>(sessionId ?? null);
// Callbacks read through refs so the mount effect does not depend on their
// identity (the leaf re-creates them each render). The view is re-mounted via
// a `key` when the agent changes, so the right callbacks are captured at mount.
const launchRef = useRef(launch);
launchRef.current = launch;
const reattachRef = useRef(reattach);
reattachRef.current = reattach;
const onSessionIdRef = useRef(onSessionId);
onSessionIdRef.current = onSessionId;
const scrollRef = useRef<HTMLDivElement | null>(null);
/**
* Folds one {@link ReplyChunk} into the visible state. Stable identity: it
* only touches state via the functional setters, so the same instance serves
* the reattach replay, the live stream, and every subsequent turn.
*/
const apply = useCallback((chunk: ReplyChunk) => {
switch (chunk.kind) {
case "textDelta":
setPending((p) => ({
text: (p?.text ?? "") + chunk.text,
tools: p?.tools ?? [],
}));
break;
case "toolActivity":
setPending((p) => ({
text: p?.text ?? "",
tools: [...(p?.tools ?? []), chunk.label],
}));
break;
case "final":
// The aggregated final content is authoritative — it replaces the
// accumulated deltas (no double rendering), carrying over the tools seen.
setPending((p) => {
const tools = p?.tools ?? [];
setTurns((prev) => [
...prev,
{ kind: "assistant", text: chunk.content, tools },
]);
return null;
});
break;
}
}, []);
// Mount: re-attach to the existing session (replay scrollback) or launch a new
// one. Decoupled from view lifecycle — unmount never closes the session.
useEffect(() => {
let disposed = false;
const existing = liveSessionId.current;
const attach = (sid: string) =>
reattachRef.current(sid, (chunk) => {
if (!disposed) apply(chunk);
}).then((scrollback) => {
if (disposed) return;
// Replay the retained conversation scrollback to rebuild prior turns,
// then subsequent chunks arrive live over the same `onReply`.
for (const chunk of scrollback) apply(chunk);
});
if (existing) {
attach(existing).catch(() => {
// Session gone (explicitly closed / exited): fall back to a fresh launch
// so the cell still works.
if (disposed) return;
launchRef.current()
.then((sid) => {
if (disposed) return;
liveSessionId.current = sid;
onSessionIdRef.current?.(sid);
return attach(sid);
})
.catch((e) => {
if (!disposed) setError(describe(e));
});
});
} else {
launchRef.current()
.then((sid) => {
if (disposed) return;
liveSessionId.current = sid;
onSessionIdRef.current?.(sid);
return attach(sid);
})
.catch((e) => {
if (!disposed) setError(describe(e));
});
}
return () => {
// Drop the local subscription only — the backend session keeps running so
// the AI is not cut off (same guarantee as the PTY detach).
disposed = true;
};
// Re-attach/launch only on (re)mount. Callbacks are read from refs; the agent
// switch re-mounts via `key`, so we must NOT depend on them.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-scroll to the latest content as turns/pending grow.
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [turns, pending]);
const submit = () => {
const prompt = input.trim();
const sid = liveSessionId.current;
if (!prompt || !sid) return;
setInput("");
setError(null);
setTurns((prev) => [...prev, { kind: "user", text: prompt }]);
void send(sid, prompt, apply).catch((e) => setError(describe(e)));
};
return (
<div
data-testid="agent-chat-view"
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
minHeight: "16rem",
boxSizing: "border-box",
background: "var(--color-surface, #1e1e1e)",
color: "var(--color-content, #e0e0e0)",
fontSize: 13,
}}
>
<div
ref={scrollRef}
data-testid="agent-chat-transcript"
style={{
flex: "1 1 auto",
minHeight: 0,
overflowY: "auto",
padding: "2rem 0.75rem 0.5rem",
display: "flex",
flexDirection: "column",
gap: 8,
}}
>
{turns.map((turn, i) =>
turn.kind === "user" ? (
<UserBubble key={i} text={turn.text} />
) : (
<AssistantBubble key={i} text={turn.text} tools={turn.tools} />
),
)}
{pending && (
<AssistantBubble
text={pending.text}
tools={pending.tools}
pending
/>
)}
</div>
{error && (
<p
role="alert"
style={{
margin: 0,
padding: "2px 8px",
fontSize: 11,
color: "crimson",
background: "var(--color-surface, #1e1e1e)",
}}
>
{error}
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
submit();
}}
style={{
display: "flex",
gap: 6,
padding: 6,
borderTop: "1px solid var(--color-border, #3a3a3a)",
}}
>
<textarea
aria-label="prompt"
data-testid="agent-chat-input"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
// Enter submits; Shift+Enter inserts a newline.
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1}
placeholder="Message à l'agent…"
style={{
flex: "1 1 auto",
resize: "none",
fontSize: 13,
fontFamily: "inherit",
background: "var(--color-bg, #141414)",
color: "var(--color-content, #e0e0e0)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "6px 8px",
}}
/>
<button
type="submit"
aria-label="send"
disabled={input.trim() === ""}
style={{
fontSize: 12,
padding: "0 12px",
borderRadius: 4,
border: "1px solid var(--color-border, #3a3a3a)",
background: "var(--color-accent, #3a6ea5)",
color: "#fff",
cursor: input.trim() === "" ? "default" : "pointer",
opacity: input.trim() === "" ? 0.5 : 1,
}}
>
Envoyer
</button>
</form>
</div>
);
}
function UserBubble({ text }: { text: string }) {
return (
<div
data-testid="chat-turn-user"
style={{
alignSelf: "flex-end",
maxWidth: "85%",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
background: "var(--color-accent, #3a6ea5)",
color: "#fff",
padding: "6px 10px",
borderRadius: 8,
}}
>
{text}
</div>
);
}
function AssistantBubble({
text,
tools,
pending,
}: {
text: string;
tools: readonly string[];
pending?: boolean;
}) {
return (
<div
data-testid={pending ? "chat-turn-pending" : "chat-turn-assistant"}
style={{
alignSelf: "flex-start",
maxWidth: "85%",
background: "var(--color-bg, #141414)",
border: "1px solid var(--color-border, #3a3a3a)",
padding: "6px 10px",
borderRadius: 8,
}}
>
{tools.length > 0 && (
<div
data-testid="chat-tool-activity"
style={{
display: "flex",
flexWrap: "wrap",
gap: 4,
marginBottom: text ? 6 : 0,
}}
>
{tools.map((label, i) => (
<span
key={i}
data-testid="chat-tool-badge"
style={{
fontSize: 10,
padding: "1px 6px",
borderRadius: 10,
background: "var(--color-surface, #2a2a2a)",
color: "var(--color-content-muted, #9a9a9a)",
}}
>
{label}
</span>
))}
</div>
)}
<span style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{text}
{pending && (
<span data-testid="chat-cursor" aria-hidden style={{ opacity: 0.6 }}>
</span>
)}
</span>
</div>
);
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as { message: unknown }).message);
}
return String(e);
}

View File

@ -1,4 +0,0 @@
/** Chat feature (§17.6): structured AI conversation view bound to the `AgentGateway`. */
export { AgentChatView } from "./AgentChatView";
export type { AgentChatViewProps } from "./AgentChatView";

View File

@ -1,26 +1,27 @@
/**
* D5 — `LayoutGrid` cell-kind routing (§17.6): an agent cell whose launch reports
* `cellKind:"chat"` must render an {@link AgentChatView}; every other cell (plain
* or a `pty` agent) keeps the unchanged {@link TerminalView}. Wired through the
* real {@link DIProvider} with the in-memory mocks, exactly like
* `LayoutGrid.test.tsx`.
* F-1 — `LayoutGrid` cell routing (Option 1, Terminal + MCP): **every** agent
* cell renders the raw {@link TerminalView}; no structured chat view is ever
* mounted. This replaces the former §17.6 `cellKind:"chat"` routing — the human
* view is now the native interactive PTY, and cross-model delegation flows
* through MCP tools, not a chat view. Wired through the real {@link DIProvider}
* with the in-memory mocks, exactly like `LayoutGrid.test.tsx`.
*
* Routing to chat is *derived from the launch* (the leaf starts as a terminal
* and swaps once the handle's `cellKind` arrives). Under jsdom xterm's `open`
* may bail, so the terminal opener that triggers the launch might not run; these
* tests therefore assert the *reachable* outcomes without depending on xterm:
* - a plain cell renders the terminal view and NEVER a chat view (hard);
* - when the launch does run, a chat agent ends up rendering the chat view.
* The decisive case: an agent cell always renders the terminal and never swaps
* to a chat view (the structured chat surface was removed in the F-2 cleanup).
*
* Under jsdom xterm's `open` may bail, so the opener that triggers the launch
* might not run; we therefore stub xterm (as in the original test) so the launch
* does fire and we genuinely exercise the post-launch routing — which must stay
* on the terminal regardless of the reported kind.
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor as rtlWaitFor } from "@testing-library/react";
// Make xterm "wire up" under jsdom: the real `Terminal.open` throws without a
// layout engine, which makes `TerminalView`'s effect bail before it ever calls
// the opener — so the launch (and thus the chat-routing swap) would never fire.
// A minimal stub lets `term.open` succeed, the opener run, and the leaf derive
// its `cellKind` from the launch exactly as in the app. We do NOT stub the chat
// view or the routing — only xterm, the headless-only obstacle.
// the opener — so the launch would never fire. A minimal stub lets `term.open`
// succeed and the opener run, so the launch (and any routing it could trigger)
// is genuinely exercised. We do NOT stub the routing — only xterm.
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
@ -64,16 +65,17 @@ import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
/** Seeds an agent in the gateway and pins it onto the (single) leaf cell. */
async function seedPinnedAgent(opts: {
chat: boolean;
}): Promise<{ gateways: Gateways; layout: MockLayoutGateway; agentGateway: MockAgentGateway }> {
async function seedPinnedAgent(): Promise<{
gateways: Gateways;
layout: MockLayoutGateway;
agentGateway: MockAgentGateway;
}> {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
if (opts.chat) agentGateway._setChatAgents([agent.id]);
// Pin the agent onto the single leaf.
const tree = await layout.loadLayout("p1");
@ -92,7 +94,7 @@ function renderGrid(gateways: Gateways) {
);
}
describe("LayoutGrid cell-kind routing (§17.6)", () => {
describe("LayoutGrid cell routing (F-1, Terminal + MCP)", () => {
it("a plain (agent-less) cell renders the terminal view, never a chat view", async () => {
const layout = new MockLayoutGateway();
const gateways = {
@ -105,34 +107,49 @@ describe("LayoutGrid cell-kind routing (§17.6)", () => {
renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// Non-regression: the plain path is the terminal, and there is no chat view.
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a pty agent cell renders the terminal view, never a chat view", async () => {
const { gateways } = await seedPinnedAgent({ chat: false });
const { gateways } = await seedPinnedAgent();
renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// A pty agent keeps the unchanged terminal path; the chat view never appears.
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a chat agent cell routes to the chat view once its launch reports cellKind 'chat'", async () => {
const { gateways } = await seedPinnedAgent({ chat: true });
it("re-mounting a known agent cell (persisted session) repaints as a terminal, never chat", async () => {
const layout = new MockLayoutGateway();
const agentGateway = new MockAgentGateway();
const terminal = new MockTerminalGateway();
const system = new MockSystemGateway();
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
// Seed a persisted session on the leaf — the pre-F-1 path would have re-mounted
// such a known agent cell as a chat view; now it must always be a terminal.
const tree = await layout.loadLayout("p1");
const leafId = leaves(tree)[0].id;
await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id });
await layout.mutateLayout("p1", {
type: "setSession",
target: leafId,
session: "running-session",
});
const gateways = { layout, agent: agentGateway, terminal, system } as unknown as Gateways;
// First mount.
const { unmount } = renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
unmount();
// Re-mount (as after a tab/layout navigation): the known agent cell with its
// persisted session must repaint as a terminal — never a chat view.
renderGrid(gateways);
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
// The leaf starts as a terminal and swaps to the chat view once the derived
// `cellKind` ("chat") arrives from the launch (xterm is stubbed so the
// opener actually runs). This is the real §17.6 routing.
await rtlWaitFor(() =>
expect(screen.queryByTestId("agent-chat-view")).toBeTruthy(),
);
// Once routed to chat, the terminal view is gone for that cell.
expect(screen.queryByTestId("terminal-view")).toBeNull();
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
});

View File

@ -0,0 +1,239 @@
/**
* F2 — mediated input for agent cells (cadrage §4.2/§7).
*
* Two contracts are exercised here, with the real {@link DIProvider} and the
* in-memory mocks:
*
* 1. **Keystrokes are mediated in agent mode.** A cell that pins an agent puts
* `TerminalView` in `agentMode`: human keystrokes (`term.onData`) MUST NOT be
* written to the PTY (input flows through {@link MediatedInput}). A plain
* (agent-less) cell keeps the raw-shell behaviour (keystrokes → PTY).
* 2. **The PTY *output* is always painted** (both modes) — xterm stays the raw
* output view, unchanged by F2.
* 3. **`MediatedInput` is mounted under the terminal** for an agent cell, and
* **never** an `AgentChatView` (the structured chat surface stays removed).
*
* xterm is stubbed (as in the sibling chat test) so `term.open` succeeds under
* jsdom and the opener/keystroke wiring genuinely runs. The stub captures the
* `onData` keystroke handler so the test can fire a keystroke and assert whether
* it reached the PTY handle (`handle.write`).
*/
import { beforeEach, describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
// Capture the keystroke handler xterm's `onData` is given, and the output sink
// `term.write` so we can assert PTY output is painted. A single shared slot is
// enough: each test renders exactly one terminal cell.
const xtermState: {
keyHandler: ((data: string) => void) | null;
writes: Uint8Array[];
} = { keyHandler: null, writes: [] };
vi.mock("@xterm/xterm", () => ({
Terminal: class {
loadAddon() {}
open() {}
onData(cb: (data: string) => void) {
xtermState.keyHandler = cb;
return { dispose() {} };
}
onResize() {
return { dispose() {} };
}
write(bytes: Uint8Array) {
xtermState.writes.push(bytes);
}
dispose() {}
get cols() {
return 80;
}
get rows() {
return 24;
}
},
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: class {
fit() {}
},
}));
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
if (typeof globalThis.ResizeObserver === "undefined") {
globalThis.ResizeObserver = class {
observe() {}
unobserve() {}
disconnect() {}
} as unknown as typeof ResizeObserver;
}
import type { Gateways, TerminalHandle } from "@/ports";
import {
MockAgentGateway,
MockInputGateway,
MockLayoutGateway,
MockSystemGateway,
MockTerminalGateway,
} from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { leaves } from "./layout";
import { LayoutGrid } from "./LayoutGrid";
/** Fresh mock set (input gateway included so MediatedInput is fully wired). */
function makeGateways(agentGateway: MockAgentGateway): Gateways {
return {
layout: new MockLayoutGateway(),
agent: agentGateway,
terminal: new MockTerminalGateway(),
system: new MockSystemGateway(),
input: new MockInputGateway(),
} as unknown as Gateways;
}
function renderGrid(gateways: Gateways) {
return render(
<DIProvider gateways={gateways}>
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
</DIProvider>,
);
}
/**
* Spy on the handle the agent gateway hands out, so we can detect whether a
* keystroke reached the PTY (`handle.write`). Returns the spy.
*/
function spyAgentWrite(gw: MockAgentGateway): ReturnType<typeof vi.fn> {
const writeSpy = vi.fn();
const original = gw.launchAgent.bind(gw);
vi.spyOn(gw, "launchAgent").mockImplementation(async (p, a, o, onData) => {
const handle: TerminalHandle = await original(p, a, o, onData);
return { ...handle, write: writeSpy };
});
return writeSpy;
}
/** Spy on the terminal gateway handle write (plain-cell PTY path). */
function spyTerminalWrite(gw: MockTerminalGateway): ReturnType<typeof vi.fn> {
const writeSpy = vi.fn();
const original = gw.openTerminal.bind(gw);
vi.spyOn(gw, "openTerminal").mockImplementation(async (o, onData) => {
const handle: TerminalHandle = await original(o, onData);
return { ...handle, write: writeSpy };
});
return writeSpy;
}
beforeEach(() => {
xtermState.keyHandler = null;
xtermState.writes = [];
});
describe("LayoutGrid F2 — mediated input for agent cells", () => {
it("an AGENT cell does NOT write keystrokes to the PTY (input is mediated)", async () => {
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
// Pin the agent onto the leaf in this run's layout gateway.
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
const writeSpy = spyAgentWrite(agentGateway);
renderGrid(gateways);
// Wait for the launch to settle (the opener ran and adopted a handle).
await waitFor(() => expect(agentGateway.launchAgent).toHaveBeenCalled());
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
// Simulate a human keystroke into xterm.
xtermState.keyHandler!("h");
xtermState.keyHandler!("i");
// In agent mode the keystroke must be swallowed — never forwarded to the PTY.
expect(writeSpy).not.toHaveBeenCalled();
});
it("a PLAIN cell still writes keystrokes to the PTY (raw shell, unchanged)", async () => {
const agentGateway = new MockAgentGateway();
const gateways = makeGateways(agentGateway);
const writeSpy = spyTerminalWrite(gateways.terminal as MockTerminalGateway);
renderGrid(gateways);
await waitFor(() =>
expect((gateways.terminal as MockTerminalGateway).openTerminal).toHaveBeenCalled(),
);
await waitFor(() => expect(xtermState.keyHandler).not.toBeNull());
xtermState.keyHandler!("l");
xtermState.keyHandler!("s");
// Plain shell: keystrokes flow straight to the PTY (current behaviour).
await waitFor(() => expect(writeSpy).toHaveBeenCalled());
});
it("paints PTY output in BOTH modes (xterm output view unchanged)", async () => {
// Agent cell: the mock greets on open → output must reach term.write.
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
// The mock agent greets on open; that output must be painted by xterm even
// though keystroke input is mediated.
await waitFor(() => expect(xtermState.writes.length).toBeGreaterThan(0));
});
it("mounts MediatedInput under an agent cell, never an AgentChatView", async () => {
const agentGateway = new MockAgentGateway();
const agent = await agentGateway.createAgent("p1", {
name: "Worker",
profileId: "claude",
});
const gateways = makeGateways(agentGateway);
const layout = gateways.layout as MockLayoutGateway;
const leafId = leaves(await layout.loadLayout("p1"))[0].id;
await layout.mutateLayout("p1", {
type: "setCellAgent",
target: leafId,
agent: agent.id,
});
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.getByTestId("mediated-input")).toBeTruthy();
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
});
it("a PLAIN cell has NO mediated input strip", async () => {
const agentGateway = new MockAgentGateway();
const gateways = makeGateways(agentGateway);
renderGrid(gateways);
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
expect(screen.getByTestId("terminal-view")).toBeTruthy();
expect(screen.queryByTestId("mediated-input")).toBeNull();
});
});

View File

@ -18,7 +18,7 @@
import { useEffect, useRef, useState } from "react";
import type { Agent, CellKind } from "@/domain";
import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain";
import type {
ConversationDetails,
@ -26,8 +26,12 @@ import type {
OpenTerminalOptions,
TerminalHandle,
} from "@/ports";
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
import { AgentChatView } from "@/features/chat";
import {
MediatedInput,
ResumeConversationPopup,
TerminalView,
useAgentBusy,
} from "@/features/terminals";
import { useGateways } from "@/app/di";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
@ -144,17 +148,6 @@ interface LeafViewProps {
visibleNodeIds: Set<string>;
}
/**
* Remembers each live session's {@link CellKind} (`"pty"` | `"chat"`) by session
* id, so that after a navigation/layout change re-mounts a leaf — when only the
* persisted session id is known and a re-attach (not a re-launch) is due — the
* grid can still route to the right view (`AgentChatView` vs `TerminalView`)
* without re-spawning. Populated at launch from the handle's derived `cellKind`
* (ARCHITECTURE §17.6). Module-scoped (survives unmount); a closed session's
* id is never reused, so stale entries are harmless.
*/
const cellKindBySession = new Map<string, CellKind>();
/**
* A launch deferred by the resume popup (T7): TerminalView asked the opener to
* launch (fresh open or reattach-failed fallback) for a cell that carries a
@ -210,6 +203,11 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
const { agent: agentGateway, system } = useGateways();
// Per-agent busy map (lot F1) feeds the mediated input strip: while the agent
// is processing a turn, "Envoyer" is dimmed (but the enqueue stays open) and
// "Interrompre" is active. Absent key ⇒ idle.
const busyMap = useAgentBusy();
// Load the project's agents for the dropdown.
const [agents, setAgents] = useState<Agent[]>([]);
useEffect(() => {
@ -330,35 +328,13 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);
// ── Cell kind routing (§17.6) ─────────────────────────────────────────────
// Which view this agent cell renders: a structured chat (`AgentChatView`) or a
// raw terminal (`TerminalView`). `null` until known. It is derived from the
// launched session's `cellKind`; on a re-mount with a persisted session we
// recover it from the module-level cache so a chat cell repaints as chat
// (re-attach, not re-spawn). Plain (agent-less) cells are always terminals.
const [cellKind, setCellKind] = useState<CellKind | null>(() =>
agent && session ? (cellKindBySession.get(session) ?? null) : null,
);
// The chat session id once a chat launch resolved — passed to `AgentChatView`
// immediately on the view swap, independent of the layout-persistence round
// trip (so the chat re-attaches to the just-spawned session, never re-spawns).
const [chatSessionId, setChatSessionId] = useState<string | null>(
agent && session && cellKindBySession.get(session) === "chat"
? session
: null,
);
// Reset the derived routing when the pinned agent changes (a different agent —
// or unpinning to a plain cell — may be a different cell kind). We re-seed from
// the persisted session's cached kind so a remount of a known chat cell stays a
// chat (re-attach, not re-spawn); otherwise routing falls back to a terminal
// until the next launch reports the kind.
useEffect(() => {
const known = agent && session ? cellKindBySession.get(session) : undefined;
setCellKind(known ?? null);
setChatSessionId(known === "chat" ? session : null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent]);
// ── Cell routing (Option 1 — Terminal + MCP) ──────────────────────────────
// Every agent cell renders the raw {@link TerminalView}: the human view is the
// native interactive PTY (live reasoning + Échap are native CLI behaviours,
// zero model parsing), and cross-model delegation flows through MCP tools, not
// a structured chat view. The former `AgentChatView`/`cellKind:"chat"` routing
// has been removed (F-2 cleanup); any `cellKind` field still emitted by the
// backend on the wire is simply ignored.
/** Performs the actual launch and persists any assigned id (T4b loop). */
const doLaunch = async (
@ -398,12 +374,6 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
if (handle.assignedConversationId) {
void vm.setCellConversation(id, handle.assignedConversationId);
}
// Record the derived cell kind so re-mounts (after navigation) route to the
// right view without re-spawning (§17.6).
const kind = handle.cellKind ?? "pty";
cellKindBySession.set(handle.sessionId, kind);
if (kind === "chat") setChatSessionId(handle.sessionId);
setCellKind(kind);
return handle;
};
@ -455,21 +425,6 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
agentGateway.reattach(sessionId, onData)
: undefined;
// ── Chat cell callbacks (§17.6) ───────────────────────────────────────────
// Only used when this cell renders as a structured chat. `launch` spawns the
// session (reusing the same `doLaunch` as the terminal path, so the singleton
// invariant and conversation persistence are shared); `reattach`/`send` map to
// the chat-specific gateway methods.
const chatLaunch = (): Promise<string> =>
// A chat launch produces no PTY output; the byte sink is a no-op.
doLaunch({ cwd, rows: 0, cols: 0 }, () => {}, conversationId ?? undefined).then(
(handle) => {
setChatSessionId(handle.sessionId);
void vm.setSession(id, handle.sessionId);
return handle.sessionId;
},
);
return (
<div
data-testid="layout-leaf"
@ -596,35 +551,47 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
</button>
)}
</div>
{/* Route by derived cell kind (§17.6): a structured AI cell renders the
chat view, every other cell (plain terminal or a TUI/PTY agent) keeps
the unchanged xterm path. Re-key on the agent so the right view/opener
is captured at mount; the persisted session drives re-attach (never a
re-spawn) when navigating. */}
{agentGateway && agentId && cellKind === "chat" ? (
<AgentChatView
key={`${id}-${agentId}-chat`}
launch={chatLaunch}
reattach={(sid, onReply) => agentGateway.reattachChat(sid, onReply)}
send={(sid, prompt, onReply) =>
agentGateway.sendPrompt(sid, prompt, onReply)
}
sessionId={chatSessionId ?? session}
onSessionId={(sid) => {
setChatSessionId(sid);
void vm.setSession(id, sid);
}}
/>
) : (
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
/>
)}
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their
cross-model delegation happens through MCP tools, not a chat view.
Re-key on the agent so the right opener is captured at mount; the
persisted session drives re-attach (never a re-spawn) when navigating.
Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only
for the human (`agentMode`) and the {@link MediatedInput} strip is
mounted **under** it — keystrokes route through IdeA, not the PTY. A
**plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has
no input strip. We stack terminal + strip in a column so the strip sits
beneath the live output. */}
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null}
/>
</div>
{agentId != null && (
<MediatedInput
projectId={projectId}
agentId={agentId}
busy={busyMap[agentId] ?? false}
/>
)}
</div>
{busyNotice && (
<div
role="status"

View File

@ -0,0 +1,108 @@
/**
* F1 — {@link MediatedInput} wired to {@link MockInputGateway} through the real
* {@link DIProvider}. Asserts the mediated-input contract (cadrage §4.2/§6):
*
* - "Envoyer" routes to `InputGateway.submit` with the right args;
* - "Interrompre" routes to `InputGateway.interrupt`;
* - while busy, "Envoyer" is dimmed/aria-disabled but the enqueue STILL goes
* through (forward/fallback — never block the user), and "Interrompre" stays
* active.
*
* Fully offline: gateways are mocks, no backend.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { MediatedInput } from "./MediatedInput";
function renderInput(
input: MockInputGateway,
props: Partial<React.ComponentProps<typeof MediatedInput>> = {},
) {
const gateways = {
input,
system: new MockSystemGateway(),
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<MediatedInput projectId="p1" agentId="a1" {...props} />
</DIProvider>,
);
}
describe("MediatedInput (with MockInputGateway)", () => {
it("mounts and renders the input strip", () => {
renderInput(new MockInputGateway());
expect(screen.getByTestId("mediated-input")).toBeTruthy();
});
it("Envoyer routes to gateway.submit with project/agent/text", async () => {
const input = new MockInputGateway();
renderInput(input);
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
target: { value: "hello agent" },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
await waitFor(() => {
expect(input.submits).toEqual([
{ projectId: "p1", agentId: "a1", text: "hello agent" },
]);
});
// No interrupt was triggered.
expect(input.interrupts).toEqual([]);
});
it("does not submit blank/whitespace-only text", async () => {
const input = new MockInputGateway();
renderInput(input);
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
target: { value: " " },
});
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
expect(input.submits).toEqual([]);
});
it("Interrompre routes to gateway.interrupt (not an enqueue)", async () => {
const input = new MockInputGateway();
renderInput(input);
fireEvent.click(screen.getByRole("button", { name: "Interrompre" }));
await waitFor(() => {
expect(input.interrupts).toEqual([{ projectId: "p1", agentId: "a1" }]);
});
expect(input.submits).toEqual([]);
});
it("while busy: Envoyer is aria-disabled but the enqueue still goes through, Interrompre stays active", async () => {
const input = new MockInputGateway();
renderInput(input, { busy: true });
const send = screen.getByRole("button", { name: "Envoyer" });
const interrupt = screen.getByRole("button", { name: "Interrompre" });
// Visually disabled (forward/fallback: dimmed, not hard-disabled).
expect(send.getAttribute("aria-disabled")).toBe("true");
// Interrompre is never disabled.
expect(interrupt.hasAttribute("disabled")).toBe(false);
// The enqueue path is NOT blocked while busy.
fireEvent.change(screen.getByLabelText("Message à l'agent"), {
target: { value: "queued while busy" },
});
fireEvent.click(send);
await waitFor(() => {
expect(input.submits).toEqual([
{ projectId: "p1", agentId: "a1", text: "queued while busy" },
]);
});
});
});

View File

@ -0,0 +1,93 @@
/**
* MediatedInput (lot F1, cadrage §4.2/§4.3).
*
* The mediated input strip rendered **under** an agent cell's terminal. Human
* keystrokes for an agent no longer go straight to the PTY — they are typed here
* and routed through the {@link InputGateway} port:
*
* - **Envoyer** → `submit` (enqueue in the agent's single FIFO).
* - **Interrompre** → `interrupt` (preempt the current turn — NOT an enqueue).
*
* Busy semantics (forward/fallback, §4.2/§6): while the agent is `busy`,
* "Envoyer" is **disabled visually** but the enqueue path is never blocked — the
* backend FIFO is the single point that serialises. So `busy` only dims the
* button; "Interrompre" stays active so the user can always preempt.
*
* Hexagonal: this component talks to {@link InputGateway} via the DI provider,
* never to `invoke()` directly. The busy flag is supplied by the caller (fed by
* {@link useAgentBusy}); this keeps the component pure and easy to test.
*/
import { useState, type FormEvent } from "react";
import { Button, Input } from "@/shared";
import { useGateways } from "@/app/di";
export interface MediatedInputProps {
/** Owning project. */
projectId: string;
/** The agent this input strip targets. */
agentId: string;
/**
* Whether the agent is currently processing a turn. Dims "Envoyer" (without
* blocking the enqueue) and is irrelevant to "Interrompre" (always active).
* Defaults to `false`.
*/
busy?: boolean;
}
/** The mediated input strip for an agent cell. */
export function MediatedInput({
projectId,
agentId,
busy = false,
}: MediatedInputProps) {
const { input } = useGateways();
const [text, setText] = useState("");
const send = async () => {
const value = text;
if (value.trim() === "") return;
// Clear optimistically: the enqueue is fire-and-forward; never block the
// user (forward/fallback). Even while busy the submit goes through.
setText("");
await input.submit(projectId, agentId, value);
};
const onSubmit = (e: FormEvent) => {
e.preventDefault();
void send();
};
const onInterrupt = () => {
void input.interrupt(projectId, agentId);
};
return (
<form
data-testid="mediated-input"
className="flex items-center gap-2 p-2 border-t border-border bg-surface"
onSubmit={onSubmit}
>
<Input
aria-label="Message à l'agent"
placeholder={busy ? "Agent occupé — sera mis en file…" : "Message…"}
value={text}
onChange={(e) => setText(e.target.value)}
/>
<Button
type="submit"
variant="primary"
// Visually disabled while busy, but the enqueue path stays open: the
// user can still press Enter to forward into the FIFO (§4.2/§6).
aria-disabled={busy || undefined}
className={busy ? "opacity-50" : undefined}
>
Envoyer
</Button>
<Button type="button" variant="danger" onClick={onInterrupt}>
Interrompre
</Button>
</form>
);
}

View File

@ -3,7 +3,11 @@
* {@link TerminalGateway} port (or a custom opener), and fits it to its container:
*
* - PTY output (gateway `onData`) → `term.write(bytes)`.
* - xterm `onData` (keystrokes) → `handle.write(bytes)`.
* - xterm `onData` (keystrokes) → `handle.write(bytes)` **only for a plain
* (non-agent) cell** (a raw shell). In **agent mode** (`agentMode`, lot F2,
* cadrage §4.2) keystrokes are NOT written to the PTY: input is mediated by
* IdeA through {@link MediatedInput}. The PTY **output** path stays live and
* INCHANGED in both modes (xterm is always the raw output view).
* - container resize (fit addon) → `handle.resize(rows, cols)`.
*
* Pure presentation: it only knows the port, never `invoke()`/`Channel`
@ -71,6 +75,15 @@ interface TerminalViewProps {
* reattach (the id is already known).
*/
onSessionId?: (sessionId: string) => void;
/**
* Agent mode (lot F2, cadrage §4.2). When `true` the cell hosts an agent, so
* keystrokes (`term.onData`) are **not** forwarded to the PTY — input is
* mediated by IdeA through {@link MediatedInput} rendered under the terminal.
* The PTY **output** stays live and unchanged (xterm remains the raw output
* view). When `false`/absent the cell is a plain shell: keystrokes go straight
* to the PTY (current behaviour). Defaults to `false`.
*/
agentMode?: boolean;
}
export function TerminalView({
@ -79,6 +92,7 @@ export function TerminalView({
reattach,
sessionId,
onSessionId,
agentMode = false,
}: TerminalViewProps) {
const { terminal } = useGateways();
const containerRef = useRef<HTMLDivElement | null>(null);
@ -100,6 +114,8 @@ export function TerminalView({
onSessionIdRef.current = onSessionId;
const terminalRef = useRef(terminal);
terminalRef.current = terminal;
const agentModeRef = useRef(agentMode);
agentModeRef.current = agentMode;
useEffect(() => {
const container = containerRef.current;
@ -135,9 +151,14 @@ export function TerminalView({
let handle: TerminalHandle | null = null;
const encoder = new TextEncoder();
// Buffer keystrokes that arrive before the PTY finished opening.
// Keystroke → PTY path. In **agent mode** (F2) keystrokes are mediated by
// IdeA (MediatedInput) and must NOT reach the PTY here; we drop them so the
// raw terminal is output-only for the human. In **plain mode** keystrokes go
// straight to the PTY (raw shell), buffering any that arrive before the PTY
// finished opening. The output path below is unchanged in both modes.
let pending = "";
const onKey = term.onData((data) => {
if (agentModeRef.current) return;
if (handle) void handle.write(encoder.encode(data));
else pending += data;
});

View File

@ -2,3 +2,7 @@
export { TerminalView } from "./TerminalView";
export { ResumeConversationPopup } from "./ResumeConversationPopup";
export { MediatedInput } from "./MediatedInput";
export type { MediatedInputProps } from "./MediatedInput";
export { useAgentBusy } from "./useAgentBusy";
export type { AgentBusyMap } from "./useAgentBusy";

View File

@ -0,0 +1,80 @@
/**
* F1 — {@link useAgentBusy} fed by `agentBusyChanged` domain events through the
* mock {@link MockSystemGateway}. Asserts the busy store tracks per-agent state
* and that a busy agent drives the {@link MediatedInput} button states.
*/
import { describe, it, expect } from "vitest";
import { render, screen, waitFor, act } from "@testing-library/react";
import type { Gateways } from "@/ports";
import { MockInputGateway, MockSystemGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { MediatedInput } from "./MediatedInput";
import { useAgentBusy } from "./useAgentBusy";
/** Tiny harness: renders the input with `busy` derived from the store. */
function BusyHarness({ agentId }: { agentId: string }) {
const busy = useAgentBusy();
return (
<MediatedInput projectId="p1" agentId={agentId} busy={busy[agentId] ?? false} />
);
}
function renderHarness(system: MockSystemGateway, agentId = "a1") {
const gateways = {
input: new MockInputGateway(),
system,
} as unknown as Gateways;
return render(
<DIProvider gateways={gateways}>
<BusyHarness agentId={agentId} />
</DIProvider>,
);
}
describe("useAgentBusy (with MockSystemGateway events)", () => {
it("starts idle: Envoyer is not aria-disabled", () => {
renderHarness(new MockSystemGateway());
const send = screen.getByRole("button", { name: "Envoyer" });
expect(send.getAttribute("aria-disabled")).toBeNull();
});
it("an agentBusyChanged(true) event dims Envoyer; (false) re-enables it", async () => {
const system = new MockSystemGateway();
renderHarness(system, "a1");
act(() => {
system.emit({ type: "agentBusyChanged", agentId: "a1", busy: true });
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"),
).toBe("true");
});
act(() => {
system.emit({ type: "agentBusyChanged", agentId: "a1", busy: false });
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"),
).toBeNull();
});
});
it("a busy event for another agent does not affect this cell", async () => {
const system = new MockSystemGateway();
renderHarness(system, "a1");
act(() => {
system.emit({ type: "agentBusyChanged", agentId: "other", busy: true });
});
// Give the effect a tick; this cell (a1) must stay idle.
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Envoyer" }).getAttribute("aria-disabled"),
).toBeNull();
});
});
});

View File

@ -0,0 +1,58 @@
/**
* Light per-agent busy store (lot F1, cadrage §4.2/§4.3).
*
* Keeps `agentBusy: Record<agentId, boolean>` fed by the discrete
* `agentBusyChanged` domain event relayed from the backend (a real event, not a
* high-frequency channel). The component consumes this to *disable* the "Envoyer"
* button visually while still allowing the enqueue (forward/fallback, §6) and to
* keep "Interrompre" active.
*
* For F1 the backend event (lot C4) may not yet be emitted; until then the store
* simply stays all-idle, and tests drive it through the mock `SystemGateway`.
*/
import { useEffect, useState } from "react";
import type { DomainEvent } from "@/domain";
import { useGateways } from "@/app/di";
/** A read-only map of agentId → busy. Absent key ⇒ idle. */
export type AgentBusyMap = Readonly<Record<string, boolean>>;
/**
* Subscribes to `agentBusyChanged` domain events and returns the current
* busy map. Unsubscribes on unmount.
*/
export function useAgentBusy(): AgentBusyMap {
const { system } = useGateways();
const [busy, setBusy] = useState<Record<string, boolean>>({});
useEffect(() => {
// The system gateway may be absent in some compositions/tests; stay all-idle
// rather than throw (the busy map is a best-effort overlay).
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event: DomainEvent) => {
if (event.type !== "agentBusyChanged") return;
setBusy((prev) =>
prev[event.agentId] === event.busy
? prev
: { ...prev, [event.agentId]: event.busy },
);
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system]);
return busy;
}

View File

@ -31,7 +31,6 @@ import type {
MemoryType,
Project,
ProfileAvailability,
ReplyChunk,
ResumableAgent,
Skill,
SkillScope,
@ -166,40 +165,6 @@ export interface AgentGateway {
agentId: string,
conversationId: string,
): Promise<ConversationDetails>;
/**
* Sends a prompt to a live **structured** (chat) session and streams the
* reply turn back through `onReply` (ARCHITECTURE §17.7, command `agent_send`).
* Each {@link ReplyChunk} is delivered as it arrives: `textDelta`s accumulate
* into the current turn, `toolActivity` shows tool badges, and the terminal
* `final` chunk freezes the turn. The chat twin of a PTY `write`. Resolves once
* the turn stream is wired (not when the turn completes).
*/
sendPrompt(
sessionId: string,
prompt: string,
onReply: (chunk: ReplyChunk) => void,
): Promise<void>;
/**
* Re-attaches a chat view to an **already-living** structured session without
* re-sending or re-spawning it (ARCHITECTURE §17.7, command
* `reattach_agent_chat`). Returns the retained conversation scrollback (the
* chunks already streamed) so the view repaints its prior turns; subsequent
* chunks then arrive over `onReply`. The chat twin of {@link reattach}.
*
* Rejects if no live structured session owns the id (the caller then opens
* fresh).
*/
reattachChat(
sessionId: string,
onReply: (chunk: ReplyChunk) => void,
): Promise<ReplyChunk[]>;
/**
* Shuts a live structured session down and tears its transport (channel +
* conversation scrollback) down (ARCHITECTURE §17.7, command
* `close_agent_session`). The chat twin of {@link TerminalGateway.closeTerminal};
* reserved for an explicit user action — navigation must never call this.
*/
closeAgentSession(sessionId: string): Promise<void>;
}
/** Options for opening a terminal. */
@ -257,14 +222,6 @@ export interface TerminalHandle {
* leaf so the next open resumes instead of re-assigning.
*/
readonly assignedConversationId?: string;
/**
* How the cell hosting this session should render (ARCHITECTURE §17.6).
* Present on handles returned by {@link AgentGateway.launchAgent}: `"chat"`
* routes the leaf to an `AgentChatView`, `"pty"` (the default) to a raw
* {@link TerminalView}. `undefined` on plain-terminal handles ⇒ treated as
* `"pty"`.
*/
readonly cellKind?: import("@/domain").CellKind;
/** Sends bytes (xterm keystrokes) to the PTY. */
write(data: Uint8Array): Promise<void>;
/** Resizes the PTY. */
@ -529,6 +486,25 @@ export interface MemoryGateway {
): Promise<MemoryIndexEntry[]>;
}
/**
* Mediated agent input (lot F1, cadrage §4.2). All human input to an *agent*
* cell flows through this port instead of being written straight to the PTY:
* "Envoyer" enqueues a task in the agent's single FIFO (`submit`), "Interrompre"
* preempts the current turn (`interrupt`). The component talks to this gateway,
* never to `invoke()` directly.
*
* Note (forward/fallback, §4.2/§6): `submit` must never be blocked client-side
* when the agent is busy — the UI may *disable the button visually* but the
* enqueue path stays available; the backend FIFO is the single point that
* serialises. So callers may still call `submit` while busy.
*/
export interface InputGateway {
/** Envoyer = enqueue: appends `text` to the agent's input FIFO. */
submit(projectId: string, agentId: string, text: string): Promise<void>;
/** Interrompre = preempt: signals the current turn to stop (not an enqueue). */
interrupt(projectId: string, agentId: string): Promise<void>;
}
/**
* AI profiles & first-run (L5). Drives the first-run wizard and profile
* management: the pre-filled reference catalogue, detection of installed CLIs,
@ -581,6 +557,7 @@ export interface EmbedderGateway {
export interface Gateways {
system: SystemGateway;
agent: AgentGateway;
input: InputGateway;
terminal: TerminalGateway;
project: ProjectGateway;
layout: LayoutGateway;