Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions desktop/src/features/agents/channelAgents.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import assert from "node:assert/strict";
import test, { mock } from "node:test";

import { attachManagedAgentToChannel } from "./channelAgents.ts";

const AGENT_PUBKEY = "a".repeat(64);

function rawAgent(status = "stopped", backend = { type: "local" }) {
return {
pubkey: AGENT_PUBKEY,
name: "reviewer",
persona_id: null,
runtime: null,
team_id: null,
relay_url: "ws://localhost:3000",
acp_command: "buzz-acp",
agent_command: "codex",
agent_command_override: null,
agent_args: [],
mcp_command: "",
turn_timeout_seconds: 30,
idle_timeout_seconds: null,
max_turn_duration_seconds: null,
parallelism: 1,
system_prompt: null,
avatar_url: null,
model: null,
provider: null,
persona_out_of_date: false,
persona_orphaned: false,
needs_restart: false,
restart_diff: [],
env_vars: {},
status,
pid: status === "running" ? 123 : null,
created_at: "2026-09-01T00:00:00Z",
updated_at: "2026-09-01T00:00:00Z",
last_started_at: null,
last_stopped_at: null,
last_exit_code: null,
last_error: null,
last_error_code: null,
log_path: null,
start_on_app_launch: false,
auto_restart_on_config_change: true,
backend,
backend_agent_id: null,
respond_to: "owner-only",
respond_to_allowlist: [],
};
}

function managedAgent(status = "stopped", backend = { type: "local" }) {
const raw = rawAgent(status, backend);
return {
...raw,
personaId: raw.persona_id,
teamId: raw.team_id,
relayUrl: raw.relay_url,
acpCommand: raw.acp_command,
agentCommand: raw.agent_command,
agentCommandOverride: raw.agent_command_override,
agentArgs: raw.agent_args,
mcpCommand: raw.mcp_command,
turnTimeoutSeconds: raw.turn_timeout_seconds,
idleTimeoutSeconds: raw.idle_timeout_seconds,
maxTurnDurationSeconds: raw.max_turn_duration_seconds,
systemPrompt: raw.system_prompt,
avatarUrl: raw.avatar_url,
personaOutOfDate: raw.persona_out_of_date,
personaOrphaned: raw.persona_orphaned,
needsRestart: raw.needs_restart,
restartDiff: raw.restart_diff,
envVars: raw.env_vars,
createdAt: raw.created_at,
updatedAt: raw.updated_at,
lastStartedAt: raw.last_started_at,
lastStoppedAt: raw.last_stopped_at,
lastExitCode: raw.last_exit_code,
lastError: raw.last_error,
lastErrorCode: raw.last_error_code,
logPath: raw.log_path,
startOnAppLaunch: raw.start_on_app_launch,
autoRestartOnConfigChange: raw.auto_restart_on_config_change,
backendAgentId: raw.backend_agent_id,
respondTo: raw.respond_to,
respondToAllowlist: raw.respond_to_allowlist,
};
}

function installTauriInvoke(t, handler) {
const prior = globalThis.window;
globalThis.window = { __TAURI_INTERNALS__: { invoke: handler } };
t.after(() => {
mock.restoreAll();
globalThis.window = prior;
});
}

test("add without starting verifies stopped state through the production seam", async (t) => {
const calls = [];
installTauriInvoke(t, async (command, args) => {
calls.push([command, args]);
if (command === "add_channel_members") {
return { added: [AGENT_PUBKEY], errors: [] };
}
if (command === "list_managed_agents") {
return [rawAgent("stopped")];
}
throw new Error(`unexpected command: ${command}`);
});

const result = await attachManagedAgentToChannel("channel-1", {
agent: managedAgent("stopped"),
ensureRunning: false,
});

assert.equal(result.membershipAdded, true);
assert.equal(result.started, false);
assert.equal(result.agent.status, "stopped");
assert.deepEqual(
calls.map(([command]) => command),
["add_channel_members", "list_managed_agents"],
);
});

test("add without starting rejects stale running input before membership", async (t) => {
const calls = [];
installTauriInvoke(t, async (command, args) => {
calls.push([command, args]);
throw new Error(`unexpected command: ${command}`);
});

await assert.rejects(
attachManagedAgentToChannel("channel-1", {
agent: managedAgent("running"),
ensureRunning: false,
}),
/no longer stopped/,
);
assert.deepEqual(calls, []);
});

test("add without starting fails closed when membership is not confirmed", async (t) => {
const calls = [];
installTauriInvoke(t, async (command, args) => {
calls.push([command, args]);
if (command === "add_channel_members") {
return { added: [], errors: [] };
}
throw new Error(`unexpected command: ${command}`);
});

await assert.rejects(
attachManagedAgentToChannel("channel-1", {
agent: managedAgent("stopped"),
ensureRunning: false,
}),
/membership was not confirmed/,
);
assert.deepEqual(
calls.map(([command]) => command),
["add_channel_members"],
);
});

test("post-membership stopped-state drift is a visible partial failure", async (t) => {
const calls = [];
installTauriInvoke(t, async (command, args) => {
calls.push([command, args]);
if (command === "add_channel_members") {
return { added: [AGENT_PUBKEY], errors: [] };
}
if (command === "list_managed_agents") {
return [rawAgent("running")];
}
throw new Error(`unexpected command: ${command}`);
});

await assert.rejects(
attachManagedAgentToChannel("channel-1", {
agent: managedAgent("stopped"),
ensureRunning: false,
}),
/was added, but its stopped state could not be verified/,
);
assert.deepEqual(
calls.map(([command]) => command),
["add_channel_members", "list_managed_agents"],
);
});

test("the existing default still adds and starts a stopped local agent", async (t) => {
const calls = [];
installTauriInvoke(t, async (command, args) => {
calls.push([command, args]);
if (command === "add_channel_members") {
return { added: [AGENT_PUBKEY], errors: [] };
}
if (command === "start_managed_agent") {
return rawAgent("running");
}
throw new Error(`unexpected command: ${command}`);
});

const result = await attachManagedAgentToChannel("channel-1", {
agent: managedAgent("stopped"),
});

assert.equal(result.membershipAdded, true);
assert.equal(result.started, true);
assert.equal(result.agent.status, "running");
assert.deepEqual(
calls.map(([command]) => command),
["add_channel_members", "start_managed_agent"],
);
});
33 changes: 32 additions & 1 deletion desktop/src/features/agents/channelAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,20 @@ export async function attachManagedAgentToChannel(
const role = input.role ?? "bot";
const ensureRunning = input.ensureRunning ?? true;
const agentPubkey = normalizePubkey(input.agent.pubkey);

if (!ensureRunning) {
if (input.agent.backend.type !== "local") {
throw new Error(
"Adding without starting is only available for local agents.",
);
}
if (input.agent.status !== "stopped") {
throw new Error(
"This agent is no longer stopped. Refresh and choose Add and start instead.",
);
}
}

const membershipResult = await addChannelMembers({
channelId,
pubkeys: [input.agent.pubkey],
Expand All @@ -163,11 +177,28 @@ export async function attachManagedAgentToChannel(
const membershipAdded = membershipResult.added.some(
(pubkey) => normalizePubkey(pubkey) === agentPubkey,
);
if (!ensureRunning && !membershipAdded) {
throw new Error("Agent membership was not confirmed.");
}

let agent = input.agent;
let started = false;

if (ensureRunning) {
if (!ensureRunning) {
try {
const verifiedAgent = (await listManagedAgents()).find(
(candidate) => normalizePubkey(candidate.pubkey) === agentPubkey,
);
if (verifiedAgent?.status !== "stopped") {
throw new Error("stopped state changed");
}
agent = verifiedAgent;
} catch {
throw new Error(
"Agent was added, but its stopped state could not be verified.",
);
}
} else {
// Running agents (local or provider) auto-discover new channel membership
// via the harness's membership notifications — no restart needed. Only
// not-yet-running agents need a start/deploy call before the first mention
Expand Down
70 changes: 49 additions & 21 deletions desktop/src/features/channels/ui/AddMemberSearchResultRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ export function formatAddCandidateName(user: UserSearchResult) {
export function AddMemberSearchResultRow({
disabled,
onSelect,
onSelectWithoutStarting,
ownerLabel,
user,
}: {
disabled: boolean;
onSelect: (user: UserSearchResult) => void;
onSelectWithoutStarting?: (user: UserSearchResult) => void;
ownerLabel?: string | null;
user: UserSearchResult;
}) {
const candidateName = formatAddCandidateName(user);

return (
<div
className={cn(
Expand All @@ -35,17 +39,20 @@ export function AddMemberSearchResultRow({
)}
data-testid={`channel-user-search-result-${user.pubkey}`}
>
<button
aria-label={`Select ${formatAddCandidateName(user)}`}
className="absolute inset-0 z-0 cursor-pointer focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
disabled={disabled}
onClick={() => onSelect(user)}
type="button"
<span
aria-hidden="true"
className={cn(
"absolute inset-0 z-0 cursor-pointer",
disabled && "pointer-events-none cursor-default",
)}
onClick={() => {
if (!disabled) onSelect(user);
}}
/>
<UserAvatar
avatarUrl={user.avatarUrl}
className="pointer-events-none relative z-10 h-8 w-8 text-xs shadow-none"
displayName={formatAddCandidateName(user)}
displayName={candidateName}
shape={user.isAgent ? "squircle" : "circle"}
size="sm"
/>
Expand All @@ -54,7 +61,7 @@ export function AddMemberSearchResultRow({
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-medium tracking-tight">
{formatAddCandidateName(user)}
{candidateName}
</span>
<span className="inline-flex shrink-0 items-center gap-1 text-xs text-muted-foreground">
<Bot aria-hidden="true" className="h-4 w-4" />
Expand All @@ -72,22 +79,43 @@ export function AddMemberSearchResultRow({
</div>
) : (
<span className="block truncate text-sm font-medium tracking-tight">
{formatAddCandidateName(user)}
{candidateName}
</span>
)}
</div>
<Button
className="relative z-20 shrink-0"
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onSelect(user);
}}
size="sm"
type="button"
>
Add
</Button>
<div className="relative z-20 flex shrink-0 items-center gap-2">
{onSelectWithoutStarting ? (
<Button
aria-label={`Add ${candidateName} without starting`}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onSelectWithoutStarting(user);
}}
size="sm"
type="button"
variant="outline"
>
Add without starting
</Button>
) : null}
<Button
aria-label={
onSelectWithoutStarting
? `Add ${candidateName} and start`
: `Add ${candidateName}`
}
disabled={disabled}
onClick={(event) => {
event.stopPropagation();
onSelect(user);
}}
size="sm"
type="button"
>
{onSelectWithoutStarting ? "Add and start" : "Add"}
</Button>
</div>
</div>
);
}
Loading