diff --git a/frontend/src/adapters/agent.test.ts b/frontend/src/adapters/agent.test.ts index ae8bac5..e3ae6a2 100644 --- a/frontend/src/adapters/agent.test.ts +++ b/frontend/src/adapters/agent.test.ts @@ -61,13 +61,34 @@ describe("TauriAgentGateway invoke payloads", () => { }); it("attach_live_agent wraps project, agent and target node in the request DTO", async () => { - invoke.mockResolvedValueOnce([ - { agentId: "agent-2", nodeId: "node-3", sessionId: "session-4" }, - ]); - await new TauriAgentGateway().attachLiveAgent("proj-1", "agent-2", "node-3"); + invoke.mockResolvedValueOnce({ + agentId: "agent-2", + nodeId: "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", { 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 () => { diff --git a/frontend/src/adapters/agent.ts b/frontend/src/adapters/agent.ts index 7b2a6be..491607e 100644 --- a/frontend/src/adapters/agent.ts +++ b/frontend/src/adapters/agent.ts @@ -26,6 +26,7 @@ import type { LiveAgent, OpenTerminalOptions, ReattachResult, + StoppedLiveAgent, TerminalHandle, } from "@/ports"; import { makeTerminalHandle } from "./terminal"; @@ -63,14 +64,14 @@ export class TauriAgentGateway implements AgentGateway { agentId: string, nodeId: string, ): Promise { - return invoke("attach_live_agent", { + return invoke("attach_live_agent", { request: { projectId, agentId, nodeId }, - }).then((list) => { - const attached = list[0]; - if (!attached) { - throw new Error(`running session for agent ${agentId} was not returned`); - } - return attached; + }); + } + + stopLiveAgent(projectId: string, agentId: string): Promise { + return invoke("stop_live_agent", { + request: { projectId, agentId }, }); } diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index 96b380b..cea2413 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -61,6 +61,7 @@ import type { ReattachResult, RemoteGateway, SkillGateway, + StoppedLiveAgent, SystemGateway, TemplateGateway, TerminalGateway, @@ -215,7 +216,8 @@ export class MockAgentGateway implements AgentGateway { .map(([agentId, nodeId]) => ({ agentId, 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; } this.liveByAgent.set(agentId, nodeId); - return { agentId, nodeId, sessionId }; + return { agentId, nodeId, sessionId, kind: "pty" }; + } + + async stopLiveAgent(projectId: string, agentId: string): Promise { + 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 { diff --git a/frontend/src/features/layout/agentAlreadyRunning.test.tsx b/frontend/src/features/layout/agentAlreadyRunning.test.tsx index 1561c9d..7480646 100644 --- a/frontend/src/features/layout/agentAlreadyRunning.test.tsx +++ b/frontend/src/features/layout/agentAlreadyRunning.test.tsx @@ -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). 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). vi.spyOn(agentGateway, "launchAgent").mockRejectedValue({ diff --git a/frontend/src/features/layout/singletonAgent.test.tsx b/frontend/src/features/layout/singletonAgent.test.tsx index 51c3929..3aef612 100644 --- a/frontend/src/features/layout/singletonAgent.test.tsx +++ b/frontend/src/features/layout/singletonAgent.test.tsx @@ -70,6 +70,7 @@ describe("singleton agent — mock gateway guard (T5)", () => { agentId: a.id, nodeId: "node-A", sessionId: "mock-agent-session-1", + kind: "pty", }, ]); }); @@ -146,6 +147,7 @@ describe("singleton agent — dropdown guard (T6)", () => { agentId: a.id, nodeId: leafId, 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" }); vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ - { agentId: a.id, nodeId: "some-other-node" }, + { agentId: a.id, nodeId: "some-other-node" } as LiveAgent, ]); 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). vi.spyOn(agent, "listLiveAgents").mockResolvedValue([ - { agentId: a.id, nodeId: leafId }, + { agentId: a.id, nodeId: leafId, sessionId: "s-own", kind: "pty" }, ]); 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). 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); diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx index 0fc783c..63a48a9 100644 --- a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -3,12 +3,19 @@ * and idle/busy state from the backend read-model, plus the current input queue. */ +import { useState } from "react"; + import type { AgentTicketState, AgentWorkState, ConversationPreviewStatus, ConversationWorkSummary, + LeafCell, } 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 { useProjectWorkState } from "./useProjectWorkState"; @@ -52,26 +59,99 @@ function summaryText(summary: ConversationWorkSummary): string | null { return lastTurn ? `Last: ${lastTurn.textPreview}` : null; } +function goToCell(nodeId: string): void { + if (typeof document === "undefined") return; + const el = document.querySelector(`[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 { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + throw new Error("Clipboard unavailable"); +} + function ConversationSummaryLine({ summary, }: { summary: ConversationWorkSummary; }) { const text = summaryText(summary); + const [expanded, setExpanded] = useState(false); + const [copied, setCopied] = useState(false); if (!text) return null; return ( -
- - {summaryLabel(summary.status)} - - {text} +
+
+ + {summaryLabel(summary.status)} + + {text} + + +
+ {expanded && ( +
+ {summary.objectivePreview && ( +

+ Goal:{" "} + {summary.objectivePreview} +

+ )} + {summary.summaryPreview && ( +

+ Summary:{" "} + {summary.summaryPreview} +

+ )} + {summary.recentTurns.length > 0 && ( +
    + {summary.recentTurns.map((turn) => ( +
  • + {turn.role}:{" "} + {turn.textPreview} +
  • + ))} +
+ )} +
+ )}
); } @@ -127,13 +207,82 @@ function TicketRow({ function AgentRow({ agent, conversations, + visibleLeaves, + layout, + projectId, + onRefresh, }: { agent: AgentWorkState; conversations: Map; + visibleLeaves: LeafCell[]; + layout: LayoutViewModel; + projectId: string; + onRefresh: () => Promise; }) { + const { agent: agentGateway } = useGateways(); + const [message, setMessage] = useState(null); + const [actionBusy, setActionBusy] = useState(false); const live = agent.live !== undefined; const busy = agent.busy.state === "busy"; 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 { + await onRefresh(); + } + + async function attach(): Promise { + 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 { + 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 (
  • @@ -173,8 +322,52 @@ function AgentRow({ {shortTicket(agent.busy.ticket)} )} + {agent.live && liveVisible && ( + + )} + {agent.live && !liveVisible && ( + + )} + {agent.live && ( + + )}
    + {agent.live && !liveVisible && !attachTarget && ( +

    No empty visible cell.

    + )} + {message && ( +

    + {message} +

    + )} {tickets.length > 0 && (
      [ summary.conversationId, @@ -239,6 +434,10 @@ export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) key={agent.agentId} agent={agent} conversations={conversations} + visibleLeaves={visibleLeaves} + layout={layoutVm} + projectId={projectId} + onRefresh={vm.refresh} /> ))}
    diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index 0063029..f696d1a 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -1,8 +1,14 @@ 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 { 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 { ProjectWorkStatePanel } from "./ProjectWorkStatePanel"; @@ -11,8 +17,9 @@ const PROJECT_ID = "project-work-state-test"; function renderPanel( workState: MockWorkStateGateway = new MockWorkStateGateway(), system: MockSystemGateway = new MockSystemGateway(), + overrides: Partial = {}, ) { - const gateways = { workState, system } as unknown as Gateways; + const gateways = { workState, system, ...overrides } as unknown as Gateways; return { workState, system, @@ -411,4 +418,311 @@ describe("ProjectWorkStatePanel", () => { expect(screen.getByText("#1 In progress")).toBeTruthy(); 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(); + }); }); diff --git a/frontend/src/ports/index.ts b/frontend/src/ports/index.ts index 0b463de..2210312 100644 --- a/frontend/src/ports/index.ts +++ b/frontend/src/ports/index.ts @@ -108,6 +108,11 @@ export interface AgentGateway { agentId: string, nodeId: string, ): Promise; + /** + * 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; /** Creates a new agent from scratch; returns the created agent. */ createAgent(projectId: string, input: CreateAgentInput): Promise; /** @@ -205,7 +210,16 @@ export interface LiveAgent { * 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. */ - 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"; } /**