Surface paseo subagents in a collapsible section above the composer (#532)
* Surface paseo subagents in a collapsible section above the composer
Subagents no longer auto-open as tabs. Each parent agent's pane shows
a compact tab attached to the top of the composer, listing its direct
children with name and status; clicking a row opens or focuses the
subagent's tab. The section is provider-neutral so Claude Code Task
and Codex subagent sources can feed the same UI later. Reconciliation
now separates pruning (`activeAgentIds`) from auto-opening
(`autoOpenAgentIds`) so manually opened subagent tabs survive.
* Tighten subagents composer code from review
Collapse duplicate helper in workspace reconciliation, swap a hand-rolled
shallow comparator for fast-deep-equal (matching repo convention), drop
an unused React default import and a single-use formatCount helper, and
extract a shared PaneOverrides type in the agent-panel test.
* Fix oxlint failures in subagents code
Switch type aliases to interfaces, hoist inline JSX styles/handlers in
SubagentsSection to satisfy react-perf rules, and lift the test
makeAgent helpers onto AGENT_DEFAULTS so their cyclomatic complexity
drops below the configured cap.
* Add missing unistyles mock exports after rebase
After rebasing onto main, SubagentsSection now imports both
useUnistyles and (transitively, via WorkspaceTabIcon) withUnistyles.
Two test mocks needed updating to expose them.
Test drift fix: the implementation contract is correct; the local
react-native-unistyles mocks were stale.
* Tuck subagents tab under the composer
Subagents tab now grows visually from beneath the composer's rounded
top edge: the surface overlaps the composer by the corner radius so
the rounded curve nests into the tab. Composer's top padding is
removed so the tab sits flush against the input box with no
click-dead gutter. The tab is full-width in both states, the header
label drops the medium weight to follow design-system §3 (content
text inside a surface is normal), and hover covers the entire
visible tab when collapsed but stays scoped to the header row when
expanded.
* Decouple subagent close-tab from archive
Closing a subagent's tab no longer archives the agent — the tab is
removed from the per-client layout, the agent stays in the parent's
track until explicitly archived. Track rows gain an archive button (X)
to make the lifecycle gesture explicit. AgentManager.archiveAgent now
cascades to children carrying the paseo.parent-agent-id label so
subagent fleets don't outlive their orchestrator.
Trade-offs documented in docs/agent-lifecycle.md, including the known
limitation that handoff agents launched via the same MCP path get
cascade-archived alongside true subagents until a richer relation
model lands.
* Stabilize subagent archive button hover
Apply the sidebar workspace row pattern to fix two issues with the
new archive button: the row no longer drops hover when the pointer
moves onto the button, and the button slot reserves layout space
when invisible so adding/removing it doesn't shift the row.
The wrapper View tracks hover via onPointerEnter/onPointerLeave
(workspace rows do the same), and the slot stays mounted with
opacity 0 + pointerEvents="none" when the button is hidden. Also
swap the X icon for the Archive icon to match the gesture's intent.
* Match design language for subagent archive button
Hover state now changes the icon color from foregroundMuted to
foreground, matching the project-row trailing-action pattern in
sidebar-workspace-list.tsx. Drops the surface3 background hover
that didn't match the design language.
Adds a tooltip ("Archive subagent") on the trigger using the
canonical Tooltip + TooltipTrigger asChild pattern. Disabled when
the slot is hidden so it never appears unbidden.
* Slot subagents into one module + harden cascade
Reshape the subagents-in-composer feature so it lives behind one
module and integrates at a few explicit sites instead of being
smeared across generic files.
- Extract `PARENT_AGENT_ID_LABEL` to `packages/server/src/shared/agent-labels.ts`. Sweeps 16 literal sites across server, client, CLI, and tests.
- `packages/app/src/subagents/` is now a deep module: `index.ts` is the only React entry (`SubagentsSection`, `useArchiveSubagent`, `useSubagentsForParent`, `selectSubagentsForParent`, `resolveCloseAgentTabPolicy`, `shouldAutoOpenAgentTab`); `policies.ts` is a second designed entry for non-RN data consumers. Internal files renamed (`section.tsx`, `select.ts`, `close-tab-policy.ts`, `auto-open-tab-policy.ts`, `use-archive-subagent.ts`).
- Move the 22-line subagent archive flow out of `agent-panel.tsx` into `useArchiveSubagent`. Agent-panel becomes a one-line consumer.
- Move the close-tab subagent branch out of `workspace-screen.tsx` into `resolveCloseAgentTabPolicy` (discriminated union).
- Move the auto-open exclusion rule out of `workspace-agent-visibility.ts` into `shouldAutoOpenAgentTab`.
- Theme chevrons in `section.tsx` via `withUnistyles`; drop the forbidden `useUnistyles()` call from the hot path.
Server-side cascade hardening:
- Single write path: extract `markRecordArchived` and route both `archiveAgent` and the off-memory cascade branch through it. Off-memory cascaded children now notify subscribers (previously silent).
- Cascade no longer swallows child failures with `try/catch warn`. Failures propagate.
- Cascade test extended from 1 weak assertion to 5 contract tests: full archive shape (archivedAt, normalized lastStatus, requiresAttention=false), running child runtime stop, off-memory branch, subscriber notification for in-memory and off-memory children, partial-failure surfacing.
Net diff vs the start of this PR: -1037 / +444 lines. The feature now slots into the repo at four explicit sites: `agent-panel.tsx` (3 imports + a hook + the section), `workspace-screen.tsx` (one policy import), `workspace-agent-visibility.ts` (one predicate import), and `agent-manager.ts` (the cascade plus the shared label constant).
This commit is contained in:
@@ -7,6 +7,8 @@ import { randomUUID } from "node:crypto";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
@@ -81,6 +83,20 @@ function createPersistedDescriptor(args: {
|
||||
};
|
||||
}
|
||||
|
||||
function expectArchivedAgentRecord(
|
||||
record: StoredAgentRecord | null,
|
||||
expectedLastStatus: "closed" | "idle",
|
||||
): void {
|
||||
expect(record).not.toBeNull();
|
||||
expect(record?.archivedAt).toEqual(
|
||||
expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/),
|
||||
);
|
||||
expect(record?.lastStatus).toBe(expectedLastStatus);
|
||||
expect(record?.requiresAttention).toBe(false);
|
||||
expect(record?.attentionReason).toBeNull();
|
||||
expect(record?.attentionTimestamp).toBeNull();
|
||||
}
|
||||
|
||||
class TestAgentClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
@@ -3762,6 +3778,275 @@ test("archiveAgent persists archivedAt and updatedAt before emitting closed stat
|
||||
expect(lifecycles.slice(-2)).toEqual(["idle", "closed"]);
|
||||
});
|
||||
|
||||
test("archiveAgent cascade archives in-memory children with the full archive contract", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-contract-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const parent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Parent",
|
||||
});
|
||||
const child = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Child",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id } },
|
||||
);
|
||||
const unrelated = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Unrelated",
|
||||
});
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
const storedParent = await storage.get(parent.id);
|
||||
const storedChild = await storage.get(child.id);
|
||||
const storedUnrelated = await storage.get(unrelated.id);
|
||||
|
||||
expectArchivedAgentRecord(storedParent, "closed");
|
||||
expectArchivedAgentRecord(storedChild, "closed");
|
||||
expect(storedUnrelated?.archivedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent cascade closes a running child runtime", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const finishRun = deferred<void>();
|
||||
|
||||
class RunningChildSession extends TestAgentSession {
|
||||
closeCalled = false;
|
||||
|
||||
override async startTurn(): Promise<{ turnId: string }> {
|
||||
const turnId = "running-child-turn";
|
||||
void (async () => {
|
||||
this.pushEvent({ type: "turn_started", provider: this.provider, turnId });
|
||||
await finishRun.promise;
|
||||
this.pushEvent({ type: "turn_completed", provider: this.provider, turnId });
|
||||
})();
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
override async close(): Promise<void> {
|
||||
this.closeCalled = true;
|
||||
}
|
||||
}
|
||||
|
||||
class RunningChildClient extends TestAgentClient {
|
||||
readonly sessions: RunningChildSession[] = [];
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new RunningChildSession(config);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new RunningChildClient();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: client,
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Parent",
|
||||
});
|
||||
const child = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Running Child",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id } },
|
||||
);
|
||||
const childSession = client.sessions[1];
|
||||
const childLifecycleEvents: string[] = [];
|
||||
const unsubscribe = manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === child.id) {
|
||||
childLifecycleEvents.push(event.agent.lifecycle);
|
||||
}
|
||||
},
|
||||
{ agentId: child.id, replayState: false },
|
||||
);
|
||||
const childRun = manager.streamAgent(child.id, "keep running");
|
||||
const drainChildRun = (async () => {
|
||||
for await (const _event of childRun) {
|
||||
// Drain the foreground turn while archive closes it.
|
||||
}
|
||||
})();
|
||||
|
||||
await manager.waitForAgentRunStart(child.id);
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
finishRun.resolve();
|
||||
await drainChildRun;
|
||||
unsubscribe();
|
||||
|
||||
expect(childSession?.closeCalled).toBe(true);
|
||||
expect(manager.getAgent(child.id)).toBeNull();
|
||||
expect(childLifecycleEvents).toContain("closed");
|
||||
});
|
||||
|
||||
test("archiveAgent cascade archives off-memory children with the full archive contract", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-off-memory-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Parent",
|
||||
});
|
||||
const child = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Off-memory Child",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id } },
|
||||
);
|
||||
const managerInternals = manager as unknown as {
|
||||
agents: Map<string, unknown>;
|
||||
};
|
||||
managerInternals.agents.delete(child.id);
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
expectArchivedAgentRecord(await storage.get(child.id), "idle");
|
||||
});
|
||||
|
||||
test("archiveAgent cascade notifies subscribers for in-memory and off-memory children", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-notifications-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Parent",
|
||||
});
|
||||
const inMemoryChild = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "In-memory Child",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id } },
|
||||
);
|
||||
const offMemoryChild = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Off-memory Child",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id } },
|
||||
);
|
||||
const managerInternals = manager as unknown as {
|
||||
agents: Map<string, unknown>;
|
||||
};
|
||||
managerInternals.agents.delete(offMemoryChild.id);
|
||||
const cascadedChildEvents: string[] = [];
|
||||
const unsubscribe = manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type !== "agent_state") {
|
||||
return;
|
||||
}
|
||||
if (event.agent.id === inMemoryChild.id || event.agent.id === offMemoryChild.id) {
|
||||
cascadedChildEvents.push(event.agent.id);
|
||||
}
|
||||
},
|
||||
{ replayState: false },
|
||||
);
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
unsubscribe();
|
||||
|
||||
expect({
|
||||
inMemoryChildNotified: cascadedChildEvents.includes(inMemoryChild.id),
|
||||
offMemoryChildNotified: cascadedChildEvents.includes(offMemoryChild.id),
|
||||
}).toEqual({
|
||||
inMemoryChildNotified: true,
|
||||
offMemoryChildNotified: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("archiveAgent cascade surfaces partial child archive failures", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-partial-failure-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
let failingChildId: string | null = null;
|
||||
|
||||
class FailingChildArchiveStorage extends AgentStorage {
|
||||
override async upsert(record: StoredAgentRecord): Promise<void> {
|
||||
if (record.id === failingChildId && record.archivedAt) {
|
||||
throw new Error(`Injected cascade archive failure for ${record.id}`);
|
||||
}
|
||||
await super.upsert(record);
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new FailingChildArchiveStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Parent",
|
||||
});
|
||||
const child = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Failing Child",
|
||||
},
|
||||
undefined,
|
||||
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id } },
|
||||
);
|
||||
failingChildId = child.id;
|
||||
|
||||
await expect(manager.archiveAgent(parent.id)).rejects.toThrow(
|
||||
`Injected cascade archive failure for ${child.id}`,
|
||||
);
|
||||
});
|
||||
|
||||
test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} from "../../shared/agent-lifecycle.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
@@ -54,8 +55,17 @@ import { getAgentProviderDefinition } from "./provider-manifest.js";
|
||||
|
||||
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
||||
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
||||
const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
type TimeoutResult = "completed" | "timed_out";
|
||||
type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string };
|
||||
|
||||
interface TimeoutOptions {
|
||||
operation: Promise<void>;
|
||||
@@ -67,6 +77,31 @@ function formatProviderList(providers: readonly string[]): string {
|
||||
return providers.length > 0 ? providers.join(", ") : "none";
|
||||
}
|
||||
|
||||
function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
const config: AgentSessionConfig = {
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
};
|
||||
if (!record.config) {
|
||||
return config;
|
||||
}
|
||||
if (record.config.title != null) config.title = record.config.title;
|
||||
if (record.config.modeId != null) config.modeId = record.config.modeId;
|
||||
if (record.config.model != null) config.model = record.config.model;
|
||||
if (record.config.thinkingOptionId != null) {
|
||||
config.thinkingOptionId = record.config.thinkingOptionId;
|
||||
}
|
||||
if (record.config.featureValues != null) {
|
||||
config.featureValues = record.config.featureValues;
|
||||
}
|
||||
if (record.config.extra != null) config.extra = record.config.extra;
|
||||
if (record.config.systemPrompt != null) {
|
||||
config.systemPrompt = record.config.systemPrompt;
|
||||
}
|
||||
if (record.config.mcpServers != null) config.mcpServers = record.config.mcpServers;
|
||||
return config;
|
||||
}
|
||||
|
||||
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
|
||||
export type {
|
||||
AgentTimelineCursor,
|
||||
@@ -960,28 +995,107 @@ export class AgentManager {
|
||||
throw new Error(`Agent ${agentId} not found in storage after snapshot`);
|
||||
}
|
||||
|
||||
const { archivedAt } = await this.markRecordArchived(stored);
|
||||
await this.closeAgent(agentId);
|
||||
|
||||
await this.cascadeArchiveChildren(agentId);
|
||||
|
||||
return { archivedAt };
|
||||
}
|
||||
|
||||
// Children created via the MCP `create_agent` tool carry the parent-agent-id
|
||||
// label pointing back at the caller. Archiving the parent cascades to those
|
||||
// children so subagent fleets don't outlive their orchestrator. Handoff agents
|
||||
// launched the same way are caught by this cascade — see docs/agent-lifecycle.md
|
||||
// for the accepted limitation.
|
||||
private async cascadeArchiveChildren(parentAgentId: string): Promise<void> {
|
||||
const registry = this.registry;
|
||||
if (!registry) {
|
||||
return;
|
||||
}
|
||||
const records = await registry.list();
|
||||
for (const record of records) {
|
||||
if (record.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
||||
continue;
|
||||
}
|
||||
if (this.agents.has(record.id)) {
|
||||
await this.archiveAgent(record.id);
|
||||
} else {
|
||||
await this.markRecordArchived(record);
|
||||
await this.cascadeArchiveChildren(record.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async markRecordArchived(record: StoredAgentRecord): Promise<ArchivedStoredAgentRecord> {
|
||||
const registry = this.requireRegistry();
|
||||
const archivedAt = new Date().toISOString();
|
||||
const normalizedStatus =
|
||||
stored.lastStatus === "running" || stored.lastStatus === "initializing"
|
||||
record.lastStatus === "running" || record.lastStatus === "initializing"
|
||||
? "idle"
|
||||
: stored.lastStatus;
|
||||
|
||||
await this.registry.upsert({
|
||||
...stored,
|
||||
: record.lastStatus;
|
||||
const archivedRecord: ArchivedStoredAgentRecord = {
|
||||
...record,
|
||||
archivedAt,
|
||||
updatedAt: archivedAt,
|
||||
lastStatus: normalizedStatus,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
};
|
||||
|
||||
await registry.upsert(archivedRecord);
|
||||
|
||||
await this.archiveNativeSessionBestEffort(record.provider, record.persistence);
|
||||
|
||||
if (this.agents.has(record.id)) {
|
||||
this.notifyAgentState(record.id);
|
||||
} else if (!archivedRecord.internal) {
|
||||
this.dispatchArchivedStoredAgent(archivedRecord);
|
||||
}
|
||||
|
||||
return archivedRecord;
|
||||
}
|
||||
|
||||
private dispatchArchivedStoredAgent(record: StoredAgentRecord): void {
|
||||
const updatedAt = new Date(record.updatedAt);
|
||||
this.dispatch({
|
||||
type: "agent_state",
|
||||
agent: {
|
||||
id: record.id,
|
||||
provider: record.provider,
|
||||
cwd: record.cwd,
|
||||
session: null,
|
||||
capabilities: STORED_AGENT_CAPABILITIES,
|
||||
config: buildStoredAgentConfig(record),
|
||||
runtimeInfo: undefined,
|
||||
lifecycle: "closed",
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt,
|
||||
availableModes: [],
|
||||
features: record.features,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
inFlightPermissionResponses: new Set(),
|
||||
pendingReplacement: false,
|
||||
activeForegroundTurnId: null,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
finalizedForegroundTurnIds: new Set(),
|
||||
unsubscribeSession: null,
|
||||
persistence: record.persistence ?? null,
|
||||
historyPrimed: true,
|
||||
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
|
||||
lastUsage: undefined,
|
||||
lastError: record.lastError ?? undefined,
|
||||
attention: { requiresAttention: false },
|
||||
internal: record.internal,
|
||||
labels: record.labels,
|
||||
},
|
||||
});
|
||||
|
||||
await this.archiveNativeSessionBestEffort(agent.provider, stored.persistence);
|
||||
|
||||
this.notifyAgentState(agentId);
|
||||
await this.closeAgent(agentId);
|
||||
|
||||
return { archivedAt };
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<void> {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { z } from "zod";
|
||||
|
||||
import { AGENT_WAIT_TIMEOUT_MS } from "./mcp-shared.js";
|
||||
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
||||
|
||||
interface StructuredContent {
|
||||
[key: string]: unknown;
|
||||
@@ -261,13 +262,13 @@ describe("Suite A: Core Fixes", () => {
|
||||
expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000);
|
||||
});
|
||||
|
||||
test("create_agent with callerAgentId sets paseo.parent-agent-id label", async () => {
|
||||
test("create_agent with callerAgentId sets the parent agent label", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createChildAgent();
|
||||
const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(snapshot?.labels).toMatchObject({
|
||||
"paseo.parent-agent-id": parentAgentId,
|
||||
[PARENT_AGENT_ID_LABEL]: parentAgentId,
|
||||
});
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js";
|
||||
import { WorkspaceGitServiceImpl } from "../workspace-git-service.js";
|
||||
import type { GitHubService } from "../../services/github-service.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
||||
|
||||
const REPO_CWD = resolvePath("/tmp/repo");
|
||||
const TARGET_CWD = resolvePath("/tmp/target");
|
||||
@@ -1246,7 +1247,7 @@ describe("create_agent MCP tool", () => {
|
||||
undefined,
|
||||
{
|
||||
labels: {
|
||||
"paseo.parent-agent-id": "voice-agent",
|
||||
[PARENT_AGENT_ID_LABEL]: "voice-agent",
|
||||
source: "voice",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js";
|
||||
import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
import type {
|
||||
AgentWorktreeSetupContinuation,
|
||||
@@ -822,7 +823,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
|
||||
const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels;
|
||||
const mergedLabels = {
|
||||
...(callerAgentId ? { "paseo.parent-agent-id": callerAgentId } : {}),
|
||||
...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}),
|
||||
...childAgentDefaultLabels,
|
||||
...labels,
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
parseConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "../shared/daemon-endpoints.js";
|
||||
export { PARENT_AGENT_ID_LABEL } from "../shared/agent-labels.js";
|
||||
export {
|
||||
DirectTcpHostConnectionSchema,
|
||||
type DirectTcpHostConnection,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
|
||||
Reference in New Issue
Block a user