feat(backend,application,frontend,domain,web-server,app-tauri): bouton Redémarrer l'agent dans la barre supérieure des cellules — #317 (QA verte)
La cellule expose un bouton Redémarrer l'agent valable pour les trois modes de présentation (headless, TUI native, CLI custom), sans jamais prendre la présentation pour vérité runtime. - Application: use case RestartCellAgentRuntime validant le binding exact cellule/runtime via les snapshots live (RestartRuntimeNotFound, ConflictAgentVisibleElsewhere, AgentNotBoundToCell) puis déléguant au scheduler self-restart existant (schedule_cell); CellAgentPresentation purement informationnelle. - Backend/app-tauri/web-server: commande restart_cell_agent sur contrat DTO identique Tauri/HTTP, stop borné par timeout typé (RestartStopTimeout), relance préservant routage TUI natif, géométrie de cellule (node/rows/cols) et conversation; évenements domaine enrichis. - Frontend: bouton dans la barre de cellule avec confirmation, overlay d'état Redémarrage…/échec (libellés par code d'erreur, Fermer/ Réessayer), gardes d'identité de cellule et de restarts externes, gateway/mock/http adapters et ports alignés. - Tests: dto_agents, workstate_actions, contrats web-server, LayoutGrid.cellControlsLayering, adapters agent/mock/http. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -97,6 +97,38 @@ describe("TauriAgentGateway invoke payloads", () => {
|
||||
expect(out.stopped[0]?.sessionId).toBe("session-4");
|
||||
});
|
||||
|
||||
it("restart_cell_agent_runtime forwards the exact cell presentation", async () => {
|
||||
const response = {
|
||||
restartId: "restart-1",
|
||||
status: "scheduled",
|
||||
agentId: "agent-2",
|
||||
nodeId: "node-3",
|
||||
kind: "structured",
|
||||
presentation: "customCli",
|
||||
layoutId: "layout-4",
|
||||
};
|
||||
invoke.mockResolvedValueOnce(response);
|
||||
|
||||
await expect(
|
||||
new TauriAgentGateway().restartCellAgentRuntime({
|
||||
projectId: "proj-1",
|
||||
nodeId: "node-3",
|
||||
agentId: "agent-2",
|
||||
presentation: "customCli",
|
||||
layoutId: "layout-4",
|
||||
}),
|
||||
).resolves.toEqual(response);
|
||||
expect(invoke).toHaveBeenCalledWith("restart_cell_agent_runtime", {
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
nodeId: "node-3",
|
||||
agentId: "agent-2",
|
||||
presentation: "customCli",
|
||||
layoutId: "layout-4",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => {
|
||||
invoke.mockResolvedValueOnce({ agent: { id: "agent-2" } });
|
||||
await new TauriAgentGateway().changeAgentProfile(
|
||||
|
||||
@ -36,6 +36,8 @@ import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachAgentChatResult,
|
||||
ReattachResult,
|
||||
RestartCellAgentRuntimeInput,
|
||||
RestartCellAgentRuntimeScheduled,
|
||||
SendAgentChatOptions,
|
||||
StoppedLiveAgent,
|
||||
TerminalHandle,
|
||||
@ -105,6 +107,14 @@ export class TauriAgentGateway implements AgentGateway {
|
||||
});
|
||||
}
|
||||
|
||||
restartCellAgentRuntime(
|
||||
input: RestartCellAgentRuntimeInput,
|
||||
): Promise<RestartCellAgentRuntimeScheduled> {
|
||||
return invoke<RestartCellAgentRuntimeScheduled>("restart_cell_agent_runtime", {
|
||||
request: input,
|
||||
});
|
||||
}
|
||||
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
// The `create_agent` command takes a single `request` DTO; `projectId` must
|
||||
// live *inside* it (camelCase), not at the top level.
|
||||
|
||||
@ -141,6 +141,45 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restarts the exact cell runtime through the HTTP command", async () => {
|
||||
const response = {
|
||||
restartId: "restart-7",
|
||||
status: "scheduled",
|
||||
agentId: "agent-1",
|
||||
nodeId: "node-2",
|
||||
kind: "pty",
|
||||
presentation: "tui",
|
||||
layoutId: null,
|
||||
};
|
||||
const { gw, calls } = httpAgentGateway(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => response,
|
||||
text: async () => JSON.stringify(response),
|
||||
}));
|
||||
|
||||
await expect(
|
||||
gw.restartCellAgentRuntime({
|
||||
projectId: "proj-1",
|
||||
nodeId: "node-2",
|
||||
agentId: "agent-1",
|
||||
presentation: "tui",
|
||||
}),
|
||||
).resolves.toEqual(response);
|
||||
const init = calls[0].init as { body: string };
|
||||
expect(JSON.parse(init.body)).toEqual({
|
||||
command: "restart_cell_agent_runtime",
|
||||
args: {
|
||||
request: {
|
||||
projectId: "proj-1",
|
||||
nodeId: "node-2",
|
||||
agentId: "agent-1",
|
||||
presentation: "tui",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the project orchestrator through the dedicated request DTO", async () => {
|
||||
const response = { orchestratorAgentId: "agent-2" };
|
||||
const { gw, calls } = httpAgentGateway(async () => ({
|
||||
|
||||
@ -53,6 +53,8 @@ import type {
|
||||
OpenTerminalOptions,
|
||||
ReattachAgentChatResult,
|
||||
ReattachResult,
|
||||
RestartCellAgentRuntimeInput,
|
||||
RestartCellAgentRuntimeScheduled,
|
||||
SendAgentChatOptions,
|
||||
StoppedLiveAgent,
|
||||
SystemGateway,
|
||||
@ -221,6 +223,14 @@ export class HttpAgentGateway implements AgentGateway {
|
||||
request: { projectId, agentId, ...(reason ? { reason } : {}) },
|
||||
});
|
||||
}
|
||||
restartCellAgentRuntime(
|
||||
input: RestartCellAgentRuntimeInput,
|
||||
): Promise<RestartCellAgentRuntimeScheduled> {
|
||||
return this.http.invoke<RestartCellAgentRuntimeScheduled>(
|
||||
"restart_cell_agent_runtime",
|
||||
{ request: input },
|
||||
);
|
||||
}
|
||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
return this.http.invoke<Agent>("create_agent", {
|
||||
request: {
|
||||
|
||||
@ -164,6 +164,8 @@ import type {
|
||||
PluginWorkspaceWriteTextInput,
|
||||
ReattachResult,
|
||||
ReattachAgentChatResult,
|
||||
RestartCellAgentRuntimeInput,
|
||||
RestartCellAgentRuntimeScheduled,
|
||||
RemoteGateway,
|
||||
ReviewPluginPackageInput,
|
||||
SetProjectPluginEnabledInput,
|
||||
@ -406,6 +408,7 @@ export class MockAgentGateway implements AgentGateway {
|
||||
private contexts = new Map<string, string>();
|
||||
/** Monotonic session counter for deterministic session ids in tests. */
|
||||
private sessionSeq = 0;
|
||||
private restartSeq = 0;
|
||||
/** Live agent PTY sessions, kept across detach so reattach can find them. */
|
||||
private sessions = new Map<string, MockPtySession>();
|
||||
/**
|
||||
@ -568,6 +571,37 @@ export class MockAgentGateway implements AgentGateway {
|
||||
return stopped;
|
||||
}
|
||||
|
||||
async restartCellAgentRuntime(
|
||||
input: RestartCellAgentRuntimeInput,
|
||||
): Promise<RestartCellAgentRuntimeScheduled> {
|
||||
const known = this.getAgents(input.projectId).some(
|
||||
(agent) => agent.id === input.agentId,
|
||||
);
|
||||
const sessionId = this.liveSessionByAgent.get(input.agentId);
|
||||
if (!known || !sessionId) {
|
||||
throw {
|
||||
code: "RUNTIME_NOT_FOUND",
|
||||
message: `agent ${input.agentId} has no live runtime`,
|
||||
} as GatewayError;
|
||||
}
|
||||
if (this.liveByAgent.get(input.agentId) !== input.nodeId) {
|
||||
throw {
|
||||
code: "AGENT_NOT_BOUND_TO_CELL",
|
||||
message: `agent ${input.agentId} is not bound to cell ${input.nodeId}`,
|
||||
} as GatewayError;
|
||||
}
|
||||
this.restartSeq += 1;
|
||||
return {
|
||||
restartId: `mock-restart-${this.restartSeq}`,
|
||||
status: "scheduled",
|
||||
agentId: input.agentId,
|
||||
nodeId: input.nodeId,
|
||||
kind: this.liveKindByAgent.get(input.agentId) ?? "pty",
|
||||
presentation: input.presentation,
|
||||
layoutId: input.layoutId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||
const list = this.getAgents(projectId);
|
||||
const slug = slugify(input.name) || "agent";
|
||||
|
||||
@ -6,7 +6,7 @@ import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { DomainEvent } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { createMockGateways, MockSystemGateway } from "./index";
|
||||
import { createMockGateways, MockAgentGateway, MockSystemGateway } from "./index";
|
||||
|
||||
// Typechecking this assignment proves every mock implements its port.
|
||||
const gateways: Gateways = createMockGateways();
|
||||
@ -90,6 +90,42 @@ describe("MockSystemGateway", () => {
|
||||
});
|
||||
|
||||
describe("other mock gateways", () => {
|
||||
it("schedules a restart only for the agent runtime bound to the exact cell", async () => {
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const agent = await agentGateway.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: "profile-1",
|
||||
});
|
||||
await agentGateway.launchAgent(
|
||||
"p1",
|
||||
agent.id,
|
||||
{ cwd: "/p1", rows: 24, cols: 80, nodeId: "node-1" },
|
||||
() => {},
|
||||
);
|
||||
|
||||
await expect(
|
||||
agentGateway.restartCellAgentRuntime({
|
||||
projectId: "p1",
|
||||
nodeId: "node-1",
|
||||
agentId: agent.id,
|
||||
presentation: "tui",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "scheduled",
|
||||
agentId: agent.id,
|
||||
nodeId: "node-1",
|
||||
presentation: "tui",
|
||||
});
|
||||
await expect(
|
||||
agentGateway.restartCellAgentRuntime({
|
||||
projectId: "p1",
|
||||
nodeId: "stale-node",
|
||||
agentId: agent.id,
|
||||
presentation: "tui",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "AGENT_NOT_BOUND_TO_CELL" });
|
||||
});
|
||||
|
||||
it("project.createProject returns a project and listProjects reflects it", async () => {
|
||||
expect(await gateways.project.listProjects()).toEqual([]);
|
||||
const created = await gateways.project.createProject("n", "/root");
|
||||
|
||||
Reference in New Issue
Block a user