mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
feat(server): add background agents
An agent can be created background in any workspace. It stays a durable, addressable agent, but default discovery, workspace status and attention leave it out. Effective background is agent.background || workspace.background. - Protocol: `create_agent_request.background` now marks the agent itself and stays the workspace intent for a workspace the request creates. Snapshots carry `background`. The daemon advertises `features.backgroundAgents`. Clients advertise the `background_agents` capability. - Daemon: the flag persists in the agent record, so restart and resume keep it. AgentDirectory owns visibility. Inclusive reads and subscriptions from sources without `background_agents` see only agents in background workspaces, because older apps would otherwise open a tab for each one. - Inheritance: omitted on agent-scoped creation, children and new workspaces take the caller agent's background. - MCP: create_agent accepts `background`. - SDK: placing a background agent in an existing workspace, or under a caller, fails fast with "Update the host to use background agents." on older hosts. - CLI: `ls -a` and `ls --background` add a BACKGROUND column.
This commit is contained in:
@@ -135,6 +135,7 @@ export async function ensureAgentLoaded(
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
background: record.background,
|
||||
env: record.launchEnv,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
|
||||
@@ -324,6 +324,8 @@ export interface CreateAgentOptions {
|
||||
// undefined is an explicit decision: the agent never appears in the sidebar.
|
||||
workspaceId: string | undefined;
|
||||
owner?: AgentOwner;
|
||||
/** Background agents stay out of discovery, tabs, tracks and attention. Fixed at creation. */
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentManagerOptions {
|
||||
@@ -387,6 +389,11 @@ export type AttentionState =
|
||||
attentionTimestamp: Date;
|
||||
};
|
||||
|
||||
/** Private helpers and background agents report to their creator, never to the user. */
|
||||
function raisesAttention(agent: Pick<ManagedAgent, "internal" | "background">): boolean {
|
||||
return !agent.internal && !agent.background;
|
||||
}
|
||||
|
||||
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
|
||||
if (input == null || !input.requiresAttention) {
|
||||
return { requiresAttention: false };
|
||||
@@ -452,6 +459,11 @@ interface ManagedAgentBase {
|
||||
* Internal agents are hidden from listings and don't trigger notifications.
|
||||
*/
|
||||
internal?: boolean;
|
||||
/**
|
||||
* Background agents are durable but hidden from default discovery and never raise attention.
|
||||
* Fixed at creation.
|
||||
*/
|
||||
background?: boolean;
|
||||
/**
|
||||
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
||||
*/
|
||||
@@ -1402,6 +1414,7 @@ export class AgentManager {
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
background: options.background,
|
||||
launchEnv: options.env ? { ...options.env } : undefined,
|
||||
historyPrimed: true,
|
||||
});
|
||||
@@ -1434,6 +1447,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1466,6 +1480,7 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1727,6 +1742,7 @@ export class AgentManager {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
background: existing.background,
|
||||
launchEnv: existing.launchEnv,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
@@ -2180,6 +2196,7 @@ export class AgentManager {
|
||||
lastError: record.lastError ?? undefined,
|
||||
attention,
|
||||
internal: record.internal,
|
||||
background: record.background,
|
||||
labels: record.labels,
|
||||
},
|
||||
});
|
||||
@@ -3806,6 +3823,7 @@ export class AgentManager {
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
launchEnv?: Record<string, string>;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
@@ -3835,6 +3853,7 @@ export class AgentManager {
|
||||
config,
|
||||
now,
|
||||
durableTimelineHasRows,
|
||||
background: options?.background === true,
|
||||
options,
|
||||
});
|
||||
|
||||
@@ -3979,6 +3998,7 @@ export class AgentManager {
|
||||
config: AgentSessionConfig;
|
||||
now: Date;
|
||||
durableTimelineHasRows: boolean;
|
||||
background: boolean;
|
||||
options:
|
||||
| {
|
||||
createdAt?: Date;
|
||||
@@ -4033,6 +4053,7 @@ export class AgentManager {
|
||||
lastError: options?.lastError,
|
||||
attention: resolveInitialAttention(options?.attention),
|
||||
internal: config.internal ?? false,
|
||||
background: params.background,
|
||||
labels: options?.labels ?? {},
|
||||
} as ActiveManagedAgent;
|
||||
}
|
||||
@@ -4966,7 +4987,7 @@ export class AgentManager {
|
||||
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
||||
agent.pendingPermissions.set(event.request.id, event.request);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!hadPendingPermissions && !agent.internal) {
|
||||
if (!hadPendingPermissions && raisesAttention(agent)) {
|
||||
this.broadcastAgentAttention(agent, "permission");
|
||||
}
|
||||
this.emitState(agent);
|
||||
@@ -5221,8 +5242,7 @@ export class AgentManager {
|
||||
// Track the new status
|
||||
this.previousStatuses.set(agent.id, currentStatus);
|
||||
|
||||
// Skip attention tracking for internal agents
|
||||
if (agent.internal) {
|
||||
if (!raisesAttention(agent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ export function toStoredAgentRecord(
|
||||
? agent.attention.attentionTimestamp.toISOString()
|
||||
: null,
|
||||
internal: options?.internal,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
owner: agent.owner,
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
@@ -149,7 +150,7 @@ export function toAgentPayload(
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
...visibilityFlags(agent),
|
||||
};
|
||||
|
||||
const usage = sanitizeUsage(agent.lastUsage);
|
||||
@@ -206,6 +207,17 @@ function buildStoredPersistenceHandle(
|
||||
return toAgentPersistenceHandle(validProviders, record.persistence);
|
||||
}
|
||||
|
||||
/** The wire carries `internal` and `background` only when set. */
|
||||
function visibilityFlags(agent: {
|
||||
internal?: boolean;
|
||||
background?: boolean;
|
||||
}): Pick<AgentSnapshotPayload, "internal" | "background"> {
|
||||
return {
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
...(agent.background ? { background: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStoredAgentPayload(
|
||||
record: StoredAgentRecord,
|
||||
validProviders: Iterable<AgentProvider>,
|
||||
@@ -258,7 +270,7 @@ export function buildStoredAgentPayload(
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
...(record.internal ? { internal: true } : {}),
|
||||
...visibilityFlags(record),
|
||||
labels: normalizeLabels(record.labels),
|
||||
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
||||
...(providerAvailable ? {} : { providerUnavailable: true }),
|
||||
|
||||
@@ -75,6 +75,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
internal: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
archivedAt: z.string().nullable().optional(),
|
||||
owner: AgentOwnerSchema.optional(),
|
||||
});
|
||||
@@ -261,6 +262,8 @@ export class AgentStorage {
|
||||
if (existing && existing.archivedAt !== undefined) {
|
||||
record.archivedAt = existing.archivedAt;
|
||||
}
|
||||
// Background is fixed at creation; a runtime opened without it cannot clear it.
|
||||
if (existing?.background) record.background = true;
|
||||
record.resumeOnStartup =
|
||||
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
|
||||
return record;
|
||||
|
||||
@@ -21,7 +21,17 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js";
|
||||
import { buildAgentPrompt } from "../prompt-attachments.js";
|
||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
resolveCreateAgentIntent,
|
||||
} from "./intent.js";
|
||||
|
||||
/** How a workspace this creation makes inherits its creator's visibility. */
|
||||
interface McpWorkspaceIntent {
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentCommandDependencies {
|
||||
finishSubscriptions?: FinishSubscriptions;
|
||||
@@ -40,7 +50,7 @@ export interface CreateAgentCommandDependencies {
|
||||
export type EnsureWorkspaceForCreate = (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
) => Promise<string>;
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
@@ -49,6 +59,7 @@ export interface CreateAgentFromSessionInput {
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
background: boolean;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
clientMessageId?: string;
|
||||
@@ -77,6 +88,8 @@ export interface CreateAgentFromMcpInput {
|
||||
promptFailure?: CreateAgentPromptFailureMode;
|
||||
notifyOnFinish: boolean;
|
||||
internal?: boolean;
|
||||
/** Omitted with a caller, the caller agent's own background applies. */
|
||||
background?: boolean;
|
||||
detached?: boolean;
|
||||
owner?: AgentOwner;
|
||||
env?: Record<string, string>;
|
||||
@@ -255,6 +268,7 @@ async function resolveSessionCreateAgent(
|
||||
// provider session either. Same as the daemon's own ephemeral helpers.
|
||||
...(sessionConfig.internal ? { persistSession: false } : {}),
|
||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
||||
background: input.background,
|
||||
},
|
||||
prompt: hasPromptContent ? prompt : undefined,
|
||||
source: input.source,
|
||||
@@ -277,12 +291,16 @@ async function resolveMcpCreateAgent(
|
||||
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
|
||||
: null;
|
||||
const cwd = resolveMcpInitialCwd(input, parentAgent);
|
||||
const workspaceIntent = {
|
||||
background: inheritWorkspaceBackground(input.background, parentAgent),
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
};
|
||||
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
workspaceIntent,
|
||||
});
|
||||
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
@@ -300,7 +318,7 @@ async function resolveMcpCreateAgent(
|
||||
dependencies,
|
||||
resolvedCwd,
|
||||
input.initialPrompt ?? "",
|
||||
parentAgent?.workspaceId,
|
||||
workspaceIntent,
|
||||
),
|
||||
),
|
||||
cwd: resolvedCwd,
|
||||
@@ -329,6 +347,7 @@ async function resolveMcpCreateAgent(
|
||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||
workspaceId: intent.workspaceId,
|
||||
owner: input.owner,
|
||||
background: inheritAgentBackground(input.background, parentAgent),
|
||||
env: input.env,
|
||||
},
|
||||
prompt: trimmedPrompt ? trimmedPrompt : undefined,
|
||||
@@ -410,16 +429,12 @@ async function ensureWorkspaceForMcpCreate(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
cwd: string,
|
||||
initialPrompt: string,
|
||||
callerWorkspaceId?: string,
|
||||
workspaceIntent: McpWorkspaceIntent,
|
||||
): Promise<string | undefined> {
|
||||
if (!dependencies.ensureWorkspaceForCreate) {
|
||||
return undefined;
|
||||
}
|
||||
return dependencies.ensureWorkspaceForCreate(
|
||||
cwd,
|
||||
{ prompt: initialPrompt },
|
||||
{ callerWorkspaceId },
|
||||
);
|
||||
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }, workspaceIntent);
|
||||
}
|
||||
|
||||
async function sendInitialPrompt(
|
||||
@@ -512,7 +527,7 @@ async function resolveMcpCwd(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
cwd: string;
|
||||
initialPrompt: string;
|
||||
callerWorkspaceId?: string;
|
||||
workspaceIntent: McpWorkspaceIntent;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}): Promise<{
|
||||
resolvedCwd: string;
|
||||
@@ -542,7 +557,7 @@ async function resolveMcpCwd(params: {
|
||||
const createdWorktree = await createMcpWorktree({
|
||||
input: {
|
||||
cwd: params.cwd,
|
||||
callerWorkspaceId: params.callerWorkspaceId,
|
||||
...params.workspaceIntent,
|
||||
worktreeSlug: worktree.worktreeName,
|
||||
branchName: worktree.branchName,
|
||||
refName: worktree.refName,
|
||||
|
||||
@@ -6,6 +6,30 @@ export interface CreateAgentCaller {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/** The agent on whose behalf something is created, as far as background inheritance goes. */
|
||||
export interface BackgroundCaller {
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/** Omitted, an agent inherits its caller agent's own background; without a caller it is visible. */
|
||||
export function inheritAgentBackground(
|
||||
requested: boolean | undefined,
|
||||
caller: BackgroundCaller | null | undefined,
|
||||
): boolean {
|
||||
return requested ?? caller?.background === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Omitted, a new workspace inherits its caller's effective background. A background caller
|
||||
* agent decides it here; otherwise `undefined` lets provisioning apply the caller workspace's value.
|
||||
*/
|
||||
export function inheritWorkspaceBackground(
|
||||
requested: boolean | undefined,
|
||||
caller: BackgroundCaller | null | undefined,
|
||||
): boolean | undefined {
|
||||
return requested ?? (caller?.background === true ? true : undefined);
|
||||
}
|
||||
|
||||
export interface CreateAgentPlacement {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { buildStoredAgentPayload } from "./agent-projections.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { AgentDirectory, type BackgroundVisibility } from "./directory.js";
|
||||
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
|
||||
const NOW = "2026-03-01T12:00:00.000Z";
|
||||
|
||||
let home: string;
|
||||
let storage: AgentStorage;
|
||||
let directory: AgentDirectory;
|
||||
|
||||
function workspace(workspaceId: string, background: boolean): PersistedWorkspaceRecord {
|
||||
return {
|
||||
workspaceId,
|
||||
projectId: "project",
|
||||
cwd: home,
|
||||
kind: "local_checkout",
|
||||
displayName: workspaceId,
|
||||
background,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), "agent-directory-"));
|
||||
storage = new AgentStorage(home, createTestLogger());
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
};
|
||||
await storage.upsert({ ...base, id: "visible", workspaceId: "open" });
|
||||
await storage.upsert({ ...base, id: "background", workspaceId: "open", background: true });
|
||||
await storage.upsert({ ...base, id: "in-background-workspace", workspaceId: "hidden" });
|
||||
await storage.upsert({ ...base, id: "internal", workspaceId: "open", internal: true });
|
||||
const workspaces = [workspace("open", false), workspace("hidden", true)];
|
||||
directory = new AgentDirectory({
|
||||
storage,
|
||||
manager: { listAgents: () => [] },
|
||||
workspaces: {
|
||||
get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null,
|
||||
list: async () => workspaces,
|
||||
},
|
||||
projectLive: () => {
|
||||
throw new Error("No live agents in this suite");
|
||||
},
|
||||
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
|
||||
isProviderVisible: () => true,
|
||||
isStoredProviderAvailable: () => true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function listedIds(includeBackground: BackgroundVisibility): Promise<string[]> {
|
||||
return (await directory.list({ includeBackground })).map((agent) => agent.id).toSorted();
|
||||
}
|
||||
|
||||
test("default lists hide agents that are background or live in a background workspace", async () => {
|
||||
expect(await listedIds(false)).toEqual(["visible"]);
|
||||
});
|
||||
|
||||
test("inclusive lists show every background agent", async () => {
|
||||
expect(await listedIds(true)).toEqual(["background", "in-background-workspace", "visible"]);
|
||||
});
|
||||
|
||||
test("workspace-only visibility hides agent-level background agents from older clients", async () => {
|
||||
expect(await listedIds("workspaces")).toEqual(["in-background-workspace", "visible"]);
|
||||
});
|
||||
|
||||
test("includes applies the same visibility to a single agent", async () => {
|
||||
const background = { workspaceId: "open", background: true };
|
||||
const inBackgroundWorkspace = { workspaceId: "hidden" };
|
||||
expect(await directory.includes(background)).toBe(false);
|
||||
expect(await directory.includes(background, "workspaces")).toBe(false);
|
||||
expect(await directory.includes(background, true)).toBe(true);
|
||||
expect(await directory.includes(inBackgroundWorkspace)).toBe(false);
|
||||
expect(await directory.includes(inBackgroundWorkspace, "workspaces")).toBe(true);
|
||||
});
|
||||
|
||||
test("prompt recency ignores background agents", async () => {
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
workspaceId: "open",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
};
|
||||
await storage.upsert({ ...base, id: "visible", lastUserMessageAt: "2026-03-02T00:00:00.000Z" });
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "background",
|
||||
background: true,
|
||||
lastUserMessageAt: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
expect(await directory.workspaceLastUserMessageTimes()).toEqual(
|
||||
new Map([["open", "2026-03-02T00:00:00.000Z"]]),
|
||||
);
|
||||
});
|
||||
@@ -3,7 +3,25 @@ import type { WorkspaceRegistry } from "../workspace-registry.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
|
||||
/** Public agent discovery owns workspace visibility for live and durable records. */
|
||||
/**
|
||||
* Which background agents a discovery read admits. An agent is effectively background when
|
||||
* it or its workspace is background.
|
||||
* - `false`: none, the default for lists.
|
||||
* - `true`: all of them.
|
||||
* - `"workspaces"`: only agents in background workspaces.
|
||||
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
|
||||
* >= v0.15.0. Inclusive reads from sources without `background_agents` use it: older apps
|
||||
* replicate inclusively and auto-open a tab for every root agent.
|
||||
*/
|
||||
export type BackgroundVisibility = boolean | "workspaces";
|
||||
|
||||
interface DiscoverableAgent {
|
||||
workspaceId?: string;
|
||||
internal?: boolean;
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/** Public agent discovery owns background visibility for live and durable records. */
|
||||
export class AgentDirectory {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
@@ -28,50 +46,53 @@ export class AgentDirectory {
|
||||
if (!previous || at > previous) times.set(workspaceId, at);
|
||||
};
|
||||
for (const agent of await this.deps.storage.list()) {
|
||||
if (!agent.internal && !liveIds.has(agent.id))
|
||||
if (!agent.internal && !agent.background && !liveIds.has(agent.id))
|
||||
record(agent.workspaceId, agent.lastUserMessageAt);
|
||||
}
|
||||
for (const agent of live) {
|
||||
if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
if (!agent.internal && !agent.background)
|
||||
record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
async includes(
|
||||
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
|
||||
includeBackground = false,
|
||||
agent: DiscoverableAgent,
|
||||
visibility: BackgroundVisibility = false,
|
||||
): Promise<boolean> {
|
||||
if (agent.internal) return false;
|
||||
if (includeBackground || !agent.workspaceId) return true;
|
||||
return !(await this.deps.workspaces.get(agent.workspaceId))?.background;
|
||||
const workspace = agent.workspaceId ? await this.deps.workspaces.get(agent.workspaceId) : null;
|
||||
return admits(agent, workspace?.background === true, visibility);
|
||||
}
|
||||
|
||||
async list(filter?: {
|
||||
labels?: Record<string, string>;
|
||||
includeArchived?: boolean;
|
||||
includeBackground?: boolean;
|
||||
includeBackground?: BackgroundVisibility;
|
||||
includeUnavailablePersisted?: boolean;
|
||||
}): Promise<AgentSnapshotPayload[]> {
|
||||
const live = this.deps.manager.listAgents();
|
||||
const liveIds = new Set(live.map((agent) => agent.id));
|
||||
const labels = Object.entries(filter?.labels ?? {});
|
||||
const backgroundIds = new Set(
|
||||
filter?.includeBackground
|
||||
const visibility = filter?.includeBackground ?? false;
|
||||
const backgroundWorkspaceIds = new Set(
|
||||
visibility === true
|
||||
? []
|
||||
: (await this.deps.workspaces.list())
|
||||
.filter((workspace) => workspace.background)
|
||||
.map((workspace) => workspace.workspaceId),
|
||||
);
|
||||
const matches = (agent: {
|
||||
archivedAt?: string | null;
|
||||
workspaceId?: string;
|
||||
labels?: Record<string, string>;
|
||||
}) =>
|
||||
const matches = (
|
||||
agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record<string, string> },
|
||||
) =>
|
||||
(filter?.includeArchived || !agent.archivedAt) &&
|
||||
(filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) &&
|
||||
admits(
|
||||
agent,
|
||||
agent.workspaceId !== undefined && backgroundWorkspaceIds.has(agent.workspaceId),
|
||||
visibility,
|
||||
) &&
|
||||
labels.every(([key, value]) => agent.labels?.[key] === value);
|
||||
const stored = (await this.deps.storage.list())
|
||||
.filter((record) => !record.internal && !liveIds.has(record.id) && matches(record))
|
||||
.filter((record) => !liveIds.has(record.id) && matches(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record),
|
||||
@@ -85,3 +106,13 @@ export class AgentDirectory {
|
||||
.filter((agent) => this.deps.isProviderVisible(agent.provider));
|
||||
}
|
||||
}
|
||||
|
||||
function admits(
|
||||
agent: DiscoverableAgent,
|
||||
inBackgroundWorkspace: boolean,
|
||||
visibility: BackgroundVisibility,
|
||||
): boolean {
|
||||
if (agent.internal) return false;
|
||||
if (agent.background) return visibility === true;
|
||||
return !inBackgroundWorkspace || visibility !== false;
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ const remoteCreate = z
|
||||
workspaceId: z.string().min(1),
|
||||
labels: z.record(z.string(), z.string()).optional(),
|
||||
notifyOnFinish: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
settings: z
|
||||
.object({
|
||||
@@ -235,6 +236,7 @@ export function createHostResolver(input: {
|
||||
featureValues: parsed.settings?.features,
|
||||
},
|
||||
workspaceId: parsed.workspaceId,
|
||||
...(parsed.background !== undefined ? { background: parsed.background } : {}),
|
||||
labels,
|
||||
initialPrompt: parsed.initialPrompt,
|
||||
idempotencyKey: parsed.idempotencyKey,
|
||||
|
||||
@@ -1211,4 +1211,137 @@ describe("Suite E: Workspace Tools", () => {
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a background agent's children and workspaces are background unless it overrides", async () => {
|
||||
const visibleAgentId = await createTopLevelAgent();
|
||||
const workspaceId = daemonHandle.daemon.agentManager.getAgent(visibleAgentId)?.workspaceId;
|
||||
const caller = await callToolStructured(topLevelClient, "create_agent", {
|
||||
workspaceId,
|
||||
title: "Background caller",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
background: true,
|
||||
});
|
||||
const callerId = str(caller.agentId);
|
||||
const background = (agentId: string) =>
|
||||
daemonHandle.daemon.agentManager.getAgent(agentId)?.background;
|
||||
expect(background(callerId)).toBe(true);
|
||||
const scoped = await createMcpClient(
|
||||
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${callerId}`,
|
||||
);
|
||||
const ownedWorkspaces: string[] = [];
|
||||
try {
|
||||
const inheritedChild = await callToolStructured(scoped, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Inherited child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
expect(background(str(inheritedChild.agentId))).toBe(true);
|
||||
const visibleChild = await callToolStructured(scoped, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Visible child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
notifyOnFinish: false,
|
||||
background: false,
|
||||
});
|
||||
expect(background(str(visibleChild.agentId))).toBe(false);
|
||||
const inheritedWorkspace = await callToolStructured(scoped, "create_workspace", {
|
||||
isolation: "local",
|
||||
});
|
||||
ownedWorkspaces.push(str(inheritedWorkspace.workspaceId));
|
||||
expect(inheritedWorkspace.background).toBe(true);
|
||||
} finally {
|
||||
await scoped.close();
|
||||
await archiveAgentIfPresent(callerId);
|
||||
await archiveAgentIfPresent(visibleAgentId);
|
||||
for (const id of ownedWorkspaces)
|
||||
await topLevelClient
|
||||
.callTool({ name: "archive_workspace", args: { workspaceId: id } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a background subagent stays out of default lists, notifies its parent and archives with it", async () => {
|
||||
const callerPrompts: string[] = [];
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn(prompt, config) {
|
||||
if (config.title === "Background parent" && typeof prompt === "string")
|
||||
callerPrompts.push(prompt);
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
const caller = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Background parent",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
mcp = await createMcpClient(
|
||||
buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: caller.id }),
|
||||
);
|
||||
const created = await callToolStructured(mcp, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Background child",
|
||||
initialPrompt: "say done and stop",
|
||||
notifyOnFinish: true,
|
||||
background: true,
|
||||
settings: { modeId: "full-access" },
|
||||
});
|
||||
const childId = str(created.agentId);
|
||||
expect((await client.fetchAgent(childId))?.agent).toMatchObject({
|
||||
background: true,
|
||||
workspaceId: caller.workspaceId,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: caller.id },
|
||||
});
|
||||
expect((await client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
|
||||
childId,
|
||||
);
|
||||
expect(
|
||||
(await client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).toContain(childId);
|
||||
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent finish notification",
|
||||
check: () => (callerPrompts.length > 0 ? true : null),
|
||||
});
|
||||
expect(parseAgentMessage(callerPrompts[0]).source).toMatchObject({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
event: "finished",
|
||||
});
|
||||
expect((await client.fetchAgent(childId))?.agent.requiresAttention).toBe(false);
|
||||
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent idle",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
await client.archiveAgent(caller.id);
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "child archived with parent",
|
||||
check: async () => ((await client.fetchAgent(childId))?.agent.archivedAt ? true : null),
|
||||
});
|
||||
} finally {
|
||||
await mcp?.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -1647,11 +1647,11 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: existingCwd }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects public internal helpers and the old top-level execution background option", async () => {
|
||||
it("rejects public internal helpers", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = new AgentManager({
|
||||
@@ -1669,9 +1669,6 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" };
|
||||
await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/);
|
||||
await expect(tool.handler({ ...input, background: true })).rejects.toThrow(
|
||||
/Unrecognized key/,
|
||||
);
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
@@ -1793,7 +1790,7 @@ describe("create_agent MCP tool", () => {
|
||||
cwd: existingCwd,
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "wks_existing" },
|
||||
{ workspaceId: "wks_existing", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1837,7 +1834,7 @@ describe("create_agent MCP tool", () => {
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2033,7 +2030,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth bug",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2068,7 +2065,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2112,6 +2109,7 @@ describe("create_agent MCP tool", () => {
|
||||
{
|
||||
labels: { source: "mcp" },
|
||||
workspaceId: "workspace-created",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2214,7 +2212,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Directory agent",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-directory-auto-title" },
|
||||
{ workspaceId: "workspace-directory-auto-title", background: false },
|
||||
);
|
||||
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
|
||||
title: "Directory Workspace Title",
|
||||
@@ -2828,12 +2826,13 @@ describe("create_agent MCP tool", () => {
|
||||
source: "voice",
|
||||
},
|
||||
workspaceId: "wks_voice",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
|
||||
it("defaults notify-on-finish on for caller agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
@@ -2853,16 +2852,6 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await expect(
|
||||
tool.handler({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: false,
|
||||
}),
|
||||
).rejects.toThrow(/Unrecognized key/);
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
@@ -3019,6 +3008,7 @@ describe("create_agent MCP tool", () => {
|
||||
source: "handoff",
|
||||
},
|
||||
workspaceId: "wks_parent",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3080,6 +3070,7 @@ describe("create_agent MCP tool", () => {
|
||||
[PARENT_AGENT_ID_LABEL]: "parent-agent",
|
||||
},
|
||||
workspaceId: "wks_parent",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3218,6 +3209,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(agentIdArg).toBeUndefined();
|
||||
expect(optionsArg).toEqual({
|
||||
workspaceId: "workspace-created",
|
||||
background: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3295,7 +3287,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "dynamic" }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3334,7 +3326,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type ArchiveDependencies,
|
||||
} from "../../workspace-archive-service.js";
|
||||
import { createAgentCommand } from "../create-agent/create.js";
|
||||
import { inheritWorkspaceBackground } from "../create-agent/intent.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../../voice-types.js";
|
||||
import type { FirstAgentContext } from "../../messages.js";
|
||||
import { everyMsToFiveFieldCron } from "@getpaseo/protocol/schedule/cadence";
|
||||
@@ -153,7 +154,7 @@ export interface PaseoToolHostDependencies {
|
||||
ensureWorkspaceForCreate?: (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
) => Promise<string>;
|
||||
browserToolsEnabled?: boolean;
|
||||
browserToolsBroker?: BrowserToolsBroker | null;
|
||||
@@ -860,6 +861,14 @@ function createToolCatalog(
|
||||
return parentAgent;
|
||||
};
|
||||
|
||||
const workspaceBackgroundIntent = (background: boolean | undefined) => {
|
||||
const caller = resolveCallerAgent();
|
||||
return {
|
||||
background: inheritWorkspaceBackground(background, caller),
|
||||
callerWorkspaceId: caller?.workspaceId,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveInheritedProviderConfig = (
|
||||
selectedProvider: string,
|
||||
): Pick<AgentSessionConfig, "providerOptions"> | undefined => {
|
||||
@@ -1191,6 +1200,12 @@ function createToolCatalog(
|
||||
.trim()
|
||||
.min(1, "initialPrompt is required")
|
||||
.describe("Required first task to run immediately after creation."),
|
||||
background: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True creates a background agent: hidden from tabs, default lists, your subagent track and notifications. You still get its finish notification and can address it by id. Omit to inherit your own value (false without a caller); use it for helpers the user does not need to watch.",
|
||||
),
|
||||
};
|
||||
const legacyCreateAgentPlacementFields = {
|
||||
relationship: AgentRelationshipInputSchema.describe(
|
||||
@@ -1397,7 +1412,7 @@ function createToolCatalog(
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your current workspace’s value (false without a caller), and override it only when the user explicitly asks.",
|
||||
"True hides the workspace from default lists and the sidebar while its contents remain durable and accessible; omit background to inherit your own visibility (true when you or your current workspace are background, false without a caller), and override it only when the user explicitly asks.",
|
||||
),
|
||||
mode: z
|
||||
.enum(["branch-off", "checkout-branch", "checkout-pr"])
|
||||
@@ -1467,10 +1482,12 @@ function createToolCatalog(
|
||||
if (!options.createDirectoryWorkspace) {
|
||||
throw new Error("Workspace provisioning is not configured");
|
||||
}
|
||||
workspace = await options.createDirectoryWorkspace(cwd, title, projectId, {
|
||||
background,
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
});
|
||||
workspace = await options.createDirectoryWorkspace(
|
||||
cwd,
|
||||
title,
|
||||
projectId,
|
||||
workspaceBackgroundIntent(background),
|
||||
);
|
||||
} else {
|
||||
let cwd =
|
||||
path !== undefined || !projectId ? resolveScopedCwd(path, { required: true }) : null;
|
||||
@@ -1501,8 +1518,7 @@ function createToolCatalog(
|
||||
...(worktreeSlug ? { worktreeSlug } : {}),
|
||||
...worktreeTarget,
|
||||
...(title ? { title } : {}),
|
||||
background,
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
...workspaceBackgroundIntent(background),
|
||||
},
|
||||
);
|
||||
if (!result.ok) {
|
||||
@@ -1648,6 +1664,7 @@ function createToolCatalog(
|
||||
mode: parsedArgs.settings?.modeId,
|
||||
promptFailure: "throw",
|
||||
notifyOnFinish,
|
||||
background: parsedArgs.background,
|
||||
detached: resolvedArgs.detached,
|
||||
callerAgentId,
|
||||
callerContext,
|
||||
@@ -1821,9 +1838,11 @@ function createToolCatalog(
|
||||
const cwd = process.cwd();
|
||||
return {
|
||||
cwd,
|
||||
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, {
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
}),
|
||||
workspaceId: await options.ensureWorkspaceForCreate(
|
||||
cwd,
|
||||
firstAgentContext,
|
||||
workspaceBackgroundIntent(undefined),
|
||||
),
|
||||
};
|
||||
}
|
||||
const caller = resolveCallerAgent();
|
||||
@@ -1927,9 +1946,11 @@ function createToolCatalog(
|
||||
}
|
||||
return {
|
||||
cwd,
|
||||
workspaceId: await options.ensureWorkspaceForCreate(cwd, firstAgentContext, {
|
||||
callerWorkspaceId: resolveCallerAgent()?.workspaceId,
|
||||
}),
|
||||
workspaceId: await options.ensureWorkspaceForCreate(
|
||||
cwd,
|
||||
firstAgentContext,
|
||||
workspaceBackgroundIntent(undefined),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1119,7 +1119,7 @@ export async function createPaseoDaemon(
|
||||
const ensureWorkspaceForCreateExternal = async (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
): Promise<string> => {
|
||||
const workspace = await workspaceProvisioning.createWorkspaceForDirectory(
|
||||
cwd,
|
||||
@@ -1172,7 +1172,7 @@ export async function createPaseoDaemon(
|
||||
const ensureWorkspaceForCreateAndBroadcastExternal = async (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { callerWorkspaceId?: string },
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
): Promise<string> => {
|
||||
const workspaceId = await ensureWorkspaceForCreateExternal(cwd, firstAgentContext, context);
|
||||
await emitWorkspaceUpdatesExternal([workspaceId]);
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
WorkspaceDescriptorPayload,
|
||||
WorkspaceProjectDescriptorPayload,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import type { BackgroundVisibility } from "../agent/directory.js";
|
||||
import { type CollectionRead, VersionedCollection } from "./internal/versioned-collection.js";
|
||||
|
||||
interface AgentDirectoryEntry {
|
||||
@@ -69,7 +70,7 @@ export class DirectorySyncService {
|
||||
synchronizeWorkspaces(
|
||||
snapshot: Iterable<WorkspaceDescriptorPayload>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> {
|
||||
this.workspaces.replaceAll(snapshot);
|
||||
const read = this.read(this.workspaces, cursor, includeBackground);
|
||||
@@ -84,7 +85,7 @@ export class DirectorySyncService {
|
||||
synchronizeAgents(
|
||||
snapshot: Iterable<AgentDirectoryEntry>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> {
|
||||
this.agents.replaceAll(snapshot);
|
||||
const read = this.read(this.agents, cursor, includeBackground);
|
||||
@@ -113,7 +114,7 @@ export class DirectorySyncService {
|
||||
value: WorkspaceDescriptorPayload | null,
|
||||
id: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
if (value) {
|
||||
this.workspaces.replace(value);
|
||||
@@ -133,7 +134,7 @@ export class DirectorySyncService {
|
||||
value: AgentDirectoryEntry | null,
|
||||
id: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
if (value) {
|
||||
this.agents.replace(value);
|
||||
@@ -148,7 +149,7 @@ export class DirectorySyncService {
|
||||
);
|
||||
}
|
||||
|
||||
private metadata(read: CollectionRead<unknown>, includeBackground = false) {
|
||||
private metadata(read: CollectionRead<unknown>, includeBackground: BackgroundVisibility = false) {
|
||||
return {
|
||||
generation: this.generationFor(includeBackground),
|
||||
headSeq: read.headSeq,
|
||||
@@ -162,7 +163,7 @@ export class DirectorySyncService {
|
||||
payload: T,
|
||||
includeSequence: boolean,
|
||||
version: DirectoryVersion | null,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): T & Partial<DirectoryVersion> {
|
||||
return version && includeSequence
|
||||
? { ...payload, ...version, generation: this.generationFor(includeBackground) }
|
||||
@@ -172,7 +173,7 @@ export class DirectorySyncService {
|
||||
private read<T>(
|
||||
collection: VersionedCollection<T>,
|
||||
cursor: DirectorySyncCursor,
|
||||
includeBackground = false,
|
||||
includeBackground: BackgroundVisibility = false,
|
||||
): CollectionRead<T> {
|
||||
if (cursor.generation !== this.generationFor(includeBackground)) {
|
||||
return collection.readSnapshot(
|
||||
@@ -185,7 +186,9 @@ export class DirectorySyncService {
|
||||
return collection.readAfter(cursor.afterSeq);
|
||||
}
|
||||
|
||||
private generationFor(includeBackground: boolean): string {
|
||||
// Each visibility scope admits a different subset, so cursors never cross scopes.
|
||||
private generationFor(includeBackground: BackgroundVisibility): string {
|
||||
if (includeBackground === "workspaces") return `${this.generation}:background-workspaces`;
|
||||
return includeBackground ? `${this.generation}:background` : this.generation;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: StoredAgentRecord["owner"];
|
||||
background?: boolean;
|
||||
} {
|
||||
return {
|
||||
createdAt: new Date(record.createdAt),
|
||||
@@ -149,6 +150,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
background: record.background,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import type { CreateAgentOptions } from "./test-utils/index.js";
|
||||
import type { CreateAgentWorktreeTarget } from "./messages.js";
|
||||
import { createRealpathAwarePathMatcher } from "../utils/path.js";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
|
||||
let ctx: DaemonTestContext;
|
||||
const tempRoots: string[] = [];
|
||||
@@ -477,7 +478,7 @@ test("background workspaces keep durable agents addressable through restart and
|
||||
),
|
||||
).toContain(hidden.id);
|
||||
const child = await ctx.client.createAgent({ config, callerAgentId: hidden.id });
|
||||
expect(child.workspaceId).toBe(hidden.workspaceId);
|
||||
expect(child).toMatchObject({ workspaceId: hidden.workspaceId, background: true });
|
||||
const inherited = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
callerAgentId: hidden.id,
|
||||
@@ -509,9 +510,16 @@ test("background workspaces keep durable agents addressable through restart and
|
||||
false,
|
||||
);
|
||||
|
||||
await expect(
|
||||
ctx.client.createAgent({ config, workspaceId: hidden.workspaceId, background: false }),
|
||||
).rejects.toThrow("workspace creation intent");
|
||||
expect(hidden.background).toBe(true);
|
||||
const visibleInHidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: hidden.workspaceId,
|
||||
background: false,
|
||||
});
|
||||
expect(visibleInHidden.background).toBeUndefined();
|
||||
expect((await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
|
||||
visibleInHidden.id,
|
||||
);
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect(
|
||||
@@ -616,3 +624,111 @@ test("default and inclusive observers retain independent snapshots, live updates
|
||||
await otherClient.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test("a background agent in a visible workspace is hidden from default discovery through restart", async () => {
|
||||
const cwd = createGitRepo();
|
||||
const config = { ...getFullAccessConfig("codex"), cwd };
|
||||
const visible = await ctx.client.createAgent({ config });
|
||||
const hidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: visible.workspaceId,
|
||||
background: true,
|
||||
});
|
||||
expect(hidden).toMatchObject({ workspaceId: visible.workspaceId, background: true });
|
||||
expect(visible.background).toBeUndefined();
|
||||
const defaultIds = async () =>
|
||||
(await ctx.client.fetchAgents()).entries.map((entry) => entry.agent.id);
|
||||
const inclusiveIds = async () =>
|
||||
(await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
);
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
expect(await inclusiveIds()).toEqual(expect.arrayContaining([visible.id, hidden.id]));
|
||||
expect((await ctx.client.fetchWorkspaces()).entries).toContainEqual(
|
||||
expect.objectContaining({ id: visible.workspaceId, background: false }),
|
||||
);
|
||||
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect((await ctx.client.fetchAgent(hidden.id))?.agent).toMatchObject({
|
||||
background: true,
|
||||
requiresAttention: false,
|
||||
});
|
||||
|
||||
const homeRoot = path.dirname(ctx.daemon.paseoHome);
|
||||
tempRoots.push(ctx.daemon.staticDir);
|
||||
await ctx.client.close();
|
||||
await ctx.daemon.daemon.stop();
|
||||
await ctx.daemon.daemon.agentManager.flush();
|
||||
ctx = await createDaemonTestContext({ paseoHomeRoot: homeRoot });
|
||||
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
expect(await inclusiveIds()).toContain(hidden.id);
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done again.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
expect((await ctx.client.fetchAgent(hidden.id))?.agent.background).toBe(true);
|
||||
expect(await defaultIds()).toEqual([visible.id]);
|
||||
await ctx.client.archiveAgent(hidden.id);
|
||||
expect(
|
||||
(await ctx.client.fetchAgentHistory()).entries.map((entry) => entry.agent.id),
|
||||
).not.toContain(hidden.id);
|
||||
expect(
|
||||
(await ctx.client.fetchAgentHistory({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).toContain(hidden.id);
|
||||
}, 30_000);
|
||||
|
||||
test("a client without background_agents never receives background agents, even in inclusive reads", async () => {
|
||||
const oldApp = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
|
||||
capabilities: { [CLIENT_CAPS.backgroundAgents]: false },
|
||||
});
|
||||
await oldApp.connect();
|
||||
try {
|
||||
const cwd = createGitRepo();
|
||||
const config = { ...getFullAccessConfig("codex"), cwd };
|
||||
const visible = await ctx.client.createAgent({ config });
|
||||
const hidden = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: visible.workspaceId,
|
||||
background: true,
|
||||
});
|
||||
const backgroundWorkspace = await ctx.client.createWorkspace({
|
||||
source: { kind: "directory", path: cwd },
|
||||
background: true,
|
||||
});
|
||||
const workspaceAgent = await ctx.client.createAgent({
|
||||
config,
|
||||
workspaceId: backgroundWorkspace.workspace?.id,
|
||||
});
|
||||
const observed = oldApp.observeAgents({ scope: "active", sync: {}, includeBackground: true });
|
||||
const snapshot = await observed.ready;
|
||||
expect(snapshot.entries.map((entry) => entry.agent.id).toSorted()).toEqual(
|
||||
[visible.id, workspaceAgent.id].toSorted(),
|
||||
);
|
||||
const upserts: string[] = [];
|
||||
observed.subscribe({
|
||||
snapshot: () => {},
|
||||
update: (message) => {
|
||||
if (message.type === "agent_update" && message.payload.kind === "upsert")
|
||||
upserts.push(message.payload.agent.id);
|
||||
},
|
||||
});
|
||||
await ctx.client.sendAgentMessage(hidden.id, "Say done.");
|
||||
await ctx.client.waitForFinish(hidden.id, 10_000);
|
||||
await ctx.client.sendAgentMessage(visible.id, "Say done.");
|
||||
await ctx.client.waitForFinish(visible.id, 10_000);
|
||||
await expect.poll(() => upserts).toContain(visible.id);
|
||||
expect(upserts).not.toContain(hidden.id);
|
||||
expect(
|
||||
(await oldApp.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).not.toContain(hidden.id);
|
||||
expect((await oldApp.fetchAgent(hidden.id))?.agent.id).toBe(hidden.id);
|
||||
await observed.release();
|
||||
} finally {
|
||||
await oldApp.close();
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type WorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||
import type { AgentMessageDelivery } from "./agent/agent-messages/index.js";
|
||||
import { AgentDirectory } from "./agent/directory.js";
|
||||
import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js";
|
||||
import { ConnectedHostSession } from "./connected-hosts/index.js";
|
||||
import type { ConnectedHosts } from "./connected-hosts/index.js";
|
||||
import { searchTimeline } from "./agent/chat-search/index.js";
|
||||
@@ -119,7 +119,12 @@ import type {
|
||||
ManagedAgent,
|
||||
} from "./agent/agent-manager.js";
|
||||
import { createAgentCommand } from "./agent/create-agent/create.js";
|
||||
import { resolveCreateAgentIntent, type CreateAgentIntent } from "./agent/create-agent/intent.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
resolveCreateAgentIntent,
|
||||
type CreateAgentIntent,
|
||||
} from "./agent/create-agent/intent.js";
|
||||
import {
|
||||
archiveAgentCommand,
|
||||
cancelAgentRunCommand,
|
||||
@@ -388,6 +393,7 @@ type CreateAgentRequestMessage = Extract<SessionInboundMessage, { type: "create_
|
||||
interface ResolvedSessionCreateAgentIntent {
|
||||
config: AgentSessionConfig;
|
||||
intent: CreateAgentIntent;
|
||||
background: boolean;
|
||||
createdDirectoryWorkspace: boolean;
|
||||
}
|
||||
|
||||
@@ -1162,8 +1168,8 @@ export class Session {
|
||||
),
|
||||
});
|
||||
this.agentUpdates = createAgentUpdatesService({
|
||||
includesWorkspace: (agent, includeBackground) =>
|
||||
this.agentDirectory.includes(agent, includeBackground),
|
||||
includesWorkspace: (agent, backgroundVisibility) =>
|
||||
this.agentDirectory.includes(agent, backgroundVisibility),
|
||||
emit: (message) => this.emit(message),
|
||||
enrichAgentPayload: (payload) => this.enrichAgentPayload(payload),
|
||||
buildStoredAgentPayload: (record) => this.buildStoredAgentPayload(record),
|
||||
@@ -4527,14 +4533,6 @@ export class Session {
|
||||
throw new Error(
|
||||
"Public internal agents were removed; create a background workspace instead.",
|
||||
);
|
||||
if (
|
||||
request.background !== undefined &&
|
||||
(request.workspaceId || (request.callerAgentId && !request.worktree))
|
||||
) {
|
||||
throw new Error(
|
||||
"background is workspace creation intent; configure the selected workspace at creation.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createSessionAgent(
|
||||
@@ -4585,8 +4583,7 @@ export class Session {
|
||||
cwd: config.cwd,
|
||||
target: worktree,
|
||||
firstAgentContext,
|
||||
background: msg.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(msg.callerAgentId),
|
||||
...this.workspaceBackgroundIntent(msg.background, msg.callerAgentId),
|
||||
});
|
||||
createdWorktreeForCleanup = createdWorktree;
|
||||
const resolvedIntent = await this.resolveSessionCreateAgentIntent({
|
||||
@@ -4617,6 +4614,7 @@ export class Session {
|
||||
agentId,
|
||||
config: resolvedIntent.config,
|
||||
workspaceId: resolvedIntent.intent.workspaceId,
|
||||
background: resolvedIntent.background,
|
||||
initialPrompt,
|
||||
source: msg.callerAgentId
|
||||
? { kind: "agent-message", agentId: msg.callerAgentId }
|
||||
@@ -4701,7 +4699,7 @@ export class Session {
|
||||
createdWorktree: null,
|
||||
cwd: config.cwd,
|
||||
initialTitle: input.workspacePromptTitle,
|
||||
background: request.background,
|
||||
background: inheritWorkspaceBackground(request.background, callerAgent),
|
||||
callerWorkspaceId: callerAgent?.workspaceId,
|
||||
}),
|
||||
cwd: config.cwd,
|
||||
@@ -4712,6 +4710,7 @@ export class Session {
|
||||
return {
|
||||
config,
|
||||
intent,
|
||||
background: inheritAgentBackground(request.background, callerAgent),
|
||||
createdDirectoryWorkspace: !createdWorktree && !request.workspaceId && !callerAgent,
|
||||
};
|
||||
}
|
||||
@@ -5663,7 +5662,22 @@ export class Session {
|
||||
return matchedEntries;
|
||||
}
|
||||
|
||||
private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{
|
||||
/**
|
||||
* Inclusive discovery for the requesting source. Exact-ID reads never pass through this.
|
||||
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
|
||||
* >= v0.15.0. Sources without the capability see background workspaces' agents only.
|
||||
*/
|
||||
private resolveBackgroundVisibility(
|
||||
includeBackground: boolean | undefined,
|
||||
): BackgroundVisibility {
|
||||
if (!includeBackground) return false;
|
||||
return this.supports(CLIENT_CAPS.backgroundAgents) ? true : "workspaces";
|
||||
}
|
||||
|
||||
private async listFetchAgentsEntries(
|
||||
request: AgentDirectoryRequestMessage,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): Promise<{
|
||||
entries: FetchAgentsResponseEntry[];
|
||||
pageInfo: FetchAgentsResponsePageInfo;
|
||||
searchTruncated?: boolean;
|
||||
@@ -5679,9 +5693,7 @@ export class Session {
|
||||
let agents = await this.listAgentPayloads({
|
||||
labels: filter?.labels,
|
||||
includeArchived: filter?.includeArchived,
|
||||
includeBackground:
|
||||
filter?.includeBackground ||
|
||||
(request.type === "fetch_agents_request" && request.includeBackground),
|
||||
includeBackground: backgroundVisibility,
|
||||
includeUnavailablePersisted: request.type === "fetch_agent_history_request",
|
||||
});
|
||||
const activePlacementsByWorkspaceId =
|
||||
@@ -6415,16 +6427,17 @@ export class Session {
|
||||
})
|
||||
: null;
|
||||
const subscriptionId = owner?.responseId;
|
||||
const backgroundVisibility = this.resolveBackgroundVisibility(
|
||||
request.includeBackground || request.filter?.includeBackground,
|
||||
);
|
||||
try {
|
||||
if (owner) {
|
||||
this.agentUpdates.beginSubscription({
|
||||
subscriptionId: owner.id,
|
||||
isProviderVisible: (provider) =>
|
||||
this.delivery.forSource(owner.source, () => this.isProviderVisibleToClient(provider)),
|
||||
filter: {
|
||||
...request.filter,
|
||||
...(request.includeBackground ? { includeBackground: true } : {}),
|
||||
},
|
||||
filter: request.filter,
|
||||
backgroundVisibility,
|
||||
syncEnabled: Boolean(request.sync),
|
||||
emit: (message) => {
|
||||
if (message.type === "agent_update") owner.emit(message);
|
||||
@@ -6434,8 +6447,8 @@ export class Session {
|
||||
}
|
||||
|
||||
const payload = request.sync
|
||||
? await this.readAgentDirectorySync(request)
|
||||
: await this.listFetchAgentsEntries(request);
|
||||
? await this.readAgentDirectorySync(request, backgroundVisibility)
|
||||
: await this.listFetchAgentsEntries(request, backgroundVisibility);
|
||||
const snapshotUpdatedAtByAgentId = new Map<string, number>();
|
||||
for (const entry of payload.entries) {
|
||||
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
|
||||
@@ -6479,7 +6492,10 @@ export class Session {
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_agent_history_request" }>,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const payload = await this.listFetchAgentsEntries(request);
|
||||
const payload = await this.listFetchAgentsEntries(
|
||||
request,
|
||||
this.resolveBackgroundVisibility(request.filter?.includeBackground),
|
||||
);
|
||||
this.emit({
|
||||
type: "fetch_agent_history_response",
|
||||
payload: {
|
||||
@@ -6844,6 +6860,7 @@ export class Session {
|
||||
|
||||
private async readAgentDirectorySync(
|
||||
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
) {
|
||||
if (request.scope !== "active" || request.filter) {
|
||||
throw new SessionRequestError(
|
||||
@@ -6851,19 +6868,19 @@ export class Session {
|
||||
"Sequenced agent directory reads require scope=active and no filter.",
|
||||
);
|
||||
}
|
||||
const snapshot = await this.listFetchAgentsEntries({
|
||||
...request,
|
||||
includeBackground: true,
|
||||
page: { limit: Number.MAX_SAFE_INTEGER },
|
||||
});
|
||||
// The canonical collection is complete; only the read below is scoped.
|
||||
const snapshot = await this.listFetchAgentsEntries(
|
||||
{ ...request, page: { limit: Number.MAX_SAFE_INTEGER } },
|
||||
true,
|
||||
);
|
||||
const result = this.directorySync.synchronizeAgents(
|
||||
snapshot.entries,
|
||||
request.sync ?? {},
|
||||
request.includeBackground,
|
||||
backgroundVisibility,
|
||||
);
|
||||
const entries = await Promise.all(
|
||||
result.entries.map(async (entry) =>
|
||||
(await this.agentDirectory.includes(entry.agent, request.includeBackground)) ? entry : null,
|
||||
(await this.agentDirectory.includes(entry.agent, backgroundVisibility)) ? entry : null,
|
||||
),
|
||||
);
|
||||
return { ...result, entries: entries.filter((entry) => entry !== null) };
|
||||
@@ -6964,11 +6981,22 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private getCallerWorkspaceId(callerAgentId?: string): string | undefined {
|
||||
private getCallerAgent(callerAgentId?: string): ManagedAgent | undefined {
|
||||
if (!callerAgentId) return undefined;
|
||||
const caller = this.agentManager.getAgent(callerAgentId);
|
||||
if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`);
|
||||
return caller.workspaceId;
|
||||
return caller;
|
||||
}
|
||||
|
||||
private workspaceBackgroundIntent(
|
||||
background: boolean | undefined,
|
||||
callerAgentId: string | undefined,
|
||||
): { background?: boolean; callerWorkspaceId?: string } {
|
||||
const caller = this.getCallerAgent(callerAgentId);
|
||||
return {
|
||||
background: inheritWorkspaceBackground(background, caller),
|
||||
callerWorkspaceId: caller?.workspaceId,
|
||||
};
|
||||
}
|
||||
|
||||
private async createRequestedWorkspace(
|
||||
@@ -7017,8 +7045,7 @@ export class Session {
|
||||
{
|
||||
expectsInitialAgent: Boolean(request.firstAgentContext),
|
||||
workspaceId,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
...this.workspaceBackgroundIntent(request.background, request.callerAgentId),
|
||||
},
|
||||
);
|
||||
await this.syncWorkspaceGitObserverForWorkspace(workspace);
|
||||
@@ -7081,8 +7108,7 @@ export class Session {
|
||||
githubPrNumber: source.githubPrNumber,
|
||||
firstAgentContext: request.firstAgentContext,
|
||||
title: request.title,
|
||||
background: request.background,
|
||||
callerWorkspaceId: this.getCallerWorkspaceId(request.callerAgentId),
|
||||
...this.workspaceBackgroundIntent(request.background, request.callerAgentId),
|
||||
},
|
||||
source.baseBranch
|
||||
? { resolveDefaultBranch: async () => source.baseBranch as string }
|
||||
@@ -7900,7 +7926,8 @@ export class Session {
|
||||
);
|
||||
const agentsById = new Map(agents.map((agent) => [agent.id, agent] as const));
|
||||
const candidates = agents
|
||||
.filter((agent) => !agent.archivedAt && agent.workspaceId === workspace.workspaceId)
|
||||
.filter((agent) => !agent.archivedAt && !agent.background)
|
||||
.filter((agent) => agent.workspaceId === workspace.workspaceId)
|
||||
.filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id)
|
||||
.filter((agent) => agent.status === "idle" || agent.status === "closed")
|
||||
.filter((agent) => agent.requiresAttention !== true)
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "../../messages.js";
|
||||
import type { ManagedAgent } from "../../agent/agent-manager.js";
|
||||
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
|
||||
import type { BackgroundVisibility } from "../../agent/directory.js";
|
||||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from "../../agent/agent-projections.js";
|
||||
|
||||
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: "agent_update" }>["payload"];
|
||||
@@ -19,6 +20,7 @@ interface AgentUpdatesSubscriptionState {
|
||||
emit: (message: SessionOutboundMessage) => void;
|
||||
syncEnabled: boolean;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility: BackgroundVisibility;
|
||||
isProviderVisible: (provider: string) => boolean;
|
||||
isBootstrapping: boolean;
|
||||
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
|
||||
@@ -31,6 +33,7 @@ export interface AgentUpdatesService {
|
||||
isProviderVisible?: (provider: string) => boolean;
|
||||
emit?: (message: SessionOutboundMessage) => void;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility?: BackgroundVisibility;
|
||||
syncEnabled?: boolean;
|
||||
}): void;
|
||||
flushBootstrapped(
|
||||
@@ -47,7 +50,10 @@ export interface AgentUpdatesService {
|
||||
}
|
||||
|
||||
export interface AgentUpdatesServiceDeps {
|
||||
includesWorkspace?(agent: AgentSnapshotPayload, includeBackground?: boolean): Promise<boolean>;
|
||||
includesWorkspace?(
|
||||
agent: AgentSnapshotPayload,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): Promise<boolean>;
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
enrichAgentPayload(payload: AgentSnapshotPayload): Promise<AgentSnapshotPayload>;
|
||||
buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload;
|
||||
@@ -60,7 +66,7 @@ export interface AgentUpdatesServiceDeps {
|
||||
project: ProjectPlacementPayload | null,
|
||||
agentId: string,
|
||||
includeSequence: boolean,
|
||||
includeBackground?: boolean,
|
||||
backgroundVisibility: BackgroundVisibility,
|
||||
): T;
|
||||
logger: pino.Logger;
|
||||
}
|
||||
@@ -173,7 +179,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
project,
|
||||
agentId,
|
||||
sub.syncEnabled === true,
|
||||
sub.filter?.includeBackground,
|
||||
sub.backgroundVisibility,
|
||||
);
|
||||
|
||||
function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void {
|
||||
@@ -194,10 +200,12 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
isProviderVisible?: (provider: string) => boolean;
|
||||
emit?: (message: SessionOutboundMessage) => void;
|
||||
filter?: AgentUpdatesFilter;
|
||||
backgroundVisibility?: BackgroundVisibility;
|
||||
syncEnabled?: boolean;
|
||||
}): void {
|
||||
subscriptions.set(input.subscriptionId, {
|
||||
...input,
|
||||
backgroundVisibility: input.backgroundVisibility ?? false,
|
||||
emit: input.emit ?? deps.emit,
|
||||
syncEnabled: input.syncEnabled ?? false,
|
||||
isProviderVisible: input.isProviderVisible ?? deps.isProviderVisibleToClient,
|
||||
@@ -249,9 +257,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
|
||||
: null;
|
||||
const backgroundVisible = await Promise.all(
|
||||
observers.map(
|
||||
(sub) => deps.includesWorkspace?.(payload, sub.filter?.includeBackground) ?? true,
|
||||
),
|
||||
observers.map((sub) => deps.includesWorkspace?.(payload, sub.backgroundVisibility) ?? true),
|
||||
);
|
||||
return (
|
||||
project !== null &&
|
||||
@@ -273,7 +279,7 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
|
||||
for (const sub of observers) {
|
||||
const matches =
|
||||
project &&
|
||||
((await deps.includesWorkspace?.(payload, sub.filter?.includeBackground)) ?? true) &&
|
||||
((await deps.includesWorkspace?.(payload, sub.backgroundVisibility)) ?? true) &&
|
||||
matchesAgentUpdatesFilter({ agent: payload, project, filter: sub.filter });
|
||||
bufferOrEmit(
|
||||
sub,
|
||||
|
||||
@@ -2013,6 +2013,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
creationLifecycle: true,
|
||||
hubAgentRpc: true,
|
||||
backgroundWorkspaces: true,
|
||||
// COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03.
|
||||
backgroundAgents: true,
|
||||
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
|
||||
directorySync: true,
|
||||
// COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14.
|
||||
|
||||
@@ -262,6 +262,7 @@ interface AgentState {
|
||||
pendingPermissionCount?: number;
|
||||
requiresAttention?: boolean;
|
||||
attentionReason?: AgentSnapshotPayload["attentionReason"];
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
function createAgent(
|
||||
@@ -273,6 +274,7 @@ function createAgent(
|
||||
provider: "codex",
|
||||
cwd: input.cwd,
|
||||
...(input.workspaceId ? { workspaceId: input.workspaceId } : {}),
|
||||
...(input.background ? { background: true } : {}),
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
effectiveThinkingOptionId: null,
|
||||
@@ -330,6 +332,15 @@ describe("WorkspaceDirectory", () => {
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
|
||||
test("a running background agent does not make its workspace running", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasRootAgent({ id: "root-agent", status: "idle" });
|
||||
workspace.hasRootAgent({ id: "classifier", status: "running", background: true });
|
||||
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("done");
|
||||
});
|
||||
|
||||
test("same-cwd workspaces attribute agent status only to the owner", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
|
||||
@@ -267,8 +267,12 @@ export class WorkspaceDirectory {
|
||||
});
|
||||
}
|
||||
|
||||
// Background agents report to their creator; they never move the workspace's status.
|
||||
const activeAgents = agents.filter(
|
||||
(agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider),
|
||||
(agent) =>
|
||||
!agent.archivedAt &&
|
||||
!agent.background &&
|
||||
this.deps.isProviderVisibleToClient(agent.provider),
|
||||
);
|
||||
this.applyAgentBucketContributions({
|
||||
activeAgents,
|
||||
|
||||
Reference in New Issue
Block a user