From eb991a2fe6b1095ae2e8ce345fe109d3d8104664 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 29 Sep 2026 23:25:26 +0200 Subject: [PATCH 1/2] Pick the browser profile a chat works in (GODM-27) --- .../src/components/browser/profile-chip.tsx | 187 ++++++++++++++++++ .../src/components/chat/browser-panel.tsx | 25 ++- apps/desktop/src/components/chat/composer.tsx | 2 +- .../src/pages/chat/chat-conversation.tsx | 29 ++- apps/desktop/src/pages/chat/chat-home.tsx | 6 + docs/ARCHITECTURE.md | 5 +- packages/core/src/browser/manager.ts | 25 ++- packages/core/src/db/migrations.ts | 8 + packages/core/src/mcp/tools.ts | 24 +-- packages/core/src/runner/mcpConfig.ts | 4 +- packages/core/src/runner/runner.ts | 14 +- packages/core/src/server/routes/chat.ts | 6 +- packages/core/src/services/conversations.ts | 26 ++- packages/core/test/fixtures/runner-harness.ts | 7 +- packages/core/test/mcp.test.ts | 16 +- packages/core/test/runner.test.ts | 22 +++ packages/core/test/scheduler.test.ts | 1 + packages/shared/src/api.ts | 4 + packages/shared/src/models.ts | 2 + 19 files changed, 372 insertions(+), 41 deletions(-) create mode 100644 apps/desktop/src/components/browser/profile-chip.tsx diff --git a/apps/desktop/src/components/browser/profile-chip.tsx b/apps/desktop/src/components/browser/profile-chip.tsx new file mode 100644 index 00000000..1b3a1983 --- /dev/null +++ b/apps/desktop/src/components/browser/profile-chip.tsx @@ -0,0 +1,187 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import type { Agent, BrowserProfile } from "@godmode/shared"; +import { Check, ChevronDown, Globe, Loader2, Plus, Settings2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { LiveDot } from "@/components/aicss/Motion"; +import { agentBrowserProfile } from "@/components/chat/browser-panel"; +import { api } from "@/lib/api"; +import { useSettings, useWorkspaces } from "@/lib/hooks"; +import { qk } from "@/lib/queryKeys"; +import { cn } from "@/lib/utils"; +import { CreateProfileDialog } from "./profile-list"; + +function ProfileTile({ profile, quiet }: { profile: BrowserProfile | null; quiet?: boolean }) { + return ( + + + {profile?.running && } + + ); +} + +/** + * Composer control for the browser profile a chat works in: a quiet button while the chat uses the default profile, + * a pill with the profile's name otherwise (dashed when it comes from the agent or its workspace). Hidden when the + * agent has no browser. + */ +export function BrowserProfileChip({ + agent, + value, + onChange, + busy, +}: { + agent: Agent | undefined; + /** The chat's own profile; null = the agent's. */ + value: string | null; + onChange: (profileId: string | null) => void | Promise; + busy?: boolean; +}) { + const { data: settings } = useSettings(); + const enabled = !!agent?.browser.enabled && settings?.browser.enabled !== false; + const { data: profiles = [] } = useQuery({ queryKey: qk.browserProfiles, queryFn: api.browser.profiles, enabled }); + const { data: workspaces = [] } = useWorkspaces(); + const navigate = useNavigate(); + const qc = useQueryClient(); + const [open, setOpen] = useState(false); + const [creating, setCreating] = useState(false); + if (!enabled || !agent) return null; + + const scopeName = (p: BrowserProfile) => (p.workspaceId ? (workspaces.find((w) => w.id === p.workspaceId)?.name ?? "Workspace") : "Global"); + const own = value ? (profiles.find((p) => p.id === value) ?? null) : null; + const fallback = agentBrowserProfile(agent, profiles); + const fallbackFrom = !fallback + ? "Loading profiles…" + : agent.browser.profileId === fallback.id + ? `Pinned in ${agent.name}'s settings` + : fallback.workspaceId + ? `Default of the ${scopeName(fallback)} workspace` + : "Your default profile"; + const shown = own ?? (fallback && (fallback.workspaceId || !fallback.isDefault) ? fallback : null); + + const pick = (profileId: string | null) => { + setOpen(false); + if (profileId !== (own?.id ?? null)) void onChange(profileId); + }; + const tooltip = own ? `This chat browses in ${own.name}` : fallback ? `Browses in ${fallback.name} — ${fallbackFrom.toLowerCase()}` : "Browser profile"; + const icon = busy ? : ; + + return ( + <> + + + + + {shown ? ( + + ) : ( + + )} + + + {tooltip} + + +
+

Browser profile

+

Runs in this chat browse with this profile's cookies and logins.

+
+ + {profiles.length > 6 && } + + No profile with that name. + + pick(null)} className="gap-2.5 py-2"> + + + {fallback && fallback.name.toLowerCase() !== "default" ? `Default — ${fallback.name}` : "Default"} + {fallbackFrom} + + {!own && } + + + + + {profiles.map((p) => ( + pick(p.id)} className="gap-2.5 py-2"> + + + {p.name} + + {[p.running ? "Running" : null, scopeName(p), p.cookieCount ? `${p.cookieCount.toLocaleString()} cookies` : "No sessions yet"] + .filter(Boolean) + .join(" · ")} + + + {own?.id === p.id && } + + ))} + + + +
+ + +
+
+
+ { + qc.setQueryData(qk.browserProfiles, (old) => (old && !old.some((x) => x.id === p.id) ? [...old, p] : old)); + void onChange(p.id); + }} + /> + + ); +} diff --git a/apps/desktop/src/components/chat/browser-panel.tsx b/apps/desktop/src/components/chat/browser-panel.tsx index 6dd4d79b..29864121 100644 --- a/apps/desktop/src/components/chat/browser-panel.tsx +++ b/apps/desktop/src/components/chat/browser-panel.tsx @@ -24,20 +24,22 @@ import { cn } from "@/lib/utils"; export type BrowserFocusMode = "watch" | "control"; -/** Mirrors the core's resolveProfileForAgent: pinned profile → workspace default → global default. */ -export function agentBrowserProfile(agent: Agent, profiles: BrowserProfile[]): BrowserProfile | null { +/** Mirrors the core's resolveProfileForAgent: the chat's profile → pinned profile → workspace default → global default. */ +export function agentBrowserProfile(agent: Agent, profiles: BrowserProfile[], chatProfileId: string | null = null): BrowserProfile | null { + const own = chatProfileId ? profiles.find((p) => p.id === chatProfileId) : undefined; + if (own) return own; const pinned = agent.browser.profileId ? profiles.find((p) => p.id === agent.browser.profileId) : undefined; if (pinned) return pinned; const workspaceDefault = agent.workspaceId ? profiles.find((p) => p.workspaceId === agent.workspaceId && p.isDefault) : undefined; return workspaceDefault ?? profiles.find((p) => !p.workspaceId && p.isDefault) ?? null; } -/** The browser profile this chat's agent drives (null when the agent has no browser). */ -export function useChatBrowser(agent: Agent | undefined): BrowserProfile | null { +/** The browser profile this chat drives (null when its agent has no browser). */ +export function useChatBrowser(agent: Agent | undefined, chatProfileId: string | null = null): BrowserProfile | null { const { data: settings } = useSettings(); const enabled = !!agent?.browser.enabled && settings?.browser.enabled !== false; const { data: profiles } = useQuery({ queryKey: qk.browserProfiles, queryFn: api.browser.profiles, enabled }); - return enabled && agent && profiles ? agentBrowserProfile(agent, profiles) : null; + return enabled && agent && profiles ? agentBrowserProfile(agent, profiles, chatProfileId) : null; } function useFrame(profile: BrowserProfile) { @@ -49,12 +51,15 @@ function useFrame(profile: BrowserProfile) { export function BrowserPanel({ profile, agent, + forChat, activity, onHide, onFocus, }: { profile: BrowserProfile; agent: Agent; + /** The profile was picked for this chat rather than inherited from the agent. */ + forChat?: boolean; /** What the agent is doing right now; null while this chat is idle. */ activity: string | null; onHide: () => void; @@ -197,15 +202,19 @@ export function BrowserPanel({ )} - + ); } -function ProfileFooter({ profile, pinned }: { profile: BrowserProfile; pinned: boolean }) { +function ProfileFooter({ profile, forChat, pinned }: { profile: BrowserProfile; forChat: boolean; pinned: boolean }) { const workspace = useWorkspaceName(profile.workspaceId); - const shared = pinned ? "Pinned in this agent's settings" : profile.workspaceId ? `Shared by every chat in ${workspace}` : "Shared by every chat without a workspace profile"; + const shared = forChat + ? "Picked for this chat" + : pinned + ? "Pinned in this agent's settings" + : profile.workspaceId ? `Shared by every chat in ${workspace}` : "Shared by every chat without a workspace profile"; return ( +
{leading}
)} diff --git a/apps/desktop/src/pages/chat/chat-conversation.tsx b/apps/desktop/src/pages/chat/chat-conversation.tsx index ff2532ad..3f597f5e 100644 --- a/apps/desktop/src/pages/chat/chat-conversation.tsx +++ b/apps/desktop/src/pages/chat/chat-conversation.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Link, useParams } from "react-router"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "motion/react"; -import type { Agent, ComputerTarget, ConversationWithMessages, Message, SendMessageInput, Vm } from "@godmode/shared"; +import type { Agent, BrowserProfile, ComputerTarget, ConversationWithMessages, Message, SendMessageInput, Vm } from "@godmode/shared"; import { computerTargetLabel } from "@godmode/shared"; import { Archive, ArchiveRestore, ArrowUpRight, Brain, MessageSquareDashed, MessageSquarePlus, Moon, Sparkles, Wand2 } from "lucide-react"; import { toast } from "sonner"; @@ -10,7 +10,8 @@ import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { AgentAvatar, EmptyState } from "@/components/common"; -import { BrowserFocus, BrowserPanel, BrowserToggle, useChatBrowser, type BrowserFocusMode } from "@/components/chat/browser-panel"; +import { BrowserFocus, BrowserPanel, BrowserToggle, agentBrowserProfile, useChatBrowser, type BrowserFocusMode } from "@/components/chat/browser-panel"; +import { BrowserProfileChip } from "@/components/browser/profile-chip"; import { ComputerFocus, ComputerPanel, ComputerShareChip, ComputerToggle, type ComputerFocusMode } from "@/components/computer/computer-panel"; import { useStartAgentChat } from "@/components/agents/agent-actions"; import { Composer, type ComposerHandle } from "@/components/chat/composer"; @@ -55,7 +56,7 @@ function ConversationView({ conversationId }: { conversationId: string }) { const composerRef = useRef(null); const [queued, setQueued] = useState>({}); const mountedAt = useRef(Date.now()); - const browser = useChatBrowser(agent); + const browser = useChatBrowser(agent, conv?.browserProfileId ?? null); const browserPanel = useUi((s) => s.browserPanel); const setBrowserPanel = useUi((s) => s.setBrowserPanel); const wide = useMediaQuery("(min-width: 1024px)"); @@ -209,6 +210,21 @@ function ConversationView({ conversationId }: { conversationId: string }) { onError: (err) => toast.error("Couldn't change the VM", { description: errorMessage(err) }), }); + const setBrowserProfile = useMutation({ + mutationFn: (browserProfileId: string | null) => api.conversations.update(conversationId, { browserProfileId }), + onSuccess: (updated) => { + qc.setQueryData(key, (old) => (old ? { ...old, ...updated } : old)); + const profiles = qc.getQueryData(qk.browserProfiles) ?? []; + const profile = updated.browserProfileId + ? profiles.find((p) => p.id === updated.browserProfileId) + : agent && agentBrowserProfile(agent, profiles); + const when = busyRef.current ? "From your next message" : "The next messages"; + if (updated.browserProfileId) toast.success(`Browsing in ${profile?.name ?? "the new profile"}`, { description: `${when} use its cookies and logins.` }); + else toast.success("Back to the default profile", { description: `${agent?.name ?? "The agent"} browses in ${profile?.name ?? "its own profile"} again.` }); + }, + onError: (err) => toast.error("Couldn't change the browser profile", { description: errorMessage(err) }), + }); + const share = useMutation({ mutationFn: (computerTarget: ComputerTarget | null) => api.conversations.update(conversationId, { computerTarget }), onSuccess: (updated, target) => { @@ -359,6 +375,12 @@ function ConversationView({ conversationId }: { conversationId: string }) { onChange={(path) => setFolder.mutate(path)} busy={setFolder.isPending} /> + setBrowserProfile.mutateAsync(id).catch(() => undefined)} + busy={setBrowserProfile.isPending} + /> setBrowserPanel(false)} onFocus={setBrowserFocus} diff --git a/apps/desktop/src/pages/chat/chat-home.tsx b/apps/desktop/src/pages/chat/chat-home.tsx index 6186d43c..01097f99 100644 --- a/apps/desktop/src/pages/chat/chat-home.tsx +++ b/apps/desktop/src/pages/chat/chat-home.tsx @@ -18,6 +18,7 @@ import { FolderChip } from "@/components/chat/folder-picker"; import { ComputerShareChip } from "@/components/computer/computer-panel"; import { InstructionsChip } from "@/components/instructions/instructions"; import { VmChip } from "@/components/vms/vm-picker"; +import { BrowserProfileChip } from "@/components/browser/profile-chip"; import { ChatDropZone } from "@/components/chat/thread"; import { liveActivityLabel, useNow } from "@/components/chat/messages"; import { VoiceMode } from "@/components/chat/voice-mode"; @@ -82,6 +83,8 @@ export default function ChatHome() { const [instructions, setInstructions] = useState(""); /** macOS VM for the new chat; null = the agent's (or its workspace's). */ const [vmId, setVmId] = useState(null); + /** Browser profile for the new chat; null = the agent's. */ + const [browserProfileId, setBrowserProfileId] = useState(null); const { data: workspaces = [] } = useWorkspaces(); const available = useMemo(() => agents.filter((a) => a.enabled), [agents]); @@ -168,6 +171,7 @@ export default function ChatHome() { agentName={selected?.name} onChange={setFolder} /> + --user-data-dir=~/.godmode/browser/` on 127.0.0.1. +* A run browses in its chat's profile (`conversations.browser_profile_id`, picked in the composer), else the agent's + pinned one, else its workspace's default, else the global default. Delegated work for an agent without a pinned + profile keeps the caller chat's profile; deleting a profile sends its chats back to their default. * On macOS a visible browser never takes focus: it is started in the background through LaunchServices (`open -g`, no startup window) and its first window opens behind the active app. * Agents get browser tools from the **browser-use MCP server** (`uvx --from browser-use==0.13.10 browser-use --mcp`) diff --git a/packages/core/src/browser/manager.ts b/packages/core/src/browser/manager.ts index c4514645..5fe1ca24 100644 --- a/packages/core/src/browser/manager.ts +++ b/packages/core/src/browser/manager.ts @@ -174,7 +174,10 @@ export async function deleteProfile(id: string): Promise { const r = requireRow(id); if (!r.workspace_id && r.is_default) throw badRequest("The global default profile can't be deleted. Make another global profile the default first."); await stopBrowser(id); - run("DELETE FROM browser_profiles WHERE id = ?", id); + tx(() => { + run("DELETE FROM browser_profiles WHERE id = ?", id); + run("UPDATE conversations SET browser_profile_id = NULL WHERE browser_profile_id = ?", id); + }); // Only ever delete directories Godmode created. const cfg = config(); for (const dir of [r.user_data_dir, join(cfg.dataDir, "browser-use", id)]) { @@ -189,8 +192,16 @@ export async function deleteProfile(id: string): Promise { bus.changed("browser-profiles"); } -/** Profile an agent should use: agent.browser.profileId ?? workspace default ?? global default. */ -export function resolveProfileForAgent(agent: Agent): BrowserProfile { +/** Profile picked for the chat itself (not inherited from its agent). */ +export function chatProfileId(conversationId: string): string | null { + return get<{ browser_profile_id: string | null }>("SELECT browser_profile_id FROM conversations WHERE id = ?", conversationId)?.browser_profile_id ?? null; +} + +/** Profile a run uses: its chat's ?? agent.browser.profileId ?? workspace default ?? global default. */ +export function resolveProfileForAgent(agent: Agent, conversationId?: string | null): BrowserProfile { + const chosen = conversationId ? chatProfileId(conversationId) : null; + const forChat = chosen ? row(chosen) : null; + if (forChat) return toProfile(forChat); const pinned = agent.browser?.profileId ? row(agent.browser.profileId) : null; if (pinned) return toProfile(pinned); if (agent.browser?.profileId) log.warn(`agent ${agent.id} references missing browser profile ${agent.browser.profileId}; using default`); @@ -593,11 +604,12 @@ export function requireRunning(profileId: string): RunningBrowser { /* ------------------------------------------------------------------ */ /** - * MCP server entry giving the agent browser tools (browser-use MCP connected to the profile's Chromium via CDP). + * MCP server entry giving the agent browser tools (browser-use MCP connected to the profile's Chromium via CDP) — + * `profileId` as resolved for the run, else the agent's. * Returns null when browser is disabled for the agent or globally; throws (with a message fit for the human) * when browser tools are enabled but can't be provided. */ -export async function browserMcpServer(agent: Agent): Promise { +export async function browserMcpServer(agent: Agent, profileId?: string | null): Promise { const settings = getSettings(); if (!settings.browser.enabled || !agent.browser?.enabled) return null; @@ -606,7 +618,8 @@ export async function browserMcpServer(agent: Agent): Promise { - const profile = requireBrowser(agent); + const profileId = requireBrowser(agent, ctx); const secret = revealForAgent(agent, credentialId); const value = field === "username" ? secret.username : secret.password; if (!value) { @@ -411,8 +411,8 @@ const TOOLS: ToolDef[] = [ ); } const login = getCredential(credentialId); - const { scope, guessHost } = await fillScopeFor(profile.id, login); - const result = await fillIntoPage(profile.id, { text: value, kind: field, selector, submit, ...scope }); + const { scope, guessHost } = await fillScopeFor(profileId, login); + const result = await fillIntoPage(profileId, { text: value, kind: field, selector, submit, ...scope }); audit(`agent:${agent.id}`, "credential.fill", credentialId, { field, runId: ctx.runId, ok: result.ok, ...(guessHost ? { guessedSite: guessHost } : {}) }); if (!result.ok) return fail(`Could not fill the ${field}: ${scrub(result.detail, value)}`); markCredentialUsed(credentialId); @@ -433,7 +433,7 @@ const TOOLS: ToolDef[] = [ submit: z.boolean().optional().describe("Press Enter after filling"), }), run: async ({ credentialId, totpId, selector, submit }, { agent, ctx }) => { - const profile = requireBrowser(agent); + const profileId = requireBrowser(agent, ctx); let id = totpId ?? null; let site: Credential | null = null; if (credentialId) { @@ -463,8 +463,8 @@ const TOOLS: ToolDef[] = [ await sleep(code.remaining * 1000 + 300); code = codeForAgent(agent, id); } - const { scope, guessHost } = await fillScopeFor(profile.id, site); - const result = await fillIntoPage(profile.id, { text: code.code, kind: "totp", selector, submit, ...scope }); + const { scope, guessHost } = await fillScopeFor(profileId, site); + const result = await fillIntoPage(profileId, { text: code.code, kind: "totp", selector, submit, ...scope }); audit(`agent:${agent.id}`, "totp.fill", id, { field: "totp", runId: ctx.runId, credentialId: credentialId ?? null, ok: result.ok, ...(guessHost ? { guessedSite: guessHost } : {}) }); if (!result.ok) return fail(`Could not fill the 2FA code: ${scrub(result.detail, code.code)}`); const remembered = guessHost && addCredentialDomain(site.id, guessHost); @@ -614,7 +614,9 @@ const TOOLS: ToolDef[] = [ if (refusal) return fail(refusal); // From a VM, work for an agent without its own VM stays in the caller's VM. const vmId = lockedVm(ctx) && !resolveVmId(null, target) ? lockedVm(ctx) : null; - const conversation = createConversation({ agentId: target.id, title: `Task from ${agent.name}`, origin: "delegation", vmId }); + // Work for an agent without its own profile stays in the browser profile picked for the caller's chat. + const browserProfileId = target.browser.profileId ? null : chatProfileId(ctx.conversationId); + const conversation = createConversation({ agentId: target.id, title: `Task from ${agent.name}`, origin: "delegation", vmId, browserProfileId }); const { run } = await sendMessage(conversation.id, { content: `[Delegated by ${agent.name}]\n\n${task}`, trigger: "delegation", diff --git a/packages/core/src/runner/mcpConfig.ts b/packages/core/src/runner/mcpConfig.ts index 7cf67be4..9bbbaf6c 100644 --- a/packages/core/src/runner/mcpConfig.ts +++ b/packages/core/src/runner/mcpConfig.ts @@ -34,7 +34,7 @@ function errorText(err: unknown): string { export async function buildMcpConfig( agent: Agent, runToken: string, - opts: { onNotice?: (text: string) => void; computer?: boolean; vm?: boolean; gatewayOnly?: boolean } = {}, + opts: { onNotice?: (text: string) => void; computer?: boolean; vm?: boolean; gatewayOnly?: boolean; browserProfileId?: string | null } = {}, ): Promise { const servers: Record = {}; // Dreams get the Godmode gateway only: no integrations, browser or computer. @@ -60,7 +60,7 @@ export async function buildMcpConfig( if (agent.browser.enabled && getSettings().browser.enabled) { try { - const browser = await browserMcpServer(agent); + const browser = await browserMcpServer(agent, opts.browserProfileId); if (browser) servers[BROWSER_MCP_NAME] = browser; } catch (err) { log.warn(`browser tools unavailable for agent ${agent.id}`, err); diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index 14400e60..e3a8af07 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -529,13 +529,19 @@ function browserProfileOf(job: Job): string | null { try { const agent = getAgent(job.agentId); job.browserProfileId = - getSettings().browser.enabled && agent.browser.enabled ? resolveProfileForAgent(agent).id : null; + getSettings().browser.enabled && agent.browser.enabled ? resolveProfileForAgent(agent, job.conversationId).id : null; } catch { job.browserProfileId = null; } return job.browserProfileId; } +/** Browser profile a live run drives; null when it has none or is over. */ +export function runBrowserProfile(runId: string): string | null { + const job = jobs.get(runId); + return job ? browserProfileOf(job) : null; +} + function isAncestor(candidate: Job, job: Job): boolean { let parentId = job.parentRunId; for (let hops = 0; parentId && hops < 16; hops++) { @@ -894,6 +900,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise { - const input = await body(c, z.object({ agentId: z.string().min(1), title: z.string().max(200).optional(), workingDirectory: folder, vmId, instructions, ...modelChoice })); + const input = await body(c, z.object({ agentId: z.string().min(1), title: z.string().max(200).optional(), workingDirectory: folder, vmId, browserProfileId, instructions, ...modelChoice })); return c.json(createConversation({ ...input, origin: "chat" }), 201); }); @@ -86,6 +88,7 @@ export function registerChatRoutes(app: Hono): void { workingDirectory: folder, computerTarget: computerTargetSchema.nullable().optional(), vmId, + browserProfileId, instructions, }), ); @@ -114,6 +117,7 @@ export function registerChatRoutes(app: Hono): void { workingDirectory: folder, computerTarget: computerTargetSchema.nullable().optional(), vmId, + browserProfileId, instructions, ...modelChoice, }), diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index c3f93306..a7a8d76f 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -51,6 +51,7 @@ interface ConversationRow { working_directory: string | null; computer_target: string | null; vm_id: string | null; + browser_profile_id: string | null; instructions: string; pinned: number; archived: number; @@ -104,6 +105,7 @@ function toConversation(r: ConversationRow): Conversation { workingDirectory: r.working_directory, computerTarget: parseComputerTarget(parseJson(r.computer_target, null)), vmId: r.vm_id ?? null, + browserProfileId: r.browser_profile_id ?? null, instructions: r.instructions, pinned: bool(r.pinned), archived: bool(r.archived), @@ -168,6 +170,14 @@ export function titleFromContent(content: string): string { return t.length > TITLE_MAX ? `${t.slice(0, TITLE_MAX - 1).trimEnd()}…` : t; } +/** Normalize a chat's browser profile from an API input: undefined = unchanged, null/"" = the agent's, else an existing profile. */ +function normalizeBrowserProfileId(value: string | null | undefined): string | null | undefined { + if (value === undefined) return undefined; + const id = value?.trim() || null; + if (id && !get<{ id: string }>("SELECT id FROM browser_profiles WHERE id = ?", id)) throw badRequest("That browser profile doesn't exist anymore"); + return id; +} + export interface ModelChoice { /** `claude --model` value; null/empty = the agent's model. */ model?: string | null; @@ -175,11 +185,20 @@ export interface ModelChoice { } export function createConversation( - input: { agentId: string; title?: string; origin?: ConversationOrigin; workingDirectory?: string | null; vmId?: string | null; instructions?: string } & ModelChoice, + input: { + agentId: string; + title?: string; + origin?: ConversationOrigin; + workingDirectory?: string | null; + vmId?: string | null; + browserProfileId?: string | null; + instructions?: string; + } & ModelChoice, ): Conversation { getAgent(input.agentId); // 404 if the agent doesn't exist const workingDirectory = normalizeWorkingDirectory(input.workingDirectory); const vmId = normalizeVmId(input.vmId) ?? null; + const browserProfileId = normalizeBrowserProfileId(input.browserProfileId) ?? null; const ts = now(); const id = newId("cnv"); const title = input.title?.trim() ? input.title.trim().slice(0, 200) : DEFAULT_CONVERSATION_TITLE; @@ -193,6 +212,7 @@ export function createConversation( effort: input.effort ?? null, working_directory: workingDirectory, vm_id: vmId, + browser_profile_id: browserProfileId, instructions: input.instructions?.trim() ?? "", pinned: 0, archived: 0, @@ -249,6 +269,7 @@ export function updateConversation(id: string, patch: ConversationPatch): Conver working_directory: patch.workingDirectory === undefined ? undefined : normalizeWorkingDirectory(patch.workingDirectory), computer_target: patch.computerTarget === undefined ? undefined : patch.computerTarget ? JSON.stringify(parseComputerTarget(patch.computerTarget)) : null, vm_id: normalizeVmId(patch.vmId), + browser_profile_id: normalizeBrowserProfileId(patch.browserProfileId), instructions: patch.instructions?.trim(), updated_at: now(), }); @@ -484,6 +505,8 @@ export async function startChat( computerTarget?: ComputerTarget | null; /** macOS VM for this chat (null/omitted = the agent's). */ vmId?: string | null; + /** Browser profile for this chat (null/omitted = the agent's). */ + browserProfileId?: string | null; instructions?: string; } & ModelChoice, ): Promise { @@ -500,6 +523,7 @@ export async function startChat( origin: input.origin ?? "chat", workingDirectory: input.workingDirectory, vmId: input.vmId, + browserProfileId: input.browserProfileId, instructions: input.instructions, model: input.model, effort: input.effort, diff --git a/packages/core/test/fixtures/runner-harness.ts b/packages/core/test/fixtures/runner-harness.ts index 3b80b9fa..17cad969 100644 --- a/packages/core/test/fixtures/runner-harness.ts +++ b/packages/core/test/fixtures/runner-harness.ts @@ -56,8 +56,11 @@ const fakeProfile: BrowserProfile = { mock.module("../../src/browser/manager", () => ({ ...realBrowser, - resolveProfileForAgent: (agent: Agent) => (testAgentIds.has(agent.id) ? fakeProfile : realBrowser.resolveProfileForAgent(agent)), - browserMcpServer: async (agent: Agent) => (testAgentIds.has(agent.id) ? null : realBrowser.browserMcpServer(agent)), + resolveProfileForAgent: (agent: Agent, conversationId?: string | null) => + testAgentIds.has(agent.id) && !(conversationId && realBrowser.chatProfileId(conversationId)) + ? fakeProfile + : realBrowser.resolveProfileForAgent(agent, conversationId), + browserMcpServer: async (agent: Agent, profileId?: string | null) => (testAgentIds.has(agent.id) ? null : realBrowser.browserMcpServer(agent, profileId)), fillIntoPage: async (profileId: string, opts: Parameters[1]) => { if (profileId !== FAKE_PROFILE_ID) return realBrowser.fillIntoPage(profileId, opts); fills.push({ diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 78d6394e..8d36acac 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -7,9 +7,10 @@ import { createTotp } from "../src/vault/totp"; import { listAudit } from "../src/services/audit"; import { listMissingLogins } from "../src/services/missingLogins"; import { listNotifications } from "../src/services/notifications"; -import { getConversation } from "../src/services/conversations"; +import { createConversation, getConversation } from "../src/services/conversations"; import { issueRunToken, resolveRunToken, revokeRunToken } from "../src/mcp/tokens"; -import { getRun, listRuns } from "../src/runner/runner"; +import { getRun, listRuns, waitForRun } from "../src/runner/runner"; +import { createProfile } from "../src/browser/manager"; import { getAgent, listAgents, updateAgent } from "../src/agents/service"; import { createRoutine, listRoutines } from "../src/services/routines"; import { updateSettings } from "../src/services/settings"; @@ -391,6 +392,17 @@ describe("agents + delegation", () => { expect(other.isError).toBeUndefined(); }); + test("agent_delegate keeps the browser profile picked for the caller's chat", async () => { + const profile = createProfile({ name: "Delegation profile", workspaceId: createWorkspace({ name: "Delegation" }).id }); + const chat = createConversation({ agentId: delegator.id, browserProfileId: profile.id }); + const token = issueRunToken({ runId: "run_mcp_profile", agentId: delegator.id, conversationId: chat.id, workspaceId: null, depth: 0 }); + const r = await call(token, "agent_delegate", { agentId: worker.id, task: "Profile check", wait: false }); + const runId = /run (run_[A-Za-z0-9]+)/.exec(r.content[0]!.text)![1]!; + expect(getConversation(getRun(runId).conversationId).browserProfileId).toBe(profile.id); + await waitForRun(runId, 20_000); + revokeRunToken(token); + }); + test("delegation limits: depth, self, non-peers", async () => { const deep = tokenFor(delegator, 3); const tooDeep = await call(deep, "agent_delegate", { agentId: worker.id, task: "x" }); diff --git a/packages/core/test/runner.test.ts b/packages/core/test/runner.test.ts index 1c05378e..81d2f349 100644 --- a/packages/core/test/runner.test.ts +++ b/packages/core/test/runner.test.ts @@ -31,6 +31,7 @@ import { waitForRun, } from "../src/runner/runner"; import { FAKE_CLAUDE } from "./fixtures/runner-harness"; +import { createProfile, deleteProfile, resolveProfileForAgent } from "../src/browser/manager"; import { now } from "../src/util"; let env: TestEnv; @@ -229,6 +230,27 @@ describe("runner end-to-end with fake claude", () => { expect((await waitForRun(b.run.id, 20_000)).status).toBe("succeeded"); }); + test("a chat can work in its own browser profile", async () => { + const browserA = await makeAgent({ name: "Browser C", browser: { enabled: true } }); + const browserB = await makeAgent({ name: "Browser D", browser: { enabled: true } }); + const own = createProfile({ name: "Chat profile", workspaceId: createWorkspace({ name: "Profiles" }).id }); + const a = await startChat({ agentId: browserA.id, content: "SLEEP a" }); + await until(() => getRun(a.run.id).status === "running", 10_000, "run a"); + + // A different profile doesn't wait for the run holding the agents' default one. + const b = await startChat({ agentId: browserB.id, content: "hello b", browserProfileId: own.id }); + expect(b.conversation.browserProfileId).toBe(own.id); + expect((await waitForRun(b.run.id, 20_000)).status).toBe("succeeded"); + expect(resolveProfileForAgent(browserB, b.conversation.id).id).toBe(own.id); + await cancelRun(a.run.id); + + expect(() => updateConversation(b.conversation.id, { browserProfileId: "bpr_missing" })).toThrow(/doesn't exist/); + expect(updateConversation(b.conversation.id, { browserProfileId: null }).browserProfileId).toBeNull(); + updateConversation(b.conversation.id, { browserProfileId: own.id }); + await deleteProfile(own.id); + expect(getConversation(b.conversation.id).browserProfileId).toBeNull(); + }); + test("cancelling a queued run never starts it", async () => { const conv = createConversation({ agentId: agent.id }); const first = await sendMessage(conv.id, { content: "SLEEP" }); diff --git a/packages/core/test/scheduler.test.ts b/packages/core/test/scheduler.test.ts index b606f789..cf9b2af2 100644 --- a/packages/core/test/scheduler.test.ts +++ b/packages/core/test/scheduler.test.ts @@ -144,6 +144,7 @@ describe("triggering", () => { workingDirectory: null, computerTarget: null, vmId: null, + browserProfileId: null, instructions: "", pinned: false, archived: false, diff --git a/packages/shared/src/api.ts b/packages/shared/src/api.ts index e30140c9..b56f55be 100644 --- a/packages/shared/src/api.ts +++ b/packages/shared/src/api.ts @@ -124,6 +124,8 @@ export interface StartChatInput { computerTarget?: ComputerTarget | null; /** Work in this macOS VM instead of the agent's. */ vmId?: ID | null; + /** Browse in this profile instead of the agent's. */ + browserProfileId?: ID | null; instructions?: string; } @@ -140,6 +142,8 @@ export interface ConversationPatch { computerTarget?: ComputerTarget | null; /** macOS VM for this chat; null = back to the agent's (or workspace's). */ vmId?: ID | null; + /** Browser profile for this chat; null = back to the agent's (or the default). */ + browserProfileId?: ID | null; /** "" = none. */ instructions?: string; } diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 7d910f37..5d419375 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -257,6 +257,8 @@ export interface Conversation { computerTarget: ComputerTarget | null; /** macOS VM this chat works in, overriding the agent's and the workspace's. null = theirs. */ vmId: ID | null; + /** Browser profile this chat works in, overriding the agent's and the workspace / global default. null = theirs. */ + browserProfileId: ID | null; /** Standing instructions for this chat only; they take precedence over the agent's, workspace and global ones. */ instructions: string; pinned: boolean; From 55bee39d14c15dc3f1fa9dc3b8ffe36dd4352e7d Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Tue, 29 Sep 2026 23:29:17 +0200 Subject: [PATCH 2/2] Keep a chat's browser profile consistent across queued runs, live view and delegation --- .../src/components/browser/profile-chip.tsx | 4 +-- .../src/pages/chat/chat-conversation.tsx | 12 ++++--- packages/core/src/browser/manager.ts | 3 +- packages/core/src/mcp/tools.ts | 10 +++--- packages/core/src/runner/runner.ts | 35 ++++++++++++++++--- packages/core/src/services/conversations.ts | 3 +- packages/core/test/mcp.test.ts | 30 ++++++++++------ packages/core/test/runner.test.ts | 16 +++++++++ 8 files changed, 84 insertions(+), 29 deletions(-) diff --git a/apps/desktop/src/components/browser/profile-chip.tsx b/apps/desktop/src/components/browser/profile-chip.tsx index 1b3a1983..37b3ab53 100644 --- a/apps/desktop/src/components/browser/profile-chip.tsx +++ b/apps/desktop/src/components/browser/profile-chip.tsx @@ -65,7 +65,7 @@ export function BrowserProfileChip({ const pick = (profileId: string | null) => { setOpen(false); - if (profileId !== (own?.id ?? null)) void onChange(profileId); + if (profileId !== value) void onChange(profileId); }; const tooltip = own ? `This chat browses in ${own.name}` : fallback ? `Browses in ${fallback.name} — ${fallbackFrom.toLowerCase()}` : "Browser profile"; const icon = busy ? : ; @@ -124,7 +124,7 @@ export function BrowserProfileChip({ {fallback && fallback.name.toLowerCase() !== "default" ? `Default — ${fallback.name}` : "Default"} {fallbackFrom} - {!own && } + {!value && } diff --git a/apps/desktop/src/pages/chat/chat-conversation.tsx b/apps/desktop/src/pages/chat/chat-conversation.tsx index 3f597f5e..76d8f73f 100644 --- a/apps/desktop/src/pages/chat/chat-conversation.tsx +++ b/apps/desktop/src/pages/chat/chat-conversation.tsx @@ -56,7 +56,11 @@ function ConversationView({ conversationId }: { conversationId: string }) { const composerRef = useRef(null); const [queued, setQueued] = useState>({}); const mountedAt = useRef(Date.now()); - const browser = useChatBrowser(agent, conv?.browserProfileId ?? null); + // A profile picked mid-run applies from the next message: keep showing the browser the running agent drives. + const [runProfile, setRunProfile] = useState<{ runId: string; profileId: string | null } | null>(null); + if ((live?.runId ?? null) !== (runProfile?.runId ?? null)) setRunProfile(live ? { runId: live.runId, profileId: conv?.browserProfileId ?? null } : null); + const chatProfileId = runProfile ? runProfile.profileId : (conv?.browserProfileId ?? null); + const browser = useChatBrowser(agent, chatProfileId); const browserPanel = useUi((s) => s.browserPanel); const setBrowserPanel = useUi((s) => s.setBrowserPanel); const wide = useMediaQuery("(min-width: 1024px)"); @@ -218,8 +222,8 @@ function ConversationView({ conversationId }: { conversationId: string }) { const profile = updated.browserProfileId ? profiles.find((p) => p.id === updated.browserProfileId) : agent && agentBrowserProfile(agent, profiles); - const when = busyRef.current ? "From your next message" : "The next messages"; - if (updated.browserProfileId) toast.success(`Browsing in ${profile?.name ?? "the new profile"}`, { description: `${when} use its cookies and logins.` }); + const when = busyRef.current ? "Your next message uses" : "The next messages use"; + if (updated.browserProfileId) toast.success(`Browsing in ${profile?.name ?? "the new profile"}`, { description: `${when} its cookies and logins.` }); else toast.success("Back to the default profile", { description: `${agent?.name ?? "The agent"} browses in ${profile?.name ?? "its own profile"} again.` }); }, onError: (err) => toast.error("Couldn't change the browser profile", { description: errorMessage(err) }), @@ -449,7 +453,7 @@ function ConversationView({ conversationId }: { conversationId: string }) { key={browser.id} profile={browser} agent={agent} - forChat={browser.id === conv.browserProfileId} + forChat={browser.id === chatProfileId} activity={activeRunId ? liveActivityLabel(live) : null} onHide={() => setBrowserPanel(false)} onFocus={setBrowserFocus} diff --git a/packages/core/src/browser/manager.ts b/packages/core/src/browser/manager.ts index b08dba51..596ed054 100644 --- a/packages/core/src/browser/manager.ts +++ b/packages/core/src/browser/manager.ts @@ -643,8 +643,7 @@ export async function browserMcpServer(agent: Agent, profileId?: string | null): throw new HttpError(424, "uv (uvx) is not installed, so browser-use can't start. Install it in Settings → Dependencies.", "uv_missing"); } - const chosen = profileId ? row(profileId) : null; - const profile = chosen ? toProfile(chosen) : resolveProfileForAgent(agent); + const profile = profileId ? getProfile(profileId) : resolveProfileForAgent(agent); const headless = agent.browser.headless ?? settings.browser.headless; const { cdpUrl } = await launchBrowser(profile.id, { headless }); diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index b0f0e25c..06cd8f21 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -24,14 +24,14 @@ import { createAgent, deleteAgent, getAgent, listAgents, peersFor, updateAgent } import { addCredentialDomain, credentialsForAgent, findCredentialsForAgent, getCredential, listCredentials, markCredentialUsed, revealForAgent } from "../vault/credentials"; import { codeForAgent, listTotp, totpForAgent } from "../vault/totp"; import { nameGuessMatchesHost } from "../vault/match"; -import { chatProfileId, currentPage, fillIntoPage, resolveProfileForAgent } from "../browser/manager"; +import { currentPage, fillIntoPage, resolveProfileForAgent } from "../browser/manager"; import { getMcpServer, mcpServerInAgentScope } from "../integrations/mcpServers"; import { loginFillScope } from "../browser/fill"; import { createConversation, sendMessage } from "../services/conversations"; import { assignVm, createVm, getVm, listVms, startVm, stopVm, suspendVm, vmInUse, vmOfRun, vmStatus } from "../vm/service"; import { resolveVmId } from "../vm/assignments"; import { getSettings } from "../services/settings"; -import { getRun, listRuns, markMissingLoginReported, runBrowserProfile, waitForRun } from "../runner/runner"; +import { getRun, listRuns, markMissingLoginReported, runBrowserProfile, runChatBrowserProfile, waitForRun } from "../runner/runner"; const log = logger("mcp"); @@ -614,8 +614,10 @@ const TOOLS: ToolDef[] = [ if (refusal) return fail(refusal); // From a VM, work for an agent without its own VM stays in the caller's VM. const vmId = lockedVm(ctx) && !resolveVmId(null, target) ? lockedVm(ctx) : null; - // Work for an agent without its own profile stays in the browser profile picked for the caller's chat. - const browserProfileId = target.browser.profileId ? null : chatProfileId(ctx.conversationId); + // Work for an agent without its own profile stays in the browser profile picked for the caller's chat, within the + // target's reach (global or its workspace's). + const inherited = target.browser.profileId ? null : runChatBrowserProfile(ctx.runId); + const browserProfileId = inherited && (!inherited.workspaceId || inherited.workspaceId === target.workspaceId) ? inherited.id : null; const conversation = createConversation({ agentId: target.id, title: `Task from ${agent.name}`, origin: "delegation", vmId, browserProfileId }); const { run } = await sendMessage(conversation.id, { content: `[Delegated by ${agent.name}]\n\n${task}`, diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index f76d0556..651b3d95 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -13,7 +13,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import type { FileSink, Subprocess } from "bun"; -import type { Agent, ComputerTarget, Effort, Message, Run, RunStatus, RunTrigger, RunUsage } from "@godmode/shared"; +import type { Agent, BrowserProfile, ComputerTarget, Effort, Message, Run, RunStatus, RunTrigger, RunUsage } from "@godmode/shared"; import { BROWSER_MCP_NAME, DEFAULT_MODEL, EFFORT_OPTIONS, isModelId, parseSlashCommand } from "@godmode/shared"; import { all, get, insert, run as sql } from "../db"; import { bus } from "../events/bus"; @@ -25,7 +25,7 @@ import { isDirectory, workingDirectoryProblem } from "../services/folders"; import { prepareSources, type RunSource } from "../services/workspaceSources"; import { getSettings } from "../services/settings"; import { reportMissingLogin } from "../services/missingLogins"; -import { BROWSER_LLM_TOOLS, browserLlmKey, currentPage, resolveProfileForAgent } from "../browser/manager"; +import { BROWSER_LLM_TOOLS, browserLlmKey, chatProfileId, currentPage, getProfile, resolveProfileForAgent } from "../browser/manager"; import { addMessage, appendTranscript, @@ -224,6 +224,8 @@ interface Job { done: Promise | null; /** Browser profile this run drives (undefined = not resolved yet, null = no browser). */ browserProfileId?: string | null; + /** The profile was picked for the run's chat rather than inherited from its agent. */ + browserFromChat?: boolean; /** Screen, window or tab this run may control (undefined = not resolved yet, null = none). */ computerTarget?: ComputerTarget | null; /** The target is the agent's own unattended access, not something shared in the chat. */ @@ -488,6 +490,8 @@ function pump() { blocked.add(job.conversationId); continue; } + // Resolve the profile right before deciding, so a chat switched to another profile stops waiting for the old one. + job.browserProfileId = undefined; const holder = browserHolder(job); if (holder) { emitActivity(job, `Waiting for the browser (in use by another run)`); @@ -526,11 +530,15 @@ function memoryHolder(job: Job): Job | null { function browserProfileOf(job: Job): string | null { if (job.browserProfileId !== undefined) return job.browserProfileId; - if (job.trigger === "dream") return (job.browserProfileId = null); + job.browserProfileId = null; + job.browserFromChat = false; + if (job.trigger === "dream") return null; try { const agent = getAgent(job.agentId); - job.browserProfileId = - getSettings().browser.enabled && agent.browser.enabled ? resolveProfileForAgent(agent, job.conversationId).id : null; + if (getSettings().browser.enabled && agent.browser.enabled) { + job.browserProfileId = resolveProfileForAgent(agent, job.conversationId).id; + job.browserFromChat = job.browserProfileId === chatProfileId(job.conversationId); + } } catch { job.browserProfileId = null; } @@ -543,6 +551,23 @@ export function runBrowserProfile(runId: string): string | null { return job ? browserProfileOf(job) : null; } +/** The profile a live run drives when it was picked for its chat (null when inherited, gone or the run is over). */ +export function runChatBrowserProfile(runId: string): BrowserProfile | null { + const job = jobs.get(runId); + const id = job ? browserProfileOf(job) : null; + if (!id || !job?.browserFromChat) return null; + try { + return getProfile(id); + } catch { + return null; + } +} + +/** Something a waiting run depends on changed (e.g. its chat's browser profile): try to start queued runs again. */ +export function retryQueued(): void { + pump(); +} + function isAncestor(candidate: Job, job: Job): boolean { let parentId = job.parentRunId; for (let hops = 0; parentId && hops < 16; hops++) { diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index a7a8d76f..b37e1345 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -26,7 +26,7 @@ import { badRequest, conflict, newId, notFound, now, parseJson } from "../util"; import { assignmentsChanged, normalizeVmId } from "../vm/assignments"; import { redact } from "../vault/vault"; import { getAgent, getDefaultAgentId } from "../agents/service"; -import { activeRunForConversation, cancelRun, listActiveRuns, startRun, waitForRun } from "../runner/runner"; +import { activeRunForConversation, cancelRun, listActiveRuns, retryQueued, startRun, waitForRun } from "../runner/runner"; import { displayToolName } from "../runner/stream"; import { normalizeWorkingDirectory } from "./folders"; import { parseComputerTarget } from "../computer/targets"; @@ -276,6 +276,7 @@ export function updateConversation(id: string, patch: ConversationPatch): Conver const conversation = getConversationSummary(id); bus.emit({ type: "conversation.updated", conversation }); if (patch.vmId !== undefined) assignmentsChanged(); + if (patch.browserProfileId !== undefined) retryQueued(); return conversation; } diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 8d36acac..34394990 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -7,9 +7,9 @@ import { createTotp } from "../src/vault/totp"; import { listAudit } from "../src/services/audit"; import { listMissingLogins } from "../src/services/missingLogins"; import { listNotifications } from "../src/services/notifications"; -import { createConversation, getConversation } from "../src/services/conversations"; +import { getConversation, startChat } from "../src/services/conversations"; import { issueRunToken, resolveRunToken, revokeRunToken } from "../src/mcp/tokens"; -import { getRun, listRuns, waitForRun } from "../src/runner/runner"; +import { cancelRun, getRun, listRuns, waitForRun } from "../src/runner/runner"; import { createProfile } from "../src/browser/manager"; import { getAgent, listAgents, updateAgent } from "../src/agents/service"; import { createRoutine, listRoutines } from "../src/services/routines"; @@ -392,15 +392,23 @@ describe("agents + delegation", () => { expect(other.isError).toBeUndefined(); }); - test("agent_delegate keeps the browser profile picked for the caller's chat", async () => { - const profile = createProfile({ name: "Delegation profile", workspaceId: createWorkspace({ name: "Delegation" }).id }); - const chat = createConversation({ agentId: delegator.id, browserProfileId: profile.id }); - const token = issueRunToken({ runId: "run_mcp_profile", agentId: delegator.id, conversationId: chat.id, workspaceId: null, depth: 0 }); - const r = await call(token, "agent_delegate", { agentId: worker.id, task: "Profile check", wait: false }); - const runId = /run (run_[A-Za-z0-9]+)/.exec(r.content[0]!.text)![1]!; - expect(getConversation(getRun(runId).conversationId).browserProfileId).toBe(profile.id); - await waitForRun(runId, 20_000); - revokeRunToken(token); + test("agent_delegate keeps the browser profile picked for the caller's chat, within the target's reach", async () => { + const browsing = await makeAgent({ name: "Browsing delegator", browser: { enabled: true }, permissions: { allowDelegation: true } }); + const global = createProfile({ name: "Delegation profile", workspaceId: null }); + const elsewhere = createProfile({ name: "Other workspace profile", workspaceId: createWorkspace({ name: "Delegation" }).id }); + const delegate = async (profileId: string) => { + const parent = await startChat({ agentId: browsing.id, content: "SLEEP", browserProfileId: profileId }); + const token = issueRunToken({ runId: parent.run.id, agentId: browsing.id, conversationId: parent.conversation.id, workspaceId: null, depth: 0 }); + const r = await call(token, "agent_delegate", { agentId: worker.id, task: "Profile check", wait: false }); + const runId = /run (run_[A-Za-z0-9]+)/.exec(r.content[0]!.text)![1]!; + const inherited = getConversation(getRun(runId).conversationId).browserProfileId; + revokeRunToken(token); + await cancelRun(parent.run.id); + await waitForRun(runId, 20_000); + return inherited; + }; + expect(await delegate(global.id)).toBe(global.id); + expect(await delegate(elsewhere.id)).toBeNull(); }); test("delegation limits: depth, self, non-peers", async () => { diff --git a/packages/core/test/runner.test.ts b/packages/core/test/runner.test.ts index 236bef94..80627a54 100644 --- a/packages/core/test/runner.test.ts +++ b/packages/core/test/runner.test.ts @@ -253,6 +253,22 @@ describe("runner end-to-end with fake claude", () => { expect(getConversation(b.conversation.id).browserProfileId).toBeNull(); }); + test("switching a chat that waits for the browser to another profile starts it", async () => { + const holder = await makeAgent({ name: "Browser E", browser: { enabled: true } }); + const waiter = await makeAgent({ name: "Browser F", browser: { enabled: true } }); + const other = createProfile({ name: "Free profile", workspaceId: createWorkspace({ name: "Free" }).id }); + const a = await startChat({ agentId: holder.id, content: "SLEEP a" }); + await until(() => getRun(a.run.id).status === "running", 10_000, "run a"); + const b = await startChat({ agentId: waiter.id, content: "hello b" }); + await new Promise((r) => setTimeout(r, 300)); + expect(getRun(b.run.id).status).toBe("queued"); + + updateConversation(b.conversation.id, { browserProfileId: other.id }); + expect((await waitForRun(b.run.id, 20_000)).status).toBe("succeeded"); + expect(getRun(a.run.id).status).toBe("running"); + await cancelRun(a.run.id); + }); + test("cancelling a queued run never starts it", async () => { const conv = createConversation({ agentId: agent.id }); const first = await sendMessage(conv.id, { content: "SLEEP" });