feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel
Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom, préférence persistée `preferred_view`, reattach live, composer + pièces jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt, cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat). Corrige le bug bloquant relevé par QA : le bouton Cancel de CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la session contrairement au contrat produit validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -161,4 +161,13 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
request: { projectId: "proj-1", agentId: "agent-2", effort: null },
|
||||
});
|
||||
});
|
||||
|
||||
it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => {
|
||||
await new TauriAgentGateway().cancelAgentChat("chat-session-1");
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith("cancel_agent_chat", {
|
||||
sessionId: "chat-session-1",
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith("close_agent_session", expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@ -18,15 +18,18 @@ import type {
|
||||
Agent,
|
||||
AgentContextDocument,
|
||||
EffortSelection,
|
||||
ReplyChunk,
|
||||
ResumableAgent,
|
||||
TerminalSession,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
AgentChatHandle,
|
||||
AgentGateway,
|
||||
ConversationDetails,
|
||||
CreateAgentInput,
|
||||
LiveAgent,
|
||||
OpenTerminalOptions,
|
||||
ReattachAgentChatResult,
|
||||
ReattachResult,
|
||||
StoppedLiveAgent,
|
||||
TerminalHandle,
|
||||
@ -175,6 +178,69 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
};
|
||||
}
|
||||
|
||||
async launchAgentChat(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
options: OpenTerminalOptions,
|
||||
): Promise<AgentChatHandle> {
|
||||
// `launch_agent` is the routing authority. For structured/headless profiles
|
||||
// the backend ignores the PTY output channel and returns a structured session
|
||||
// id; for non-structured profiles it may still route to PTY, so callers gate
|
||||
// this method on `profile.structuredAdapter`.
|
||||
const channel = new Channel<number[]>();
|
||||
const res = await invoke<LaunchAgentResponse>("launch_agent", {
|
||||
request: {
|
||||
projectId,
|
||||
agentId,
|
||||
rows: options.rows,
|
||||
cols: options.cols,
|
||||
conversationId: options.conversationId ?? null,
|
||||
nodeId: options.nodeId ?? null,
|
||||
},
|
||||
onOutput: channel,
|
||||
});
|
||||
return {
|
||||
sessionId: res.sessionId,
|
||||
...(res.assignedConversationId
|
||||
? { assignedConversationId: res.assignedConversationId }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async reattachAgentChat(
|
||||
sessionId: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<ReattachAgentChatResult> {
|
||||
const channel = new Channel<ReplyChunk>();
|
||||
channel.onmessage = onChunk;
|
||||
return invoke<ReattachAgentChatResult>("reattach_agent_chat", {
|
||||
sessionId,
|
||||
onReply: channel,
|
||||
});
|
||||
}
|
||||
|
||||
async sendAgentChat(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<void> {
|
||||
const channel = new Channel<ReplyChunk>();
|
||||
channel.onmessage = onChunk;
|
||||
await invoke("agent_send", {
|
||||
sessionId,
|
||||
prompt,
|
||||
onReply: channel,
|
||||
});
|
||||
}
|
||||
|
||||
async closeAgentChat(sessionId: string): Promise<void> {
|
||||
await invoke("close_agent_session", { sessionId });
|
||||
}
|
||||
|
||||
async cancelAgentChat(sessionId: string): Promise<void> {
|
||||
await invoke("cancel_agent_chat", { sessionId });
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
|
||||
@ -99,6 +99,7 @@ import type {
|
||||
Unsubscribe,
|
||||
} from "@/domain";
|
||||
import type {
|
||||
AgentChatHandle,
|
||||
AgentGateway,
|
||||
ConversationGateway,
|
||||
ConversationPageRequest,
|
||||
@ -145,6 +146,7 @@ import type {
|
||||
PluginWorkspaceWriteBinaryInput,
|
||||
PluginWorkspaceWriteTextInput,
|
||||
ReattachResult,
|
||||
ReattachAgentChatResult,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
SaveOpenCodeProviderProfileInput,
|
||||
@ -392,6 +394,10 @@ 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>();
|
||||
/** Runtime kind per live agent (`agentId → kind`). */
|
||||
private liveKindByAgent = new Map<string, "pty" | "structured">();
|
||||
/** Retained structured reply chunks per live chat session. */
|
||||
private chatScrollback = new Map<string, ReplyChunk[]>();
|
||||
|
||||
private getAgents(projectId: string): Agent[] {
|
||||
if (!this.agents.has(projectId)) this.agents.set(projectId, []);
|
||||
@ -416,7 +422,7 @@ export class MockAgentGateway implements AgentGateway {
|
||||
agentId,
|
||||
nodeId,
|
||||
sessionId: this.liveSessionByAgent.get(agentId)!,
|
||||
kind: "pty" as const,
|
||||
kind: this.liveKindByAgent.get(agentId) ?? "pty",
|
||||
}));
|
||||
}
|
||||
|
||||
@ -449,7 +455,11 @@ export class MockAgentGateway implements AgentGateway {
|
||||
throw err;
|
||||
}
|
||||
const sessionId = this.liveSessionByAgent.get(agentId);
|
||||
if (!sessionId || !this.sessions.has(sessionId)) {
|
||||
if (
|
||||
!sessionId ||
|
||||
(this.liveKindByAgent.get(agentId) !== "structured" &&
|
||||
!this.sessions.has(sessionId))
|
||||
) {
|
||||
const err: GatewayError = {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} has no live session`,
|
||||
@ -457,7 +467,12 @@ export class MockAgentGateway implements AgentGateway {
|
||||
throw err;
|
||||
}
|
||||
this.liveByAgent.set(agentId, nodeId);
|
||||
return { agentId, nodeId, sessionId, kind: "pty" };
|
||||
return {
|
||||
agentId,
|
||||
nodeId,
|
||||
sessionId,
|
||||
kind: this.liveKindByAgent.get(agentId) ?? "pty",
|
||||
};
|
||||
}
|
||||
|
||||
async stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
|
||||
@ -480,9 +495,12 @@ export class MockAgentGateway implements AgentGateway {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) session.closed = true;
|
||||
this.sessions.delete(sessionId);
|
||||
this.chatScrollback.delete(sessionId);
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
this.liveByAgent.delete(agentId);
|
||||
return { agentId, sessionId, kind: "pty" };
|
||||
const kind = this.liveKindByAgent.get(agentId) ?? "pty";
|
||||
this.liveKindByAgent.delete(agentId);
|
||||
return { agentId, sessionId, kind };
|
||||
}
|
||||
|
||||
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
@ -718,11 +736,13 @@ export class MockAgentGateway implements AgentGateway {
|
||||
if (options.nodeId) {
|
||||
this.liveByAgent.set(agentId, options.nodeId);
|
||||
this.liveSessionByAgent.set(agentId, sessionId);
|
||||
this.liveKindByAgent.set(agentId, "pty");
|
||||
}
|
||||
const clearLive = () => {
|
||||
if (this.liveByAgent.get(agentId) === options.nodeId) {
|
||||
this.liveByAgent.delete(agentId);
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
this.liveKindByAgent.delete(agentId);
|
||||
}
|
||||
};
|
||||
|
||||
@ -752,6 +772,101 @@ export class MockAgentGateway implements AgentGateway {
|
||||
return handle;
|
||||
}
|
||||
|
||||
async launchAgentChat(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
options: OpenTerminalOptions,
|
||||
): Promise<AgentChatHandle> {
|
||||
const list = this.getAgents(projectId);
|
||||
if (!list.some((a) => a.id === agentId)) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent ${agentId} not found in project ${projectId}`,
|
||||
} as GatewayError;
|
||||
}
|
||||
const liveNode = this.liveByAgent.get(agentId);
|
||||
if (liveNode !== undefined && options.nodeId && liveNode !== options.nodeId) {
|
||||
throw {
|
||||
code: "AGENT_ALREADY_RUNNING",
|
||||
message: `agent ${agentId} is already running in cell ${liveNode}`,
|
||||
} as GatewayError;
|
||||
}
|
||||
this.sessionSeq += 1;
|
||||
const sessionId = `mock-agent-chat-${this.sessionSeq}`;
|
||||
this.chatScrollback.set(sessionId, []);
|
||||
if (options.nodeId) {
|
||||
this.liveByAgent.set(agentId, options.nodeId);
|
||||
this.liveSessionByAgent.set(agentId, sessionId);
|
||||
this.liveKindByAgent.set(agentId, "structured");
|
||||
}
|
||||
return {
|
||||
sessionId,
|
||||
...(!options.conversationId
|
||||
? { assignedConversationId: `mock-conversation-${sessionId}` }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
async reattachAgentChat(
|
||||
sessionId: string,
|
||||
_onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<ReattachAgentChatResult> {
|
||||
const chunks = this.chatScrollback.get(sessionId);
|
||||
if (!chunks) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent chat session ${sessionId} is not alive`,
|
||||
} as GatewayError;
|
||||
}
|
||||
return { sessionId, scrollback: structuredClone(chunks) };
|
||||
}
|
||||
|
||||
async sendAgentChat(
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
onChunk: (chunk: ReplyChunk) => void,
|
||||
): Promise<void> {
|
||||
const chunks = this.chatScrollback.get(sessionId);
|
||||
if (!chunks) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent chat session ${sessionId} not found`,
|
||||
} as GatewayError;
|
||||
}
|
||||
const content = `Agent: reçu « ${prompt} ».`;
|
||||
const streamed: ReplyChunk[] = [
|
||||
{ kind: "toolActivity", label: "Analyse du prompt" },
|
||||
{ kind: "textDelta", text: "Agent: reçu " },
|
||||
{ kind: "textDelta", text: `« ${prompt} ».` },
|
||||
{ kind: "final", content },
|
||||
];
|
||||
for (const chunk of streamed) {
|
||||
await Promise.resolve();
|
||||
chunks.push(structuredClone(chunk));
|
||||
onChunk(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
async closeAgentChat(sessionId: string): Promise<void> {
|
||||
this.chatScrollback.delete(sessionId);
|
||||
for (const [agentId, liveSessionId] of this.liveSessionByAgent) {
|
||||
if (liveSessionId === sessionId) {
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
this.liveByAgent.delete(agentId);
|
||||
this.liveKindByAgent.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cancelAgentChat(sessionId: string): Promise<void> {
|
||||
if (!this.chatScrollback.has(sessionId)) {
|
||||
throw {
|
||||
code: "NOT_FOUND",
|
||||
message: `agent chat session ${sessionId} not found`,
|
||||
} as GatewayError;
|
||||
}
|
||||
}
|
||||
|
||||
async reattach(
|
||||
sessionId: string,
|
||||
onData: (bytes: Uint8Array) => void,
|
||||
@ -772,6 +887,7 @@ export class MockAgentGateway implements AgentGateway {
|
||||
if (liveSessionId === sessionId) {
|
||||
this.liveSessionByAgent.delete(agentId);
|
||||
this.liveByAgent.delete(agentId);
|
||||
this.liveKindByAgent.delete(agentId);
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user