feat(agent): vue chat frontend + routage cellKind (D5) — §17

Le frontend consomme l'exécution structurée livrée en D4 :
- AgentChatView : jumeau chat de TerminalView. Accumule les textDelta du
  tour courant, badges toolActivity, fige sur final (le contenu final
  remplace les deltas, pas de double rendu). Saisie Enter/Shift+Enter.
  Au montage : reattach (rejoue le scrollback) ou launch ; au démontage :
  détache seulement (jamais de close). Vue pure pilotée par le port.
- LayoutGrid/LeafView : routage cellKind — AgentChatView si "chat",
  TerminalView sinon (chemin PTY inchangé). Cache cellKindBySession pour
  router correctement après ré-attache/navigation.
- Port AgentGateway : sendPrompt / reattachChat / closeAgentSession ;
  cellKind sur TerminalHandle. Adapter Tauri (invoke + Channel<ReplyChunk>)
  et mock (MockChatSession, _setChatAgents) étendus.
- Types TS mirrors : CellKind, ReplyChunk, ReattachChatDto + cellKind sur
  TerminalSession.

Tests (QA) : 21 Vitest verts — routage chat/pty (non-régression terminal),
accumulation/non-doublon (garde anti-always-green delta!=final), ré-attache
sans re-spawn, envoi Enter vs Shift+Enter, badges toolActivity, mock stream.
npx vitest run : 345 passed, 0 failed. npm run build vert.

Reste D6 (messagerie inter-agents via send_blocking) et D7 (menu restreint
Claude/Codex + retrait custom) pour clore §17.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 23:30:20 +02:00
parent f4d5727a69
commit 5059f37890
11 changed files with 1478 additions and 30 deletions

View File

@ -27,8 +27,10 @@ import type {
MemoryIndexEntry,
MemoryLink,
MemoryType,
CellKind,
Project,
ProfileAvailability,
ReplyChunk,
ResumableAgent,
Skill,
SkillScope,
@ -159,6 +161,68 @@ 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
@ -185,6 +249,19 @@ 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, []);
@ -373,6 +450,7 @@ export class MockAgentGateway implements AgentGateway {
cwd: `/home/user/mock-project`,
rows,
cols,
cellKind: this.chatAgents.has(agentId) ? "chat" : "pty",
},
};
}
@ -457,26 +535,53 @@ export class MockAgentGateway implements AgentGateway {
this.sessionSeq += 1;
const sessionId = `mock-agent-session-${this.sessionSeq}`;
const cwd = options.cwd;
const enc = new TextEncoder();
const session = new MockPtySession(sessionId, onData);
this.sessions.set(sessionId, session);
const cellKind: CellKind = this.chatAgents.has(agentId) ? "chat" : "pty";
// Record liveness for `listLiveAgents` + the guard above (only when the
// caller pins a node).
// caller pins a node) — shared by both cell kinds.
if (options.nodeId) {
this.liveByAgent.set(agentId, options.nodeId);
this.liveSessionByAgent.set(agentId, sessionId);
}
// Greet so something is visible immediately (mirrors MockTerminalGateway).
queueMicrotask(() =>
session.emit(enc.encode(`agent ${agentId} @ ${cwd}\r\n`)),
);
const handle = makeMockHandle(session, () => {
this.sessions.delete(sessionId);
const clearLive = () => {
if (this.liveByAgent.get(agentId) === options.nodeId) {
this.liveByAgent.delete(agentId);
this.liveSessionByAgent.delete(agentId);
}
});
};
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,
};
}
// Simulate session assignment (T4b): a fresh cell (no conversation id) gets a
// newly-minted id surfaced on the handle so the caller persists it; a cell
// that already carries an id resumes it and nothing new is assigned.
@ -489,6 +594,58 @@ 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,
@ -573,6 +730,28 @@ 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