feat(workstate): UI des actions contrôlées (Lot D frontend)

Câble les actions contrôlées dans ProjectWorkStatePanel : port et adaptateur
agent étendus aux nouvelles commandes, adaptateur mock aligné.

Tests verts : workstate + projects (25), agent (8), singletonAgent +
agentAlreadyRunning (9), tsc --noEmit OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 19:51:52 +02:00
parent 3408c96974
commit 1c6441bb35
8 changed files with 611 additions and 33 deletions

View File

@ -61,13 +61,34 @@ describe("TauriAgentGateway invoke payloads", () => {
}); });
it("attach_live_agent wraps project, agent and target node in the request DTO", async () => { it("attach_live_agent wraps project, agent and target node in the request DTO", async () => {
invoke.mockResolvedValueOnce([ invoke.mockResolvedValueOnce({
{ agentId: "agent-2", nodeId: "node-3", sessionId: "session-4" }, agentId: "agent-2",
]); nodeId: "node-3",
await new TauriAgentGateway().attachLiveAgent("proj-1", "agent-2", "node-3"); sessionId: "session-4",
kind: "pty",
});
const out = await new TauriAgentGateway().attachLiveAgent(
"proj-1",
"agent-2",
"node-3",
);
expect(invoke).toHaveBeenCalledWith("attach_live_agent", { expect(invoke).toHaveBeenCalledWith("attach_live_agent", {
request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" }, request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" },
}); });
expect(out.sessionId).toBe("session-4");
});
it("stop_live_agent wraps project and agent in the request DTO", async () => {
invoke.mockResolvedValueOnce({
agentId: "agent-2",
sessionId: "session-4",
kind: "pty",
});
const out = await new TauriAgentGateway().stopLiveAgent("proj-1", "agent-2");
expect(invoke).toHaveBeenCalledWith("stop_live_agent", {
request: { projectId: "proj-1", agentId: "agent-2" },
});
expect(out.sessionId).toBe("session-4");
}); });
it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => { it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => {

View File

@ -26,6 +26,7 @@ import type {
LiveAgent, LiveAgent,
OpenTerminalOptions, OpenTerminalOptions,
ReattachResult, ReattachResult,
StoppedLiveAgent,
TerminalHandle, TerminalHandle,
} from "@/ports"; } from "@/ports";
import { makeTerminalHandle } from "./terminal"; import { makeTerminalHandle } from "./terminal";
@ -63,14 +64,14 @@ export class TauriAgentGateway implements AgentGateway {
agentId: string, agentId: string,
nodeId: string, nodeId: string,
): Promise<LiveAgent> { ): Promise<LiveAgent> {
return invoke<LiveAgent[]>("attach_live_agent", { return invoke<LiveAgent>("attach_live_agent", {
request: { projectId, agentId, nodeId }, request: { projectId, agentId, nodeId },
}).then((list) => { });
const attached = list[0]; }
if (!attached) {
throw new Error(`running session for agent ${agentId} was not returned`); stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
} return invoke<StoppedLiveAgent>("stop_live_agent", {
return attached; request: { projectId, agentId },
}); });
} }

View File

@ -61,6 +61,7 @@ import type {
ReattachResult, ReattachResult,
RemoteGateway, RemoteGateway,
SkillGateway, SkillGateway,
StoppedLiveAgent,
SystemGateway, SystemGateway,
TemplateGateway, TemplateGateway,
TerminalGateway, TerminalGateway,
@ -215,7 +216,8 @@ export class MockAgentGateway implements AgentGateway {
.map(([agentId, nodeId]) => ({ .map(([agentId, nodeId]) => ({
agentId, agentId,
nodeId, nodeId,
sessionId: this.liveSessionByAgent.get(agentId), sessionId: this.liveSessionByAgent.get(agentId)!,
kind: "pty" as const,
})); }));
} }
@ -256,7 +258,32 @@ export class MockAgentGateway implements AgentGateway {
throw err; throw err;
} }
this.liveByAgent.set(agentId, nodeId); this.liveByAgent.set(agentId, nodeId);
return { agentId, nodeId, sessionId }; return { agentId, nodeId, sessionId, kind: "pty" };
}
async stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
const list = this.getAgents(projectId);
if (!list.some((a) => a.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
const sessionId = this.liveSessionByAgent.get(agentId);
if (!sessionId) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} has no live session`,
};
throw err;
}
const session = this.sessions.get(sessionId);
if (session) session.closed = true;
this.sessions.delete(sessionId);
this.liveSessionByAgent.delete(agentId);
this.liveByAgent.delete(agentId);
return { agentId, sessionId, kind: "pty" };
} }
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> { async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {

View File

@ -103,7 +103,7 @@ describe("R0d — launch refused with AGENT_ALREADY_RUNNING", () => {
// The live set reports the agent already hosted in cell B (the host). // The live set reports the agent already hosted in cell B (the host).
vi.spyOn(agentGateway, "listLiveAgents").mockResolvedValue([ vi.spyOn(agentGateway, "listLiveAgents").mockResolvedValue([
{ agentId: agent.id, nodeId: bId, sessionId: "s-1" }, { agentId: agent.id, nodeId: bId, sessionId: "s-1", kind: "pty" },
]); ]);
// The launch from cell A is refused by the backend (singleton invariant). // The launch from cell A is refused by the backend (singleton invariant).
vi.spyOn(agentGateway, "launchAgent").mockRejectedValue({ vi.spyOn(agentGateway, "launchAgent").mockRejectedValue({

View File

@ -70,6 +70,7 @@ describe("singleton agent — mock gateway guard (T5)", () => {
agentId: a.id, agentId: a.id,
nodeId: "node-A", nodeId: "node-A",
sessionId: "mock-agent-session-1", sessionId: "mock-agent-session-1",
kind: "pty",
}, },
]); ]);
}); });
@ -146,6 +147,7 @@ describe("singleton agent — dropdown guard (T6)", () => {
agentId: a.id, agentId: a.id,
nodeId: leafId, nodeId: leafId,
sessionId: "mock-agent-session-1", sessionId: "mock-agent-session-1",
kind: "pty",
}, },
]); ]);
}); });
@ -156,7 +158,7 @@ describe("singleton agent — dropdown guard (T6)", () => {
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" }); const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: a.id, nodeId: "some-other-node" }, { agentId: a.id, nodeId: "some-other-node" } as LiveAgent,
]); ]);
renderGrid(layout, agent); renderGrid(layout, agent);
@ -190,7 +192,7 @@ describe("singleton agent — dropdown guard (T6)", () => {
// The agent is live in THIS very cell (same node id as the leaf). // The agent is live in THIS very cell (same node id as the leaf).
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: a.id, nodeId: leafId }, { agentId: a.id, nodeId: leafId, sessionId: "s-own", kind: "pty" },
]); ]);
renderGrid(layout, agent); renderGrid(layout, agent);
@ -239,7 +241,7 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
// The agent is live in cell B (a currently-visible leaf). // The agent is live in cell B (a currently-visible leaf).
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
{ agentId: ag.id, nodeId: b, sessionId: "s-1" }, { agentId: ag.id, nodeId: b, sessionId: "s-1", kind: "pty" },
]); ]);
renderGrid(layout, agent); renderGrid(layout, agent);

View File

@ -3,12 +3,19 @@
* and idle/busy state from the backend read-model, plus the current input queue. * and idle/busy state from the backend read-model, plus the current input queue.
*/ */
import { useState } from "react";
import type { import type {
AgentTicketState, AgentTicketState,
AgentWorkState, AgentWorkState,
ConversationPreviewStatus, ConversationPreviewStatus,
ConversationWorkSummary, ConversationWorkSummary,
LeafCell,
} from "@/domain"; } from "@/domain";
import { useGateways } from "@/app/di";
import { leaves } from "@/features/layout/layout";
import { useLayout } from "@/features/layout/useLayout";
import type { LayoutViewModel } from "@/features/layout/useLayout";
import { Button, Panel, Spinner, cn } from "@/shared"; import { Button, Panel, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "./useProjectWorkState"; import { useProjectWorkState } from "./useProjectWorkState";
@ -52,26 +59,99 @@ function summaryText(summary: ConversationWorkSummary): string | null {
return lastTurn ? `Last: ${lastTurn.textPreview}` : null; return lastTurn ? `Last: ${lastTurn.textPreview}` : null;
} }
function goToCell(nodeId: string): void {
if (typeof document === "undefined") return;
const el = document.querySelector<HTMLElement>(`[data-node-id="${nodeId}"]`);
if (!el) return;
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
const previous = el.style.outline;
el.style.outline = "2px solid var(--color-primary, #5b9bd5)";
window.setTimeout(() => {
el.style.outline = previous;
}, 1200);
}
async function copyToClipboard(text: string): Promise<void> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return;
}
throw new Error("Clipboard unavailable");
}
function ConversationSummaryLine({ function ConversationSummaryLine({
summary, summary,
}: { }: {
summary: ConversationWorkSummary; summary: ConversationWorkSummary;
}) { }) {
const text = summaryText(summary); const text = summaryText(summary);
const [expanded, setExpanded] = useState(false);
const [copied, setCopied] = useState(false);
if (!text) return null; if (!text) return null;
return ( return (
<div className="mt-1 flex min-w-0 items-start gap-2 pl-1 text-xs text-muted"> <div className="mt-1 min-w-0 pl-1 text-xs text-muted">
<span <div className="flex min-w-0 items-start gap-2">
className={cn( <span
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium", className={cn(
summary.status === "ready" "mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
? "bg-success/10 text-success" summary.status === "ready"
: "bg-raised text-muted", ? "bg-success/10 text-success"
)} : "bg-raised text-muted",
> )}
{summaryLabel(summary.status)} >
</span> {summaryLabel(summary.status)}
<span className="min-w-0 break-words">{text}</span> </span>
<span className="min-w-0 flex-1 break-words">{text}</span>
<button
type="button"
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content"
onClick={() => setExpanded((v) => !v)}
>
{expanded ? "Hide" : "View"}
</button>
<button
type="button"
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content disabled:opacity-50"
disabled={!text}
onClick={() => {
void copyToClipboard(text).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1000);
});
}}
>
{copied ? "Copied" : "Copy"}
</button>
</div>
{expanded && (
<div
aria-label={`conversation ${summary.conversationId} details`}
className="mt-1 space-y-1 border-l border-border pl-2"
>
{summary.objectivePreview && (
<p>
<span className="font-medium text-content">Goal:</span>{" "}
{summary.objectivePreview}
</p>
)}
{summary.summaryPreview && (
<p>
<span className="font-medium text-content">Summary:</span>{" "}
{summary.summaryPreview}
</p>
)}
{summary.recentTurns.length > 0 && (
<ul className="space-y-0.5">
{summary.recentTurns.map((turn) => (
<li key={`${turn.atMs}-${turn.role}-${turn.textPreview}`}>
<span className="font-medium text-content">{turn.role}:</span>{" "}
{turn.textPreview}
</li>
))}
</ul>
)}
</div>
)}
</div> </div>
); );
} }
@ -127,13 +207,82 @@ function TicketRow({
function AgentRow({ function AgentRow({
agent, agent,
conversations, conversations,
visibleLeaves,
layout,
projectId,
onRefresh,
}: { }: {
agent: AgentWorkState; agent: AgentWorkState;
conversations: Map<string, ConversationWorkSummary>; conversations: Map<string, ConversationWorkSummary>;
visibleLeaves: LeafCell[];
layout: LayoutViewModel;
projectId: string;
onRefresh: () => Promise<void>;
}) { }) {
const { agent: agentGateway } = useGateways();
const [message, setMessage] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState(false);
const live = agent.live !== undefined; const live = agent.live !== undefined;
const busy = agent.busy.state === "busy"; const busy = agent.busy.state === "busy";
const tickets = [...agent.tickets].sort((a, b) => a.position - b.position); const tickets = [...agent.tickets].sort((a, b) => a.position - b.position);
const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
const currentTurn =
busy || tickets.some((ticket) => ticket.status === "inProgress");
async function refreshAfterAction(): Promise<void> {
await onRefresh();
}
async function attach(): Promise<void> {
if (!agent.live || !attachTarget || !agentGateway?.attachLiveAgent) return;
setActionBusy(true);
setMessage(null);
try {
const attached = await agentGateway.attachLiveAgent(
projectId,
agent.agentId,
attachTarget.id,
);
await layout.attachLiveAgentToCell(
attachTarget.id,
agent.agentId,
attached.sessionId,
);
await refreshAfterAction();
goToCell(attachTarget.id);
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
setActionBusy(false);
}
}
async function stop(): Promise<void> {
if (!agent.live || !agentGateway?.stopLiveAgent) return;
const warning = currentTurn
? " The current turn will be interrupted."
: "";
if (!window.confirm(`Stop ${agent.name}?${warning}`)) return;
setActionBusy(true);
setMessage(null);
try {
const stopped = await agentGateway.stopLiveAgent(projectId, agent.agentId);
if (visibleNodeIds.has(agent.live.nodeId)) {
const leaf = visibleLeaves.find((candidate) => candidate.id === agent.live?.nodeId);
if (!leaf?.session || leaf.session === stopped.sessionId) {
await layout.setSession(agent.live.nodeId, null);
}
}
await refreshAfterAction();
} catch (e) {
setMessage(e instanceof Error ? e.message : String(e));
} finally {
setActionBusy(false);
}
}
return ( return (
<li className="py-2 first:pt-0 last:pb-0"> <li className="py-2 first:pt-0 last:pb-0">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@ -173,8 +322,52 @@ function AgentRow({
{shortTicket(agent.busy.ticket)} {shortTicket(agent.busy.ticket)}
</code> </code>
)} )}
{agent.live && liveVisible && (
<Button
size="sm"
variant="ghost"
onClick={() => goToCell(agent.live!.nodeId)}
>
Open
</Button>
)}
{agent.live && !liveVisible && (
<Button
size="sm"
variant="ghost"
disabled={!attachTarget || !agentGateway?.attachLiveAgent}
loading={actionBusy}
title={
attachTarget
? undefined
: "No empty visible cell available"
}
onClick={() => void attach()}
>
Attach
</Button>
)}
{agent.live && (
<Button
size="sm"
variant="danger"
loading={actionBusy}
disabled={!agentGateway?.stopLiveAgent}
onClick={() => void stop()}
>
Stop
</Button>
)}
</span> </span>
</div> </div>
{agent.live && !liveVisible && !attachTarget && (
<p className="mt-1 text-xs text-muted">No empty visible cell.</p>
)}
{message && (
<p role="alert" className="mt-1 text-xs text-danger">
{message}
</p>
)}
{tickets.length > 0 && ( {tickets.length > 0 && (
<ul <ul
aria-label={`${agent.name} tickets`} aria-label={`${agent.name} tickets`}
@ -195,7 +388,9 @@ function AgentRow({
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) { export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
const vm = useProjectWorkState(projectId); const vm = useProjectWorkState(projectId);
const layoutVm = useLayout(projectId);
const agents = vm.state?.agents ?? []; const agents = vm.state?.agents ?? [];
const visibleLeaves = layoutVm.layout ? leaves(layoutVm.layout) : [];
const conversations = new Map( const conversations = new Map(
(vm.state?.conversations ?? []).map((summary) => [ (vm.state?.conversations ?? []).map((summary) => [
summary.conversationId, summary.conversationId,
@ -239,6 +434,10 @@ export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps)
key={agent.agentId} key={agent.agentId}
agent={agent} agent={agent}
conversations={conversations} conversations={conversations}
visibleLeaves={visibleLeaves}
layout={layoutVm}
projectId={projectId}
onRefresh={vm.refresh}
/> />
))} ))}
</ul> </ul>

View File

@ -1,8 +1,14 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react"; import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { DIProvider } from "@/app/di"; import { DIProvider } from "@/app/di";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock"; import {
MockAgentGateway,
MockLayoutGateway,
MockSystemGateway,
MockWorkStateGateway,
} from "@/adapters/mock";
import { leaves } from "@/features/layout/layout";
import type { Gateways } from "@/ports"; import type { Gateways } from "@/ports";
import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel"; import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
@ -11,8 +17,9 @@ const PROJECT_ID = "project-work-state-test";
function renderPanel( function renderPanel(
workState: MockWorkStateGateway = new MockWorkStateGateway(), workState: MockWorkStateGateway = new MockWorkStateGateway(),
system: MockSystemGateway = new MockSystemGateway(), system: MockSystemGateway = new MockSystemGateway(),
overrides: Partial<Gateways> = {},
) { ) {
const gateways = { workState, system } as unknown as Gateways; const gateways = { workState, system, ...overrides } as unknown as Gateways;
return { return {
workState, workState,
system, system,
@ -411,4 +418,311 @@ describe("ProjectWorkStatePanel", () => {
expect(screen.getByText("#1 In progress")).toBeTruthy(); expect(screen.getByText("#1 In progress")).toBeTruthy();
await waitFor(() => expect(spy).toHaveBeenCalled()); await waitFor(() => expect(spy).toHaveBeenCalled());
}); });
it("Open focuses a visible live cell and does not launch", async () => {
const workState = new MockWorkStateGateway();
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
const host = document.createElement("div");
host.dataset.nodeId = leafId;
document.body.appendChild(host);
const launchSpy = vi.spyOn(agent, "launchAgent");
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-open",
name: "Visible",
profileId: "codex",
live: { nodeId: leafId, sessionId: "session-open", kind: "pty" },
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
renderPanel(workState, new MockSystemGateway(), { layout, agent });
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
expect(host.style.outline).toContain("2px solid");
expect(launchSpy).not.toHaveBeenCalled();
host.remove();
});
it("Attach rebinds a hidden live agent to the deterministic empty cell", async () => {
const workState = new MockWorkStateGateway();
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Hidden",
profileId: "codex",
});
await agent.launchAgent(
PROJECT_ID,
created.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: "hidden-node" },
() => {},
);
const attachSpy = vi.spyOn(agent, "attachLiveAgent");
const launchSpy = vi.spyOn(agent, "launchAgent");
const target = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: created.id,
name: "Hidden",
profileId: "codex",
live: { nodeId: "hidden-node", sessionId: "mock-agent-session-1", kind: "pty" },
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
renderPanel(workState, new MockSystemGateway(), { layout, agent });
const attach = await screen.findByRole("button", { name: "Attach" });
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(false));
fireEvent.click(attach);
await waitFor(() =>
expect(attachSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, target),
);
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];
expect(leaf.agent).toBe(created.id);
expect(leaf.session).toBe("mock-agent-session-1");
});
expect(launchSpy).not.toHaveBeenCalled();
});
it("disables Attach when no visible target cell exists", async () => {
const workState = new MockWorkStateGateway();
const layout = new MockLayoutGateway();
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
await layout.mutateLayout(PROJECT_ID, {
type: "setCellAgent",
target: leafId,
agent: "other-agent",
});
await layout.mutateLayout(PROJECT_ID, {
type: "setSession",
target: leafId,
session: "occupied-session",
});
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-hidden",
name: "Hidden",
profileId: "codex",
live: { nodeId: "hidden-node", sessionId: "session-hidden", kind: "pty" },
busy: { state: "idle" },
tickets: [],
},
],
conversations: [],
});
renderPanel(workState, new MockSystemGateway(), {
layout,
agent: new MockAgentGateway(),
});
const attach = await screen.findByRole("button", { name: "Attach" });
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(true));
expect(await screen.findByText("No empty visible cell.")).toBeTruthy();
});
it("Stop confirms with a busy warning, stops the live agent, and refreshes Work", async () => {
const workState = new MockWorkStateGateway();
const layout = new MockLayoutGateway();
const agent = new MockAgentGateway();
const created = await agent.createAgent(PROJECT_ID, {
name: "Runner",
profileId: "codex",
});
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
await agent.launchAgent(
PROJECT_ID,
created.id,
{ cwd: "/x", rows: 24, cols: 80, nodeId: leafId },
() => {},
);
await layout.mutateLayout(PROJECT_ID, {
type: "setCellAgent",
target: leafId,
agent: created.id,
});
await layout.mutateLayout(PROJECT_ID, {
type: "setSession",
target: leafId,
session: "mock-agent-session-1",
});
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: created.id,
name: "Runner",
profileId: "codex",
live: { nodeId: leafId, sessionId: "mock-agent-session-1", kind: "pty" },
busy: { state: "busy", ticket: "ticket-stop", sinceMs: 1 },
tickets: [
{
ticketId: "ticket-stop",
conversationId: "conv-stop",
position: 0,
status: "inProgress",
source: { kind: "human" },
requesterLabel: "Human",
taskPreview: "Do work",
taskLen: 7,
},
],
},
],
conversations: [],
});
const stopSpy = vi.spyOn(agent, "stopLiveAgent");
const refreshSpy = vi.spyOn(workState, "getProjectWorkState");
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
renderPanel(workState, new MockSystemGateway(), { layout, agent });
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
await waitFor(() =>
expect(confirmSpy).toHaveBeenCalledWith(
"Stop Runner? The current turn will be interrupted.",
),
);
await waitFor(() => expect(stopSpy).toHaveBeenCalledWith(PROJECT_ID, created.id));
await waitFor(() => expect(refreshSpy).toHaveBeenCalled());
await waitFor(async () => {
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];
expect(leaf.session).toBeNull();
});
confirmSpy.mockRestore();
});
it("View conversation exposes only Lot C preview fields", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-view",
name: "Viewer",
profileId: "codex",
busy: { state: "idle" },
tickets: [
{
ticketId: "ticket-view",
conversationId: "conversation-view",
position: 0,
status: "queued",
source: { kind: "human" },
requesterLabel: "Human",
taskPreview: "Inspect preview",
taskLen: 15,
},
],
},
],
conversations: [
{
conversationId: "conversation-view",
status: "ready",
objectivePreview: "Keep it compact",
summaryPreview: "Only the exposed summary preview.",
summaryLen: 33,
upTo: "internal-turn-id",
recentTurns: [
{
role: "response",
source: { kind: "agent", agentId: "agent-view" },
atMs: 42,
textPreview: "Recent exposed turn.",
textLen: 20,
},
],
},
],
});
renderPanel(workState);
const list = await screen.findByLabelText("Viewer tickets");
fireEvent.click(within(list).getByRole("button", { name: "View" }));
expect(within(list).getByText("Goal:")).toBeTruthy();
expect(within(list).getByText("Keep it compact")).toBeTruthy();
expect(within(list).getByText("Summary:")).toBeTruthy();
expect(
within(list).getByText("Only the exposed summary preview."),
).toBeTruthy();
expect(within(list).getByText("response:")).toBeTruthy();
expect(within(list).getByText("Recent exposed turn.")).toBeTruthy();
expect(list.textContent).not.toContain("internal-turn-id");
});
it("Copy summary copies only the exposed preview text", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal("navigator", { clipboard: { writeText } });
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-copy",
name: "Copier",
profileId: "codex",
busy: { state: "idle" },
tickets: [
{
ticketId: "ticket-copy",
conversationId: "conversation-copy",
position: 0,
status: "queued",
source: { kind: "human" },
requesterLabel: "Human",
taskPreview: "Copy preview",
taskLen: 12,
},
],
},
],
conversations: [
{
conversationId: "conversation-copy",
status: "ready",
objectivePreview: null,
summaryPreview: "Copy this summary preview only.",
summaryLen: 31,
upTo: null,
recentTurns: [
{
role: "prompt",
source: { kind: "human" },
atMs: 1,
textPreview: "Do not copy this turn.",
textLen: 22,
},
],
},
],
});
renderPanel(workState);
const list = await screen.findByLabelText("Copier tickets");
fireEvent.click(within(list).getByRole("button", { name: "Copy" }));
await waitFor(() =>
expect(writeText).toHaveBeenCalledWith("Copy this summary preview only."),
);
expect(await within(list).findByRole("button", { name: "Copied" })).toBeTruthy();
vi.unstubAllGlobals();
});
}); });

View File

@ -108,6 +108,11 @@ export interface AgentGateway {
agentId: string, agentId: string,
nodeId: string, nodeId: string,
): Promise<LiveAgent>; ): Promise<LiveAgent>;
/**
* Stops an already-live agent session without going through terminal teardown
* commands from the Work panel. The backend owns the live-agent lifecycle.
*/
stopLiveAgent?(projectId: string, agentId: string): Promise<StoppedLiveAgent>;
/** Creates a new agent from scratch; returns the created agent. */ /** Creates a new agent from scratch; returns the created agent. */
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>; createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
/** /**
@ -205,7 +210,16 @@ export interface LiveAgent {
* The live PTY session id, when the backend exposes it. Required for opening * The live PTY session id, when the backend exposes it. Required for opening
* a background/running agent in a new visible cell without respawning it. * a background/running agent in a new visible cell without respawning it.
*/ */
sessionId?: string; sessionId: string;
/** Runtime kind backing the live agent. */
kind: "pty" | "structured";
}
/** Result returned when the backend stops a live agent. */
export interface StoppedLiveAgent {
agentId: string;
sessionId: string;
kind: "pty" | "structured";
} }
/** /**