Polish chat replies and managed agent creation (#81)

This commit is contained in:
Wes
2026-03-16 11:34:44 -07:00
committed by GitHub
parent 0cebddaf3e
commit fa939ec1c3
15 changed files with 161 additions and 40 deletions

View File

@@ -13,7 +13,7 @@ use crate::{
DiscoverManagedAgentPrereqsRequest, ManagedAgentLogResponse, ManagedAgentPrereqsInfo,
ManagedAgentSummary, MintManagedAgentTokenRequest, MintManagedAgentTokenResponse,
RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_ARG, DEFAULT_AGENT_COMMAND,
DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND,
},
relay::{
build_authed_request, managed_agent_owner_pubkey, relay_ws_url, send_json_request,
@@ -94,6 +94,11 @@ pub async fn create_managed_agent(
if name.is_empty() {
return Err("agent name is required".to_string());
}
if let Some(parallelism) = input.parallelism {
if !(1..=32).contains(&parallelism) {
return Err("parallelism must be between 1 and 32".to_string());
}
}
let owner_pubkey = if input.mint_token {
Some(managed_agent_owner_pubkey(&state).await?)
@@ -209,6 +214,16 @@ pub async fn create_managed_agent(
.turn_timeout_seconds
.filter(|seconds| *seconds > 0)
.unwrap_or(DEFAULT_AGENT_TURN_TIMEOUT_SECONDS),
parallelism: input
.parallelism
.filter(|count| (1..=32).contains(count))
.unwrap_or(DEFAULT_AGENT_PARALLELISM),
system_prompt: input
.system_prompt
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
created_at: now_iso(),
updated_at: now_iso(),
last_started_at: None,

View File

@@ -88,6 +88,8 @@ pub fn build_managed_agent_summary(
agent_args: record.agent_args.clone(),
mcp_command: record.mcp_command.clone(),
turn_timeout_seconds: record.turn_timeout_seconds,
parallelism: record.parallelism,
system_prompt: record.system_prompt.clone(),
has_api_token: record.api_token.is_some(),
status,
pid,
@@ -167,10 +169,16 @@ pub fn start_managed_agent_process(
"SPROUT_ACP_TURN_TIMEOUT",
record.turn_timeout_seconds.to_string(),
);
command.env("SPROUT_ACP_AGENTS", record.parallelism.to_string());
command.env(
"GOOSE_MODE",
std::env::var("GOOSE_MODE").unwrap_or_else(|_| "auto".to_string()),
);
if let Some(system_prompt) = &record.system_prompt {
command.env("SPROUT_ACP_SYSTEM_PROMPT", system_prompt);
} else {
command.env_remove("SPROUT_ACP_SYSTEM_PROMPT");
}
command.env_remove("SPROUT_ACP_PRIVATE_KEY");
command.env_remove("SPROUT_ACP_API_TOKEN");

View File

@@ -24,6 +24,9 @@ pub struct ManagedAgentRecord {
pub agent_args: Vec<String>,
pub mcp_command: String,
pub turn_timeout_seconds: u64,
#[serde(default = "default_agent_parallelism")]
pub parallelism: u32,
pub system_prompt: Option<String>,
pub created_at: String,
pub updated_at: String,
pub last_started_at: Option<String>,
@@ -48,6 +51,8 @@ pub struct ManagedAgentSummary {
pub agent_args: Vec<String>,
pub mcp_command: String,
pub turn_timeout_seconds: u64,
pub parallelism: u32,
pub system_prompt: Option<String>,
pub has_api_token: bool,
pub status: String,
pub pid: Option<u32>,
@@ -71,6 +76,8 @@ pub struct CreateManagedAgentRequest {
pub agent_args: Vec<String>,
pub mcp_command: Option<String>,
pub turn_timeout_seconds: Option<u64>,
pub parallelism: Option<u32>,
pub system_prompt: Option<String>,
#[serde(default)]
pub mint_token: bool,
#[serde(default)]
@@ -155,3 +162,8 @@ pub const DEFAULT_AGENT_COMMAND: &str = "goose";
pub const DEFAULT_MCP_COMMAND: &str = "sprout-mcp-server";
pub const DEFAULT_AGENT_ARG: &str = "acp";
pub const DEFAULT_AGENT_TURN_TIMEOUT_SECONDS: u64 = 300;
pub const DEFAULT_AGENT_PARALLELISM: u32 = 1;
fn default_agent_parallelism() -> u32 {
DEFAULT_AGENT_PARALLELISM
}

View File

@@ -53,6 +53,8 @@ export function CreateAgentDialog({
() => new Set<TokenScope>(DEFAULT_MANAGED_AGENT_SCOPES),
);
const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("300");
const [parallelism, setParallelism] = React.useState("1");
const [systemPrompt, setSystemPrompt] = React.useState("");
const [selectedProviderId, setSelectedProviderId] =
React.useState<string>("custom");
const [hasSyncedProviderSelection, setHasSyncedProviderSelection] =
@@ -133,6 +135,8 @@ export function CreateAgentDialog({
setAgentArgs("acp");
setMcpCommand("sprout-mcp-server");
setTurnTimeoutSeconds("300");
setParallelism("1");
setSystemPrompt("");
setSelectedProviderId("custom");
setHasSyncedProviderSelection(false);
setShowAdvanced(false);
@@ -202,6 +206,11 @@ export function CreateAgentDialog({
Number.parseInt(turnTimeoutSeconds, 10) > 0
? Number.parseInt(turnTimeoutSeconds, 10)
: undefined,
parallelism:
Number.parseInt(parallelism, 10) > 0
? Number.parseInt(parallelism, 10)
: undefined,
systemPrompt: systemPrompt.trim() || undefined,
mintToken,
tokenName: tokenName.trim() || undefined,
tokenScopes: [...selectedScopes],
@@ -280,7 +289,8 @@ export function CreateAgentDialog({
Advanced setup
</p>
<p className="text-sm text-muted-foreground">
Relay overrides, raw commands, timeout, and doctor guidance.
Relay overrides, raw commands, timeout, parallelism, prompt
override, and doctor guidance.
</p>
</div>
<span className="shrink-0 self-center text-muted-foreground">
@@ -298,14 +308,18 @@ export function CreateAgentDialog({
agentArgs={agentArgs}
agentCommand={agentCommand}
mcpCommand={mcpCommand}
onParallelismChange={setParallelism}
onAcpCommandChange={setAcpCommand}
onAgentArgsChange={setAgentArgs}
onAgentCommandChange={setAgentCommand}
onMcpCommandChange={setMcpCommand}
onRelayUrlChange={setRelayUrl}
onSystemPromptChange={setSystemPrompt}
onTurnTimeoutChange={setTurnTimeoutSeconds}
parallelism={parallelism}
relayUrl={relayUrl}
selectedProviderId={selectedProviderId}
systemPrompt={systemPrompt}
turnTimeoutSeconds={turnTimeoutSeconds}
/>

View File

@@ -6,6 +6,7 @@ import type {
import { MANAGED_AGENT_SCOPE_OPTIONS } from "@/features/tokens/lib/scopeOptions";
import { cn } from "@/shared/lib/cn";
import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { describeResolvedCommand } from "./agentUi";
export function CreateAgentBasicsFields({
@@ -98,28 +99,36 @@ export function CreateAgentRuntimeFields({
agentArgs,
agentCommand,
mcpCommand,
parallelism,
relayUrl,
selectedProviderId,
systemPrompt,
turnTimeoutSeconds,
onAcpCommandChange,
onAgentArgsChange,
onAgentCommandChange,
onMcpCommandChange,
onParallelismChange,
onRelayUrlChange,
onSystemPromptChange,
onTurnTimeoutChange,
}: {
acpCommand: string;
agentArgs: string;
agentCommand: string;
mcpCommand: string;
parallelism: string;
relayUrl: string;
selectedProviderId: string;
systemPrompt: string;
turnTimeoutSeconds: string;
onAcpCommandChange: (value: string) => void;
onAgentArgsChange: (value: string) => void;
onAgentCommandChange: (value: string) => void;
onMcpCommandChange: (value: string) => void;
onParallelismChange: (value: string) => void;
onRelayUrlChange: (value: string) => void;
onSystemPromptChange: (value: string) => void;
onTurnTimeoutChange: (value: string) => void;
}) {
return (
@@ -165,7 +174,7 @@ export function CreateAgentRuntimeFields({
</div>
) : null}
<div className="grid gap-4 md:grid-cols-[1.5fr,1.2fr,0.8fr]">
<div className="grid gap-4 md:grid-cols-[1.5fr,1.2fr,0.8fr,0.8fr]">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-runtime-args">
Agent runtime args
@@ -199,9 +208,48 @@ export function CreateAgentRuntimeFields({
<Input
id="agent-timeout"
onChange={(event) => onTurnTimeoutChange(event.target.value)}
placeholder="300"
value={turnTimeoutSeconds}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-parallelism">
Parallelism
</label>
<Input
data-testid="agent-parallelism-input"
id="agent-parallelism"
inputMode="numeric"
max="32"
min="1"
onChange={(event) => onParallelismChange(event.target.value)}
placeholder="1"
step="1"
type="number"
value={parallelism}
/>
<p className="text-xs text-muted-foreground">
Number of ACP worker subprocesses. `sprout-acp` allows 1-32.
</p>
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="agent-system-prompt">
System prompt override
</label>
<Textarea
data-testid="agent-system-prompt-input"
id="agent-system-prompt"
onChange={(event) => onSystemPromptChange(event.target.value)}
placeholder="Leave blank to send no ACP system prompt"
value={systemPrompt}
/>
<p className="text-xs text-muted-foreground">
Blank means no override. `sprout-acp` will not add a `[System]`
prompt.
</p>
</div>
</>
);

View File

@@ -54,7 +54,10 @@ export function ManagedAgentLogPanel({
<span>{selectedAgent.name}</span>
<span>{selectedAgent.status}</span>
</div>
<pre className="max-h-[22rem] overflow-auto whitespace-pre-wrap px-4 py-4">
<pre
className="max-h-[22rem] overflow-auto whitespace-pre-wrap px-4 py-4"
data-testid="managed-agent-log-content"
>
{logContent?.trim() ? logContent : "No log output yet."}
</pre>
</div>

View File

@@ -239,10 +239,6 @@ export function formatTimelineMessages(
const authorPubkey =
authorPubkeyByEventId.get(event.id) ?? getEffectiveAuthorPubkey(event);
const thread = getThreadReference(event.tags);
const parentEvent = thread.parentId
? eventsById.get(thread.parentId)
: undefined;
return {
id: event.id,
createdAt: event.created_at,
@@ -262,8 +258,6 @@ export function formatTimelineMessages(
parentId: thread.parentId,
rootId: thread.rootId,
depth: getDepth(event),
replyToAuthor: parentEvent ? getAuthorLabel(parentEvent) : null,
replyToSnippet: parentEvent?.content ?? null,
accent: currentPubkey === authorPubkey,
pending: event.pending,
kind: event.kind,

View File

@@ -16,8 +16,6 @@ export type TimelineMessage = {
parentId?: string | null;
rootId?: string | null;
depth: number;
replyToAuthor?: string | null;
replyToSnippet?: string | null;
accent?: boolean;
pending?: boolean;
highlighted?: boolean;

View File

@@ -37,7 +37,6 @@ export function MessageRow({
>(null);
const [reactionPending, setReactionPending] = React.useState(false);
const visibleDepth = Math.min(message.depth, 6);
const compressedDepth = Math.max(message.depth - visibleDepth, 0);
const indentPx = visibleDepth * 28;
const initials = message.author
.split(" ")
@@ -190,25 +189,6 @@ export function MessageRow({
)}
<div className="min-w-0 flex-1 space-y-0">
{message.parentId ? (
<div className="mb-1 flex min-w-0 flex-wrap items-center gap-2 text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
Reply
</span>
<span className="truncate normal-case tracking-normal">
{message.replyToAuthor
? `to ${message.replyToAuthor}`
: "to an earlier message"}
</span>
{compressedDepth > 0 ? (
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px]">
+{compressedDepth} more level
{compressedDepth === 1 ? "" : "s"}
</span>
) : null}
</div>
) : null}
<div className="flex min-w-0 flex-wrap items-center gap-2">
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
@@ -291,13 +271,6 @@ export function MessageRow({
{reactionErrorMessage}
</p>
) : null}
<div className="mt-2 flex items-center gap-2">
{message.replyToSnippet ? (
<p className="truncate text-xs text-muted-foreground">
{message.replyToSnippet}
</p>
) : null}
</div>
{expandedDiffId === message.id ? (
<React.Suspense
fallback={

View File

@@ -443,6 +443,7 @@ export function AppSidebar({
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
data-testid="open-agents-view"
isActive={selectedView === "agents"}
onClick={onSelectAgents}
tooltip="Agents"

View File

@@ -214,6 +214,8 @@ type RawManagedAgent = {
agent_args: string[];
mcp_command: string;
turn_timeout_seconds: number;
parallelism: number;
system_prompt: string | null;
has_api_token: boolean;
status: ManagedAgent["status"];
pid: number | null;
@@ -711,6 +713,8 @@ function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
agentArgs: agent.agent_args,
mcpCommand: agent.mcp_command,
turnTimeoutSeconds: agent.turn_timeout_seconds,
parallelism: agent.parallelism,
systemPrompt: agent.system_prompt,
hasApiToken: agent.has_api_token,
status: agent.status,
pid: agent.pid,
@@ -804,6 +808,8 @@ export async function createManagedAgent(
agentArgs: input.agentArgs,
mcpCommand: input.mcpCommand,
turnTimeoutSeconds: input.turnTimeoutSeconds,
parallelism: input.parallelism,
systemPrompt: input.systemPrompt,
mintToken: input.mintToken,
tokenScopes: input.tokenScopes,
tokenName: input.tokenName,

View File

@@ -253,6 +253,8 @@ export type ManagedAgent = {
agentArgs: string[];
mcpCommand: string;
turnTimeoutSeconds: number;
parallelism: number;
systemPrompt: string | null;
hasApiToken: boolean;
status: "running" | "stopped";
pid: number | null;
@@ -273,6 +275,8 @@ export type CreateManagedAgentInput = {
agentArgs?: string[];
mcpCommand?: string;
turnTimeoutSeconds?: number;
parallelism?: number;
systemPrompt?: string;
mintToken?: boolean;
tokenScopes?: TokenScope[];
tokenName?: string;

View File

@@ -208,6 +208,8 @@ type RawManagedAgent = {
agent_args: string[];
mcp_command: string;
turn_timeout_seconds: number;
parallelism: number;
system_prompt: string | null;
has_api_token: boolean;
status: "running" | "stopped";
pid: number | null;
@@ -224,6 +226,7 @@ type RawCreateManagedAgentResponse = {
agent: RawManagedAgent;
private_key_nsec: string;
api_token: string | null;
profile_sync_error: string | null;
spawn_error: string | null;
};
@@ -469,6 +472,8 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
agent_args: [...agent.agent_args],
mcp_command: agent.mcp_command,
turn_timeout_seconds: agent.turn_timeout_seconds,
parallelism: agent.parallelism,
system_prompt: agent.system_prompt,
has_api_token: agent.has_api_token,
status: agent.status,
pid: agent.pid,
@@ -2030,6 +2035,8 @@ async function handleCreateManagedAgent(args: {
agentArgs?: string[];
mcpCommand?: string;
turnTimeoutSeconds?: number;
parallelism?: number;
systemPrompt?: string;
mintToken?: boolean;
tokenScopes?: string[];
tokenName?: string;
@@ -2059,6 +2066,8 @@ async function handleCreateManagedAgent(args: {
: ["acp"],
mcp_command: args.input.mcpCommand ?? "sprout-mcp-server",
turn_timeout_seconds: args.input.turnTimeoutSeconds ?? 300,
parallelism: args.input.parallelism ?? 1,
system_prompt: args.input.systemPrompt?.trim() || null,
has_api_token: token !== null,
status: args.input.spawnAfterCreate ? "running" : "stopped",
pid: args.input.spawnAfterCreate ? 42000 + mockManagedAgents.length : null,
@@ -2072,7 +2081,10 @@ async function handleCreateManagedAgent(args: {
private_key_nsec: `nsec1mock${pubkey.slice(0, 20)}`,
api_token: token,
log_lines: [
`sprout-acp starting: relay=${args.input.relayUrl ?? DEFAULT_RELAY_WS_URL} agent_pubkey=${pubkey}`,
`sprout-acp starting: relay=${args.input.relayUrl ?? DEFAULT_RELAY_WS_URL} agent_pubkey=${pubkey} parallelism=${args.input.parallelism ?? 1}`,
args.input.systemPrompt?.trim()
? `system prompt override configured (${args.input.systemPrompt.trim().length} chars)`
: "system prompt override not set",
args.input.spawnAfterCreate
? "connected to relay at ws://localhost:3000"
: "profile created; harness not started",
@@ -2086,6 +2098,7 @@ async function handleCreateManagedAgent(args: {
agent: cloneManagedAgent(managedAgent),
private_key_nsec: managedAgent.private_key_nsec,
api_token: managedAgent.api_token,
profile_sync_error: null,
spawn_error: null,
};
}

View File

@@ -214,6 +214,7 @@ test("supports nested replies with visible indentation", async ({ page }) => {
await page.getByTestId("message-input").fill(firstReply);
await page.getByTestId("send-message").click();
await expect(rows.last()).toContainText(firstReply);
await expect(rows.last()).not.toContainText("Welcome to #general");
await rows.last().hover();
await replyButtons.last().click();
@@ -221,6 +222,7 @@ test("supports nested replies with visible indentation", async ({ page }) => {
await page.getByTestId("message-input").fill(nestedReply);
await page.getByTestId("send-message").click();
await expect(rows.last()).toContainText(nestedReply);
await expect(rows.last()).not.toContainText(firstReply);
const rootBox = await rows.nth(0).boundingBox();
const firstReplyBox = await rows.nth(1).boundingBox();

View File

@@ -68,6 +68,36 @@ test("creates a new mocked stream", async ({ page }) => {
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
});
test("create agent supports parallelism and system prompt overrides", async ({
page,
}) => {
const agentName = `Parallel agent ${Date.now()}`;
await page.goto("/");
await page.getByTestId("open-agents-view").click();
await page.getByRole("button", { name: "Create agent" }).click();
await page.getByTestId("agent-name-input").fill(agentName);
await page.getByRole("button", { name: "Advanced setup" }).click();
await page.getByTestId("agent-parallelism-input").fill("3");
await page
.getByTestId("agent-system-prompt-input")
.fill("You are concise and parallelize independent work.");
await page.getByTestId("create-agent-submit").click();
await expect(
page.getByRole("heading", { name: "Agent created" }),
).toBeVisible();
await page.getByRole("button", { name: "Done" }).click();
await expect(page.getByTestId("managed-agent-log-content")).toContainText(
"parallelism=3",
);
await expect(page.getByTestId("managed-agent-log-content")).toContainText(
"system prompt override configured",
);
});
test("opens a mocked channel from the home feed", async ({ page }) => {
const mentionsSection = page.locator("section").filter({
has: page.getByRole("heading", { name: "@Mentions" }),