mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix: prod triage 2026-07-08 — MCP auth residue, gateway envelopes, verb-loop cap, A2A interjection, manual spawn UX (#334)
* fix(auth): pass agent UUID to CLI-arg MCP servers (optimal/docs/search) The container token is HMAC-signed over the agent UUID (#314), but the optimal/docs/search MCP servers received the slug as their CLI arg and sent X-Agent-ID=<slug>, so every research/RAG/docs call 401ed with signature mismatch under enforced auth. Pass the already-computed agent_uuid in the three args lists instead. * fix(gateway): include remediate in gateway.rejected audit details Conventions-gate rejections carry the offending file:line listing only in the envelope's remediate field, which the audit row dropped -- ops logs showed just the violation count with no way to see what blocked. * fix(gateway): return envelope on do/commit git failure A GitError from the commit verb propagated to the generic middleware handler, so agents got a raw error blob with no remediate/next. Catch it and return an error envelope; 'no changes added to commit' with an explicit files list now names the mismatch and the omit-files fallback. * fix(agent-sdk): absolute rejection cap breaks slow-drip verb loops The verb circuit breaker only counted rejections inside a 60s sliding window, so an agent retrying i_am_done every 3-4 minutes looped for 30+ minutes without tripping it. Add a session-scoped cumulative per-(verb, task) cap at 3x the windowed limit that trips regardless of pacing. * feat(a2a): CEO chime-in interjects into the viewed conversation Previously reply_as_ceo re-homed the message into a canonical CEO<->target conversation with no panel surface, so a chime-in reported success but was invisible and only opportunistically delivered. interject_as_ceo now inserts the message into the conversation being viewed (from_agent=ceo, directed via an @target content prefix), bumps that conversation's counters with the unread ping keyed to the addressed participant, and both participants see it in transcript and read_a2a. * feat(panel): manual spawn carries task + message, surfaces refusals The agent detail page spawned with no request body (task/message impossible), the spawn button could double-fire (2.5ms double-POST seen live), and refusal reasons never reached the UI: readiness refusals were generic 500s and the already-running no-op looked like success. Detail page now uses SpawnAgentDialog, a synchronous ref guard blocks re-entry, AgentReadinessError maps to 409 with its reason shown, already_running is signalled and toasted, and a task_id builds a task-aware prompt instructing the claim (task_id alone never did), with the CEO's message appended as a note. * test(panel): align a2a page test with the interjection footer copy The chime-in rebuild changed the composer footer; the page-level test asserting the old copy was outside the rebuild's scoped vitest run. * fix(api): commit the request DB session before the response is sent FastAPI unwinds yield-dependencies after the response bytes go out, so get_db's post-yield commit raced the client's next request -- a verb could return ok while its claim/status write was still uncommitted (the e2e ok-without-effect flake family), and a failed commit was silently lost behind an already-sent 200. DbCommitMiddleware (innermost, pure ASGI) commits the session stashed by get_db_committed before forwarding http.response.start; commit failure now surfaces as a 5xx. get_db is untouched for its direct non-request callers. * fix(db): invalidate, not rollback, the session on request cancellation With the commit moved into the send path, the flow-verb timeout can cancel mid-commit; rolling back then issues another command over an asyncpg connection stranded mid-wire-protocol, and the poisoned connection segfaults uvloop/asyncpg when a later checkout recycles it (3/3 identical CI faulthandler dumps). On CancelledError discard the connection via session.invalidate() -- SQLAlchemy's documented handling for a timeout during commit -- and keep rollback for plain exceptions. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -147,7 +147,7 @@ describe("A2APage", () => {
|
||||
expect(screen.getByPlaceholderText(/chime in/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/direct A2A message from you to the selected participant/i,
|
||||
/posts into this conversation — visible to both participants/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Live")).toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
|
||||
// Detail-page parity fix: the grid card's spawn affordance (SpawnAgentDialog,
|
||||
// which collects task id + message) was already correct, but this page called
|
||||
// spawnAgent.mutateAsync({ agentId }) directly from a bare button — no task,
|
||||
// no message, no double-fire guard. Both bare buttons must now render the
|
||||
// shared SpawnAgentDialog instead.
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useParams: () => ({ agentId: "fe-dev-2" }),
|
||||
useRouter: () => ({ back: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-agents", () => ({
|
||||
useAgentStatus: vi.fn(),
|
||||
useAgentDefinition: vi.fn(() => ({ data: undefined })),
|
||||
useStopAgent: vi.fn(() => ({ mutateAsync: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/agents", () => ({
|
||||
AgentStatusCards: () => null,
|
||||
ResolveWaitDialog: () => null,
|
||||
AgentStreamViewer: () => null,
|
||||
SpawnAgentDialog: ({
|
||||
agentId,
|
||||
agentName,
|
||||
trigger,
|
||||
}: {
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
trigger: React.ReactNode;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="spawn-agent-dialog"
|
||||
data-agent-id={agentId}
|
||||
data-agent-name={agentName}
|
||||
>
|
||||
{trigger}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { useAgentStatus } from "@/hooks/use-agents";
|
||||
import AgentDetailPage from "../page";
|
||||
|
||||
describe("AgentDetailPage — spawn dialog parity", () => {
|
||||
it("renders SpawnAgentDialog (not a bare button) in the error state", () => {
|
||||
vi.mocked(useAgentStatus).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
error: new Error("not found"),
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useAgentStatus>);
|
||||
|
||||
render(<AgentDetailPage />);
|
||||
|
||||
const dialog = screen.getByTestId("spawn-agent-dialog");
|
||||
expect(dialog).toHaveAttribute("data-agent-id", "fe-dev-2");
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Spawn Agent/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders SpawnAgentDialog in the header when the agent is not active", () => {
|
||||
vi.mocked(useAgentStatus).mockReturnValue({
|
||||
data: {
|
||||
agent_id: "fe-dev-2",
|
||||
state: "stopped",
|
||||
task_id: null,
|
||||
error_count: 0,
|
||||
started_at: null,
|
||||
waiting_for: null,
|
||||
},
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
refetch: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useAgentStatus>);
|
||||
|
||||
render(<AgentDetailPage />);
|
||||
|
||||
const dialog = screen.getByTestId("spawn-agent-dialog");
|
||||
expect(dialog).toHaveAttribute("data-agent-id", "fe-dev-2");
|
||||
expect(screen.getByRole("button", { name: "Spawn" })).toBeInTheDocument();
|
||||
// Active-state Stop buttons must not render alongside a down agent.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Stop" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
useAgentStatus,
|
||||
useStopAgent,
|
||||
useSpawnAgent,
|
||||
useAgentDefinition,
|
||||
} from "@/hooks/use-agents";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -33,6 +32,7 @@ import {
|
||||
AgentStatusCards,
|
||||
ResolveWaitDialog,
|
||||
AgentStreamViewer,
|
||||
SpawnAgentDialog,
|
||||
} from "@/components/agents";
|
||||
|
||||
// Role display labels
|
||||
@@ -66,7 +66,6 @@ export default function AgentDetailPage() {
|
||||
const { data: agent, isLoading, error, refetch } = useAgentStatus(agentId);
|
||||
const { data: definition } = useAgentDefinition(agentId);
|
||||
const stopAgent = useStopAgent();
|
||||
const spawnAgent = useSpawnAgent();
|
||||
|
||||
// Get display values from definition or fallback
|
||||
const displayName = definition?.name || agentId;
|
||||
@@ -88,15 +87,6 @@ export default function AgentDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSpawn = async () => {
|
||||
try {
|
||||
await spawnAgent.mutateAsync({ agentId });
|
||||
toast.success("Agent spawned successfully");
|
||||
} catch {
|
||||
toast.error("Failed to spawn agent");
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -113,10 +103,16 @@ export default function AgentDetailPage() {
|
||||
<p className="text-muted-foreground mt-2">
|
||||
The agent may not be running or the ID is invalid.
|
||||
</p>
|
||||
<Button className="mt-4" onClick={handleSpawn}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Spawn Agent
|
||||
</Button>
|
||||
<SpawnAgentDialog
|
||||
agentId={agentId}
|
||||
agentName={displayName}
|
||||
trigger={
|
||||
<Button className="mt-4">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Spawn Agent
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -178,10 +174,16 @@ export default function AgentDetailPage() {
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onClick={handleSpawn}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Spawn
|
||||
</Button>
|
||||
<SpawnAgentDialog
|
||||
agentId={agentId}
|
||||
agentName={displayName}
|
||||
trigger={
|
||||
<Button>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Spawn
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,14 +119,50 @@ describe("A2AReplyComposer", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("states the pairwise seam honestly in the helper text", () => {
|
||||
it("states the interjection semantics honestly in the helper text", () => {
|
||||
renderComposer();
|
||||
// Guard the honesty note: the reply is a DIRECT CEO->participant message,
|
||||
// not an injection into the watched transcript.
|
||||
// Guard the honesty note: the message posts into THIS conversation,
|
||||
// visible to both participants — not a re-homed CEO<->target DM.
|
||||
expect(
|
||||
screen.getByText(
|
||||
/direct A2A message from you to the selected participant/i,
|
||||
),
|
||||
screen.getByText(/posts into this conversation/i),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/visible to both participants/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("A2AReplyComposer when one participant is the CEO", () => {
|
||||
beforeEach(() => {
|
||||
mutate.mockReset();
|
||||
});
|
||||
|
||||
function renderCeoComposer(lastSender: string | null = "be-dev-1") {
|
||||
return render(
|
||||
<A2AReplyComposer
|
||||
conversationId="conv-ceo"
|
||||
agentA="ceo"
|
||||
agentB="be-dev-1"
|
||||
lastSender={lastSender}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("never offers the CEO itself as a recipient", () => {
|
||||
const { container } = renderCeoComposer();
|
||||
expect(container.querySelector('[data-value="ceo"]')).toBeNull();
|
||||
expect(container.querySelector('[data-value="be-dev-1"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("always sends to the other participant, even when they spoke last as agent_a", () => {
|
||||
renderCeoComposer("ceo");
|
||||
fireEvent.change(screen.getByPlaceholderText(/chime in/i), {
|
||||
target: { value: "Following up" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ to_agent: "be-dev-1" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { lastSenderOf, pickDefaultRecipient } from "../a2a-utils";
|
||||
import {
|
||||
lastSenderOf,
|
||||
pickDefaultRecipient,
|
||||
recipientOptions,
|
||||
} from "../a2a-utils";
|
||||
|
||||
describe("lastSenderOf", () => {
|
||||
it("returns null for an empty transcript", () => {
|
||||
@@ -31,3 +35,20 @@ describe("pickDefaultRecipient", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recipientOptions", () => {
|
||||
it("returns both participants when neither is the CEO", () => {
|
||||
expect(recipientOptions("be-dev-1", "be-qa")).toEqual([
|
||||
"be-dev-1",
|
||||
"be-qa",
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes the CEO when it's agent_a", () => {
|
||||
expect(recipientOptions("ceo", "be-dev-1")).toEqual(["be-dev-1"]);
|
||||
});
|
||||
|
||||
it("excludes the CEO when it's agent_b", () => {
|
||||
expect(recipientOptions("be-dev-1", "ceo")).toEqual(["be-dev-1"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ import { toast } from "sonner";
|
||||
import { getAgentDisplayName } from "@/lib/agent-utils";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { useReplyAsCeo } from "@/hooks/use-a2a-live";
|
||||
import { pickDefaultRecipient } from "./a2a-utils";
|
||||
import { pickDefaultRecipient, recipientOptions } from "./a2a-utils";
|
||||
|
||||
interface A2AReplyComposerProps {
|
||||
conversationId: string;
|
||||
@@ -38,8 +38,14 @@ export function A2AReplyComposer({
|
||||
const [chosenRecipient, setChosenRecipient] = useState<string | null>(null);
|
||||
const reply = useReplyAsCeo();
|
||||
|
||||
// Excludes "ceo" from the options: in the CEO's own conversation with an
|
||||
// agent, one of {agentA, agentB} is "ceo" itself, and it must never be a
|
||||
// selectable/default reply target.
|
||||
const options = recipientOptions(agentA, agentB);
|
||||
const recipient =
|
||||
chosenRecipient ?? pickDefaultRecipient(agentA, agentB, lastSender);
|
||||
options.length === 1
|
||||
? options[0]
|
||||
: (chosenRecipient ?? pickDefaultRecipient(agentA, agentB, lastSender));
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -86,7 +92,7 @@ export function A2AReplyComposer({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[agentA, agentB].map((slug) => (
|
||||
{options.map((slug) => (
|
||||
<SelectItem key={slug} value={slug}>
|
||||
{getAgentDisplayName(slug)}
|
||||
</SelectItem>
|
||||
@@ -104,9 +110,8 @@ export function A2AReplyComposer({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Sends a direct A2A message from you to the selected participant. It
|
||||
lands in your own conversation with that agent, not inside this
|
||||
transcript.
|
||||
Posts into this conversation — visible to both participants, addressed
|
||||
to whoever you pick above.
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
import type { A2AChatMessage } from "@/lib/api/a2a";
|
||||
|
||||
/** The human CEO's fixed slug — never a valid reply target (the CEO composes
|
||||
* as itself, so it can't be its own recipient). */
|
||||
export const CEO_SLUG = "ceo";
|
||||
|
||||
/**
|
||||
* Slug of the sender of the chronologically latest message, or null when the
|
||||
* transcript is empty. Sorts defensively — the API contract is oldest-first,
|
||||
@@ -20,6 +24,16 @@ export function lastSenderOf(
|
||||
return sorted[sorted.length - 1].from_agent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Valid reply recipients for a conversation: both participants, minus the
|
||||
* CEO itself when it's a party. A CEO<->agent conversation has exactly one
|
||||
* possible target (the agent) — no picker ambiguity, and critically no way
|
||||
* to select "ceo" and have the CEO reply to itself.
|
||||
*/
|
||||
export function recipientOptions(agentA: string, agentB: string): string[] {
|
||||
return [agentA, agentB].filter((slug) => slug !== CEO_SLUG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default reply recipient: the participant who spoke last (the natural
|
||||
* "answer them" target), falling back to agent_a when the transcript is empty
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
// Production-triage bug: a manual spawn POSTed TWICE 2.5ms apart, both
|
||||
// rejected "Agent already running" with no visible reason. Covers the fix:
|
||||
// a synchronous re-entrancy guard against the double-fire, an
|
||||
// already_running-aware toast, and the real backend refusal message
|
||||
// reaching the CEO instead of a generic "Failed to spawn agent".
|
||||
|
||||
const { mutateAsync, toastSuccess, toastError, toastInfo } = vi.hoisted(() => ({
|
||||
mutateAsync: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-agents", () => ({
|
||||
useSpawnAgent: () => ({ mutateAsync, isPending: false }),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: toastSuccess, error: toastError, info: toastInfo },
|
||||
}));
|
||||
|
||||
// client.ts registers axios interceptors that pull in the rate-limit store
|
||||
// at import time; stub it so importing the real getErrorMessage is side-effect
|
||||
// free (mirrors lib/__tests__/client.test.ts).
|
||||
vi.mock("@/store/rate-limit-store", () => ({
|
||||
useRateLimitStore: { getState: vi.fn(() => ({ hitRateLimit: vi.fn() })) },
|
||||
}));
|
||||
|
||||
import { SpawnAgentDialog } from "../spawn-agent-dialog";
|
||||
|
||||
function openDialog() {
|
||||
render(
|
||||
<SpawnAgentDialog
|
||||
agentId="fe-dev-2"
|
||||
agentName="fe-dev-2"
|
||||
trigger={<button type="button">Open Spawn</button>}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open Spawn" }));
|
||||
}
|
||||
|
||||
function submitButton() {
|
||||
return screen.getByRole("button", { name: /Spawn Agent/i });
|
||||
}
|
||||
|
||||
describe("SpawnAgentDialog", () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockReset();
|
||||
toastSuccess.mockReset();
|
||||
toastError.mockReset();
|
||||
toastInfo.mockReset();
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it("submits task id and initial prompt from the form", async () => {
|
||||
mutateAsync.mockResolvedValue({ already_running: false });
|
||||
openDialog();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/Task ID/i), {
|
||||
target: { value: "task-123" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText(/Initial Prompt/i), {
|
||||
target: { value: "go fix it" },
|
||||
});
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
|
||||
expect(mutateAsync).toHaveBeenCalledWith({
|
||||
agentId: "fe-dev-2",
|
||||
request: { task_id: "task-123", initial_prompt: "go fix it" },
|
||||
});
|
||||
expect(toastSuccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a distinct toast when the spawn was skipped as already-running", async () => {
|
||||
mutateAsync.mockResolvedValue({ already_running: true });
|
||||
openDialog();
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
await waitFor(() => expect(toastInfo).toHaveBeenCalledTimes(1));
|
||||
expect(toastInfo.mock.calls[0][0]).toMatch(/already running/i);
|
||||
expect(toastSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces the backend's actual refusal reason, not a generic message", async () => {
|
||||
// Shape returned by axios on the 409 AgentReadinessError mapping.
|
||||
const axiosLikeError = {
|
||||
isAxiosError: true,
|
||||
message: "Request failed with status code 409",
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
detail:
|
||||
"spawn refused for fe-dev-2 (task=t1): state=awaiting_qa " +
|
||||
"requires role in {'qa'} but agent fe-dev-2 is 'developer'",
|
||||
},
|
||||
},
|
||||
};
|
||||
mutateAsync.mockRejectedValue(axiosLikeError);
|
||||
openDialog();
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
await waitFor(() => expect(toastError).toHaveBeenCalledTimes(1));
|
||||
expect(toastError.mock.calls[0][0]).toContain("state=awaiting_qa");
|
||||
expect(toastError.mock.calls[0][0]).not.toBe("Failed to spawn agent");
|
||||
});
|
||||
|
||||
it("blocks a second submit fired before the first mutation settles", async () => {
|
||||
let resolveSpawn: (v: { already_running: boolean }) => void = () => {};
|
||||
mutateAsync.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveSpawn = resolve;
|
||||
}),
|
||||
);
|
||||
openDialog();
|
||||
|
||||
// Two synchronous clicks, mirroring the 2.5ms-apart double-fire from the
|
||||
// production report — the second must never reach mutateAsync.
|
||||
fireEvent.click(submitButton());
|
||||
fireEvent.click(submitButton());
|
||||
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
resolveSpawn({ already_running: false });
|
||||
await waitFor(() => expect(toastSuccess).toHaveBeenCalledTimes(1));
|
||||
expect(mutateAsync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useSpawnAgent } from "@/hooks/use-agents";
|
||||
import { getErrorMessage } from "@/lib/api/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -32,21 +33,34 @@ export function SpawnAgentDialog({
|
||||
const [taskId, setTaskId] = useState("");
|
||||
const [initialPrompt, setInitialPrompt] = useState("");
|
||||
const spawnAgent = useSpawnAgent();
|
||||
// Synchronous re-entrancy guard: `spawnAgent.isPending` only flips on a
|
||||
// re-render, which lags a fast double-click/double-fire by a tick or two —
|
||||
// the guard below blocks a second call within the same synchronous burst
|
||||
// regardless of render timing.
|
||||
const submittingRef = useRef(false);
|
||||
|
||||
const handleSpawn = async () => {
|
||||
if (submittingRef.current) return;
|
||||
submittingRef.current = true;
|
||||
try {
|
||||
await spawnAgent.mutateAsync({
|
||||
const result = await spawnAgent.mutateAsync({
|
||||
agentId,
|
||||
request: {
|
||||
task_id: taskId || undefined,
|
||||
initial_prompt: initialPrompt || undefined,
|
||||
},
|
||||
});
|
||||
toast.success(`Agent ${agentName} spawned successfully`);
|
||||
if (result.already_running) {
|
||||
toast.info(`Agent ${agentName} already running — spawn skipped`);
|
||||
} else {
|
||||
toast.success(`Agent ${agentName} spawned successfully`);
|
||||
}
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch {
|
||||
toast.error("Failed to spawn agent");
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error));
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -88,9 +88,9 @@ export interface A2AChatMessage {
|
||||
}
|
||||
|
||||
/**
|
||||
* CEO reply payload. The backend sends a DIRECT CEO -> to_agent message (it
|
||||
* lands in the CEO<->to_agent pairwise conversation), not an injection into
|
||||
* the watched transcript.
|
||||
* CEO interjection payload. The backend posts this INTO the conversation
|
||||
* being viewed (readable by both participants), addressed to `to_agent` —
|
||||
* not a re-homed CEO<->to_agent pairwise DM.
|
||||
*/
|
||||
export interface AdminReplyRequest {
|
||||
to_agent: string;
|
||||
|
||||
@@ -420,6 +420,9 @@ export interface AgentStatusResponse {
|
||||
error_count: number;
|
||||
started_at: string | null;
|
||||
waiting_for: string | null;
|
||||
// Only set on the spawn response: true when the spawn was a no-op because
|
||||
// the agent was already active (see SpawnAgentResponse on the backend).
|
||||
already_running?: boolean;
|
||||
}
|
||||
|
||||
// OrchestratorStatusResponse from backend
|
||||
|
||||
Reference in New Issue
Block a user