chore(merge): integrate main into workspace runtimes

This commit is contained in:
Mohamed Boudra
2026-08-14 13:28:32 +02:00
603 changed files with 33782 additions and 21333 deletions
@@ -15,7 +15,7 @@ import {
} from "./agent-manager.js";
import { AgentStorage } from "./agent-storage.js";
import { toAgentPayload } from "./agent-projections.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
import type { StoredAgentRecord } from "./agent-storage.js";
@@ -43,6 +43,9 @@ import type {
import type { PaseoToolCatalog } from "./tools/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
@@ -3712,7 +3715,7 @@ test("setLabels merges and persists labels", async () => {
});
});
test("detachAgent removes only the parent label from a live agent and emits state", async () => {
test("detachAgent removes relationship lifecycle labels from a live agent and emits state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -3743,6 +3746,8 @@ test("detachAgent removes only the parent label from a live agent and emits stat
{
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
team: "infra",
},
workspaceId: undefined,
@@ -3770,7 +3775,7 @@ test("detachAgent removes only the parent label from a live agent and emits stat
expect(emittedLabels).toContainEqual({ team: "infra" });
});
test("detachAgent removes the parent label from a stored-only agent", async () => {
test("detachAgent removes relationship lifecycle labels from a stored-only agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -3801,6 +3806,8 @@ test("detachAgent removes the parent label from a stored-only agent", async () =
{
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
role: "reviewer",
},
workspaceId: undefined,
@@ -6846,6 +6853,182 @@ test("archiveAgent cascade archives in-memory children with the full archive con
expect(storedUnrelated?.archivedAt).toBeUndefined();
});
test("archiveAgent detaches an open same-workspace child instead of cascading", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Open child" },
undefined,
{
workspaceId: "workspace-a",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
[MOBILE_OPEN_AGENT_TAB_LABEL]: "true",
},
},
);
await manager.archiveAgent(parent.id);
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
expect(storedChild?.labels[DESKTOP_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
expect(storedChild?.labels[MOBILE_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
expect(manager.getAgent(child.id)?.id).toBe(child.id);
});
test("archiveAgent detaches a cross-workspace child even when its tab is closed", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-cross-workspace-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Cross-workspace child" },
undefined,
{
workspaceId: "workspace-b",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
},
},
);
await manager.archiveAgent(parent.id);
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.workspaceId).toBe("workspace-b");
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
});
test("archiveAgent re-reads a child before deciding whether to cascade", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-"));
class ChildOpensAfterCascadeListStorage extends AgentStorage {
childId: string | null = null;
override async list(): Promise<StoredAgentRecord[]> {
const records = await super.list();
const parentIsArchived = records.some(
(record) => record.id !== this.childId && Boolean(record.archivedAt),
);
const staleChild = records.find((record) => record.id === this.childId);
if (parentIsArchived && staleChild) {
await super.upsert({
...staleChild,
labels: { ...staleChild.labels, [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
});
}
return records;
}
}
const storage = new ChildOpensAfterCascadeListStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Late-open child" },
undefined,
{
workspaceId: "workspace-a",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
},
},
);
storage.childId = child.id;
await manager.archiveAgent(parent.id);
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
});
test("archiveAgent cannot overtake a received child open-tab update", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-race-"));
const markerWriteStarted = deferred<void>();
const releaseMarkerWrite = deferred<void>();
class BlockingOpenMarkerStorage extends AgentStorage {
childId: string | null = null;
override async applySnapshot(
agent: ManagedAgent,
options?: { title?: string | null; internal?: boolean },
): Promise<void> {
if (agent.id === this.childId && agent.labels[MOBILE_OPEN_AGENT_TAB_LABEL] === "true") {
markerWriteStarted.resolve();
await releaseMarkerWrite.promise;
}
await super.applySnapshot(agent, options);
}
}
const storage = new BlockingOpenMarkerStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Parent" },
undefined,
{ workspaceId: "workspace-a" },
);
const child = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Opening child" },
undefined,
{
workspaceId: "workspace-a",
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
},
);
storage.childId = child.id;
const markOpen = manager.updateAgentMetadata(child.id, {
labels: { [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
});
await markerWriteStarted.promise;
const archiveParent = manager.archiveAgent(parent.id);
releaseMarkerWrite.resolve();
await Promise.all([markOpen, archiveParent]);
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).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");
@@ -7,7 +7,9 @@ import {
} from "@getpaseo/protocol/agent-lifecycle";
import {
getParentAgentIdFromLabels,
hasOpenAgentTab,
isDelegatedAgent,
isOpenAgentTabLabel,
PARENT_AGENT_ID_LABEL,
} from "@getpaseo/protocol/agent-labels";
import type { Logger } from "pino";
@@ -620,6 +622,27 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str
return null;
}
function shouldDetachFromArchivedParent(
parent: StoredAgentRecord,
child: StoredAgentRecord,
): boolean {
const isCrossWorkspace =
parent.workspaceId !== undefined &&
child.workspaceId !== undefined &&
parent.workspaceId !== child.workspaceId;
return isCrossWorkspace || hasOpenAgentTab(child.labels);
}
function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch {
const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null };
for (const label of Object.keys(labels)) {
if (isOpenAgentTabLabel(label)) {
patch[label] = null;
}
}
return patch;
}
export class AgentManager {
private readonly clients = new Map<AgentProvider, AgentClient>();
private readonly providerEnabled = new Map<AgentProvider, boolean>();
@@ -638,6 +661,7 @@ export class AgentManager {
private readonly backgroundTasks = new Set<Promise<void>>();
private readonly agentRegistrationTasks = new Set<Promise<void>>();
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null;
@@ -1568,6 +1592,10 @@ export class AgentManager {
}
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
}
private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> {
const agent = this.requireAgent(agentId);
if (!this.registry) {
throw new Error("Agent storage is not configured");
@@ -1601,6 +1629,10 @@ export class AgentManager {
return;
}
const records = await registry.list();
const parent = records.find((record) => record.id === parentAgentId);
if (!parent) {
throw new Error(`Archived parent ${parentAgentId} not found in storage`);
}
for (const record of records) {
if (record.archivedAt) {
continue;
@@ -1608,11 +1640,27 @@ export class AgentManager {
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
continue;
}
if (this.agents.has(record.id)) {
await this.archiveAgent(record.id);
} else {
await this.archiveSnapshot(record.id, new Date().toISOString());
const child = await registry.get(record.id);
if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
continue;
}
await this.runLifecycleMutation(child.id, async () => {
const currentChild = await registry.get(child.id);
if (
!currentChild ||
currentChild.archivedAt ||
currentChild.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId
) {
return;
}
if (shouldDetachFromArchivedParent(parent, currentChild)) {
await this.detachAgentUnlocked(currentChild.id);
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
await this.archiveSnapshot(currentChild.id, new Date().toISOString());
}
});
}
}
@@ -1785,8 +1833,10 @@ export class AgentManager {
}
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
const agent = this.requireAgent(agentId);
await this.writeLabels(agent.id, labels);
await this.runLifecycleMutation(agentId, async () => {
const agent = this.requireAgent(agentId);
await this.writeLabels(agent.id, labels);
});
}
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
@@ -1828,6 +1878,14 @@ export class AgentManager {
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
}
private async detachAgentUnlocked(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
const registry = this.requireRegistry();
const liveAgent = this.agents.get(agentId);
@@ -1842,7 +1900,7 @@ export class AgentManager {
return { record, live: true, previousParentAgentId: null };
}
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
@@ -1858,7 +1916,7 @@ export class AgentManager {
return { record, live: false, previousParentAgentId: null };
}
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
if (!result.record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
@@ -1964,6 +2022,18 @@ export class AgentManager {
title?: string;
labels?: Record<string, string>;
},
): Promise<void> {
await this.runLifecycleMutation(agentId, () =>
this.updateAgentMetadataUnlocked(agentId, updates),
);
}
private async updateAgentMetadataUnlocked(
agentId: string,
updates: {
title?: string;
labels?: Record<string, string>;
},
): Promise<void> {
const liveAgent = this.getAgent(agentId);
if (liveAgent) {
@@ -1979,6 +2049,24 @@ export class AgentManager {
await this.writeStoredMetadata(agentId, updates);
}
private async runLifecycleMutation<T>(agentId: string, mutation: () => Promise<T>): Promise<T> {
// Parent cascade classifies a child inside the same lane used by open-tab
// label writes, so a received ownership update cannot be overtaken.
const previous = this.lifecycleMutationTails.get(agentId) ?? Promise.resolve();
const result = previous.catch(() => undefined).then(mutation);
const tail = result.then(
() => undefined,
() => undefined,
);
this.lifecycleMutationTails.set(agentId, tail);
void tail.finally(() => {
if (this.lifecycleMutationTails.get(agentId) === tail) {
this.lifecycleMutationTails.delete(agentId);
}
});
return result;
}
async runAgent(
agentId: string,
prompt: AgentPromptInput,
@@ -46,8 +46,14 @@ interface FinishNotificationScenarioOptions {
interface FinishNotificationScenario {
startWatchingChild(): void;
requestChildPermission(requestId?: string): void;
resolveChildPermission(requestId?: string): void;
resolveChildPermissionFromState(requestId?: string): void;
resolveChildPermissionWhileIdle(requestId?: string): void;
finishChild(): void;
finishChildAndReadParentPrompt(): Promise<string>;
closeChildAndReadParentPrompt(): Promise<string>;
parentPrompts(): string[];
wasParentPrompted(): boolean;
}
@@ -57,11 +63,13 @@ function createFinishNotificationScenario(
let subscriber: ((event: AgentManagerEvent) => void) | null = null;
let resolveParentPrompt: ((prompt: string) => void) | null = null;
let parentPrompted = false;
const parentPrompts: string[] = [];
const childAgent: ManagedAgent = Object.create(null);
Reflect.set(childAgent, "id", "child-agent");
Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" });
Reflect.set(childAgent, "pendingPermissions", new Map());
const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", "caller-agent");
@@ -91,6 +99,7 @@ function createFinishNotificationScenario(
Reflect.set(agentManager, "hasInFlightRun", () => Boolean(options?.parentPromptError));
Reflect.set(agentManager, "streamAgent", (_agentId: string, prompt: string) => {
parentPrompted = true;
parentPrompts.push(prompt);
resolveParentPrompt?.(prompt);
return (async function* noop() {})();
});
@@ -123,6 +132,65 @@ function createFinishNotificationScenario(
logger: options?.logger ?? createTestLogger(),
});
},
requestChildPermission(requestId = "permission-1") {
childAgent.lifecycle = "running";
childAgent.pendingPermissions.set(requestId, {
id: requestId,
provider: "claude",
kind: "tool",
name: "Run command",
description: "Write the QA sentinel",
input: {
file_path: "/tmp/permission-qa.txt",
content: "PASEO_PERMISSION_NOTIFY_QA_OK\n",
},
});
subscriber?.({
type: "agent_state",
agent: childAgent,
});
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_requested",
provider: "codex",
request: childAgent.pendingPermissions.get(requestId)!,
},
});
},
resolveChildPermission(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_resolved",
provider: "codex",
requestId,
resolution: { behavior: "allow" },
},
});
},
resolveChildPermissionFromState(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
subscriber?.({ type: "agent_state", agent: childAgent });
},
resolveChildPermissionWhileIdle(requestId = "permission-1") {
childAgent.pendingPermissions.delete(requestId);
childAgent.lifecycle = "idle";
subscriber?.({ type: "agent_state", agent: childAgent });
subscriber?.({
type: "agent_stream",
agentId: "child-agent",
event: {
type: "permission_resolved",
provider: "codex",
requestId,
resolution: { behavior: "allow" },
},
});
},
finishChild() {
childAgent.lifecycle = "running";
subscriber?.({
@@ -144,6 +212,28 @@ function createFinishNotificationScenario(
return parentPrompt;
},
async closeChildAndReadParentPrompt() {
const parentPrompt = new Promise<string>((resolve) => {
resolveParentPrompt = resolve;
});
childAgent.lifecycle = "running";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
childAgent.lifecycle = "closed";
subscriber?.({
type: "agent_state",
agent: childAgent,
});
return parentPrompt;
},
parentPrompts() {
return parentPrompts;
},
wasParentPrompted() {
return parentPrompted;
},
@@ -209,6 +299,135 @@ test("finish notifications tell the parent the child's last assistant message",
);
});
test("finish notifications truncate oversized child responses", async () => {
const included = "x".repeat(4000);
const omitted = "TAIL-MARKER".repeat(50);
const scenario = createFinishNotificationScenario({
childLastAssistantMessage: included + omitted,
});
scenario.startWatchingChild();
const parentPrompt = await scenario.finishChildAndReadParentPrompt();
expect(parentPrompt).toContain(included);
expect(parentPrompt).toContain(
`[truncated ${omitted.length} chars; use get_agent_activity for the full response]`,
);
expect(parentPrompt).not.toContain("TAIL-MARKER");
});
test("closing a watched child notifies the caller", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
const parentPrompt = await scenario.closeChildAndReadParentPrompt();
expect(parentPrompt).toEqual(
formatSystemNotificationPrompt("Agent child-agent (Child Agent) was closed."),
);
});
test("finish notifications survive permission responses", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => {
expect(scenario.parentPrompts()).toHaveLength(1);
});
expect(scenario.parentPrompts()[0]).toContain("needs permission.");
const permissionPayload = scenario
.parentPrompts()[0]
.match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1];
expect(permissionPayload).toBeDefined();
expect(JSON.parse(permissionPayload!)).toEqual({
agentId: "child-agent",
requestId: "permission-1",
request: {
id: "permission-1",
provider: "claude",
kind: "tool",
name: "Run command",
description: "Write the QA sentinel",
input: {
file_path: "/tmp/permission-qa.txt",
content: "PASEO_PERMISSION_NOTIFY_QA_OK\n",
},
},
});
scenario.resolveChildPermission();
scenario.finishChild();
await vi.waitFor(() => {
expect(scenario.parentPrompts()).toHaveLength(2);
});
expect(scenario.parentPrompts()[1]).toContain("finished.");
});
test("an idle permission resolution waits for the resumed run to finish", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1));
scenario.resolveChildPermissionWhileIdle();
scenario.requestChildPermission("permission-2");
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
expect(scenario.parentPrompts().every((prompt) => prompt.includes("needs permission."))).toBe(
true,
);
scenario.resolveChildPermission("permission-2");
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(scenario.parentPrompts()[2]).toContain("finished.");
});
test("finish notifications report every concurrently pending permission", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission("permission-1");
scenario.requestChildPermission("permission-2");
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
expect(
scenario.parentPrompts().map((prompt) => {
const payload = prompt.match(/<permission-request>\n([\s\S]+?)\n<\/permission-request>/)?.[1];
return JSON.parse(payload!).requestId;
}),
).toEqual(["permission-1", "permission-2"]);
scenario.resolveChildPermission("permission-1");
scenario.resolveChildPermission("permission-2");
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(scenario.parentPrompts()[2]).toContain("finished.");
});
test("finish notifications survive repeated permission cycles", async () => {
const scenario = createFinishNotificationScenario();
scenario.startWatchingChild();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(1));
scenario.resolveChildPermissionFromState();
scenario.requestChildPermission();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(2));
scenario.resolveChildPermission();
scenario.finishChild();
await vi.waitFor(() => expect(scenario.parentPrompts()).toHaveLength(3));
expect(
scenario.parentPrompts().map((prompt) => prompt.match(/(needs permission|finished)\./)?.[1]),
).toEqual(["needs permission", "needs permission", "finished"]);
});
test("detaching a child ends its parent-owned finish notification", async () => {
const scenario = createFinishNotificationScenario({
childParentAgentId: null,
@@ -258,6 +477,7 @@ it("does not notify archived callers", async () => {
Reflect.set(childAgent, "id", "child-agent");
Reflect.set(childAgent, "lifecycle", "idle");
Reflect.set(childAgent, "config", { title: "Child Agent" });
Reflect.set(childAgent, "pendingPermissions", new Map());
const callerAgent: ManagedAgent = Object.create(null);
Reflect.set(callerAgent, "id", "caller-agent");
@@ -1,6 +1,10 @@
import type { Logger } from "pino";
import type { AgentPromptInput, AgentRunOptions } from "./agent-sdk-types.js";
import type {
AgentPermissionRequest,
AgentPromptInput,
AgentRunOptions,
} from "./agent-sdk-types.js";
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import { ensureAgentLoaded } from "./agent-loading.js";
@@ -249,20 +253,49 @@ export interface SetupFinishNotificationParams {
logger: Logger;
}
type FinishNotificationReason = "finished" | "errored" | "needs permission" | "was closed";
const FINISH_NOTIFICATION_MESSAGE_LIMIT = 4000;
interface FinishNotificationBodyInput {
childAgentId: string;
title: string;
reason: "finished" | "errored" | "needs permission";
reason: FinishNotificationReason;
lastAssistantMessage: string | null;
permissionRequest?: AgentPermissionRequest;
}
function formatFinishNotificationBody(params: FinishNotificationBodyInput): string {
const statusLine = `Agent ${params.childAgentId} (${params.title}) ${params.reason}.`;
const lastAssistantMessage = params.lastAssistantMessage?.trim();
if (!lastAssistantMessage) {
return statusLine;
const sections = [statusLine];
if (params.reason === "needs permission" && params.permissionRequest) {
sections.push(
"Respond with `respond_to_permission` using the `agentId` and `requestId` below.",
`<permission-request>\n${JSON.stringify(
{
agentId: params.childAgentId,
requestId: params.permissionRequest.id,
request: params.permissionRequest,
},
null,
2,
)}\n</permission-request>`,
);
}
return `${statusLine}\n\n<agent-response>\n${lastAssistantMessage}\n</agent-response>`;
let lastAssistantMessage = params.lastAssistantMessage?.trim();
if (lastAssistantMessage) {
if (lastAssistantMessage.length > FINISH_NOTIFICATION_MESSAGE_LIMIT) {
const omitted = lastAssistantMessage.length - FINISH_NOTIFICATION_MESSAGE_LIMIT;
lastAssistantMessage = `${lastAssistantMessage.slice(0, FINISH_NOTIFICATION_MESSAGE_LIMIT)}\n[truncated ${omitted} chars; use get_agent_activity for the full response]`;
}
sections.push(`<agent-response>\n${lastAssistantMessage}\n</agent-response>`);
}
return sections.join("\n\n");
}
interface NotifySafelyOptions {
terminal?: boolean;
permissionRequest?: AgentPermissionRequest;
}
export function setupFinishNotification(params: SetupFinishNotificationParams): void {
@@ -275,16 +308,21 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
logger,
} = params;
let hasSeenRunning = false;
let fired = false;
let stopped = false;
const notifiedPermissionRequestIds = new Set<string>();
let unsubscribe: (() => void) | null = null;
let notificationQueue = Promise.resolve();
async function notify(reason: "finished" | "errored" | "needs permission"): Promise<void> {
if (fired) {
return;
}
fired = true;
function stop(): void {
if (stopped) return;
stopped = true;
unsubscribe?.();
}
async function notify(
reason: FinishNotificationReason,
permissionRequest?: AgentPermissionRequest,
): Promise<void> {
const callerRecord = await agentStorage.get(callerAgentId);
if (callerRecord?.archivedAt) {
return;
@@ -301,6 +339,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
title,
reason,
lastAssistantMessage,
permissionRequest,
});
await sendPromptToAgent({
@@ -313,24 +352,35 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
});
}
function notifySafely(reason: "finished" | "errored" | "needs permission"): void {
void notify(reason).catch((error) => {
logger.error(
{ err: error, childAgentId, callerAgentId, reason },
"Failed to notify caller agent",
);
});
function notifySafely(reason: FinishNotificationReason, options: NotifySafelyOptions = {}): void {
if (stopped) return;
if (options.terminal ?? true) stop();
notificationQueue = notificationQueue
.then(() => notify(reason, options.permissionRequest))
.catch((error) => {
logger.error(
{ err: error, childAgentId, callerAgentId, reason },
"Failed to notify caller agent",
);
});
}
unsubscribe = agentManager.subscribe(
(event) => {
if (fired) {
if (stopped) {
return;
}
if (event.type === "agent_state") {
for (const requestId of notifiedPermissionRequestIds) {
if (!event.agent.pendingPermissions.has(requestId)) {
notifiedPermissionRequestIds.delete(requestId);
}
}
if (event.agent.lifecycle === "running") {
hasSeenRunning = true;
if (event.agent.pendingPermissions.size === 0) {
hasSeenRunning = true;
}
return;
}
if (event.agent.lifecycle === "error") {
@@ -342,15 +392,33 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
return;
}
if (event.agent.lifecycle === "closed") {
fired = true;
unsubscribe?.();
notifySafely("was closed");
return;
}
return;
}
if (event.event.type === "permission_requested") {
notifySafely("needs permission");
// A permission pause is an intermediate checkpoint. Forget the run
// observed before it so an idle state during follow-up startup cannot
// masquerade as the final completion.
hasSeenRunning = false;
if (!notifiedPermissionRequestIds.has(event.event.request.id)) {
notifiedPermissionRequestIds.add(event.event.request.id);
notifySafely("needs permission", {
terminal: false,
permissionRequest: event.event.request,
});
}
return;
}
if (event.event.type === "permission_resolved") {
notifiedPermissionRequestIds.delete(event.event.requestId);
const childAgent = agentManager.getAgent(childAgentId);
if (childAgent?.pendingPermissions.size === 0) {
hasSeenRunning = childAgent.lifecycle === "running";
}
}
},
{ agentId: childAgentId, replayState: false },
@@ -363,7 +431,7 @@ export function setupFinishNotification(params: SetupFinishNotificationParams):
// transitioning to "running").
const childSnapshot = agentManager.getAgent(childAgentId);
if (!childSnapshot || childSnapshot.lifecycle === "closed") {
unsubscribe();
stop();
return;
}
if (childSnapshot.lifecycle === "running") {
@@ -1,5 +1,6 @@
import type {
AgentProviderNotice,
AgentTaskItem,
ProviderOptions,
ToolPolicy,
} from "@getpaseo/protocol/agent-types";
@@ -7,7 +8,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
import type { ProviderWorkspace } from "./providers/workspace/index.js";
export type { AgentProviderNotice };
export type { AgentProviderNotice, AgentTaskItem };
export type { ProviderWorkspace } from "./providers/workspace/index.js";
export type AgentProvider = string;
@@ -385,7 +386,7 @@ export type AgentTimelineItem =
| { type: "assistant_message"; text: string; messageId?: string }
| { type: "reasoning"; text: string }
| ToolCallTimelineItem
| { type: "todo"; items: { text: string; completed: boolean }[] }
| { type: "todo"; items: AgentTaskItem[] }
| { type: "error"; message: string }
| CompactionTimelineItem;
@@ -671,7 +672,6 @@ export type FetchCatalogOptions =
| {
scope: "global";
force: boolean;
timeoutMs?: number;
}
| {
scope: "workspace";
@@ -679,9 +679,14 @@ export type FetchCatalogOptions =
workspaceId?: string;
workspace?: ProviderWorkspace;
force: boolean;
timeoutMs?: number;
};
export interface ProviderRefreshContext {
readonly signal: AbortSignal;
/** Track an upstream operation so timeout errors identify the work still pending. */
runActivity<T>(name: string, operation: () => Promise<T>): Promise<T>;
}
export interface ProviderCatalog {
models: AgentModelDefinition[];
modes: AgentMode[];
@@ -691,6 +696,7 @@ export interface ProviderCatalog {
export interface ResolveAgentDefaultModeInput {
config: AgentSessionConfig;
env?: Record<string, string>;
signal?: AbortSignal;
}
export interface AgentClient {
@@ -712,8 +718,13 @@ export interface AgentClient {
* process, separate upstream calls, static modes, or private helpers; callers
* outside the provider do not get separate runtime model/mode probes.
* The registry is responsible for merging configured model overrides.
* ProviderSnapshotManager supplies a shared context. Providers must pass its
* signal downstream and finish resource cleanup before rejecting on abort.
*/
fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog>;
fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog>;
/** Apply provider-owned defaults to a model supplied through provider configuration. */
resolveConfiguredModel?(model: AgentModelDefinition): AgentModelDefinition;
resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise<string | undefined>;
@@ -732,7 +743,7 @@ export interface AgentClient {
* Check if this provider is available (CLI binary is installed).
* Returns true if available, false otherwise.
*/
isAvailable(options?: FetchCatalogOptions): Promise<boolean>;
isAvailable(options?: FetchCatalogOptions, signal?: AbortSignal): Promise<boolean>;
getDiagnostic?(): Promise<{ diagnostic: string }>;
/**
* Archive a durable native session (best-effort). Runtime release belongs to AgentSession.close().
@@ -102,6 +102,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
id: overrides.id ?? "agent-test",
provider: core.provider,
cwd: core.cwd,
workspaceId: overrides.workspaceId,
session: core.session,
capabilities: overrides.capabilities ?? buildDefaultCapabilities(),
config: core.config,
@@ -433,6 +434,47 @@ describe("AgentStorage", () => {
expect(record?.internal).toBe(true);
});
test("queries agents by provider session and native handle", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "matching-session",
provider: "codex",
persistence: {
provider: "codex",
sessionId: "session-1",
nativeHandle: "thread-1",
},
}),
);
await storage.applySnapshot(
createManagedAgent({
id: "other-session",
provider: "codex",
persistence: { provider: "codex", sessionId: "session-2" },
}),
);
await expect(storage.listByProviderSession("codex", "session-1")).resolves.toMatchObject([
{ id: "matching-session" },
]);
await expect(storage.listByProviderSession("codex", "thread-1")).resolves.toMatchObject([
{ id: "matching-session" },
]);
});
test("queries agents by workspace", async () => {
await storage.applySnapshot(
createManagedAgent({ id: "workspace-agent", workspaceId: "workspace-1" }),
);
await storage.applySnapshot(
createManagedAgent({ id: "other-workspace-agent", workspaceId: "workspace-2" }),
);
await expect(storage.listByWorkspace("workspace-1")).resolves.toMatchObject([
{ id: "workspace-agent" },
]);
});
test("internal flag is persisted and reloaded", async () => {
await storage.applySnapshot(
createManagedAgent({
@@ -126,6 +126,24 @@ export class AgentStorage {
return this.cache.get(agentId) ?? null;
}
async listByProviderSession(
provider: string,
providerHandleId: string,
): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values()).filter(
(record) =>
record.persistence?.provider === provider &&
(record.persistence.sessionId === providerHandleId ||
record.persistence.nativeHandle === providerHandleId),
);
}
async listByWorkspace(workspaceId: string): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values()).filter((record) => record.workspaceId === workspaceId);
}
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
await this.load();
const agentId = this.daemonAgentIdsByExecution.get(daemonExecutionKey(owner));
@@ -192,8 +192,9 @@ async function importProviderSessionNow(
): Promise<ImportedProviderSession> {
const { provider, providerHandleId, labels } = input.request;
const matchingRecords = (await input.agentStorage.list()).filter((record) =>
recordMatchesProviderHandle(record, { provider, providerHandleId }),
const matchingRecords = await input.agentStorage.listByProviderSession(
provider,
providerHandleId,
);
const activeRecord = matchingRecords.find((record) => !record.archivedAt);
if (activeRecord) {
@@ -273,16 +274,18 @@ async function serializeProviderSessionImport<T>(
async function resolveProviderSessionImportMutationKey(
input: ImportProviderSessionInput,
): Promise<string> {
const identity = {
provider: input.request.provider,
providerHandleId: input.request.providerHandleId,
};
const matchingRecord = (await input.agentStorage.list()).find((record) =>
recordMatchesProviderHandle(record, identity),
);
const matchingRecord = (
await input.agentStorage.listByProviderSession(
input.request.provider,
input.request.providerHandleId,
)
).at(0);
return matchingRecord
? `agent\0${matchingRecord.id}`
: `handle\0${toProviderSessionHandleKey(identity.provider, identity.providerHandleId)}`;
: `handle\0${toProviderSessionHandleKey(
input.request.provider,
input.request.providerHandleId,
)}`;
}
async function rollbackArchivedImport(
@@ -312,17 +315,6 @@ async function rollbackArchivedImport(
}
}
function recordMatchesProviderHandle(
record: StoredAgentRecord,
identity: { provider: string; providerHandleId: string },
): boolean {
return (
record.persistence?.provider === identity.provider &&
(record.persistence.sessionId === identity.providerHandleId ||
record.persistence.nativeHandle === identity.providerHandleId)
);
}
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
if (!since) {
return null;
@@ -146,6 +146,15 @@ let parentAgentCwd: string;
let worktreeRepoCwd: string;
let launchConfigsByProvider: Record<AgentProvider, AgentSessionConfig[]>;
const seededAgentProfile = {
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
};
function createRecordingAgentClients(): Record<AgentProvider, AgentClient> {
const baseClients = createTestAgentClients();
launchConfigsByProvider = {};
@@ -285,7 +294,10 @@ beforeAll(async () => {
parentAgentCwd = await makeCwd("parent-agent-cwd");
worktreeRepoCwd = await makeCwd("worktree-repo");
daemonHandle = await createTestPaseoDaemon({ agentClients: createRecordingAgentClients() });
daemonHandle = await createTestPaseoDaemon({
agentClients: createRecordingAgentClients(),
agentProfiles: [seededAgentProfile],
});
topLevelClient = await createMcpClient(`http://127.0.0.1:${daemonHandle.port}/mcp/agents`);
const parentPayload = await callToolStructured(topLevelClient, "create_agent", {
@@ -825,6 +837,12 @@ describe("Suite D: Provider Tools", () => {
expect(payload.provider).toBe("claude");
expect(Array.isArray(payload.models)).toBe(true);
});
test("list_profiles returns configured agent profiles, including notes", async () => {
const payload = await callToolStructured(topLevelClient, "list_profiles");
const profiles = recordArr(payload.profiles);
expect(profiles).toEqual([seededAgentProfile]);
});
});
describe("Suite E: Worktree Tools", () => {
@@ -52,6 +52,8 @@ import type { ForgeService } from "../../services/forge-service.js";
import { areEquivalentPaths } from "../../utils/path.js";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { MutableDaemonConfigSchema, type AgentProfile } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import type { BrowserToolsBroker, BrowserToolsExecuteInput } from "../browser-tools/broker.js";
import type { BrowserToolsResponsePayload } from "../browser-tools/errors.js";
import { readPaseoWorktreeMetadata } from "../../utils/worktree-metadata.js";
@@ -4901,6 +4903,78 @@ describe("provider listing MCP tool", () => {
});
});
function daemonConfigStoreStub(agentProfiles?: AgentProfile[]): Pick<DaemonConfigStore, "get"> {
const config = MutableDaemonConfigSchema.parse({
relay: { enabled: true },
mcp: { injectIntoAgents: true },
...(agentProfiles !== undefined ? { agentProfiles } : {}),
});
return { get: () => config };
}
describe("agent profile listing MCP tool", () => {
const logger = createTestLogger();
it("returns configured profiles, including notes", async () => {
const { agentManager, agentStorage } = createTestDeps();
const profiles: AgentProfile[] = [
{
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
];
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
daemonConfigStore: daemonConfigStoreStub(profiles),
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles });
});
it("returns an empty array when no profiles are configured", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
daemonConfigStore: daemonConfigStoreStub(),
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles: [] });
});
it("returns an empty array when no daemon config store is provided", async () => {
const { agentManager, agentStorage } = createTestDeps();
const server = await createAgentMcpServer({
agentManager,
agentStorage,
providerSnapshotManager: createOpenCodeManager().manager,
logger,
});
const tool = registeredTool(server, "list_profiles");
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles: [] });
});
});
describe("provider MCP tools", () => {
const logger = createTestLogger();
@@ -0,0 +1,80 @@
import type { ProviderRefreshContext } from "./agent-sdk-types.js";
interface RunProviderRefreshOptions<T> {
label: string;
timeoutMs: number;
operation: (context: ProviderRefreshContext) => Promise<T>;
}
export function runProviderRefreshActivity<T>(
context: ProviderRefreshContext | undefined,
name: string,
operation: () => Promise<T>,
): Promise<T> {
return context ? context.runActivity(name, operation) : operation();
}
export async function raceProviderRefreshAbort<T>(
signal: AbortSignal | undefined,
operation: Promise<T>,
): Promise<T> {
if (!signal) return await operation;
signal.throwIfAborted();
let handleAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
handleAbort = () => reject(signal.reason);
signal.addEventListener("abort", handleAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (handleAbort) signal.removeEventListener("abort", handleAbort);
}
}
export async function runProviderRefreshWithDeadline<T>(
options: RunProviderRefreshOptions<T>,
): Promise<T> {
const controller = new AbortController();
const activityCounts = new Map<string, number>();
let timeoutError: Error | undefined;
const context: ProviderRefreshContext = {
signal: controller.signal,
async runActivity(name, operation) {
controller.signal.throwIfAborted();
activityCounts.set(name, (activityCounts.get(name) ?? 0) + 1);
try {
const result = await operation();
controller.signal.throwIfAborted();
return result;
} finally {
const remaining = (activityCounts.get(name) ?? 1) - 1;
if (remaining === 0) {
activityCounts.delete(name);
} else {
activityCounts.set(name, remaining);
}
}
},
};
const timer = setTimeout(() => {
const pending = Array.from(activityCounts.keys());
const suffix = pending.length > 0 ? `; pending: ${pending.join(", ")}` : "";
timeoutError = new Error(
`Timed out refreshing ${options.label} after ${options.timeoutMs}ms${suffix}`,
);
controller.abort(timeoutError);
}, options.timeoutMs);
try {
const result = await options.operation(context);
if (timeoutError) throw timeoutError;
return result;
} catch (error) {
throw timeoutError ?? error;
} finally {
clearTimeout(timer);
}
}
@@ -13,6 +13,7 @@ import type {
AgentSession,
AgentStreamEvent,
FetchCatalogOptions,
ProviderRefreshContext,
ProviderCatalog,
ResolveAgentCreateConfigInput,
ResolveAgentCreateConfigResult,
@@ -24,6 +25,7 @@ import {
resolveDefaultAgentCreateConfig,
} from "./create-agent-mode.js";
import { normalizeAgentModelDefinition } from "./agent-sdk-types.js";
import { runProviderRefreshActivity } from "./provider-refresh-deadline.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js";
import type {
@@ -92,7 +94,11 @@ export interface ProviderDefinition extends AgentProviderDefinition {
* Single catalog discovery call used by ProviderSnapshotManager. Should spawn
* at most one provider runtime process and return both models and modes.
*/
fetchCatalog: (options: FetchCatalogOptions, client?: AgentClient) => Promise<ProviderCatalog>;
fetchCatalog: (
options: FetchCatalogOptions,
client?: AgentClient,
context?: ProviderRefreshContext,
) => Promise<ProviderCatalog>;
}
export interface BuildProviderRegistryOptions {
@@ -507,8 +513,8 @@ function wrapClientProvider(
options,
),
),
fetchCatalog: async (options) => {
const catalog = await inner.fetchCatalog(options);
fetchCatalog: async (options, context) => {
const catalog = await inner.fetchCatalog(options, context);
return {
...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
@@ -518,10 +524,11 @@ function wrapClientProvider(
};
},
resolveDefaultModeId: inner.resolveDefaultModeId
? async ({ config, env }: ResolveAgentDefaultModeInput) =>
? async ({ config, env, signal }: ResolveAgentDefaultModeInput) =>
await inner.resolveDefaultModeId?.({
config: { ...config, provider: inner.provider },
env,
signal,
})
: undefined,
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
@@ -561,7 +568,7 @@ function wrapClientProvider(
};
}
: undefined,
isAvailable: () => inner.isAvailable(),
isAvailable: (signal) => inner.isAvailable(signal),
getDiagnostic: inner.getDiagnostic?.bind(inner),
};
}
@@ -621,7 +628,11 @@ function createRegistryEntry(
resolveCreateConfig: modelClient.resolveCreateConfig ?? resolveDefaultAgentCreateConfig,
isCreateConfigUnattended:
modelClient.isCreateConfigUnattended ?? isDefaultAgentCreateConfigUnattended,
fetchCatalog: async (options: FetchCatalogOptions, client?: AgentClient) => {
fetchCatalog: async (
options: FetchCatalogOptions,
client?: AgentClient,
context?: ProviderRefreshContext,
) => {
const catalogClient = client ?? modelClient;
if (hasReplacementModels) {
// Replacement models skip runtime model discovery, but additionalModels
@@ -629,23 +640,29 @@ function createRegistryEntry(
// the single catalog API; otherwise use static/empty modes with no runtime.
const models = mergeModelAdditions(provider, replacementModels, additionalModels);
if (hasStaticModes) {
const defaultModeId = await catalogClient.resolveDefaultModeId?.({
config: {
provider,
cwd: options.scope === "workspace" ? options.cwd : process.cwd(),
},
});
const defaultModeId = await runProviderRefreshActivity(
context,
"default-mode",
async () =>
await catalogClient.resolveDefaultModeId?.({
config: {
provider,
cwd: options.scope === "workspace" ? options.cwd : process.cwd(),
},
signal: context?.signal,
}),
);
return {
models,
modes: decorateModes(resolved.definition.modes),
defaultModeId,
};
}
const catalog = await catalogClient.fetchCatalog(options);
const catalog = await catalogClient.fetchCatalog(options, context);
return { ...catalog, models, modes: decorateModes(catalog.modes) };
}
const catalog = await catalogClient.fetchCatalog(options);
const catalog = await catalogClient.fetchCatalog(options, context);
return {
...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
@@ -10,6 +10,7 @@ import type {
AgentProvider,
FetchCatalogOptions,
ProviderWorkspace,
ProviderRefreshContext,
ResolveAgentCreateConfigInput,
} from "./agent-sdk-types.js";
import type { ManagedAgent } from "./agent-manager.js";
@@ -79,6 +80,48 @@ async function withEnv(key: string, value: string, run: () => Promise<void>): Pr
}
}
function waitUntilAborted(signal?: AbortSignal): Promise<boolean> {
return new Promise((_resolve, reject) => {
signal?.addEventListener("abort", () => reject(signal.reason), { once: true });
});
}
function waitUntilAvailabilityAborted(
_options?: FetchCatalogOptions,
signal?: AbortSignal,
): Promise<boolean> {
return waitUntilAborted(signal);
}
function waitForDelay(delayMs: number): Promise<void> {
return new Promise((finish) => setTimeout(finish, delayMs));
}
function waitForAbortWithCleanup(
signal: AbortSignal,
cleanupState: { cleanedUp: boolean },
): Promise<void> {
return new Promise((_resolve, reject) => {
const handleAbort = () => {
cleanupState.cleanedUp = true;
reject(signal.reason);
};
signal.addEventListener("abort", handleAbort, { once: true });
});
}
async function runTestCatalogActivities(
context: ProviderRefreshContext,
cleanupState: { cleanedUp: boolean },
): Promise<void> {
const waitForAgents = () => waitForDelay(50);
const waitForProviders = () => waitForAbortWithCleanup(context.signal, cleanupState);
await Promise.all([
context.runActivity("app.agents", waitForAgents),
context.runActivity("provider.list", waitForProviders),
]);
}
describe("ProviderSnapshotManager public surface", () => {
test("validates complete Hub agent configurations through the current provider contract", async () => {
const manager = new ProviderSnapshotManager({
@@ -391,7 +434,9 @@ describe("ProviderSnapshotManager public surface", () => {
test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => {
// never-resolving isAvailable forces the timeout path
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
const isAvailable = vi.fn((_options?: FetchCatalogOptions, signal?: AbortSignal) =>
waitUntilAborted(signal),
);
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 1,
@@ -417,9 +462,192 @@ describe("ProviderSnapshotManager public surface", () => {
}
});
test("defaults provider refreshes to a two-minute deadline", async () => {
vi.useFakeTimers();
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
claude: { enabled: false },
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
},
extraClients: {
codex: createExtraClient("codex", { isAvailable: waitUntilAvailabilityAborted }),
},
});
try {
const entryPromise = manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
await vi.advanceTimersByTimeAsync(120_000);
await expect(entryPromise).resolves.toMatchObject({
status: "error",
error: "Timed out refreshing Codex after 120000ms; pending: availability",
});
} finally {
manager.destroy();
vi.useRealTimers();
}
});
test("one refresh timeout covers availability and catalog discovery", async () => {
vi.useFakeTimers();
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 100,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: () =>
new Promise((settle) => {
setTimeout(() => settle(true), 60);
}),
fetchCatalog: () =>
new Promise((settle) => {
setTimeout(() => settle({ models: [], modes: [] }), 60);
}),
}),
},
});
try {
const entryPromise = manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
await vi.advanceTimersByTimeAsync(120);
await expect(entryPromise).resolves.toMatchObject({
provider: "codex",
status: "error",
error: "Timed out refreshing Codex after 100ms",
});
} finally {
manager.destroy();
vi.useRealTimers();
}
});
test("timeout names pending catalog activities, aborts them, and waits for cleanup", async () => {
vi.useFakeTimers();
let attempt = 0;
const cleanupState = { cleanedUp: false };
const fetchCatalog = vi.fn(
async (_options: FetchCatalogOptions, context?: ProviderRefreshContext) => {
attempt += 1;
if (attempt > 1) {
expect(cleanupState.cleanedUp).toBe(true);
return { models: [], modes: [] };
}
if (!context) throw new Error("missing refresh context");
await runTestCatalogActivities(context, cleanupState);
return { models: [], modes: [] };
},
);
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 100,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog,
}),
},
});
try {
const first = manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
await vi.advanceTimersByTimeAsync(100);
await expect(first).resolves.toMatchObject({
status: "error",
error: "Timed out refreshing Codex after 100ms; pending: provider.list",
});
expect(cleanupState.cleanedUp).toBe(true);
await manager.refreshSnapshotForCwd({ cwd: "/tmp/project", providers: ["codex"] });
expect(fetchCatalog).toHaveBeenCalledTimes(2);
expect(
manager.getSnapshot("/tmp/project").find((entry) => entry.provider === "codex"),
).toMatchObject({ provider: "codex", status: "ready" });
} finally {
manager.destroy();
vi.useRealTimers();
}
});
test("each provider refresh gets its own timeout", async () => {
vi.useFakeTimers();
const neverReturnsCatalog = (_options: FetchCatalogOptions, context?: ProviderRefreshContext) =>
new Promise<never>((_resolve, reject) => {
context?.signal.addEventListener("abort", () => reject(context.signal.reason), {
once: true,
});
});
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 100,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: neverReturnsCatalog,
}),
claude: createExtraClient("claude", {
isAvailable: async () => true,
fetchCatalog: neverReturnsCatalog,
}),
},
});
try {
const codexPromise = manager.getProvider({
cwd: "/tmp/project",
provider: "codex",
wait: true,
});
await vi.advanceTimersByTimeAsync(50);
const claudePromise = manager.getProvider({
cwd: "/tmp/project",
provider: "claude",
wait: true,
});
await vi.advanceTimersByTimeAsync(50);
await expect(codexPromise).resolves.toMatchObject({
provider: "codex",
status: "error",
});
await expect(
manager.getProvider({ cwd: "/tmp/project", provider: "claude", wait: false }),
).resolves.toMatchObject({ provider: "claude", status: "loading" });
await vi.advanceTimersByTimeAsync(50);
await expect(claudePromise).resolves.toMatchObject({
provider: "claude",
status: "error",
});
} finally {
manager.destroy();
vi.useRealTimers();
}
});
test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => {
vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1");
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
const isAvailable = vi.fn(waitUntilAvailabilityAborted);
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
@@ -446,7 +674,7 @@ describe("ProviderSnapshotManager public surface", () => {
test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => {
vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1");
const isAvailable = vi.fn(() => new Promise<boolean>(() => {}));
const isAvailable = vi.fn(waitUntilAvailabilityAborted);
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: 5,
@@ -17,6 +17,10 @@ import {
type ProviderSnapshotEntry,
type ProviderWorkspace,
} from "./agent-sdk-types.js";
import {
raceProviderRefreshAbort,
runProviderRefreshWithDeadline,
} from "./provider-refresh-deadline.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { WorkspaceGitService } from "../workspace-git-service.js";
import type { ManagedProcessRegistry } from "../managed-processes/managed-processes.js";
@@ -42,7 +46,8 @@ import {
validateAgentConfigurationAgainstProvider,
} from "./agent-configuration-validator.js";
const DEFAULT_REFRESH_TIMEOUT_MS = 60_000;
const DEFAULT_REFRESH_TIMEOUT_MS = 120_000;
const MAX_REFRESH_TIMEOUT_MS = 2_147_483_647;
const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000;
const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS";
export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global";
@@ -51,14 +56,19 @@ export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global";
// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers).
// Allow operators to bump the ceiling via env var without rebuilding.
function resolveRefreshTimeoutMs(option: number | undefined): number {
if (typeof option === "number" && Number.isFinite(option) && option > 0) {
if (
typeof option === "number" &&
Number.isSafeInteger(option) &&
option > 0 &&
option <= MAX_REFRESH_TIMEOUT_MS
) {
return option;
}
const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR];
if (fromEnv) {
// Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate.
const parsed = Number(fromEnv);
if (Number.isFinite(parsed) && parsed > 0) {
if (Number.isSafeInteger(parsed) && parsed > 0 && parsed <= MAX_REFRESH_TIMEOUT_MS) {
return parsed;
}
}
@@ -580,7 +590,7 @@ export class ProviderSnapshotManager {
client.resolveCreateConfig?.bind(client) ?? definition.resolveCreateConfig,
isCreateConfigUnattended:
client.isCreateConfigUnattended?.bind(client) ?? definition.isCreateConfigUnattended,
fetchCatalog: client.fetchCatalog.bind(client),
fetchCatalog: (options, _client, context) => client.fetchCatalog(options, context),
};
}
@@ -899,23 +909,29 @@ export class ProviderSnapshotManager {
}
const client = this.ensureClient(provider, definition);
const catalogOptions = createFetchCatalogOptions(catalogScope, force);
const available = await withTimeout(
client.isAvailable(catalogOptions),
this.refreshTimeoutMs,
`Timed out checking ${definition.label} availability after ${this.refreshTimeoutMs}ms`,
);
if (!available) {
const catalog = await runProviderRefreshWithDeadline({
label: definition.label,
timeoutMs: this.refreshTimeoutMs,
operation: async (context) => {
const catalogOptions = createFetchCatalogOptions(catalogScope, force);
const available = await context.runActivity("availability", () =>
raceProviderRefreshAbort(
context.signal,
client.isAvailable(catalogOptions, context.signal),
),
);
if (!available) {
return null;
}
return await definition.fetchCatalog(catalogOptions, client, context);
},
});
if (!catalog) {
setEntry({ ...base, status: "unavailable", enabled: true });
return;
}
const catalog = await withTimeout(
definition.fetchCatalog({ ...catalogOptions, timeoutMs: this.refreshTimeoutMs }, client),
this.refreshTimeoutMs,
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
);
setEntry({
...base,
defaultModeId:
@@ -85,6 +85,7 @@ import {
type AgentTimelineItem,
type AgentUsage,
type FetchCatalogOptions,
type ProviderRefreshContext,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
@@ -97,6 +98,10 @@ import {
type ToolCallDetail,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import {
raceProviderRefreshAbort,
runProviderRefreshActivity,
} from "../provider-refresh-deadline.js";
import {
isDefaultAgentCreateConfigUnattended,
resolveDefaultAgentCreateConfig,
@@ -274,7 +279,6 @@ export function buildACPClientCapabilities(
// sign-in URL in the browser) when probing an ACP agent for models/modes.
// NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it.
const PROBE_ENV: Record<string, string> = { NO_BROWSER: "true" };
const ACP_CATALOG_TIMEOUT_MS = 60_000;
const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000;
function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } {
@@ -949,68 +953,85 @@ export class ACPAgentClient implements AgentClient {
return session;
}
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const workspace = providerWorkspaceFromCatalogOptions(options);
const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd);
const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS;
let probe: UninitializedACPProcess | null = null;
try {
const catalogProbe = (async () => {
const initializedProbe = await this.spawnProcess(PROBE_ENV, {
workspace,
initializeTimeoutMs: timeoutMs,
onSpawned: (spawned) => {
probe = spawned;
},
});
probe = initializedProbe;
const response = await this.runACPRequest(() =>
initializedProbe.connection.newSession({
cwd,
mcpServers: [],
}),
);
const transformed = this.transformSessionResponse(response);
const derivedModels = deriveModelDefinitionsFromACP(
this.provider,
transformed.models,
transformed.configOptions,
);
const models = this.catalogModelResolver
? await this.catalogModelResolver({
connection: initializedProbe.connection,
sessionId: response.sessionId,
models: derivedModels,
configOptions: transformed.configOptions,
runRequest: (request) => this.runACPRequest(request),
transformConfigOptions: (configOptions) =>
this.configOptionsTransformer
? this.configOptionsTransformer(configOptions)
: configOptions,
logger: this.logger,
provider: this.provider,
})
: derivedModels;
const modeInfo = deriveModesFromACP(
this.defaultModes,
transformed.modes,
transformed.configOptions,
);
return {
models: this.modelTransformer ? this.modelTransformer(models) : models,
modes: modeInfo.modes,
};
})();
let closePromise: Promise<void> | null = null;
const closeProbe = (): Promise<void> => {
if (!probe) return Promise.resolve();
closePromise ??= this.closeProbe(probe);
return closePromise;
};
const handleAbort = () => void closeProbe().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
return await withTimeout(
catalogProbe,
timeoutMs,
`ACP catalog probe timed out after ${timeoutMs}ms`,
try {
const initializedProbe = await runProviderRefreshActivity(context, "initialize", () =>
raceProviderRefreshAbort(
context?.signal,
this.spawnProcess(PROBE_ENV, {
workspace,
onSpawned: (spawned) => {
probe = spawned;
if (context?.signal.aborted) void closeProbe().catch(() => undefined);
},
}),
),
);
probe = initializedProbe;
const response = await runProviderRefreshActivity(context, "session/new", () =>
raceProviderRefreshAbort(
context?.signal,
this.runACPRequest(() =>
initializedProbe.connection.newSession({
cwd,
mcpServers: [],
}),
),
),
);
const transformed = this.transformSessionResponse(response);
const derivedModels = deriveModelDefinitionsFromACP(
this.provider,
transformed.models,
transformed.configOptions,
);
const models = this.catalogModelResolver
? await runProviderRefreshActivity(context, "catalog.resolve", () =>
raceProviderRefreshAbort(
context?.signal,
this.catalogModelResolver?.({
connection: initializedProbe.connection,
sessionId: response.sessionId,
models: derivedModels,
configOptions: transformed.configOptions,
runRequest: (request) => this.runACPRequest(request),
transformConfigOptions: (configOptions) =>
this.configOptionsTransformer
? this.configOptionsTransformer(configOptions)
: configOptions,
logger: this.logger,
provider: this.provider,
}) ?? Promise.resolve(derivedModels),
),
)
: derivedModels;
const modeInfo = deriveModesFromACP(
this.defaultModes,
transformed.modes,
transformed.configOptions,
);
return {
models: this.modelTransformer ? this.modelTransformer(models) : models,
modes: modeInfo.modes,
};
} finally {
if (probe) {
await this.closeProbe(probe);
}
context?.signal.removeEventListener("abort", handleAbort);
await closeProbe();
}
}
@@ -68,6 +68,12 @@ describe("Claude SDK env", () => {
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
runtimeSettings: {
env: {
MCP_TIMEOUT: "claude-startup-timeout",
MCP_TOOL_TIMEOUT: "claude-tool-timeout",
},
},
});
const session = await client.createSession(
{
@@ -82,6 +88,8 @@ describe("Claude SDK env", () => {
expect(result.sessionId).toBe("managed-agent-env-session");
expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
expect(capturedEnv?.MCP_TIMEOUT).toBe("claude-startup-timeout");
expect(capturedEnv?.MCP_TOOL_TIMEOUT).toBe("claude-tool-timeout");
} finally {
await session.close();
}
@@ -26,6 +26,7 @@ import type {
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
close(): Promise<void>;
}
function createStateWorkspace(overrides: Partial<ProviderWorkspace> = {}): ProviderWorkspace {
@@ -1436,6 +1437,52 @@ describe("ClaudeAgentSession context window usage", () => {
});
}
test("emits canonical task snapshots from Claude TaskCreate results", async () => {
const session = await createSessionForTest();
try {
session.translateMessageToEvents({
type: "assistant",
message: {
content: [
{
type: "tool_use",
id: "task-create-1",
name: "TaskCreate",
input: { subject: "Inspect provider", activeForm: "Inspecting provider" },
},
],
},
} as unknown as SDKMessage);
const events = session.translateMessageToEvents({
type: "user",
message: {
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
},
toolUseResult: { task: { id: "1", subject: "Inspect provider" } },
} as unknown as SDKMessage);
expect(events).toContainEqual({
type: "timeline",
provider: "claude",
item: {
type: "todo",
items: [
{
id: "1",
text: "Inspect provider",
activeForm: "Inspecting provider",
status: "pending",
completed: false,
},
],
},
});
} finally {
await session.close();
}
});
async function collectStreamEvents(session: AgentSession, prompt = "turn") {
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, prompt)) {
@@ -45,6 +45,7 @@ import {
} from "./model-manifest.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
import { ClaudeTaskState } from "./task-state.js";
import {
ClaudeTaskProtocolSource,
type ClaudeHookObservationInput,
@@ -122,9 +123,11 @@ import {
type McpServerConfig,
type ProviderCatalog,
type ProviderWorkspace,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
import {
checkProviderLaunchAvailable,
createProviderEnv,
@@ -409,7 +412,7 @@ interface ClaudeAgentClientOptions {
runtimeSettings?: ProviderRuntimeSettings;
queryFactory?: ClaudeQueryFactory;
resolveBinary?: () => Promise<string>;
resolveVersion?: () => Promise<string>;
resolveVersion?: (signal?: AbortSignal) => Promise<string>;
configDir?: string;
}
@@ -1494,7 +1497,7 @@ export class ClaudeAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly queryFactory?: ClaudeQueryFactory;
private readonly resolveBinary: () => Promise<string>;
private readonly resolveVersion: () => Promise<string>;
private readonly resolveVersion: (signal?: AbortSignal) => Promise<string>;
private readonly configDir?: string;
constructor(options: ClaudeAgentClientOptions) {
@@ -1504,7 +1507,8 @@ export class ClaudeAgentClient implements AgentClient {
this.queryFactory = options.queryFactory;
this.resolveBinary = options.resolveBinary ?? (() => resolveClaudeBinary(this.runtimeSettings));
this.resolveVersion =
options.resolveVersion ?? (() => resolveClaudeCodeVersion(this.runtimeSettings));
options.resolveVersion ??
((signal) => resolveClaudeCodeVersion(this.runtimeSettings, signal));
this.configDir = options.configDir;
}
@@ -1560,19 +1564,26 @@ export class ClaudeAgentClient implements AgentClient {
});
}
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const workspace = providerWorkspaceFromCatalogOptions(options);
let claudeCodeVersion: string | undefined;
try {
claudeCodeVersion = workspace
? await resolveWorkspaceClaudeCodeVersion(workspace, this.runtimeSettings)
: await this.resolveVersion();
claudeCodeVersion = await runProviderRefreshActivity(context, "version", () =>
workspace
? resolveWorkspaceClaudeCodeVersion(workspace, this.runtimeSettings)
: this.resolveVersion(context?.signal),
);
} catch (error) {
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
}
const models = workspace
? getClaudeModels(claudeCodeVersion)
: await getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion);
: await runProviderRefreshActivity(context, "settings", () =>
getClaudeModelsWithSettings(this.logger, this.configDir, claudeCodeVersion),
);
const modes = detectIneligibleAutoModeTransport(
workspace
? providerWorkspaceEnvironment([this.runtimeSettings?.env])
@@ -1714,6 +1725,7 @@ async function resolveClaudeBinary(runtimeSettings?: ProviderRuntimeSettings): P
export async function resolveClaudeCodeVersion(
runtimeSettings?: ProviderRuntimeSettings,
signal?: AbortSignal,
): Promise<string> {
const launch = await resolveProviderLaunch({
commandConfig: runtimeSettings?.command,
@@ -1727,6 +1739,7 @@ export async function resolveClaudeCodeVersion(
const { stdout, stderr } = await execCommand(executable, [...launch.args, "--version"], {
...createProviderEnvSpec({ runtimeSettings }),
timeout: 5_000,
signal,
});
const version = parseClaudeCodeVersion(`${stdout}\n${stderr}`);
if (!version) {
@@ -2081,6 +2094,7 @@ class ClaudeAgentSession implements AgentSession {
private autonomousTurn: AutonomousTurnState | null = null;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private readonly timelineAssembler = new TimelineAssembler();
private readonly taskState = new ClaudeTaskState();
private readonly taskProtocolSource = new ClaudeTaskProtocolSource({
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
readWorkflowResult: (outputFile) =>
@@ -2855,6 +2869,7 @@ class ClaudeAgentSession implements AgentSession {
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
this.historyLoadPromise = this.loadPersistedHistory(sessionId);
if (oldSessionId && oldSessionId !== sessionId) {
this.dispatchEvents([
@@ -2885,6 +2900,7 @@ class ClaudeAgentSession implements AgentSession {
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
this.rewindTurnAnchors.length = 0;
this.taskState.reset();
}
private rememberUserMessageId(messageId: string | null | undefined): void {
@@ -3134,14 +3150,7 @@ class ClaudeAgentSession implements AgentSession {
return createProviderEnv({
baseEnv: process.env,
runtimeSettings: this.runtimeSettings,
overlays: [
{
// Increase MCP timeouts for long-running tool calls (10 minutes)
MCP_TIMEOUT: "600000",
MCP_TOOL_TIMEOUT: "600000",
},
this.launchEnv,
],
overlays: [this.launchEnv],
});
}
@@ -3900,6 +3909,7 @@ class ClaudeAgentSession implements AgentSession {
}
const events: AgentStreamEvent[] = [];
this.appendTaskStateEvent(message, events);
// Subagent identity and lifecycle are announced by Claude Code's task protocol, so they are
// read rather than inferred from sidechain frames. `task_started` precedes the child's first
@@ -3964,6 +3974,11 @@ class ClaudeAgentSession implements AgentSession {
return events;
}
private appendTaskStateEvent(message: SDKMessage, events: AgentStreamEvent[]): void {
const item = this.taskState.observe(message);
if (item) events.push({ type: "timeline", provider: "claude", item });
}
/**
* A frame from inside a subagent, routed to that child rather than the parent's transcript.
*/
@@ -4622,6 +4637,7 @@ class ClaudeAgentSession implements AgentSession {
private async loadPersistedHistory(sessionId: string): Promise<void> {
try {
this.taskState.reset();
const historyPath = await this.resolveHistoryPath(sessionId);
if (!historyPath) return;
const content = this.workspace
@@ -4750,7 +4766,8 @@ class ClaudeAgentSession implements AgentSession {
}
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const items = this.convertHistoryEntry(entry);
const taskSnapshot = this.taskState.observe(entry);
const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)];
const isVisibleUserEntry =
entry.type === "user" &&
typeof entry.uuid === "string" &&
@@ -0,0 +1,127 @@
import { describe, expect, test } from "vitest";
import { ClaudeTaskState } from "./task-state.js";
function toolUse(id: string, name: string, input: Record<string, unknown>) {
return { type: "assistant", message: { content: [{ type: "tool_use", id, name, input }] } };
}
function toolResult(id: string, result: Record<string, unknown>) {
return {
type: "user",
message: { content: [{ type: "tool_result", tool_use_id: id, content: "ok" }] },
toolUseResult: result,
};
}
describe("ClaudeTaskState", () => {
test("accumulates TaskCreate and TaskUpdate mutations by task id", () => {
const state = new ClaudeTaskState();
state.observe(
toolUse("create-1", "TaskCreate", { subject: "Alpha", activeForm: "Doing alpha" }),
);
expect(state.observe(toolResult("create-1", { task: { id: "1", subject: "Alpha" } }))).toEqual({
type: "todo",
items: [
{ id: "1", text: "Alpha", activeForm: "Doing alpha", status: "pending", completed: false },
],
});
state.observe(toolUse("update-1", "TaskUpdate", { taskId: "1", status: "in_progress" }));
expect(state.observe(toolResult("update-1", { success: true, taskId: "1" }))).toEqual({
type: "todo",
items: [
{
id: "1",
text: "Alpha",
activeForm: "Doing alpha",
status: "in_progress",
completed: false,
},
],
});
});
test("removes deleted tasks and ignores replayed results", () => {
const state = new ClaudeTaskState();
state.observe(toolUse("create", "TaskCreate", { subject: "Disposable" }));
state.observe(toolResult("create", { task: { id: "1", subject: "Disposable" } }));
state.observe(toolUse("delete", "TaskUpdate", { taskId: "1", status: "deleted" }));
const result = toolResult("delete", { success: true, taskId: "1" });
expect(state.observe(result)).toEqual({ type: "todo", items: [] });
expect(state.observe(result)).toBeNull();
});
test("preserves status when TaskUpdate changes only descriptive fields", () => {
const state = new ClaudeTaskState();
state.observe(toolUse("create", "TaskCreate", { subject: "Original" }));
state.observe(toolResult("create", { task: { id: "1", subject: "Original" } }));
state.observe(toolUse("complete", "TaskUpdate", { taskId: "1", status: "completed" }));
state.observe(toolResult("complete", { success: true, taskId: "1" }));
state.observe(toolUse("rename", "TaskUpdate", { taskId: "1", subject: "Renamed" }));
expect(state.observe(toolResult("rename", { success: true, taskId: "1" }))).toEqual({
type: "todo",
items: [{ id: "1", text: "Renamed", status: "completed", completed: true }],
});
});
test("replaces state from TodoWrite and TaskList snapshots", () => {
const state = new ClaudeTaskState();
expect(
state.observe(
toolUse("legacy", "TodoWrite", {
todos: [{ content: "Legacy", status: "in_progress", activeForm: "Working" }],
}),
),
).toEqual({
type: "todo",
items: [
{
id: "legacy:0",
text: "Legacy",
activeForm: "Working",
status: "in_progress",
completed: false,
},
],
});
state.observe(toolUse("list", "TaskList", {}));
expect(
state.observe(
toolResult("list", {
tasks: [{ id: "7", subject: "Current", status: "completed", activeForm: "Finishing" }],
}),
),
).toEqual({
type: "todo",
items: [
{ id: "7", text: "Current", activeForm: "Finishing", status: "completed", completed: true },
],
});
});
test("keeps synthetic TodoWrite task ids stable across snapshots", () => {
const state = new ClaudeTaskState();
state.observe(
toolUse("first", "TodoWrite", {
todos: [{ content: "Stable", status: "pending" }],
}),
);
expect(
state.observe(
toolUse("second", "TodoWrite", {
todos: [{ content: "Stable", status: "completed" }],
}),
),
).toEqual({
type: "todo",
items: [{ id: "legacy:0", text: "Stable", status: "completed", completed: true }],
});
});
test("does not confuse Claude's subagent Task tool with task tracking", () => {
const state = new ClaudeTaskState();
expect(state.observe(toolUse("subagent", "Task", { description: "delegate" }))).toBeNull();
});
});
@@ -0,0 +1,202 @@
import type { AgentTaskItem, AgentTimelineItem } from "../../agent-sdk-types.js";
type TaskToolName = "TodoWrite" | "TaskCreate" | "TaskUpdate" | "TaskList";
interface PendingTaskTool {
name: TaskToolName;
input: Record<string, unknown>;
}
function record(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function string(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function taskStatus(value: unknown): AgentTaskItem["status"] | "deleted" {
if (value === "completed" || value === "deleted" || value === "in_progress") return value;
return "pending";
}
function retainedTaskStatus(task: AgentTaskItem): AgentTaskItem["status"] {
if (task.status) return task.status;
return task.completed ? "completed" : "pending";
}
function toTaskItem(value: unknown): AgentTaskItem | null {
const task = record(value);
if (!task) return null;
const text = string(task.subject) ?? string(task.content) ?? string(task.text);
if (!text) return null;
const status = taskStatus(task.status);
if (status === "deleted") return null;
const id = string(task.id) ?? string(task.taskId);
const activeForm = string(task.activeForm) ?? string(task.active_form);
return {
...(id ? { id } : {}),
text,
status,
completed: status === "completed",
...(activeForm ? { activeForm } : {}),
};
}
function toolUses(message: Record<string, unknown>): Array<Record<string, unknown>> {
const content = record(message.message)?.content;
return Array.isArray(content)
? content.flatMap((block) => {
const candidate = record(block);
return candidate?.type === "tool_use" ? [candidate] : [];
})
: [];
}
function toolResultId(message: Record<string, unknown>): string | undefined {
const content = record(message.message)?.content;
if (!Array.isArray(content)) return undefined;
for (const block of content) {
const candidate = record(block);
if (candidate?.type === "tool_result") return string(candidate.tool_use_id);
}
return undefined;
}
function structuredResult(message: Record<string, unknown>): Record<string, unknown> | null {
return record(message.toolUseResult) ?? record(message.tool_use_result);
}
/** Accumulates Claude's snapshot and ID-based task tools into canonical todo snapshots. */
export class ClaudeTaskState {
private readonly tasks = new Map<string, AgentTaskItem>();
private readonly calls = new Map<string, PendingTaskTool>();
private readonly appliedResults = new Set<string>();
observe(value: unknown): Extract<AgentTimelineItem, { type: "todo" }> | null {
const message = record(value);
if (!message) return null;
let snapshot: Extract<AgentTimelineItem, { type: "todo" }> | null = null;
for (const block of toolUses(message)) {
const id = string(block.id);
const name = string(block.name);
if (!id || !isTaskToolName(name)) continue;
const input = record(block.input) ?? {};
this.calls.set(id, { name, input });
if (name === "TodoWrite") snapshot = this.replaceLegacyTodos(input.todos);
}
const resultId = toolResultId(message);
if (!resultId || this.appliedResults.has(resultId)) return snapshot;
const call = this.calls.get(resultId);
if (!call) return snapshot;
this.appliedResults.add(resultId);
this.calls.delete(resultId);
return this.applyResult(call, structuredResult(message)) ?? snapshot;
}
reset(): void {
this.tasks.clear();
this.calls.clear();
this.appliedResults.clear();
}
private replaceLegacyTodos(value: unknown): Extract<AgentTimelineItem, { type: "todo" }> {
this.tasks.clear();
if (Array.isArray(value)) {
for (const [index, taskValue] of value.entries()) {
const item = toTaskItem(taskValue);
if (!item) continue;
const id = item.id ?? `legacy:${index}`;
this.tasks.set(id, { ...item, id });
}
}
return this.snapshot();
}
private applyResult(
call: PendingTaskTool,
result: Record<string, unknown> | null,
): Extract<AgentTimelineItem, { type: "todo" }> | null {
if (result?.success === false) return null;
if (call.name === "TaskCreate") return this.applyCreate(call.input, result);
if (call.name === "TaskUpdate") return this.applyUpdate(call.input, result);
if (call.name === "TaskList") return this.applyList(result);
return null;
}
private applyCreate(
input: Record<string, unknown>,
result: Record<string, unknown> | null,
): Extract<AgentTimelineItem, { type: "todo" }> | null {
const resultTask = record(result?.task);
const id = string(resultTask?.id) ?? string(result?.taskId);
const text = string(resultTask?.subject) ?? string(input.subject);
if (!id || !text) return null;
const activeForm = string(input.activeForm);
this.tasks.set(id, {
id,
text,
status: "pending",
completed: false,
...(activeForm ? { activeForm } : {}),
});
return this.snapshot();
}
private applyUpdate(
input: Record<string, unknown>,
result: Record<string, unknown> | null,
): Extract<AgentTimelineItem, { type: "todo" }> | null {
const id = string(input.taskId) ?? string(result?.taskId);
if (!id) return null;
const current = this.tasks.get(id);
if (!current) return null;
const statusValue = input.status ?? record(result?.statusChange)?.to;
const status =
statusValue === undefined ? retainedTaskStatus(current) : taskStatus(statusValue);
if (status === "deleted") {
this.tasks.delete(id);
return this.snapshot();
}
const text = string(input.subject);
const activeForm = string(input.activeForm);
this.tasks.set(id, {
...current,
...(text ? { text } : {}),
...(activeForm ? { activeForm } : {}),
status,
completed: status === "completed",
});
return this.snapshot();
}
private applyList(
result: Record<string, unknown> | null,
): Extract<AgentTimelineItem, { type: "todo" }> | null {
const tasks = result?.tasks;
if (!Array.isArray(tasks)) return null;
this.tasks.clear();
for (const taskValue of tasks) {
const item = toTaskItem(taskValue);
if (item?.id) this.tasks.set(item.id, item);
}
return this.snapshot();
}
private snapshot(): Extract<AgentTimelineItem, { type: "todo" }> {
return { type: "todo", items: [...this.tasks.values()] };
}
}
function isTaskToolName(value: string | undefined): value is TaskToolName {
return (
value === "TodoWrite" ||
value === "TaskCreate" ||
value === "TaskUpdate" ||
value === "TaskList"
);
}
@@ -23,10 +23,30 @@ import {
codexAppServerTurnInputFromPrompt,
listCodexSkills,
mapCodexPatchNotificationToToolCall,
mapCodexPlanUpdateToTodo,
mapCodexPlanToToolCall,
normalizeCodexOutputSchema,
toAgentUsage,
} from "./codex-app-server-agent.js";
describe("mapCodexPlanUpdateToTodo", () => {
test("preserves checklist progress without creating a plan card", () => {
expect(
mapCodexPlanUpdateToTodo([
{ step: "Inspect", status: "completed" },
{ step: "Implement", status: "inProgress" },
{ step: "Verify", status: "pending" },
]),
).toEqual({
type: "todo",
items: [
{ id: "0", text: "Inspect", status: "completed", completed: true },
{ id: "1", text: "Implement", status: "in_progress", completed: false },
{ id: "2", text: "Verify", status: "pending", completed: false },
],
});
});
});
import { CodexAppServerClient } from "./codex/app-server-transport.js";
import {
createFakeCodexAppServer,
@@ -71,6 +91,11 @@ type CodexTestSession = AgentSession & {
client: CodexClientLike | null;
};
type TurnTerminalEvent = Extract<
AgentStreamEvent,
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
>;
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
const CODEX_PROVIDER = "codex";
@@ -180,6 +205,41 @@ function createProviderWithFakeAppServer(appServer: FakeCodexAppServer): CodexAp
return provider;
}
async function startCompactionTurnTest(): Promise<{
appServer: FakeCodexAppServer;
session: AgentSession;
events: AgentStreamEvent[];
terminalEvent: Promise<TurnTerminalEvent>;
}> {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
const terminalEvent = new Promise<TurnTerminalEvent>((resolve) => {
session.subscribe((event) => {
const isCompaction = event.type === "timeline" && event.item.type === "compaction";
const isTerminal =
event.type === "turn_completed" ||
event.type === "turn_failed" ||
event.type === "turn_canceled";
if (isCompaction || isTerminal) {
events.push(event);
}
if (isTerminal) {
resolve(event);
}
});
});
await session.startTurn("exercise compaction lifecycle");
appServer.startsTurn({ threadId: "thread-1", turnId: "codex-turn-1" });
return { appServer, session, events, terminalEvent };
}
function archivedThreadHandle() {
return {
sessionId: "archived-thread-id",
@@ -2365,6 +2425,80 @@ describe("Codex app-server provider", () => {
});
});
test("keeps a settled child completed until Codex starts another child turn", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
createConfig({ cwd: "/workspace/project" }),
null,
createTestLogger(),
async () => appServer.child,
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
const resultPromise = session.run("Delegate the investigation.");
await appServer.waitForTurnStart();
appServer.startsSubAgent({
callId: "call-settled-child",
threadId: "settled-child-thread",
agentPath: "/root/settled-child",
});
appServer.completeTurn({ threadId: "settled-child-thread" });
appServer.says({
threadId: "settled-child-thread",
itemId: "late-child-message",
text: "Late trailing output.",
});
const providerUpserts = events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
expect(providerUpserts.at(-1)).toMatchObject({
id: "settled-child-thread",
status: "completed",
});
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "call-settled-child",
status: "completed",
detail: { type: "sub_agent", log: "[Assistant] Late trailing output." },
},
});
appServer.completesSubAgentActivity({
callId: "late-child-interaction",
threadId: "settled-child-thread",
agentPath: "/root/settled-child",
kind: "interacted",
});
expect(
events.findLast(
(event) => event.type === "provider_subagent" && event.event.type === "upsert",
),
).toMatchObject({ event: { id: "settled-child-thread", status: "completed" } });
appServer.startsTurn({
threadId: "settled-child-thread",
turnId: "next-child-turn",
});
expect(
events.findLast(
(event) => event.type === "provider_subagent" && event.event.type === "upsert",
),
).toMatchObject({ event: { id: "settled-child-thread", status: "running" } });
appServer.completeTurn({ threadId: "settled-child-thread" });
appServer.completeTurn();
await resultPromise;
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("updates a registered child with its later native activity name", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
@@ -3577,6 +3711,10 @@ describe("Codex app-server provider", () => {
const liveEvents: AgentStreamEvent[] = [];
session.subscribe((event) => liveEvents.push(event));
asInternals(session).handleNotification("turn/started", {
threadId: "v2-child-thread",
turn: { id: "v2-child-turn-after-resume" },
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
@@ -3605,6 +3743,10 @@ describe("Codex app-server provider", () => {
});
liveEvents.length = 0;
asInternals(session).handleNotification("turn/started", {
threadId: "legacy-child-thread",
turn: { id: "legacy-child-turn-after-resume" },
});
asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "legacy-child-thread",
itemId: "legacy-child-message-after-resume",
@@ -4007,6 +4149,99 @@ describe("Codex app-server provider", () => {
]);
});
test("completes a pending Codex compaction when its turn ends", async () => {
const { appServer, session, events, terminalEvent } = await startCompactionTurnTest();
try {
appServer.startsCompaction({
threadId: "thread-1",
itemId: "compact-without-completion",
});
appServer.completeTurn();
await terminalEvent;
expect(
events.map((event) =>
event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type,
),
).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("does not complete a Codex compaction twice when its item finishes before the turn", async () => {
const { appServer, session, events, terminalEvent } = await startCompactionTurnTest();
try {
appServer.startsCompaction({
threadId: "thread-1",
itemId: "compact-completed-normally",
});
appServer.completesCompaction({
threadId: "thread-1",
itemId: "compact-completed-normally",
});
appServer.completeTurn();
await terminalEvent;
expect(
events.map((event) =>
event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type,
),
).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("does not let a late compaction completion consume the current pending item", async () => {
const { appServer, session, events, terminalEvent } = await startCompactionTurnTest();
try {
appServer.startsCompaction({ threadId: "thread-1", itemId: "current-compaction" });
appServer.completesCompaction({ threadId: "thread-1", itemId: "older-compaction" });
appServer.completeTurn();
await terminalEvent;
expect(
events.map((event) =>
event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type,
),
).toEqual(["compaction:loading", "compaction:completed", "turn_completed"]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each([
{ status: "failed", terminalType: "turn_failed" },
{ status: "interrupted", terminalType: "turn_canceled" },
])("completes a pending compaction before a $status turn", async ({ status, terminalType }) => {
const { appServer, session, events, terminalEvent } = await startCompactionTurnTest();
try {
appServer.startsCompaction({ threadId: "thread-1", itemId: `compact-${status}` });
appServer.completeTurn({
status,
error: status === "failed" ? { message: "Compaction failed" } : null,
});
await terminalEvent;
expect(
events.map((event) =>
event.type === "timeline" ? `${event.item.type}:${event.item.status}` : event.type,
),
).toEqual(["compaction:loading", "compaction:completed", terminalType]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("emits and dedupes Codex thread/compacted notifications", () => {
const session = createSession();
session.activeForegroundTurnId = null;
@@ -34,8 +34,11 @@ import {
type ListImportableSessionsOptions,
type ProviderCatalog,
type ProviderWorkspace,
type ProviderRefreshContext,
type ResolveAgentDefaultModeInput,
} from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import { runProviderRefreshActivity } from "../provider-refresh-deadline.js";
import type { Logger } from "pino";
import type { ChildProcess, ChildProcessWithoutNullStreams } from "node:child_process";
@@ -968,6 +971,26 @@ export function planStepsToMarkdown(steps: Array<{ step: string; status: string
return normalizePlanMarkdown(lines.join("\n"));
}
export function mapCodexPlanUpdateToTodo(
steps: Array<{ step?: string | null; status?: string | null }>,
): Extract<AgentTimelineItem, { type: "todo" }> {
return {
type: "todo",
items: steps.flatMap((entry, index) => {
const text = entry.step?.trim();
if (!text) return [];
const status = normalizeCodexTaskStatus(entry.status);
return [{ id: String(index), text, status, completed: status === "completed" }];
}),
};
}
function normalizeCodexTaskStatus(status: string | null | undefined) {
if (status === "completed") return "completed" as const;
if (status === "inProgress" || status === "in_progress") return "in_progress" as const;
return "pending" as const;
}
export function mapCodexPlanToToolCall(params: {
callId: string;
text: string;
@@ -1669,6 +1692,12 @@ interface CodexSubAgentActivity {
kind: "started" | "interacted" | "interrupted";
}
function isTerminalSubAgentStatus(
status: ToolCallTimelineItem["status"],
): status is "completed" | "failed" | "canceled" {
return status === "completed" || status === "failed" || status === "canceled";
}
function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null {
const record = toObjectRecord(item);
if (!record) {
@@ -3189,6 +3218,10 @@ interface CodexPendingPermissionHandler {
planText?: string;
}
interface ConsumedRootCompaction {
itemId?: string;
}
export class CodexAppServerAgentSession implements AgentSession {
readonly provider = CODEX_PROVIDER;
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
@@ -3254,6 +3287,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly userMessageTurnIds: string[] = [];
private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
private pendingRootCompactionItemIds = new Set<string>();
private pendingAnonymousRootCompactions = 0;
// Codex can report one completed compaction through both channels:
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
@@ -4937,7 +4972,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
switch (parsed.kind) {
case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running");
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
return;
case "turn_started":
case "turn_completed":
@@ -5210,10 +5245,13 @@ export class CodexAppServerAgentSession implements AgentSession {
},
};
}
this.emitSubAgentActivityUpdate(
callId,
activity.kind === "interrupted" ? "canceled" : "running",
);
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
if (activity.kind === "interrupted") {
nextStatus = "canceled";
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
nextStatus = undefined;
}
this.emitSubAgentActivityUpdate(callId, nextStatus);
return true;
}
@@ -5225,6 +5263,16 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!this.isContextCompactionItem(item)) {
return false;
}
const consumedPendingCompaction = this.consumePendingRootCompaction(item.id);
const hasDifferentPendingCompaction =
this.pendingRootCompactionItemIds.size > 0 || this.pendingAnonymousRootCompactions > 0;
const isLateCompletionForOlderItem =
item.id !== undefined &&
consumedPendingCompaction === undefined &&
hasDifferentPendingCompaction;
if (isLateCompletionForOlderItem) {
return true;
}
if (this.unpairedCompactionNotificationCompletions > 0) {
this.unpairedCompactionNotificationCompletions -= 1;
return true;
@@ -5297,6 +5345,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private emitSubAgentActivityUpdate(
callId: string,
status?: ToolCallTimelineItem["status"],
options?: { reopen?: boolean },
): void {
const state = this.subAgentCallsByCallId.get(callId);
if (!state || state.toolCall.detail.type !== "sub_agent") {
@@ -5307,7 +5356,14 @@ export class CodexAppServerAgentSession implements AgentSession {
childTimeline.length > 0
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
: "";
const resolvedStatus = status ?? state.toolCall.status;
let resolvedStatus = status ?? state.toolCall.status;
if (
status === "running" &&
!options?.reopen &&
isTerminalSubAgentStatus(state.toolCall.status)
) {
resolvedStatus = state.toolCall.status;
}
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
@@ -5446,7 +5502,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId);
}
this.emitSubAgentActivityUpdate(callId, "running");
this.emitSubAgentActivityUpdate(callId);
}
private handleSubAgentContextCompactionItem(
@@ -5582,7 +5638,7 @@ export class CodexAppServerAgentSession implements AgentSession {
): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true });
return;
}
this.currentTurnId = parsed.turnId;
@@ -5612,6 +5668,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitSubAgentActivityUpdate(subAgentCallId, status);
return;
}
this.completePendingRootCompactions();
if (parsed.status === "failed") {
this.emitEvent({
type: "turn_failed",
@@ -5652,6 +5709,8 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingFileChangeOutputDeltas.clear();
this.pendingAssistantMessageBoundary = false;
this.warnedIncompleteEditToolCallIds.clear();
this.pendingRootCompactionItemIds.clear();
this.pendingAnonymousRootCompactions = 0;
this.unpairedCompactionNotificationCompletions = 0;
this.unpairedCompactionItemCompletions = 0;
}
@@ -5659,6 +5718,14 @@ export class CodexAppServerAgentSession implements AgentSession {
private handlePlanUpdatedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "plan_updated" }>,
): void {
if (!this.planModeEnabled) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: mapCodexPlanUpdateToTodo(parsed.plan),
});
return;
}
const timelineItem = mapCodexPlanToToolCall({
callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`,
text: planStepsToMarkdown(
@@ -5670,13 +5737,9 @@ export class CodexAppServerAgentSession implements AgentSession {
});
if (timelineItem) {
this.rememberPlanResult(timelineItem);
// In plan mode, the same plan is rendered through the synthetic approval
// permission. Keep the remembered text for that card, but do not also
// emit a static timeline plan panel.
if (this.planModeEnabled) {
return;
}
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
// Older Codex app-server builds reported Plan-mode proposals through
// turn/plan/updated. Retain that compatibility path only while Plan mode is active.
return;
}
}
@@ -5707,6 +5770,61 @@ export class CodexAppServerAgentSession implements AgentSession {
return undefined;
}
private trackPendingRootCompaction(itemId?: string): void {
if (itemId) {
this.pendingRootCompactionItemIds.add(itemId);
return;
}
this.pendingAnonymousRootCompactions += 1;
}
private consumePendingRootCompaction(itemId?: string): ConsumedRootCompaction | undefined {
if (itemId) {
if (this.pendingRootCompactionItemIds.delete(itemId)) {
return { itemId };
}
if (
this.pendingRootCompactionItemIds.size === 0 &&
this.pendingAnonymousRootCompactions > 0
) {
this.pendingAnonymousRootCompactions -= 1;
return {};
}
return undefined;
}
const pendingItemId = this.pendingRootCompactionItemIds.values().next().value;
if (typeof pendingItemId === "string") {
this.pendingRootCompactionItemIds.delete(pendingItemId);
return { itemId: pendingItemId };
}
if (this.pendingAnonymousRootCompactions > 0) {
this.pendingAnonymousRootCompactions -= 1;
return {};
}
return undefined;
}
private completePendingRootCompactions(): void {
// Some Codex builds end a turn without completing the contextCompaction
// item. Close every loading timeline row before emitting the terminal turn.
for (const itemId of this.pendingRootCompactionItemIds) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed", itemId),
});
}
for (let index = 0; index < this.pendingAnonymousRootCompactions; index += 1) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed"),
});
}
this.pendingRootCompactionItemIds.clear();
this.pendingAnonymousRootCompactions = 0;
}
private createContextCompactionTimelineItem(
status: "loading" | "completed",
itemId?: string,
@@ -5756,11 +5874,12 @@ export class CodexAppServerAgentSession implements AgentSession {
this.unpairedCompactionItemCompletions -= 1;
return;
}
const pendingItemId = this.consumePendingRootCompaction()?.itemId;
this.unpairedCompactionNotificationCompletions += 1;
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed"),
item: this.createContextCompactionTimelineItem("completed", pendingItemId),
...(parsed.turnId ? { turnId: parsed.turnId } : {}),
});
}
@@ -6085,6 +6204,7 @@ export class CodexAppServerAgentSession implements AgentSession {
return;
}
if (this.isContextCompactionItem(parsed.item)) {
this.trackPendingRootCompaction(parsed.item.id);
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
@@ -6565,7 +6685,10 @@ export class CodexAppServerAgentClient implements AgentClient {
return this.goalsEnabledPromise;
}
private resolveAutoReviewEnabled(workspace?: ProviderWorkspace): Promise<boolean> {
private resolveAutoReviewEnabled(
workspace?: ProviderWorkspace,
signal?: AbortSignal,
): Promise<boolean> {
if (workspace) {
const cached = this.autoReviewEnabledByRuntime.get(workspace);
if (cached) return cached;
@@ -6577,30 +6700,32 @@ export class CodexAppServerAgentClient implements AgentClient {
this.autoReviewEnabledByRuntime.set(workspace, pending);
return pending;
}
if (signal) return this.probeAutoReviewEnabled(signal);
if (!this.autoReviewEnabledPromise) {
this.autoReviewEnabledPromise = (async () => {
try {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
const versionOutput = await resolveBinaryVersion(launchPrefix.command);
const enabled = codexVersionAtLeast(versionOutput, CODEX_AUTO_REVIEW_MIN_VERSION);
this.logger.trace(
{
provider: CODEX_PROVIDER,
versionOutput,
enabled,
},
"provider.codex.config.auto_review_resolved",
);
return enabled;
} catch (error) {
this.logger.warn({ err: error }, "Failed to probe codex version for auto-review gate");
return false;
}
})();
this.autoReviewEnabledPromise = this.probeAutoReviewEnabled();
}
return this.autoReviewEnabledPromise;
}
private async probeAutoReviewEnabled(signal?: AbortSignal): Promise<boolean> {
try {
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
signal?.throwIfAborted();
const versionOutput = await resolveBinaryVersion(launchPrefix.command, signal);
signal?.throwIfAborted();
const enabled = codexVersionAtLeast(versionOutput, CODEX_AUTO_REVIEW_MIN_VERSION);
this.logger.trace(
{ provider: CODEX_PROVIDER, versionOutput, enabled },
"provider.codex.config.auto_review_resolved",
);
return enabled;
} catch (error) {
if (signal?.aborted) throw signal.reason;
this.logger.warn({ err: error }, "Failed to probe codex version for auto-review gate");
return false;
}
}
private async spawnAppServer(
launchEnv?: Record<string, string>,
options?: { goalsEnabled?: boolean; agentId?: string; workspace?: ProviderWorkspace },
@@ -6801,11 +6926,16 @@ export class CodexAppServerAgentClient implements AgentClient {
});
}
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const workspace = providerWorkspaceFromCatalogOptions(options);
const [models, autoReviewEnabled] = await Promise.all([
this.fetchModelsFromAppServer(workspace),
this.resolveAutoReviewEnabled(workspace),
this.fetchModelsFromAppServer(workspace, context),
runProviderRefreshActivity(context, "version", () =>
this.resolveAutoReviewEnabled(workspace, context?.signal),
),
]);
return {
models,
@@ -6816,25 +6946,47 @@ export class CodexAppServerAgentClient implements AgentClient {
};
}
async resolveDefaultModeId(): Promise<string> {
return (await this.resolveAutoReviewEnabled()) ? "auto-review" : DEFAULT_CODEX_MODE_ID;
async resolveDefaultModeId(input: ResolveAgentDefaultModeInput): Promise<string> {
return (await this.resolveAutoReviewEnabled(undefined, input.signal))
? "auto-review"
: DEFAULT_CODEX_MODE_ID;
}
private async fetchModelsFromAppServer(
workspace?: ProviderWorkspace,
context?: ProviderRefreshContext,
): Promise<AgentModelDefinition[]> {
// Codex model/list is global to the app server in this flow; cwd/force are intentionally ignored.
const child = await this.spawnAppServer(undefined, { workspace });
const client = new CodexAppServerClient(child, this.logger);
let client: CodexAppServerClient | undefined;
let disposePromise: Promise<void> | undefined;
const dispose = () => {
if (!client) return Promise.resolve();
disposePromise ??= client.dispose();
return disposePromise;
};
const handleAbort = () => void dispose().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
try {
await client.request("initialize", buildCodexAppServerInitializeParams());
await runProviderRefreshActivity(context, "app-server.start", async () => {
const child = await this.spawnAppServer(undefined, { workspace });
client = new CodexAppServerClient(child, this.logger);
if (context?.signal.aborted) await dispose();
});
if (!client) throw new Error("Codex app-server did not start");
await runProviderRefreshActivity(context, "initialize", () =>
client!.request("initialize", buildCodexAppServerInitializeParams()),
);
client.notify("initialized", {});
const rawResponse = await client.request("model/list", {});
const rawResponse = await runProviderRefreshActivity(context, "model/list", () =>
client!.request("model/list", {}),
);
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const configuredDefaults = await readCodexConfiguredDefaults(client, this.logger);
const configuredDefaults = await runProviderRefreshActivity(context, "config/read", () =>
readCodexConfiguredDefaults(client!, this.logger),
);
const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
const hasConfiguredDefaultModel =
@@ -6849,7 +7001,8 @@ export class CodexAppServerAgentClient implements AgentClient {
}),
);
} finally {
await client.dispose();
context?.signal.removeEventListener("abort", handleAbort);
await dispose();
}
}
@@ -51,8 +51,13 @@ export interface FakeCodexAppServer {
waitForTurnStart(): Promise<JsonObject>;
nextResponse(): Promise<string>;
startsTurn(params: { threadId: string; turnId?: string }): void;
startsCompaction(params: { threadId: string; itemId: string }): void;
updatesPlan(params: { threadId: string; steps: string[] }): void;
completeTurn(params?: { threadId?: string }): void;
completeTurn(params?: {
threadId?: string;
status?: "completed" | "failed" | "interrupted";
error?: { message: string } | null;
}): void;
startsSubAgent(params: {
callId: string;
threadId: string;
@@ -320,6 +325,12 @@ export function createFakeCodexAppServer(
})}\n`,
);
},
startsCompaction(params) {
writeNotification("item/started", {
threadId: params.threadId,
item: { type: "contextCompaction", id: params.itemId },
});
},
updatesPlan(params) {
child.stdout.write(
`${JSON.stringify({
@@ -337,7 +348,10 @@ export function createFakeCodexAppServer(
method: "turn/completed",
params: {
threadId: params.threadId ?? "thread-1",
turn: { status: "completed" },
turn: {
status: params.status ?? "completed",
error: params.error ?? null,
},
},
})}\n`,
);
@@ -379,6 +379,28 @@ describe("codex tool-call mapper", () => {
});
});
it.each([
["shutdown", "canceled"],
["notFound", "failed"],
] as const)("maps terminal child state %s to %s", (childStatus, expectedStatus) => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
id: `call-sub-agent-${childStatus}`,
tool: "closeAgent",
status: "completed",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: childStatus, message: null },
},
});
expect(item).toMatchObject({
type: "tool_call",
callId: `call-sub-agent-${childStatus}`,
status: expectedStatus,
});
});
it("maps mcp read_file completion with detail", () => {
const item = expectMapped(
mapCodexToolCallFromThreadItem(
@@ -569,10 +569,17 @@ function readStatus(value: unknown): string | undefined {
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
const normalized = status.trim().toLowerCase();
if (normalized === "error" || normalized === "errored") {
return "running";
switch (normalized) {
case "error":
case "errored":
return "running";
case "shutdown":
return "canceled";
case "notfound":
return "failed";
default:
return normalizeToolCallStatus(status, null, null);
}
return normalizeToolCallStatus(status, null, null);
}
function resolveCollabAgentStatus(
@@ -135,11 +135,15 @@ export function toDiagnosticErrorMessage(error: unknown): string {
return formatNonErrorDiagnostic(error);
}
export async function resolveBinaryVersion(binaryPath: string): Promise<string> {
export async function resolveBinaryVersion(
binaryPath: string,
signal?: AbortSignal,
): Promise<string> {
try {
const { stdout } = await execCommand(binaryPath, ["--version"], {
...createProviderEnvSpec(),
timeout: 5_000,
signal,
});
return stdout.trim() || "unknown";
} catch (error) {
@@ -5,6 +5,7 @@ import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js";
import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js";
const TEST_ACP_TIMEOUT_MS = 1_000;
@@ -91,8 +92,15 @@ describe("GenericACPAgentClient diagnostics", () => {
});
await expect(
client.fetchCatalog({ cwd: tmpdir(), force: true, timeoutMs: TEST_ACP_TIMEOUT_MS }),
).rejects.toThrow(`ACP catalog probe timed out after ${TEST_ACP_TIMEOUT_MS}ms`);
runProviderRefreshWithDeadline({
label: "Grok",
timeoutMs: TEST_ACP_TIMEOUT_MS,
operation: (context) =>
client.fetchCatalog({ scope: "workspace", cwd: tmpdir(), force: true }, context),
}),
).rejects.toThrow(
`Timed out refreshing Grok after ${TEST_ACP_TIMEOUT_MS}ms; pending: session/new`,
);
const pid = Number(await readFile(pidPath, "utf8"));
await expectProcessExit(pid);
@@ -116,7 +124,7 @@ describe("GenericACPAgentClient diagnostics", () => {
},
});
await client.fetchCatalog({ cwd: testDir, force: true, timeoutMs: TEST_ACP_TIMEOUT_MS });
await client.fetchCatalog({ scope: "workspace", cwd: testDir, force: true });
const session = await client.createSession({ provider: "acp", cwd: testDir });
await session.close();
@@ -219,4 +219,123 @@ describe("JsonlRpcProcess", () => {
await rejection;
});
test("reassembles protocol v2 chunked responses into the logical frame", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
try {
let buffer = "";
const sentRequest = new Promise<Record<string, unknown>>((resolve) => {
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) return;
resolve(JSON.parse(buffer.slice(0, newlineIndex)) as Record<string, unknown>);
});
});
const request = transport.request({ type: "chunked" });
const command = await sentRequest;
// Emit a logical response split into protocol v2 chunk frames.
const value = "x".repeat(1024 * 1024);
const logical = JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: { value },
});
const bytes = Buffer.from(logical, "utf8");
expect(bytes.byteLength).toBeGreaterThan(1024 * 1024);
const chunkPayload = 256 * 1024;
const count = Math.ceil(bytes.length / chunkPayload);
expect(count).toBeGreaterThan(1);
for (let index = 0; index < count; index++) {
child.stdout.write(
`${JSON.stringify({
type: "rpc_chunk",
chunkId: "seq1",
index,
count,
byteLength: bytes.length,
data: bytes
.subarray(index * chunkPayload, (index + 1) * chunkPayload)
.toString("base64"),
})}\n`,
);
}
await expect(request).resolves.toEqual({ value });
} finally {
await transport.close();
}
});
test("rejects protocol v2 chunks larger than the transport payload limit", async () => {
const child = createInMemoryChildProcess();
const transport = startProcess({ child });
try {
let buffer = "";
const sentRequest = new Promise<Record<string, unknown>>((resolve) => {
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) return;
resolve(JSON.parse(buffer.slice(0, newlineIndex)) as Record<string, unknown>);
});
});
const request = transport.request({ type: "oversized-chunks" });
const command = await sentRequest;
const logicalResponse = (value: string): Buffer =>
Buffer.from(
JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: { value },
}),
"utf8",
);
const oversized = logicalResponse(`invalid-${"x".repeat(1024 * 1024)}`);
const split = Math.ceil(oversized.byteLength / 2);
expect(split).toBeGreaterThan(256 * 1024);
for (let index = 0; index < 2; index += 1) {
child.stdout.write(
`${JSON.stringify({
type: "rpc_chunk",
chunkId: "oversized",
index,
count: 2,
byteLength: oversized.byteLength,
data: oversized.subarray(index * split, (index + 1) * split).toString("base64"),
})}\n`,
);
}
const valid = logicalResponse(`valid-${"x".repeat(1024 * 1024)}`);
const count = Math.ceil(valid.byteLength / (256 * 1024));
for (let index = 0; index < count; index += 1) {
child.stdout.write(
`${JSON.stringify({
type: "rpc_chunk",
chunkId: "valid",
index,
count,
byteLength: valid.byteLength,
data: valid.subarray(index * 256 * 1024, (index + 1) * 256 * 1024).toString("base64"),
})}\n`,
);
}
const response = (await request) as { value: string };
expect(response.value.startsWith("valid-")).toBe(true);
} finally {
await transport.close();
}
});
});
@@ -15,6 +15,11 @@ export const JSONL_RPC_NO_TIMEOUT = null;
const STDERR_BUFFER_LIMIT = 8192;
const GRACEFUL_SHUTDOWN_TIMEOUT_MS = 2_000;
const FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
const RPC_V2_MAX_FRAME_BYTES = 1024 * 1024;
const RPC_V2_MAX_REASSEMBLED_BYTES = 64 * 1024 * 1024;
const RPC_V2_CHUNK_PAYLOAD_BYTES = 256 * 1024;
const RPC_V2_MAX_ENCODED_CHUNK_LENGTH = Math.ceil(RPC_V2_CHUNK_PAYLOAD_BYTES / 3) * 4;
const RPC_V2_MAX_CHUNK_COUNT = Math.ceil(RPC_V2_MAX_REASSEMBLED_BYTES / RPC_V2_CHUNK_PAYLOAD_BYTES);
export interface JsonlRpcLaunch {
command: string;
@@ -38,6 +43,30 @@ interface PendingRequest {
timer: NodeJS.Timeout | null;
}
/**
* Reassembly state for a protocol v2 chunked frame sequence. Some agents that
* speak this JSONL RPC protocol (OMP) cap single-line frames at 1 MiB and emit
* larger frames as ordered `rpc_chunk` frames; the transport reassembles them
* into the original logical frame before routing it.
*/
interface PendingChunkSequence {
chunkId: string;
count: number;
byteLength: number;
nextIndex: number;
chunks: Buffer[];
receivedBytes: number;
}
/** Validated metadata fields of a protocol v2 `rpc_chunk` frame. */
interface RpcChunkMetadata {
chunkId: string;
index: number;
count: number;
byteLength: number;
data: string;
}
export interface JsonlRpcExit {
code: number | null;
signal: NodeJS.Signals | null;
@@ -51,6 +80,16 @@ export interface JsonlRpcProcessOptions {
spawn?: (launch: JsonlRpcLaunch) => ChildProcessWithoutNullStreams;
}
export function supportsJsonlRpcProtocolV2(message: Record<string, unknown>): boolean {
return (
message.type === "ready" &&
Array.isArray(message.supportedProtocolVersions) &&
message.supportedProtocolVersions.includes(2) &&
message.maxFrameBytes === RPC_V2_MAX_FRAME_BYTES &&
message.maxReassembledFrameBytes === RPC_V2_MAX_REASSEMBLED_BYTES
);
}
function assertChildWithPipes(
child: ChildProcess,
): asserts child is ChildProcessWithoutNullStreams {
@@ -79,6 +118,7 @@ export class JsonlRpcProcess {
private nextRequestId = 1;
private disposed = false;
private stdoutBuffer = "";
private pendingChunks: PendingChunkSequence | null = null;
constructor(private readonly options: JsonlRpcProcessOptions) {
this.diagnosticName = options.diagnosticName ?? "JSONL RPC";
@@ -218,6 +258,20 @@ export class JsonlRpcProcess {
return;
}
const message = parsed as Record<string, unknown>;
if (message.type === "rpc_chunk") {
const reassembled = this.consumeRpcChunk(message);
if (reassembled !== undefined) {
// The reassembled frame is a complete logical frame; route it through
// the same path as any other parsed line.
this.handleLine(JSON.stringify(reassembled));
}
return;
}
this.dispatchFrame(message);
}
/** Route a complete logical frame to its consumer (response or subscriber). */
private dispatchFrame(message: Record<string, unknown>): void {
if (message.type === "response") {
this.handleResponse(message as unknown as JsonlRpcResponse);
return;
@@ -227,6 +281,173 @@ export class JsonlRpcProcess {
}
}
/**
* Reassemble a protocol v2 `rpc_chunk` frame. Returns the reconstructed
* logical frame once the final chunk of a sequence arrives, `undefined` while
* a sequence is still in progress, and drops (returning `undefined`) any
* malformed or out-of-order chunk after logging it.
*/
private consumeRpcChunk(chunk: Record<string, unknown>): Record<string, unknown> | undefined {
const metadata = this.parseChunkMetadata(chunk);
if (metadata === undefined) {
return undefined;
}
const bytes = this.decodeChunkPayload(metadata.data);
if (bytes === undefined) {
return undefined;
}
return this.appendChunk(metadata, bytes);
}
/** Validate a `rpc_chunk` frame's metadata, or reset the sequence and return undefined. */
private parseChunkMetadata(chunk: Record<string, unknown>): RpcChunkMetadata | undefined {
const chunkId = chunk.chunkId;
const data = chunk.data;
const index = chunk.index;
const count = chunk.count;
const byteLength = chunk.byteLength;
if (
typeof chunkId !== "string" ||
chunkId.length === 0 ||
chunkId.length > 128 ||
typeof data !== "string" ||
data.length === 0 ||
typeof index !== "number" ||
!Number.isSafeInteger(index) ||
index < 0 ||
typeof count !== "number" ||
!Number.isSafeInteger(count) ||
count < 2 ||
count > RPC_V2_MAX_CHUNK_COUNT ||
index >= count ||
typeof byteLength !== "number" ||
!Number.isSafeInteger(byteLength) ||
byteLength < RPC_V2_MAX_FRAME_BYTES ||
byteLength > RPC_V2_MAX_REASSEMBLED_BYTES
) {
this.options.logger.warn(
{ chunk },
`Ignoring malformed ${this.diagnosticName} rpc_chunk frame`,
);
this.pendingChunks = null;
return undefined;
}
return { chunkId, index, count, byteLength, data };
}
/** Decode a chunk payload, or reset the sequence and return undefined on bad base64. */
private decodeChunkPayload(data: string): Buffer | undefined {
try {
if (data.length > RPC_V2_MAX_ENCODED_CHUNK_LENGTH) {
throw new Error("encoded payload exceeds the transport limit");
}
const bytes = Buffer.from(data, "base64");
if (bytes.byteLength > RPC_V2_CHUNK_PAYLOAD_BYTES || bytes.toString("base64") !== data) {
throw new Error("invalid base64 payload");
}
return bytes;
} catch {
this.options.logger.warn({}, `Ignoring ${this.diagnosticName} rpc_chunk with invalid base64`);
this.pendingChunks = null;
return undefined;
}
}
/** Append a chunk to the in-progress sequence, finalizing it once complete. */
private appendChunk(
metadata: RpcChunkMetadata,
bytes: Buffer,
): Record<string, unknown> | undefined {
let pending = this.pendingChunks;
if (pending === null) {
if (metadata.index !== 0) {
this.options.logger.warn(
{},
`Ignoring ${this.diagnosticName} rpc_chunk sequence that did not start at index 0`,
);
return undefined;
}
pending = {
chunkId: metadata.chunkId,
count: metadata.count,
byteLength: metadata.byteLength,
nextIndex: 0,
chunks: [],
receivedBytes: 0,
};
this.pendingChunks = pending;
}
if (
pending.chunkId !== metadata.chunkId ||
pending.count !== metadata.count ||
pending.byteLength !== metadata.byteLength ||
pending.nextIndex !== metadata.index
) {
this.options.logger.warn(
{},
`Ignoring out-of-order ${this.diagnosticName} rpc_chunk; dropping sequence`,
);
this.pendingChunks = null;
return undefined;
}
pending.chunks.push(bytes);
pending.receivedBytes += bytes.byteLength;
pending.nextIndex += 1;
if (pending.receivedBytes > pending.byteLength) {
this.options.logger.warn(
{},
`Ignoring ${this.diagnosticName} rpc_chunk sequence exceeding its declared length`,
);
this.pendingChunks = null;
return undefined;
}
if (pending.nextIndex < pending.count) {
return undefined;
}
if (pending.receivedBytes !== pending.byteLength) {
this.options.logger.warn(
{},
`Ignoring ${this.diagnosticName} rpc_chunk sequence with a length mismatch`,
);
this.pendingChunks = null;
return undefined;
}
this.pendingChunks = null;
return this.finalizeChunks(pending.chunks);
}
/** Reassemble and parse a complete chunk sequence into its logical frame. */
private finalizeChunks(chunks: Buffer[]): Record<string, unknown> | undefined {
let json: string;
try {
json = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
} catch {
this.options.logger.warn(
{},
`Ignoring ${this.diagnosticName} rpc_chunk sequence with invalid UTF-8`,
);
return undefined;
}
let frame: unknown;
try {
frame = JSON.parse(json);
} catch {
this.options.logger.warn(
{},
`Ignoring ${this.diagnosticName} rpc_chunk sequence with invalid JSON`,
);
return undefined;
}
if (!frame || typeof frame !== "object" || Array.isArray(frame)) {
return undefined;
}
return frame as Record<string, unknown>;
}
private handleResponse(response: JsonlRpcResponse): void {
if (!response.id) {
return;
@@ -68,6 +68,47 @@ describe("MockLoadTestAgentClient", () => {
await session.interrupt();
});
test("streams a configured assistant response through the normal timeline", async () => {
vi.useFakeTimers();
const response = [
"```mermaid",
"flowchart LR",
" Start --> Middle",
' Middle --> End["<i>Done</i>"]',
"```",
].join("\n");
const client = new MockLoadTestAgentClient();
const session = await client.createSession({
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
featureValues: {
mockStreamingAssistantResponse: response,
mockStreamingAssistantIntervalMs: 20,
},
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const resultPromise = session.run("Render a diagram while streaming.");
await vi.runAllTimersAsync();
await expect(resultPromise).resolves.toMatchObject({ finalText: response, canceled: false });
const streamedText = events
.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
)
.join("");
expect(streamedText).toBe(response);
expect(
events.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
.length,
).toBeGreaterThan(5);
expect(events.at(-1)).toMatchObject({ type: "turn_completed", provider: "mock" });
});
test("can withhold the provider user-message echo until an immediate interrupt", async () => {
vi.useFakeTimers();
const client = new MockLoadTestAgentClient();
@@ -644,6 +644,8 @@ export class MockLoadTestAgentSession implements AgentSession {
private modeId: string | null;
private modelId: string | null;
private readonly assistantResponse: string | null;
private readonly streamingAssistantResponse: string | null;
private readonly streamingAssistantIntervalMs: number;
private readonly rewindError: string | null;
private remainingPromptRejections: number;
@@ -656,6 +658,18 @@ export class MockLoadTestAgentSession implements AgentSession {
typeof options.config.featureValues?.mockAssistantResponse === "string"
? options.config.featureValues.mockAssistantResponse
: null;
this.streamingAssistantResponse =
typeof options.config.featureValues?.mockStreamingAssistantResponse === "string"
? options.config.featureValues.mockStreamingAssistantResponse
: null;
const requestedStreamingInterval =
options.config.featureValues?.mockStreamingAssistantIntervalMs;
this.streamingAssistantIntervalMs =
typeof requestedStreamingInterval === "number" &&
Number.isFinite(requestedStreamingInterval) &&
requestedStreamingInterval >= 1
? Math.min(requestedStreamingInterval, 1_000)
: MOCK_LOAD_TEST_INTERVAL_MS;
this.rewindError =
typeof options.config.featureValues?.mockRewindError === "string"
? options.config.featureValues.mockRewindError
@@ -721,6 +735,8 @@ export class MockLoadTestAgentSession implements AgentSession {
const scheduleTurn = () => {
if (shouldEmitTurnFailure(prompt)) {
this.scheduleFailedTurn(turn);
} else if (this.streamingAssistantResponse !== null) {
this.scheduleStreamingAssistantTurn(turn, this.streamingAssistantResponse);
} else if (this.assistantResponse !== null) {
this.scheduleSettledAssistantTurn(turn, this.assistantResponse);
} else if (structuredBranchName) {
@@ -1040,6 +1056,32 @@ export class MockLoadTestAgentSession implements AgentSession {
turn.timer.unref?.();
}
private scheduleStreamingAssistantTurn(turn: ActiveTurn, finalText: string): void {
const tokens = tokenize(finalText);
const emitNext = () => {
if (this.activeTurn !== turn) {
return;
}
this.clearTurnTimer(turn);
this.emitTurnStarted(turn);
const token = tokens.shift();
if (token === undefined) {
this.finishTurnWithText(turn, finalText);
return;
}
turn.emittedTokens += 1;
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: token,
messageId: turn.assistantMessageId,
});
turn.timer = setTimeout(emitNext, this.streamingAssistantIntervalMs);
turn.timer.unref?.();
};
turn.timer = setTimeout(emitNext, 0);
turn.timer.unref?.();
}
private emitSettledAssistantTurn(turn: ActiveTurn, finalText: string): void {
if (this.activeTurn !== turn) {
return;
@@ -7,6 +7,7 @@ import type {
AgentSession,
AgentSessionConfig,
FetchCatalogOptions,
ProviderRefreshContext,
ProviderCatalog,
} from "../agent-sdk-types.js";
@@ -24,10 +25,6 @@ const CAPABILITIES: AgentCapabilityFlags = {
supportsRewindBoth: false,
};
function neverResolves<T>(): Promise<T> {
return new Promise<T>(() => {});
}
export class MockSlowProviderClient implements AgentClient {
readonly provider: AgentProvider = MOCK_SLOW_PROVIDER_ID;
readonly capabilities = CAPABILITIES;
@@ -36,8 +33,19 @@ export class MockSlowProviderClient implements AgentClient {
return process.env.PASEO_ENABLE_MOCK_SLOW === "true";
}
async fetchCatalog(_options: FetchCatalogOptions): Promise<ProviderCatalog> {
return neverResolves<ProviderCatalog>();
async fetchCatalog(
_options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
return await (context?.runActivity(
"mock.catalog",
() =>
new Promise<ProviderCatalog>((_resolve, reject) => {
context.signal.addEventListener("abort", () => reject(context.signal.reason), {
once: true,
});
}),
) ?? new Promise<ProviderCatalog>(() => {}));
}
async getDiagnostic(): Promise<{ diagnostic: string }> {
@@ -34,10 +34,12 @@ import {
type ListImportableSessionsOptions,
type ProviderCatalog,
type ProviderWorkspace,
type ProviderRefreshContext,
type ToolCallDetail,
} from "../../agent-sdk-types.js";
import type { PaseoToolCatalog } from "../../tools/types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
import { runProviderTurn } from "../provider-runner.js";
import {
checkProviderLaunchAvailable,
@@ -110,7 +112,6 @@ import {
import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js";
const OMP_PROVIDER = "omp";
const OMP_CATALOG_REQUEST_TIMEOUT_MS = 120_000;
const QUESTION_RESPONSE_HEADER = "Response";
const QUESTION_COMMENT_HEADER = "Comment";
const OMP_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response...";
@@ -2325,26 +2326,47 @@ export class OmpAgentClient implements AgentClient {
}
}
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const launchMode = this.resolveLaunchMode(undefined);
const workspace = providerWorkspaceFromCatalogOptions(options);
const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd);
const runtimeSession = await this.runtime.startSession({
cwd,
workspace,
protocolMode: "rpc-ui",
modeId: launchMode.modeId,
extraArgs: launchMode.extraArgs,
});
let runtimeSession: OmpRuntimeSession | undefined;
let closePromise: Promise<void> | undefined;
const closeSession = () => {
if (!runtimeSession) return Promise.resolve();
closePromise ??= runtimeSession.close();
return closePromise;
};
const handleAbort = () => void closeSession().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
try {
await runProviderRefreshActivity(context, "runtime.start", async () => {
runtimeSession = await this.runtime.startSession({
cwd,
workspace,
protocolMode: "rpc-ui",
modeId: launchMode.modeId,
extraArgs: launchMode.extraArgs,
signal: context?.signal,
});
if (context?.signal.aborted) await closeSession();
});
if (!runtimeSession) throw new Error("OMP catalog runtime did not start");
const catalogSession = runtimeSession;
const models = transformOmpModels(
(await runtimeSession.getAvailableModels(OMP_CATALOG_REQUEST_TIMEOUT_MS)).map((model) =>
mapOmpModel(model, this.provider),
),
(
await runProviderRefreshActivity(context, "get_available_models", () =>
catalogSession.getAvailableModels(null),
)
).map((model) => mapOmpModel(model, this.provider)),
);
return { models, modes: [...OMP_MODES] };
} finally {
await runtimeSession.close();
context?.signal.removeEventListener("abort", handleAbort);
await closeSession();
}
}
@@ -14,7 +14,11 @@ type OmpChild = ChildProcessWithoutNullStreams & {
killedSignals: Array<NodeJS.Signals | number | undefined>;
};
function createOmpChild(): OmpChild {
function createOmpChild(options?: {
supportedProtocolVersions?: number[];
emitReady?: boolean;
maxFrameBytes?: number;
}): OmpChild {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(),
stdout: new PassThrough(),
@@ -28,6 +32,20 @@ function createOmpChild(): OmpChild {
queueMicrotask(() => child.emit("exit", null, signal ?? null));
return true;
}) as ChildProcessWithoutNullStreams["kill"];
// Real OMP writes a `ready` frame immediately after launch; the runtime waits
// for it before negotiating the RPC protocol. Advertise v1-only by default so
// the session stays on protocol v1 and command streams are unaffected.
if (options?.emitReady !== false) {
child.stdout.write(
`${JSON.stringify({
type: "ready",
protocolVersion: 1,
supportedProtocolVersions: options?.supportedProtocolVersions ?? [1],
maxFrameBytes: options?.maxFrameBytes ?? 1024 * 1024,
maxReassembledFrameBytes: 64 * 1024 * 1024,
})}\n`,
);
}
return child;
}
@@ -243,4 +261,136 @@ describe("OMP CLI runtime", () => {
await expect(session.prompt("hello")).resolves.toEqual({ requestId: "req_1" });
});
test("negotiates RPC protocol v2 when OMP advertises it", async () => {
const child = createOmpChild({ supportedProtocolVersions: [1, 2] });
const commands: Record<string, unknown>[] = [];
replyToCommands(child, (command) => {
commands.push(command);
if (command.type === "negotiate_protocol") {
return { protocolVersion: command.protocolVersion };
}
if (command.type === "get_available_models") {
return {
models: [{ provider: "opencode-go", id: "deepseek-v4-flash", name: "DeepSeek V4 Flash" }],
};
}
return undefined;
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await expect(session.getAvailableModels()).resolves.toEqual([
expect.objectContaining({ provider: "opencode-go", id: "deepseek-v4-flash" }),
]);
expect(commands.map(withoutRequestId)).toContainEqual({
type: "negotiate_protocol",
protocolVersion: 2,
});
});
test("stays on protocol v1 when OMP advertises incompatible framing limits", async () => {
const child = createOmpChild({
supportedProtocolVersions: [1, 2],
maxFrameBytes: 512 * 1024,
});
const commands: Record<string, unknown>[] = [];
replyToCommands(child, (command) => {
commands.push(command);
return { models: [] };
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
await session.getAvailableModels();
expect(commands.map(withoutRequestId)).toEqual([{ type: "get_available_models" }]);
await session.close();
});
test("rejects startup when OMP exits before advertising readiness", async () => {
const child = createOmpChild({ emitReady: false });
const startup = createRuntime(child).startSession({ cwd: "/workspace/project" });
child.stderr.write("startup exploded");
child.emit("exit", 7, null);
await expect(startup).rejects.toThrow("startup exploded");
});
test("rejects startup when OMP exits during protocol negotiation", async () => {
const child = createOmpChild({ supportedProtocolVersions: [1, 2] });
child.stdin.on("data", () => {
child.stderr.write("negotiation exploded");
child.emit("exit", 8, null);
});
await expect(createRuntime(child).startSession({ cwd: "/workspace/project" })).rejects.toThrow(
"negotiation exploded",
);
});
test("reassembles chunked protocol v2 responses for large payloads", async () => {
const child = createOmpChild({ supportedProtocolVersions: [1, 2] });
const models = Array.from({ length: 2_000 }, (_, index) => ({
provider: "p",
id: `model-${index}`,
name: `model-${index}-${"x".repeat(512)}`,
}));
let buffer = "";
child.stdin.on("data", (chunk) => {
buffer += chunk.toString();
for (;;) {
const newlineIndex = buffer.indexOf("\n");
if (newlineIndex === -1) break;
const line = buffer.slice(0, newlineIndex);
buffer = buffer.slice(newlineIndex + 1);
const command = JSON.parse(line) as Record<string, unknown>;
if (command.type === "negotiate_protocol") {
child.stdout.write(
`${JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: { protocolVersion: 2 },
})}\n`,
);
continue;
}
if (command.type === "get_available_models") {
// Emit a logical response larger than the 1 MiB protocol-v1 cap as a
// chunked (protocol v2) frame sequence, like real OMP does.
const logical = JSON.stringify({
id: command.id,
type: "response",
command: command.type,
success: true,
data: { models },
});
const bytes = Buffer.from(logical, "utf8");
expect(bytes.byteLength).toBeGreaterThan(1024 * 1024);
const chunkPayload = 256 * 1024;
const count = Math.ceil(bytes.length / chunkPayload);
for (let index = 0; index < count; index++) {
child.stdout.write(
`${JSON.stringify({
type: "rpc_chunk",
chunkId: "c1",
index,
count,
byteLength: bytes.length,
data: bytes
.subarray(index * chunkPayload, (index + 1) * chunkPayload)
.toString("base64"),
})}\n`,
);
}
}
}
});
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const result = await session.getAvailableModels();
expect(result).toHaveLength(2_000);
expect(result[0]).toEqual(expect.objectContaining({ id: "model-0" }));
});
});
@@ -2,7 +2,12 @@ import type { ChildProcessWithoutNullStreams } from "node:child_process";
import type { Logger } from "pino";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { JsonlRpcProcess, type JsonlRpcLaunch } from "../jsonl-rpc-process.js";
import {
JSONL_RPC_DEFAULT_TIMEOUT_MS,
JsonlRpcProcess,
supportsJsonlRpcProtocolV2,
type JsonlRpcLaunch,
} from "../jsonl-rpc-process.js";
import {
buildOmpLaunch,
type OmpRuntime,
@@ -45,6 +50,8 @@ import {
const DEFAULT_OMP_COMMAND: [string, ...string[]] = [process.env.OMP_COMMAND ?? "omp"];
const DEFAULT_COMMANDS_RPC_NAME = "get_available_commands";
/** How long to wait for OMP's startup `ready` frame before failing startup. */
const OMP_READY_TIMEOUT_MS = 10_000;
export interface OmpCliRuntimeOptions {
logger: Logger;
@@ -99,10 +106,76 @@ export class OmpCliRuntime implements OmpRuntime {
...(runtimeSpawn ? { spawn: runtimeSpawn } : {}),
};
const process = new JsonlRpcProcess(processOptions);
return new OmpCliRuntimeSession(process, this.commandsRpcName);
const handleAbort = () => void process.close(input.signal?.reason).catch(() => undefined);
input.signal?.addEventListener("abort", handleAbort, { once: true });
try {
await negotiateOmpProtocolV2(process, this.options.logger);
input.signal?.throwIfAborted();
return new OmpCliRuntimeSession(process, this.commandsRpcName);
} catch (error) {
const startupError = error instanceof Error ? error : new Error(String(error));
await process.close(startupError);
throw startupError;
} finally {
input.signal?.removeEventListener("abort", handleAbort);
}
}
}
/**
* Wait for OMP to advertise readiness and negotiate RPC protocol v2 when it is
* supported. OMP caps protocol-v1 single-line frames at 1 MiB; `get_available_models`
* (and other large payloads) can exceed that and are returned as an overflow error.
* Protocol v2 lifts the ceiling to 64 MiB by chunking oversized frames, which the
* JSONL transport reassembles. Supported OMP versions send a `ready` frame immediately
* after launch; startup fails if the process exits or never becomes ready.
*/
async function negotiateOmpProtocolV2(process: JsonlRpcProcess, logger: Logger): Promise<void> {
const ready = await waitForOmpReadyFrame(process);
if (!supportsJsonlRpcProtocolV2(ready)) {
return;
}
const response = (await process.request(
{ type: "negotiate_protocol", protocolVersion: 2 },
JSONL_RPC_DEFAULT_TIMEOUT_MS,
)) as { protocolVersion?: unknown } | undefined;
if (response?.protocolVersion !== 2) {
throw new Error("OMP did not accept RPC protocol v2");
}
logger.debug({}, "Negotiated OMP RPC protocol v2 (chunked frame transport)");
}
function waitForOmpReadyFrame(process: JsonlRpcProcess): Promise<Record<string, unknown>> {
return new Promise((resolve, reject) => {
let settled = false;
let unsubscribeMessage = (): void => {};
let unsubscribeExit = (): void => {};
let timer: NodeJS.Timeout | undefined;
const finish = (result: Record<string, unknown> | Error): void => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
unsubscribeMessage();
unsubscribeExit();
if (result instanceof Error) {
reject(result);
} else {
resolve(result);
}
};
unsubscribeMessage = process.onMessage((message) => {
if (message.type === "ready") {
finish(message);
}
});
unsubscribeExit = process.onExit(({ error }) => finish(error));
timer = setTimeout(
() => finish(new Error("Timed out waiting for OMP to become ready")),
OMP_READY_TIMEOUT_MS,
);
});
}
class OmpCliRuntimeSession implements OmpRuntimeSession {
private readonly subscribers = new Set<(event: OmpRuntimeEvent) => void>();
activeBranchEntryId?: string;
@@ -166,7 +239,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
return data.messages ?? [];
}
async getAvailableModels(timeoutMs?: number): Promise<OmpModel[]> {
async getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]> {
const data = OmpModelsResultSchema.parse(
await this.request({ type: "get_available_models" }, timeoutMs),
);
@@ -287,7 +360,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
await this.process.close(new Error("OMP RPC session is closed"));
}
private request(command: OmpRpcCommand, timeoutMs?: number): Promise<unknown> {
private request(command: OmpRpcCommand, timeoutMs?: number | null): Promise<unknown> {
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
}
@@ -33,6 +33,7 @@ export interface OmpStartSessionInput {
workspace?: ProviderWorkspace;
agentId?: string;
cwd: string;
signal?: AbortSignal;
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
@@ -55,7 +56,7 @@ export interface OmpRuntimeSession {
abort(): Promise<void>;
getState(): Promise<OmpSessionState>;
getMessages(): Promise<OmpAgentMessage[]>;
getAvailableModels(timeoutMs?: number): Promise<OmpModel[]>;
getAvailableModels(timeoutMs?: number | null): Promise<OmpModel[]>;
setModel(provider: string, modelId: string): Promise<OmpModel>;
setThinkingLevel(level: OmpThinkingLevel): Promise<void>;
getSessionStats(): Promise<OmpSessionStats>;
@@ -274,7 +274,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
return this.messages;
}
async getAvailableModels(_timeoutMs?: number): Promise<OmpModel[]> {
async getAvailableModels(_timeoutMs?: number | null): Promise<OmpModel[]> {
return this.models;
}
@@ -15,7 +15,7 @@ const TODO_PHASES = [
] as const;
describe("OMP todo mapper", () => {
test("maps todo tool results and collapses statuses to completed booleans", () => {
test("maps todo tool results without losing progress status", () => {
expect(
mapOmpTodoToolResult(
parseToolResult({
@@ -37,9 +37,9 @@ describe("OMP todo mapper", () => {
).toEqual({
type: "todo",
items: [
{ text: "alpha task", completed: false },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
{ text: "alpha task", status: "in_progress", completed: false },
{ text: "beta task", status: "pending", completed: false },
{ text: "gamma task", status: "pending", completed: false },
],
});
@@ -48,9 +48,9 @@ describe("OMP todo mapper", () => {
).toEqual({
type: "todo",
items: [
{ text: "alpha task", completed: true },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
{ text: "alpha task", status: "completed", completed: true },
{ text: "beta task", status: "in_progress", completed: false },
{ text: "gamma task", status: "pending", completed: false },
],
});
});
@@ -67,8 +67,8 @@ describe("OMP todo mapper", () => {
).toEqual({
type: "todo",
items: [
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
{ text: "beta task", status: "in_progress", completed: false },
{ text: "gamma task", status: "pending", completed: false },
],
});
});
@@ -89,9 +89,9 @@ describe("OMP todo mapper", () => {
{
type: "todo",
items: [
{ text: "alpha task", completed: true },
{ text: "beta task", completed: false },
{ text: "gamma task", completed: false },
{ text: "alpha task", status: "completed", completed: true },
{ text: "beta task", status: "in_progress", completed: false },
{ text: "gamma task", status: "pending", completed: false },
],
},
]);
@@ -41,11 +41,18 @@ function mapOmpTodoItems(items: readonly OmpTodoItem[]): AgentTimelineItem | nul
type: "todo",
items: items.map((item) => ({
text: item.content,
status: normalizeOmpTodoStatus(item.status),
completed: item.status === "completed",
})),
};
}
function normalizeOmpTodoStatus(status: OmpTodoItem["status"]) {
if (status === "completed") return "completed" as const;
if (status === "in_progress") return "in_progress" as const;
return "pending" as const;
}
function resultDetails(result: OmpToolResult): Record<string, unknown> | null {
if (typeof result === "string" || result === null) {
return null;
@@ -2,6 +2,7 @@ import { afterEach, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { runProviderRefreshWithDeadline } from "../provider-refresh-deadline.js";
import {
TestOpenCodeClient,
TestOpenCodeHarness,
@@ -11,32 +12,15 @@ afterEach(() => {
vi.useRealTimers();
});
test("allows a slow provider.list call to succeed instead of failing after 10 seconds", async () => {
test("the catalog deadline aborts provider.list and releases the server", async () => {
vi.useFakeTimers();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListImplementation = () =>
new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
connected: ["zai"],
all: [
{
id: "zai",
name: "Z.AI",
models: {
"glm-5.1": {
name: "GLM 5.1",
limit: { context: 128_000 },
},
},
},
],
},
});
}, 15_000);
openCodeClient.providerListImplementation = (_parameters, options) =>
new Promise((_resolve, reject) => {
const signal = (options as { signal: AbortSignal }).signal;
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
runtime.enqueueClient(openCodeClient);
@@ -44,24 +28,72 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
serverManager: runtime,
createClient: runtime.createClient,
});
const modelsPromise = client.fetchCatalog({
scope: "workspace",
cwd: "/tmp/opencode-models",
force: false,
const modelsPromise = runProviderRefreshWithDeadline({
label: "OpenCode",
timeoutMs: 100,
operation: (context) =>
client.fetchCatalog(
{ scope: "workspace", cwd: "/tmp/opencode-models", force: false },
context,
),
});
const rejection = expect(modelsPromise).rejects.toThrow(
"Timed out refreshing OpenCode after 100ms; pending: provider.list",
);
await vi.advanceTimersByTimeAsync(15_000);
await vi.advanceTimersByTimeAsync(100);
await expect(modelsPromise).resolves.toMatchObject({
models: [
{
provider: "opencode",
id: "zai/glm-5.1",
label: "GLM 5.1",
},
],
});
await rejection;
expect(openCodeClient.calls.providerList).toHaveLength(1);
expect(openCodeClient.calls.providerListOptions[0]).toMatchObject({
signal: expect.objectContaining({ aborted: true }),
});
expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]);
});
test("the catalog deadline aborts app.agents and releases the server", async () => {
vi.useFakeTimers();
const runtime = new TestOpenCodeHarness();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.providerListResponse = {
data: {
connected: ["openai"],
all: [{ id: "openai", name: "OpenAI", models: {} }],
},
};
openCodeClient.appAgentsImplementation = (_parameters, options) =>
new Promise((_resolve, reject) => {
const signal = (options as { signal: AbortSignal }).signal;
signal.addEventListener("abort", () => reject(signal.reason), { once: true });
});
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const catalogPromise = runProviderRefreshWithDeadline({
label: "OpenCode",
timeoutMs: 100,
operation: (context) =>
client.fetchCatalog(
{ scope: "workspace", cwd: "/tmp/opencode-agents", force: false },
context,
),
});
const rejection = expect(catalogPromise).rejects.toThrow(
"Timed out refreshing OpenCode after 100ms; pending: app.agents",
);
await vi.advanceTimersByTimeAsync(100);
await rejection;
expect(openCodeClient.calls.appAgents).toHaveLength(1);
expect(openCodeClient.calls.appAgentsOptions[0]).toMatchObject({
signal: expect.objectContaining({ aborted: true }),
});
expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]);
});
test("uses a new server for explicit catalog refresh", async () => {
@@ -480,6 +480,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
models: {
"big-pickle": {
name: "Big Pickle",
variants: { max: { reasoningEffort: "max" } },
limit: {
context: 200_000,
},
@@ -529,6 +530,11 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
expect(catalog.models[0]).toMatchObject({
id: TEST_MODEL,
label: "Big Pickle",
thinkingOptions: [
{ id: "default", label: "Default", isDefault: true },
{ id: "max", label: "max" },
],
defaultThinkingOptionId: "default",
metadata: {
providerId: "opencode",
modelId: "big-pickle",
@@ -824,7 +830,40 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, 180_000);
});
describe("OpenCode adapter context-window normalization", () => {
describe("OpenCode adapter normalization", () => {
test("omits OpenCode's implicit default variant from new and updated sessions", async () => {
const runtime = new TestOpenCodeHarness();
const openCode = new TestOpenCodeClient();
openCode.sessionCreateResponse = { data: { id: "ses_default_variant" } };
openCode.sessionPromptAsyncEvents = [
{ type: "session.idle", properties: { sessionID: "ses_default_variant" } },
];
runtime.enqueueClient(openCode);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/workspace/repo",
model: "catalog-provider/single-variant-model",
thinkingOptionId: "default",
});
await collectTurnEvents(streamSession(session, "Use the model default"));
await session.setThinkingOption?.("max");
await collectTurnEvents(streamSession(session, "Use max"));
await session.setThinkingOption?.("default");
await collectTurnEvents(streamSession(session, "Return to the model default"));
expect(openCode.calls.sessionPromptAsync).toEqual([
expect.not.objectContaining({ variant: expect.anything() }),
expect.objectContaining({ variant: "max" }),
expect.not.objectContaining({ variant: expect.anything() }),
]);
await session.close();
});
test("builds OpenCode file parts for image prompt blocks", () => {
expect(
__openCodeInternals.buildOpenCodePromptParts([
@@ -1878,7 +1917,13 @@ describe("OpenCode adapter startTurn error handling", () => {
provider: "opencode",
item: {
type: "todo",
items: [{ text: "Inspect current directory and existing files", completed: true }],
items: [
{
text: "Inspect current directory and existing files",
status: "completed",
completed: true,
},
],
},
},
]);
@@ -41,6 +41,7 @@ import {
type AgentTimelineItem,
type AgentUsage,
type FetchCatalogOptions,
type ProviderRefreshContext,
type ImportableProviderSession,
type ImportProviderSessionContext,
type ImportProviderSessionInput,
@@ -53,6 +54,10 @@ import {
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import {
raceProviderRefreshAbort,
runProviderRefreshActivity,
} from "../provider-refresh-deadline.js";
import {
isDefaultAgentCreateConfigUnattended,
resolveDefaultAgentCreateConfig,
@@ -68,6 +73,7 @@ import { execCommand } from "../../../utils/spawn.js";
import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
import {
OpenCodeServerManager,
type OpenCodeServerAcquisition,
type OpenCodeServerManagerLike,
} from "./opencode/server-manager.js";
import { resolveOpenCodeHomeDir } from "./opencode/paths.js";
@@ -112,6 +118,7 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_DEFAULT_VARIANT_ID = "default";
const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept";
const OPENCODE_PERSISTED_SESSION_LIMIT = 200;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
@@ -297,9 +304,9 @@ type OpenCodeMcpConfig =
};
const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"] as const;
const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30_000;
const OPENCODE_METADATA_CONCURRENCY = 4;
const openCodeMetadataLimit = pLimit(OPENCODE_METADATA_CONCURRENCY);
const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS: AgentSlashCommand[] = [
{
name: "compact",
@@ -581,6 +588,14 @@ function normalizeOpenCodeModeId(modeId: string | null | undefined): string | nu
return trimmed;
}
function normalizeOpenCodeVariantId(variantId: string | null | undefined): string | null {
const trimmed = typeof variantId === "string" ? variantId.trim() : "";
if (!trimmed || trimmed === OPENCODE_DEFAULT_VARIANT_ID) {
return null;
}
return trimmed;
}
function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): string | undefined {
const normalizedModeId = normalizeOpenCodeModeId(modeId);
if (normalizedModeId === null) {
@@ -592,15 +607,19 @@ function resolveOpenCodeRuntimeAgentId(modeId: string | null | undefined): strin
}
function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConfig {
if (normalizeOpenCodeModeId(config.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
return { ...config };
const normalized = {
...config,
thinkingOptionId: normalizeOpenCodeVariantId(config.thinkingOptionId) ?? undefined,
};
if (normalizeOpenCodeModeId(normalized.modeId) !== OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
return normalized;
}
return {
...config,
...normalized,
modeId: OPENCODE_BUILD_MODE_ID,
featureValues: {
...config.featureValues,
...normalized.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
},
};
@@ -724,11 +743,13 @@ function buildOpenCodeModelDefinition(
},
): AgentModelDefinition {
const rawVariants = model.variants ? Object.keys(model.variants) : [];
const thinkingOptions = rawVariants.map((id, index) => ({
id,
label: id,
isDefault: index === 0,
}));
// OpenCode lists only overrides; its base model behavior is selected by omitting `variant`.
const thinkingOptions = rawVariants.length
? [
{ id: OPENCODE_DEFAULT_VARIANT_ID, label: "Default", isDefault: true },
...rawVariants.map((id) => ({ id, label: id })),
]
: [];
return {
provider: "opencode",
@@ -1408,18 +1429,26 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const workspace = providerWorkspaceFromCatalogOptions(options);
if (workspace && !workspace.allowsHostService("opencode")) {
throw new Error("OpenCode is unavailable in the selected workspace runtime");
}
const acquisition = options.force
? await this.serverManager.acquireNew()
: await this.serverManager.acquireCurrent();
const { url } = acquisition.server;
const isGlobalCatalog = options.scope === "global";
let acquisition: OpenCodeServerAcquisition | undefined;
try {
await runProviderRefreshActivity(context, "server.acquire", async () => {
acquisition = options.force
? await this.serverManager.acquireNew(context?.signal)
: await this.serverManager.acquireCurrent(context?.signal);
});
if (!acquisition) throw new Error("OpenCode server acquisition did not complete");
context?.signal.throwIfAborted();
const { url } = acquisition.server;
const isGlobalCatalog = options.scope === "global";
// OpenCode treats the catalog directory as a workspace. The global catalog
// is not a project, so use the neutral OpenCode home instead of user home.
const directory = isGlobalCatalog ? this.resolveHomeDir() : options.cwd;
@@ -1434,12 +1463,12 @@ export class OpenCodeAgentClient implements AgentClient {
const client = this.createOpenCodeClient({ baseUrl: url, directory });
const [models, modes] = await Promise.all([
this.fetchModelsFromClient(client, directory),
this.fetchModesFromClient(client, directory),
this.fetchModelsFromClient(client, directory, context),
this.fetchModesFromClient(client, directory, context),
]);
return { models, modes };
} finally {
await acquisition.release();
await acquisition?.release();
}
}
@@ -1636,12 +1665,18 @@ export class OpenCodeAgentClient implements AgentClient {
private async fetchModelsFromClient(
client: OpencodeClient,
directory: string,
context?: ProviderRefreshContext,
): Promise<AgentModelDefinition[]> {
const response = await openCodeMetadataLimit(() =>
withTimeout(
client.provider.list({ directory }),
OPENCODE_PROVIDER_LIST_TIMEOUT_MS,
`OpenCode provider.list timed out after ${OPENCODE_PROVIDER_LIST_TIMEOUT_MS / 1000}s - server may not be authenticated or connected to any providers`,
const response = await runProviderRefreshActivity(context, "provider.list", () =>
raceProviderRefreshAbort(
context?.signal,
openCodeMetadataLimit(() => {
context?.signal.throwIfAborted();
return client.provider.list(
{ directory },
context ? { signal: context.signal } : undefined,
);
}),
),
);
@@ -1668,7 +1703,7 @@ export class OpenCodeAgentClient implements AgentClient {
}
const models: AgentModelDefinition[] = [];
this.modelContextWindows.clear();
const contextWindows = new Map<string, number>();
for (const provider of providers.all) {
if (!isAccessible(provider)) {
continue;
@@ -1678,7 +1713,7 @@ export class OpenCodeAgentClient implements AgentClient {
const definition = buildOpenCodeModelDefinition(provider, modelId, model);
const contextWindowMaxTokens = extractOpenCodeModelContextWindow(model);
if (contextWindowMaxTokens !== undefined) {
this.modelContextWindows.set(
contextWindows.set(
buildOpenCodeModelLookupKey(provider.id, modelId),
contextWindowMaxTokens,
);
@@ -1687,18 +1722,25 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
context?.signal.throwIfAborted();
this.modelContextWindows.clear();
for (const [key, value] of contextWindows) this.modelContextWindows.set(key, value);
return models;
}
private async fetchModesFromClient(
client: OpencodeClient,
directory: string,
context?: ProviderRefreshContext,
): Promise<AgentMode[]> {
const response = await openCodeMetadataLimit(() =>
withTimeout(
client.app.agents({ directory }),
10_000,
"OpenCode app.agents timed out after 10s",
const response = await runProviderRefreshActivity(context, "app.agents", () =>
raceProviderRefreshAbort(
context?.signal,
openCodeMetadataLimit(() => {
context?.signal.throwIfAborted();
return client.app.agents({ directory }, context ? { signal: context.signal } : undefined);
}),
),
);
@@ -1919,6 +1961,7 @@ function mapOpenCodeTodosToTimelineItems(
return [
{
text,
status: normalizeOpenCodeTodoStatus(todo.status),
completed: todo.status === "completed",
},
];
@@ -1926,6 +1969,12 @@ function mapOpenCodeTodosToTimelineItems(
};
}
function normalizeOpenCodeTodoStatus(status?: string | null) {
if (status === "completed") return "completed" as const;
if (status === "in_progress" || status === "inProgress") return "in_progress" as const;
return "pending" as const;
}
function createCompactionTimelineItem(
status: Extract<AgentTimelineItem, { type: "compaction" }>["status"],
trigger?: Extract<AgentTimelineItem, { type: "compaction" }>["trigger"],
@@ -3202,10 +3251,7 @@ class OpenCodeAgentSession implements AgentSession {
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void> {
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
const normalizedThinkingOptionId = normalizeOpenCodeVariantId(thinkingOptionId);
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
}
@@ -138,6 +138,25 @@ describe("OpenCodeServerManager generations", () => {
expect(await runtime.managedProcesses.list()).toEqual([]);
});
test("aborted acquisition transfers no reference and leaves startup reusable", async () => {
const { manager, runtime } = createTestManager([4477], { autoAnnounce: false });
const controller = new AbortController();
const abortedAcquisition = manager.acquireCurrent(controller.signal);
await runtime.settle();
controller.abort(new Error("catalog refresh expired"));
await expect(abortedAcquisition).rejects.toThrow("catalog refresh expired");
runtime.processForPort(4477).announceListening();
const nextAcquisition = await manager.acquireCurrent();
expect(nextAcquisition.server.url).toBe("http://127.0.0.1:4477");
expect(runtime.launchedPorts).toEqual([4477]);
await nextAcquisition.release();
expect(runtime.terminatedPorts).toEqual([4477]);
});
test("shutdown kills a server that is still starting", async () => {
const { manager, runtime } = createTestManager([4472], { autoAnnounce: false });
@@ -680,8 +680,8 @@ describe("translateOpenCodeEvent", () => {
item: {
type: "todo",
items: [
{ text: "Outline", completed: false },
{ text: "Ship", completed: true },
{ text: "Outline", status: "pending", completed: false },
{ text: "Ship", status: "completed", completed: true },
],
},
},
@@ -25,8 +25,8 @@ export interface OpenCodeServerAcquisition {
}
export interface OpenCodeServerManagerLike {
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
acquireNew(): Promise<OpenCodeServerAcquisition>;
acquireCurrent(signal?: AbortSignal): Promise<OpenCodeServerAcquisition>;
acquireNew(signal?: AbortSignal): Promise<OpenCodeServerAcquisition>;
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
acquireExisting(url: string): OpenCodeServerAcquisition | null;
shutdown(): Promise<void>;
@@ -137,13 +137,17 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
process.on("SIGINT", cleanup);
}
async acquireCurrent(): Promise<OpenCodeServerAcquisition> {
const server = await this.getCurrentServer();
async acquireCurrent(signal?: AbortSignal): Promise<OpenCodeServerAcquisition> {
signal?.throwIfAborted();
const server = await waitForServerAcquisition(this.getCurrentServer(), signal);
signal?.throwIfAborted();
return this.acquireServer(server);
}
async acquireNew(): Promise<OpenCodeServerAcquisition> {
const server = await this.getNewServer();
async acquireNew(signal?: AbortSignal): Promise<OpenCodeServerAcquisition> {
signal?.throwIfAborted();
const server = await waitForServerAcquisition(this.getNewServer(), signal);
signal?.throwIfAborted();
return this.acquireServer(server);
}
@@ -533,6 +537,23 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
}
async function waitForServerAcquisition<T>(
operation: Promise<T>,
signal: AbortSignal | undefined,
): Promise<T> {
if (!signal) return await operation;
let handleAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
handleAbort = () => reject(signal.reason);
signal.addEventListener("abort", handleAbort, { once: true });
});
try {
return await Promise.race([operation, aborted]);
} finally {
if (handleAbort) signal.removeEventListener("abort", handleAbort);
}
}
async function resolveOpenCodeBinary(): Promise<string> {
const found = await findExecutable("opencode");
if (!found) {
@@ -71,6 +71,7 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
export class TestOpenCodeClient {
readonly calls = {
appAgents: [] as unknown[],
appAgentsOptions: [] as unknown[],
commandList: [] as unknown[],
eventSubscribe: [] as unknown[],
experimentalSessionList: [] as unknown[],
@@ -79,6 +80,7 @@ export class TestOpenCodeClient {
mcpConnect: [] as unknown[],
permissionReply: [] as unknown[],
providerList: [] as unknown[],
providerListOptions: [] as unknown[],
questionReject: [] as unknown[],
questionReply: [] as unknown[],
sessionAbort: [] as unknown[],
@@ -95,6 +97,9 @@ export class TestOpenCodeClient {
};
appAgentsResponse: OpenCodeResponse = { data: [] };
appAgentsImplementation:
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
| null = null;
commandListResponse: OpenCodeResponse = { data: [] };
eventStream: AsyncIterable<unknown>;
experimentalSessionListResponse: OpenCodeResponse = { data: [] };
@@ -102,7 +107,9 @@ export class TestOpenCodeClient {
mcpConnectResponse: OpenCodeResponse = {};
permissionReplyResponse: OpenCodeResponse = {};
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
providerListImplementation: (() => Promise<OpenCodeResponse>) | null = null;
providerListImplementation:
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
| null = null;
globalEventImplementation:
| ((options: unknown) => Promise<{ stream: AsyncIterable<unknown> }>)
| null = null;
@@ -141,8 +148,12 @@ export class TestOpenCodeClient {
asSdkClient(): OpencodeClient {
return {
app: {
agents: async (parameters: unknown) => {
agents: async (parameters: unknown, options: unknown) => {
this.calls.appAgents.push(parameters);
this.calls.appAgentsOptions.push(options);
if (this.appAgentsImplementation) {
return await this.appAgentsImplementation(parameters, options);
}
return this.appAgentsResponse;
},
},
@@ -195,10 +206,11 @@ export class TestOpenCodeClient {
},
},
provider: {
list: async (parameters: unknown) => {
list: async (parameters: unknown, options: unknown) => {
this.calls.providerList.push(parameters);
this.calls.providerListOptions.push(options);
return this.providerListImplementation
? await this.providerListImplementation()
? await this.providerListImplementation(parameters, options)
: this.providerListResponse;
},
},
@@ -37,9 +37,11 @@ import {
type ListImportableSessionsOptions,
type ProviderCatalog,
type ProviderWorkspace,
type ProviderRefreshContext,
type ToolCallDetail,
} from "../../agent-sdk-types.js";
import { importSessionFromPersistence } from "../../provider-session-import.js";
import { runProviderRefreshActivity } from "../../provider-refresh-deadline.js";
import { runProviderTurn } from "../provider-runner.js";
import {
checkProviderLaunchAvailable,
@@ -90,7 +92,6 @@ import {
const PI_PROVIDER = "pi";
const DEFAULT_PI_THINKING_LEVEL: PiThinkingLevel = "medium";
const PI_BINARY_COMMAND = process.env.PI_COMMAND ?? process.env.PI_ACP_PI_COMMAND ?? "pi";
const PI_CATALOG_REQUEST_TIMEOUT_MS = 120_000;
const PASEO_PI_TREE_EXTENSION_COMMAND = "paseo_tree";
const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries";
const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE";
@@ -2517,22 +2518,43 @@ export class PiRpcAgentClient implements AgentClient {
}
}
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
async fetchCatalog(
options: FetchCatalogOptions,
context?: ProviderRefreshContext,
): Promise<ProviderCatalog> {
const workspace = providerWorkspaceFromCatalogOptions(options);
const cwd = workspace?.cwd ?? (options.scope === "global" ? homedir() : options.cwd);
const runtimeSession = await this.runtime.startSession({
cwd,
workspace,
});
let runtimeSession: PiRuntimeSession | undefined;
let closePromise: Promise<void> | undefined;
const closeSession = () => {
if (!runtimeSession) return Promise.resolve();
closePromise ??= runtimeSession.close();
return closePromise;
};
const handleAbort = () => void closeSession().catch(() => undefined);
context?.signal.addEventListener("abort", handleAbort, { once: true });
try {
await runProviderRefreshActivity(context, "runtime.start", async () => {
runtimeSession = await this.runtime.startSession({
cwd,
workspace,
signal: context?.signal,
});
if (context?.signal.aborted) await closeSession();
});
if (!runtimeSession) throw new Error("Pi catalog runtime did not start");
const catalogSession = runtimeSession;
const models = transformPiModels(
(await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map((model) =>
mapPiModel(model, PI_PROVIDER),
),
(
await runProviderRefreshActivity(context, "get_available_models", () =>
catalogSession.getAvailableModels(null),
)
).map((model) => mapPiModel(model, PI_PROVIDER)),
);
return { models, modes: [] };
} finally {
await runtimeSession.close();
context?.signal.removeEventListener("abort", handleAbort);
await closeSession();
}
}
@@ -96,6 +96,10 @@ export class PiCliRuntime implements PiRuntime {
...(runtimeSpawn ? { spawn: runtimeSpawn } : {}),
};
const process = new JsonlRpcProcess(processOptions);
if (input.signal?.aborted) {
await process.close(input.signal.reason);
input.signal.throwIfAborted();
}
return new PiCliRuntimeSession(process, this.commandsRpcName);
}
}
@@ -29,6 +29,7 @@ export interface PiStartSessionInput {
cwd: string;
workspace?: ProviderWorkspace;
agentId?: string;
signal?: AbortSignal;
env?: Record<string, string>;
protocolMode?: "rpc" | "rpc-ui";
model?: string;
@@ -18,8 +18,17 @@ class ProviderSnapshots {
}
describe("resolveStructuredGenerationProviders", () => {
test("uses explicit configured provider models without refreshing provider snapshots", async () => {
const snapshots = new ProviderSnapshots([]);
test("tries the configured model before dynamically discovered fallbacks", async () => {
const snapshots = new ProviderSnapshots([
{
provider: "work-claude",
status: READY,
enabled: true,
models: [
{ provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true },
],
},
]);
const providers = await resolveStructuredGenerationProviders({
cwd: "/tmp/repo",
@@ -31,8 +40,11 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
expect(providers).toEqual([{ provider: "mock", model: "ten-second-stream" }]);
expect(snapshots.calls).toEqual([]);
expect(providers).toEqual([
{ provider: "mock", model: "ten-second-stream" },
{ provider: "work-claude", model: "claude-haiku-2026" },
]);
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
test("falls back to dynamic defaults and current selection when no provider is configured", async () => {
@@ -155,7 +167,7 @@ describe("resolveStructuredGenerationProviders", () => {
]);
});
test("uses explicit configured provider models as-is instead of waiting to normalize aliases", async () => {
test("normalizes configured nested-provider aliases from the provider snapshot", async () => {
const snapshots = new ProviderSnapshots([
{
provider: "opencode",
@@ -186,8 +198,8 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
expect(providers).toEqual([{ provider: "plexus", model: "small-fast" }]);
expect(snapshots.calls).toEqual([]);
expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]);
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
test("keeps explicit candidates when provider snapshots are in error state", async () => {
@@ -215,7 +227,10 @@ describe("resolveStructuredGenerationProviders", () => {
},
});
expect(providers).toEqual([{ provider: "current-provider", model: "configured-model" }]);
expect(snapshots.calls).toEqual([]);
expect(providers).toEqual([
{ provider: "current-provider", model: "configured-model" },
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
]);
expect(snapshots.calls).toEqual([{ cwd: "/tmp/repo", wait: true }]);
});
});
@@ -44,22 +44,6 @@ export async function resolveStructuredGenerationProviders(
options: ResolveStructuredGenerationProvidersOptions,
): Promise<StructuredGenerationProvider[]> {
const configuredProviders = readConfiguredProviders(options.daemonConfig);
if (configuredProviders.length > 0) {
const explicitProviders = resolveExplicitConfiguredProviders(configuredProviders);
if (explicitProviders.length === configuredProviders.length) {
return dedupeProviders(explicitProviders);
}
const providerEntries = await options.providerSnapshotManager.listProviders({
cwd: options.cwd,
wait: false,
});
const providers = resolveConfiguredProviders(configuredProviders, providerEntries);
if (providers.length > 0) {
return dedupeProviders(providers);
}
}
const providerEntries = await options.providerSnapshotManager.listProviders({
cwd: options.cwd,
wait: true,
@@ -100,42 +84,6 @@ export async function resolveStructuredGenerationProviders(
return dedupeProviders(providers);
}
function resolveConfiguredProviders(
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
providerEntries: readonly ProviderSnapshotEntry[],
): StructuredGenerationProvider[] {
const enabledEntries = providerEntries.filter((entry) => entry.enabled);
const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0);
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
const providers: StructuredGenerationProvider[] = [];
for (const configured of configuredProviders) {
const resolved = resolveConfiguredCandidate(configured, modelEntries, entriesByProvider);
if (resolved) {
providers.push(resolved);
}
}
return providers;
}
function resolveExplicitConfiguredProviders(
configuredProviders: readonly { provider: string; model?: string; thinkingOptionId?: string }[],
): StructuredGenerationProvider[] {
const providers: StructuredGenerationProvider[] = [];
for (const configured of configuredProviders) {
const provider = configured.provider.trim();
const model = configured.model?.trim();
if (!provider || !model) {
continue;
}
providers.push({
provider,
model,
...(configured.thinkingOptionId ? { thinkingOptionId: configured.thinkingOptionId } : {}),
});
}
return providers;
}
function resolveCurrentSelection(
selection: ResolveStructuredGenerationProvidersOptions["currentSelection"],
readyEntries: readonly ProviderSnapshotEntry[],
@@ -4,6 +4,8 @@ import type { Logger } from "pino";
import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-types.js";
import type { AgentManager } from "../agent-manager.js";
import { AgentProfileSchema } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../../daemon-config-store.js";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -98,6 +100,7 @@ export interface PaseoToolHostDependencies {
getDaemonTcpPort?: () => number | null;
scheduleService?: ScheduleService | null;
providerSnapshotManager: ProviderSnapshotManager;
daemonConfigStore?: Pick<DaemonConfigStore, "get">;
github?: ForgeService;
workspaceGitService?: Pick<
WorkspaceGitService,
@@ -543,6 +546,7 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
workspaceScripts,
scheduleService,
providerSnapshotManager,
daemonConfigStore,
callerAgentId,
resolveSpeakHandler,
resolveCallerContext,
@@ -2919,6 +2923,30 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
},
);
registerTool(
"list_profiles",
{
title: "List agent profiles",
description:
"List agent profiles: named provider/model/mode bundles a human configured for specific " +
"kinds of work. Read each profile's `notes` to pick the one that fits the task you're " +
"delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " +
"`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " +
"list if none are configured.",
inputSchema: {},
outputSchema: {
profiles: z.array(AgentProfileSchema),
},
},
async () => {
const profiles = daemonConfigStore?.get().agentProfiles ?? [];
return {
content: [],
structuredContent: ensureValidJson({ profiles }),
};
},
);
registerTool(
"inspect_provider",
{
+11 -21
View File
@@ -158,9 +158,7 @@ import {
type WorkspaceRuntimeService,
} from "./workspace-runtime/index.js";
import { createProviderProbeService, type ProviderProbeService } from "./provider-probe/index.js";
import { FileBackedChatService } from "./chat/chat-service.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
import { BrowserToolsBroker } from "./browser-tools/broker.js";
@@ -181,11 +179,11 @@ import { createConfiguredTerminalManager } from "../terminal/terminal-manager-fa
import { applyTerminalAgentHookSetting } from "../terminal/agent-hooks/terminal-agent-hook-setting.js";
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { createRelayRuntime, type RelayRuntime } from "./relay-runtime.js";
import type { PushNotificationSender } from "./push/notifications.js";
import type { PushNotificationSender } from "./push/index.js";
import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { AgentClient, AgentProvider } from "./agent/agent-sdk-types.js";
import type { FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages";
import type { AgentProfile, FirstAgentContext, TerminalProfile } from "@getpaseo/protocol/messages";
import type {
AgentProviderRuntimeSettingsMap,
ProviderOverride,
@@ -419,6 +417,7 @@ export interface PaseoDaemonConfig {
enableTerminalAgentHooks?: boolean;
appendSystemPrompt?: string;
terminalProfiles?: TerminalProfile[];
agentProfiles?: AgentProfile[];
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -448,6 +447,7 @@ export interface PaseoDaemonConfig {
dictationFinalTimeoutMs?: number;
downloadTokenTtlMs?: number;
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
providerCatalogRefreshTimeoutMs?: number;
metadataGeneration?: {
providers?: Array<{
provider: string;
@@ -558,6 +558,10 @@ function createInitialMutableDaemonConfig(config: PaseoDaemonConfig): MutableDae
initialConfig.terminalProfiles = config.terminalProfiles;
}
if (config.agentProfiles !== undefined) {
initialConfig.agentProfiles = config.agentProfiles;
}
return initialConfig;
}
@@ -1021,10 +1025,6 @@ export async function createPaseoDaemon(
await workspaceRegistry?.remove(workspaceId);
providerWorkspaceBindings.delete(workspaceId);
}
const chatService = new FileBackedChatService({
paseoHome: config.paseoHome,
logger,
});
const github = createGitHubService();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger,
@@ -1058,6 +1058,7 @@ export async function createPaseoDaemon(
};
const providerSnapshotManager = new ProviderSnapshotManager({
logger: providerSnapshotLogger,
refreshTimeoutMs: config.providerCatalogRefreshTimeoutMs,
runtimeSettings: config.agentProviderSettings,
providerOverrides: config.providerOverrides,
workspaceGitService,
@@ -1134,8 +1135,6 @@ export async function createPaseoDaemon(
void workspaceReconciliation.reconcileNow().catch((error) => {
logger.warn({ err: error }, "Initial workspace reconciliation failed");
});
await chatService.initialize();
logger.info({ elapsed: elapsed() }, "Chat service initialized");
const checkoutDiffManager = new CheckoutDiffManager({
logger,
paseoHome: config.paseoHome,
@@ -1388,6 +1387,7 @@ export async function createPaseoDaemon(
});
const hubRelationships = new HubRelationshipController({
paseoHome: config.paseoHome,
hostname: getHostname(),
serverId,
daemonPublicKey: daemonKeyPair.publicKeyB64,
logger,
@@ -1412,15 +1412,6 @@ export async function createPaseoDaemon(
}),
});
const loopService = new LoopService({
paseoHome: config.paseoHome,
logger,
agentManager,
createAgent,
ensureWorkspaceForCreate: ensureWorkspaceForCreateAndBroadcastExternal,
});
await loopService.initialize();
logger.info({ elapsed: elapsed() }, "Loop service initialized");
const createScheduleLocalWorkspaceExternal = async (input: {
cwd: string;
firstAgentContext: FirstAgentContext;
@@ -1520,6 +1511,7 @@ export async function createPaseoDaemon(
getDaemonTcpPort: () => (boundListenTarget?.type === "tcp" ? boundListenTarget.port : null),
scheduleService,
providerSnapshotManager,
daemonConfigStore,
github,
workspaceGitService,
findWorkspaceIdForCwd: findWorkspaceIdForCwdExternal,
@@ -1808,8 +1800,6 @@ export async function createPaseoDaemon(
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
checkoutDiffManager,
serviceProxy,
@@ -1,205 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import type pino from "pino";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import {
buildChatMentionNotification,
notifyChatMentions,
prepareChatMentionFanout,
} from "./chat-mentions.js";
function storedAgent(overrides: Partial<StoredAgentRecord> & { id: string }): StoredAgentRecord {
return {
internal: false,
archivedAt: null,
lastStatus: "idle",
...overrides,
} as StoredAgentRecord;
}
function liveAgent(overrides: Partial<ManagedAgent> & { id: string }): ManagedAgent {
return { internal: false, lifecycle: "idle", ...overrides } as ManagedAgent;
}
async function prepare(input: {
authorAgentId: string;
mentionAgentIds: string[];
storedAgents?: StoredAgentRecord[];
liveAgents?: ManagedAgent[];
roomPosterAgentIds?: string[];
limit?: number;
}) {
const result = await prepareChatMentionFanout({
authorAgentId: input.authorAgentId,
mentionAgentIds: input.mentionAgentIds,
storedAgents: input.storedAgents ?? [],
liveAgents: input.liveAgents ?? [],
listRoomPosterAgentIds: async () => input.roomPosterAgentIds ?? [],
limit: input.limit,
});
if (!result.ok) {
throw new Error(`expected ok prepare, got error: ${result.error}`);
}
return result.prepared;
}
describe("chat mentions", () => {
test("@everyone in an empty room resolves to no targets", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [storedAgent({ id: "unrelated-agent" })],
liveAgents: [liveAgent({ id: "live-unrelated-agent" })],
roomPosterAgentIds: [],
});
expect(prepared.targetMentionAgentIds).toEqual([]);
});
test("@everyone in a single-poster room excludes the author", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [storedAgent({ id: "author-agent" })],
roomPosterAgentIds: ["author-agent"],
});
expect(prepared.targetMentionAgentIds).toEqual([]);
});
test("@everyone only expands to active posters in the room", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [
storedAgent({ id: "agent-a" }),
storedAgent({ id: "agent-b" }),
storedAgent({ id: "unrelated-agent" }),
],
liveAgents: [liveAgent({ id: "agent-c" }), liveAgent({ id: "live-unrelated-agent" })],
roomPosterAgentIds: ["agent-a", "agent-b", "agent-c"],
});
expect([...prepared.targetMentionAgentIds].sort()).toEqual(["agent-a", "agent-b", "agent-c"]);
});
test("@everyone excludes archived and error-state agents", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [
storedAgent({ id: "active-agent" }),
storedAgent({ id: "archived-agent", archivedAt: "2026-03-28T00:00:00.000Z" }),
storedAgent({ id: "stored-error-agent", lastStatus: "error" }),
],
liveAgents: [liveAgent({ id: "live-error-agent", lifecycle: "error" })],
roomPosterAgentIds: [
"active-agent",
"archived-agent",
"stored-error-agent",
"live-error-agent",
],
});
expect(prepared.targetMentionAgentIds).toEqual(["active-agent"]);
});
test("@everyone deduplicates with explicit mentions and keeps explicit non-everyone mentions", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone", "agent-a", "custom-title"],
storedAgents: [storedAgent({ id: "agent-a" })],
liveAgents: [liveAgent({ id: "agent-b" })],
roomPosterAgentIds: ["agent-a", "agent-b"],
});
expect([...prepared.targetMentionAgentIds].sort()).toEqual([
"agent-a",
"agent-b",
"custom-title",
]);
});
test("does not list room posters when @everyone is not mentioned", async () => {
const listRoomPosterAgentIds = vi.fn(async () => []);
const result = await prepareChatMentionFanout({
authorAgentId: "author-agent",
mentionAgentIds: ["agent-a"],
storedAgents: [storedAgent({ id: "agent-a" })],
liveAgents: [],
listRoomPosterAgentIds,
});
expect(result.ok).toBe(true);
expect(listRoomPosterAgentIds).not.toHaveBeenCalled();
});
test("rejects @everyone fan-out above the hard cap", async () => {
const posters = Array.from({ length: 26 }, (_, index) => `agent-${index}`);
const result = await prepareChatMentionFanout({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: posters.map((id) => storedAgent({ id })),
liveAgents: [],
listRoomPosterAgentIds: async () => posters,
});
expect(result).toEqual({
ok: false,
error:
"@everyone would notify 26 agents, which exceeds the limit of 25. Narrow the room or mention specific agents.",
});
});
test("notification body strips inline mentions but keeps the room context", () => {
expect(
buildChatMentionNotification({
room: "coord-room",
authorAgentId: "author-agent",
body: "@agent-a @everyone Check the latest status.",
mentionAgentIds: ["agent-a", "everyone"],
}),
).toContain("Check the latest status.");
});
test("notifyChatMentions delegates sends for resolved targets", async () => {
const resolveAgentIdentifier = vi.fn(async (identifier: string) => ({
ok: true as const,
agentId: identifier,
}));
const sendAgentMessage = vi.fn(async () => {});
const logger = {
warn: vi.fn(),
} as unknown as pino.Logger;
const storedAgents = [storedAgent({ id: "agent-a" })];
const liveAgents = [liveAgent({ id: "agent-b" })];
await notifyChatMentions({
room: "coord-room",
authorAgentId: "author-agent",
body: "@everyone Check status",
mentionAgentIds: ["everyone"],
logger,
storedAgents,
liveAgents,
prepared: {
targetMentionAgentIds: ["agent-a", "agent-b"],
roomPosterAgentIds: ["agent-a", "agent-b"],
},
resolveAgentIdentifier,
sendAgentMessage,
});
expect(resolveAgentIdentifier).toHaveBeenCalledTimes(2);
expect(sendAgentMessage).toHaveBeenCalledTimes(2);
expect(sendAgentMessage).toHaveBeenCalledWith(
"agent-a",
expect.stringContaining('room "coord-room"'),
);
expect(sendAgentMessage).toHaveBeenCalledWith(
"agent-b",
expect.stringContaining("Check status"),
);
});
});
@@ -1,194 +0,0 @@
import type pino from "pino";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
export const CHAT_MENTION_FANOUT_LIMIT = 25;
export interface ChatMentionNotificationInput {
room: string;
authorAgentId: string;
body: string;
mentionAgentIds: string[];
}
export interface PreparedChatMentionFanout {
targetMentionAgentIds: string[];
roomPosterAgentIds: string[];
}
export type PrepareChatMentionFanoutResult =
| { ok: true; prepared: PreparedChatMentionFanout }
| { ok: false; error: string };
export interface PrepareChatMentionFanoutInput {
authorAgentId: string;
mentionAgentIds: string[];
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
listRoomPosterAgentIds: () => Promise<string[]>;
limit?: number;
}
export interface NotifyChatMentionsInput extends ChatMentionNotificationInput {
logger: pino.Logger;
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
prepared: PreparedChatMentionFanout;
resolveAgentIdentifier: (
identifier: string,
) => Promise<{ ok: true; agentId: string } | { ok: false; error: string }>;
sendAgentMessage: (agentId: string, text: string) => Promise<void>;
}
export async function prepareChatMentionFanout(
input: PrepareChatMentionFanoutInput,
): Promise<PrepareChatMentionFanoutResult> {
const mentionsEveryone = input.mentionAgentIds.includes("everyone");
const roomPosterAgentIds = mentionsEveryone ? await input.listRoomPosterAgentIds() : [];
const targetMentionAgentIds = expandChatMentionTargets({
authorAgentId: input.authorAgentId,
mentionAgentIds: input.mentionAgentIds,
storedAgents: input.storedAgents,
liveAgents: input.liveAgents,
roomPosterAgentIds,
});
if (mentionsEveryone) {
const limit = input.limit ?? CHAT_MENTION_FANOUT_LIMIT;
if (targetMentionAgentIds.length > limit) {
return {
ok: false,
error: `@everyone would notify ${targetMentionAgentIds.length} agents, which exceeds the limit of ${limit}. Narrow the room or mention specific agents.`,
};
}
}
return { ok: true, prepared: { targetMentionAgentIds, roomPosterAgentIds } };
}
export async function notifyChatMentions(input: NotifyChatMentionsInput): Promise<void> {
const { targetMentionAgentIds } = input.prepared;
if (targetMentionAgentIds.length === 0) {
return;
}
const notification = buildChatMentionNotification({
room: input.room,
authorAgentId: input.authorAgentId,
body: input.body,
mentionAgentIds: targetMentionAgentIds,
});
await Promise.all(
targetMentionAgentIds.map(async (mentionedAgentId) => {
const resolved = await input.resolveAgentIdentifier(mentionedAgentId);
if (!resolved.ok) {
input.logger.warn(
{ mentionedAgentId, room: input.room, error: resolved.error },
"Failed to resolve chat mention target",
);
return;
}
// Re-check eligibility on the resolved canonical id: explicit mentions may
// be custom titles that resolve to an archived or error-state agent.
if (
!isChatMentionTargetEligible({
agentId: resolved.agentId,
authorAgentId: input.authorAgentId,
storedAgents: input.storedAgents,
liveAgents: input.liveAgents,
})
) {
return;
}
try {
await input.sendAgentMessage(resolved.agentId, notification);
} catch (error) {
input.logger.warn(
{ err: error, mentionedAgentId: resolved.agentId, room: input.room },
"Failed to notify mentioned agent about chat message",
);
}
}),
);
}
export function buildChatMentionNotification(input: ChatMentionNotificationInput): string {
const mentioned = input.mentionAgentIds.map((agentId) => `@${agentId}`).join(", ");
const bodyWithoutMentions = input.body.replace(/(^|\s)@[A-Za-z0-9][A-Za-z0-9._-]*/g, "$1").trim();
const body = bodyWithoutMentions.length > 0 ? bodyWithoutMentions : input.body;
return [
`Chat mention from ${input.authorAgentId} in room "${input.room}".`,
`Mentioned agents: ${mentioned}.`,
"Message:",
body,
`Read the room with: paseo chat read ${input.room} --limit 20`,
].join("\n");
}
function expandChatMentionTargets(input: {
authorAgentId: string;
mentionAgentIds: string[];
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
roomPosterAgentIds: string[];
}): string[] {
const candidates = new Set<string>();
const mentionsEveryone = input.mentionAgentIds.includes("everyone");
for (const mentionAgentId of input.mentionAgentIds) {
if (mentionAgentId === "everyone" || mentionAgentId === input.authorAgentId) {
continue;
}
candidates.add(mentionAgentId);
}
if (mentionsEveryone) {
for (const posterAgentId of input.roomPosterAgentIds) {
if (posterAgentId !== input.authorAgentId) {
candidates.add(posterAgentId);
}
}
}
const targets: string[] = [];
for (const candidate of candidates) {
if (
isChatMentionTargetEligible({
agentId: candidate,
authorAgentId: input.authorAgentId,
storedAgents: input.storedAgents,
liveAgents: input.liveAgents,
})
) {
targets.push(candidate);
}
}
return targets;
}
function isChatMentionTargetEligible(input: {
agentId: string;
authorAgentId: string;
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
}): boolean {
if (input.agentId === input.authorAgentId) {
return false;
}
const stored = input.storedAgents.find((record) => record.id === input.agentId);
if (stored?.internal || stored?.archivedAt || stored?.lastStatus === "error") {
return false;
}
const live = input.liveAgents.find((agent) => agent.id === input.agentId);
if (live) {
return !live.internal && live.lifecycle !== "error";
}
return true;
}
@@ -1,191 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import {
type ChatServiceError,
FileBackedChatService,
parseMentionAgentIds,
type PostChatMessageInput,
} from "./chat-service.js";
describe("FileBackedChatService", () => {
let paseoHome: string;
let service: FileBackedChatService;
async function sendChatMessage(input: PostChatMessageInput) {
return await service.dispatchMessage(input);
}
beforeEach(async () => {
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-chat-service-"));
service = new FileBackedChatService({
paseoHome,
logger: pino({ level: "silent" }),
});
await service.initialize();
});
afterEach(async () => {
await rm(paseoHome, { recursive: true, force: true });
});
test("creates rooms, enforces unique names, and persists to disk", async () => {
const created = await service.createRoom({
name: "cli-features-epic",
purpose: "Coordination room",
});
await expect(
service.createRoom({
name: "CLI-FEATURES-EPIC",
}),
).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_room_name_taken",
});
const raw = await readFile(path.join(paseoHome, "chat", "rooms.json"), "utf8");
expect(raw).toContain("cli-features-epic");
expect(created.name).toBe("cli-features-epic");
expect(created.purpose).toBe("Coordination room");
expect(created.messageCount).toBe(0);
});
test("resolves rooms by name or ID, validates replies, and reads filtered messages", async () => {
const room = await service.createRoom({ name: "auth-refactor" });
const first = await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "first message for @agent-b and @agent-c and again @agent-b",
});
await sendChatMessage({
room: room.id,
authorAgentId: "agent-b",
body: "reply",
replyToMessageId: first.id,
});
await expect(
sendChatMessage({
room: room.name,
authorAgentId: "agent-b",
body: "bad reply",
replyToMessageId: "missing",
}),
).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_message_not_found",
});
const all = await service.readMessages({ room: room.name, limit: 10 });
expect(all).toHaveLength(2);
expect(all[0]?.mentionAgentIds).toEqual(["agent-b", "agent-c"]);
const byAuthor = await service.readMessages({
room: room.id,
authorAgentId: "agent-b",
limit: 10,
});
expect(byAuthor).toHaveLength(1);
expect(byAuthor[0]?.body).toBe("reply");
const detail = await service.inspectRoom({ room: room.name });
expect(detail.room.messageCount).toBe(2);
expect(detail.room.lastMessageAt).toBeTruthy();
});
test("lists unique agents who have posted to a room", async () => {
const room = await service.createRoom({ name: "incident-room" });
const otherRoom = await service.createRoom({ name: "other-room" });
await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "first",
});
await sendChatMessage({
room: room.name,
authorAgentId: "agent-b",
body: "second",
});
await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "third",
});
await sendChatMessage({
room: otherRoom.name,
authorAgentId: "unrelated-agent",
body: "different room",
});
await expect(service.listRoomPosterAgentIds({ room: room.name })).resolves.toEqual([
"agent-a",
"agent-b",
]);
});
test("waits for new messages after a cursor and times out with an empty result", async () => {
const room = await service.createRoom({ name: "loop-status" });
const first = await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "ready",
});
const waitPromise = service.waitForMessages({
room: room.name,
afterMessageId: first.id,
timeoutMs: 1000,
});
await sendChatMessage({
room: room.name,
authorAgentId: "agent-b",
body: "new work",
});
const waited = await waitPromise;
expect(waited).toHaveLength(1);
expect(waited[0]?.body).toBe("new work");
const timedOut = await service.waitForMessages({
room: room.name,
afterMessageId: waited[0]?.id,
timeoutMs: 10,
});
expect(timedOut).toEqual([]);
});
test("deletes rooms, removes messages, and rejects pending waiters", async () => {
const room = await service.createRoom({ name: "schedule-jobs" });
await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "hello",
});
const waitPromise = service.waitForMessages({
room: room.name,
timeoutMs: 1000,
});
const deleted = await service.deleteRoom({ room: room.id });
expect(deleted.room.messageCount).toBe(1);
await expect(waitPromise).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_room_deleted",
});
await expect(service.inspectRoom({ room: room.name })).rejects.toMatchObject<
Partial<ChatServiceError>
>({
code: "chat_room_not_found",
});
});
test("extracts inline mentions from chat bodies", () => {
expect(
parseMentionAgentIds(
"Checking with @agent-a, (@agent_b), @everyone, and duplicate @agent-a again.",
),
).toEqual(["agent-a", "agent_b", "everyone"]);
expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]);
});
});
@@ -1,465 +0,0 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type pino from "pino";
import { z } from "zod";
import { writeJsonFileAtomic } from "../atomic-file.js";
import {
ChatMessageSchema,
ChatRoomDetailSchema,
ChatRoomSchema,
type ChatMessage,
type ChatRoom,
type ChatRoomDetail,
} from "@getpaseo/protocol/chat/types";
const ChatStorePayloadSchema = z.object({
rooms: z.array(ChatRoomSchema),
messages: z.array(ChatMessageSchema),
});
type ChatStorePayload = z.infer<typeof ChatStorePayloadSchema>;
function normalizeRoomName(name: string): string {
return name.trim().toLocaleLowerCase();
}
function trimToNull(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
const CHAT_MENTION_PATTERN = /(?:^|[\s(])@([A-Za-z0-9][A-Za-z0-9._-]*)/g;
export function parseMentionAgentIds(body: string): string[] {
const mentionAgentIds = new Set<string>();
for (const match of body.matchAll(CHAT_MENTION_PATTERN)) {
const agentId = match[1]?.trim();
if (agentId) {
mentionAgentIds.add(agentId);
}
}
return Array.from(mentionAgentIds).sort();
}
export class ChatServiceError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "ChatServiceError";
this.code = code;
}
}
interface Waiter {
roomId: string;
afterMessageId: string | null;
resolve: (messages: ChatMessage[]) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout> | null;
}
export interface CreateChatRoomInput {
name: string;
purpose?: string | null;
}
export interface InspectChatRoomInput {
room: string;
}
export interface DeleteChatRoomInput {
room: string;
}
export interface PostChatMessageInput {
room: string;
authorAgentId: string;
body: string;
replyToMessageId?: string | null;
}
export interface ReadChatMessagesInput {
room: string;
limit?: number;
since?: string;
authorAgentId?: string;
}
export interface ListChatRoomPosterAgentIdsInput {
room: string;
}
export interface WaitForChatMessagesInput {
room: string;
afterMessageId?: string | null;
timeoutMs?: number;
}
export interface DeleteChatRoomResult {
room: ChatRoomDetail;
}
export interface InspectChatRoomResult {
room: ChatRoomDetail;
}
export class FileBackedChatService {
private readonly filePath: string;
private readonly logger: pino.Logger;
private loaded = false;
private readonly rooms = new Map<string, ChatRoom>();
private readonly messagesByRoomId = new Map<string, ChatMessage[]>();
private persistQueue: Promise<void> = Promise.resolve();
private readonly waitersByRoomId = new Map<string, Set<Waiter>>();
constructor(options: { paseoHome: string; logger: pino.Logger }) {
this.filePath = path.join(options.paseoHome, "chat", "rooms.json");
this.logger = options.logger.child({ component: "chat-service" });
}
async initialize(): Promise<void> {
await this.load();
}
async createRoom(input: CreateChatRoomInput): Promise<ChatRoomDetail> {
await this.load();
const name = input.name.trim();
if (name.length === 0) {
throw new ChatServiceError("invalid_chat_room_name", "Chat room name is required");
}
if (this.findRoomByName(name)) {
throw new ChatServiceError(
"chat_room_name_taken",
`Chat room already exists with name: ${name}`,
);
}
const now = new Date().toISOString();
const room = ChatRoomSchema.parse({
id: randomUUID(),
name,
purpose: trimToNull(input.purpose),
createdAt: now,
updatedAt: now,
});
this.rooms.set(room.id, room);
await this.enqueuePersist();
return this.toRoomDetail(room);
}
async listRooms(): Promise<ChatRoomDetail[]> {
await this.load();
return Array.from(this.rooms.values())
.map((room) => this.toRoomDetail(room))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async inspectRoom(input: InspectChatRoomInput): Promise<InspectChatRoomResult> {
await this.load();
const room = this.resolveRoom(input.room);
return {
room: this.toRoomDetail(room),
};
}
async deleteRoom(input: DeleteChatRoomInput): Promise<DeleteChatRoomResult> {
await this.load();
const room = this.resolveRoom(input.room);
const detail = this.toRoomDetail(room);
this.rooms.delete(room.id);
this.messagesByRoomId.delete(room.id);
await this.enqueuePersist();
this.rejectWaiters(
room.id,
new ChatServiceError("chat_room_deleted", `Chat room deleted: ${room.name}`),
);
return { room: detail };
}
async dispatchMessage(input: PostChatMessageInput): Promise<ChatMessage> {
await this.load();
const room = this.resolveRoom(input.room);
const body = input.body.trim();
if (body.length === 0) {
throw new ChatServiceError("invalid_chat_message", "Chat message body is required");
}
const authorAgentId = input.authorAgentId.trim();
if (authorAgentId.length === 0) {
throw new ChatServiceError("invalid_chat_author", "Chat message author is required");
}
const messages = this.getRoomMessages(room.id);
const replyToMessageId = trimToNull(input.replyToMessageId);
if (replyToMessageId) {
const replyTarget = messages.find((message) => message.id === replyToMessageId);
if (!replyTarget) {
throw new ChatServiceError(
"chat_message_not_found",
`Reply target not found: ${replyToMessageId}`,
);
}
}
const createdAt = new Date().toISOString();
const message = ChatMessageSchema.parse({
id: randomUUID(),
roomId: room.id,
authorAgentId,
body,
replyToMessageId,
mentionAgentIds: parseMentionAgentIds(body),
createdAt,
});
messages.push(message);
this.messagesByRoomId.set(room.id, messages);
this.rooms.set(
room.id,
ChatRoomSchema.parse({
...room,
updatedAt: createdAt,
}),
);
await this.enqueuePersist();
this.notifyWaiters(room.id);
return message;
}
async readMessages(input: ReadChatMessagesInput): Promise<ChatMessage[]> {
await this.load();
const room = this.resolveRoom(input.room);
const messages = [...this.getRoomMessages(room.id)];
const since = trimToNull(input.since);
const authorAgentId = trimToNull(input.authorAgentId);
const limit = this.normalizeLimit(input.limit);
const filtered = messages.filter((message) => {
if (since && message.createdAt < since) {
return false;
}
if (authorAgentId && message.authorAgentId !== authorAgentId) {
return false;
}
return true;
});
if (limit === 0 || filtered.length <= limit) {
return filtered;
}
return filtered.slice(filtered.length - limit);
}
async listRoomPosterAgentIds(input: ListChatRoomPosterAgentIdsInput): Promise<string[]> {
await this.load();
const room = this.resolveRoom(input.room);
const posters = new Set<string>();
for (const message of this.getRoomMessages(room.id)) {
posters.add(message.authorAgentId);
}
return Array.from(posters);
}
async waitForMessages(input: WaitForChatMessagesInput): Promise<ChatMessage[]> {
await this.load();
const room = this.resolveRoom(input.room);
const timeoutMs = Math.max(0, Math.floor(input.timeoutMs ?? 0));
const afterMessageId = trimToNull(input.afterMessageId);
if (afterMessageId) {
const existing = this.selectMessagesAfter(room.id, afterMessageId);
if (existing.length > 0) {
return existing;
}
const knownMessage = this.getRoomMessages(room.id).some(
(message) => message.id === afterMessageId,
);
if (!knownMessage) {
throw new ChatServiceError(
"chat_message_not_found",
`Wait cursor not found: ${afterMessageId}`,
);
}
}
return new Promise<ChatMessage[]>((resolve, reject) => {
const waiter: Waiter = {
roomId: room.id,
afterMessageId,
resolve: (messages) => {
if (waiter.timeout) {
clearTimeout(waiter.timeout);
waiter.timeout = null;
}
this.removeWaiter(waiter);
resolve(messages);
},
reject: (error) => {
if (waiter.timeout) {
clearTimeout(waiter.timeout);
waiter.timeout = null;
}
this.removeWaiter(waiter);
reject(error);
},
timeout: null,
};
if (timeoutMs > 0) {
waiter.timeout = setTimeout(() => {
waiter.resolve([]);
}, timeoutMs);
}
const roomWaiters = this.waitersByRoomId.get(room.id) ?? new Set<Waiter>();
roomWaiters.add(waiter);
this.waitersByRoomId.set(room.id, roomWaiters);
});
}
private async load(): Promise<void> {
if (this.loaded) {
return;
}
this.rooms.clear();
this.messagesByRoomId.clear();
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = ChatStorePayloadSchema.parse(JSON.parse(raw));
for (const room of parsed.rooms) {
this.rooms.set(room.id, room);
}
for (const message of parsed.messages) {
const messages = this.messagesByRoomId.get(message.roomId) ?? [];
messages.push(message);
this.messagesByRoomId.set(message.roomId, messages);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load chat store");
}
}
this.loaded = true;
}
private async enqueuePersist(): Promise<void> {
const nextPersist = this.persistQueue.then(() => this.persist());
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
private async persist(): Promise<void> {
const payload: ChatStorePayload = {
rooms: Array.from(this.rooms.values()).sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
),
messages: Array.from(this.messagesByRoomId.values())
.flat()
.sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
};
await writeJsonFileAtomic(this.filePath, payload);
}
private findRoomByName(name: string): ChatRoom | null {
const normalizedName = normalizeRoomName(name);
for (const room of this.rooms.values()) {
if (normalizeRoomName(room.name) === normalizedName) {
return room;
}
}
return null;
}
private resolveRoom(roomSelector: string): ChatRoom {
const selector = roomSelector.trim();
if (selector.length === 0) {
throw new ChatServiceError("invalid_chat_room", "Chat room name or ID is required");
}
const byId = this.rooms.get(selector);
if (byId) {
return byId;
}
const byName = this.findRoomByName(selector);
if (byName) {
return byName;
}
throw new ChatServiceError("chat_room_not_found", `Chat room not found: ${selector}`);
}
private getRoomMessages(roomId: string): ChatMessage[] {
return this.messagesByRoomId.get(roomId) ?? [];
}
private toRoomDetail(room: ChatRoom): ChatRoomDetail {
const messages = this.getRoomMessages(room.id);
return ChatRoomDetailSchema.parse({
...room,
messageCount: messages.length,
lastMessageAt: messages[messages.length - 1]?.createdAt ?? null,
});
}
private normalizeLimit(limit: number | undefined): number {
if (limit === undefined) {
return 20;
}
const normalized = Math.max(0, Math.floor(limit));
return normalized;
}
private selectMessagesAfter(roomId: string, afterMessageId: string): ChatMessage[] {
const messages = this.getRoomMessages(roomId);
const index = messages.findIndex((message) => message.id === afterMessageId);
if (index === -1) {
return [];
}
return messages.slice(index + 1);
}
private notifyWaiters(roomId: string): void {
const waiters = this.waitersByRoomId.get(roomId);
if (!waiters || waiters.size === 0) {
return;
}
for (const waiter of Array.from(waiters)) {
const messages =
waiter.afterMessageId === null
? this.getRoomMessages(roomId).slice(-1)
: this.selectMessagesAfter(roomId, waiter.afterMessageId);
if (messages.length === 0) {
continue;
}
waiter.resolve(messages);
}
}
private removeWaiter(waiter: Waiter): void {
const waiters = this.waitersByRoomId.get(waiter.roomId);
if (!waiters) {
return;
}
waiters.delete(waiter);
if (waiters.size === 0) {
this.waitersByRoomId.delete(waiter.roomId);
}
}
private rejectWaiters(roomId: string, error: Error): void {
const waiters = this.waitersByRoomId.get(roomId);
if (!waiters) {
return;
}
for (const waiter of Array.from(waiters)) {
waiter.reject(error);
}
}
}
@@ -3,7 +3,7 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "./test-utils/paseo-
import { DaemonClient } from "./test-utils/daemon-client.js";
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
import type { AgentSnapshotPayload } from "./messages.js";
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
import type { PushNotificationSender, PushPayload } from "./push/index.js";
import { PRESENCE_THRESHOLD_MS } from "./agent-attention-policy.js";
class RecordingPushNotificationSender implements PushNotificationSender {
+13 -1
View File
@@ -136,7 +136,6 @@ describe("server config", () => {
workspaceId: "configured-runtime",
runtimeId: "fixture",
cwd: source,
hostVisiblePath: source,
materializedFreshContent: true,
});
await expect(
@@ -146,6 +145,19 @@ describe("server config", () => {
expect(runtimeIds.has("configured-runtime")).toBe(false);
});
test("loads the provider catalog refresh timeout", async () => {
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-"));
roots.push(paseoHome);
await writeFile(
path.join(paseoHome, "config.json"),
JSON.stringify({ agents: { catalogRefreshTimeoutMs: 180_000 } }),
);
const config = loadConfig(paseoHome, { env: {} });
expect(config.providerCatalogRefreshTimeoutMs).toBe(180_000);
});
test("resolves bundled web UI path from source-tree modules", () => {
const root = path.parse(process.cwd()).root;
expect(
+16 -1
View File
@@ -439,6 +439,18 @@ function resolveBrowserToolsEnabled(persisted: ReturnType<typeof loadPersistedCo
return persisted.daemon?.browserTools?.enabled ?? false;
}
/**
* Both profile lists stay `undefined` when absent rather than defaulting to an
* empty array: for terminal profiles that is what selects the built-in
* defaults, so an empty array has to keep meaning "the user removed them all".
*/
function resolveProfileLists(persisted: ReturnType<typeof loadPersistedConfig>) {
return {
terminalProfiles: persisted.daemon?.terminalProfiles,
agentProfiles: persisted.daemon?.agentProfiles,
};
}
function resolveStaticLoadConfigSettings(
env: NodeJS.ProcessEnv,
cli: CliConfigOverrides | undefined,
@@ -451,7 +463,7 @@ function resolveStaticLoadConfigSettings(
browserToolsEnabled: resolveBrowserToolsEnabled(persisted),
autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false,
appendSystemPrompt: resolveAppendSystemPrompt(persisted),
terminalProfiles: persisted.daemon?.terminalProfiles,
...resolveProfileLists(persisted),
hostnames: mergeHostnames([
persisted.daemon?.hostnames,
parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS),
@@ -484,6 +496,7 @@ export function loadConfig(
autoArchiveAfterMerge,
appendSystemPrompt,
terminalProfiles,
agentProfiles,
hostnames,
trustedProxies,
appBaseUrl,
@@ -527,6 +540,7 @@ export function loadConfig(
enableTerminalAgentHooks: persisted.daemon?.enableTerminalAgentHooks ?? false,
appendSystemPrompt,
terminalProfiles,
agentProfiles,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -548,6 +562,7 @@ export function loadConfig(
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
voiceLlmModel: voiceLlm.model,
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
metadataGeneration: persisted.agents?.metadataGeneration,
providerOverrides,
log: resolveLogConfigFromEnv(env, persisted),
@@ -85,6 +85,76 @@ describe("DaemonConfigStore", () => {
expect(loadPersistedConfig(paseoHome).daemon?.relay?.enabled).toBe(true);
});
test("patch round-trips agent profiles through the strictly-parsed persisted config", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
store.patch({
agentProfiles: [
{
id: "profile_ui",
name: "UI work",
icon: "🎨",
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
notes: "Use for components, layout and design tokens.",
},
],
});
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{
id: "profile_ui",
name: "UI work",
icon: "🎨",
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
notes: "Use for components, layout and design tokens.",
},
]);
expect(store.get().agentProfiles).toHaveLength(1);
});
test("patch replaces the whole agent profile list rather than merging entries", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles: [
{ id: "a", name: "Keep", provider: "claude" },
{ id: "b", name: "Drop", provider: "codex" },
],
});
store.patch({ agentProfiles: [{ id: "a", name: "Keep", provider: "claude" }] });
expect(store.get().agentProfiles).toEqual([{ id: "a", name: "Keep", provider: "claude" }]);
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toHaveLength(1);
});
test("rolls back config when a field transition fails", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -359,6 +359,7 @@ function mergeMutableConfigIntoPersistedConfig(params: {
...(mutable.terminalProfiles !== undefined
? { terminalProfiles: mutable.terminalProfiles }
: {}),
...(mutable.agentProfiles !== undefined ? { agentProfiles: mutable.agentProfiles } : {}),
},
agents: nextAgents,
} as PersistedConfig;
@@ -27,10 +27,6 @@ interface SupervisorHeartbeatMessage {
type: "paseo:supervisor-heartbeat";
}
interface WorkerHeartbeatMessage {
type: "paseo:worker-heartbeat";
}
interface BootstrapResult {
paseoHome: string;
logger: ReturnType<typeof createRootLogger>;
@@ -232,13 +228,6 @@ async function main() {
const supervisorPid = process.ppid;
let lastSupervisorHeartbeatAt = Date.now();
let supervisorExitRequested = false;
const sendWorkerHeartbeat = () => {
try {
process.send?.({ type: "paseo:worker-heartbeat" } satisfies WorkerHeartbeatMessage);
} catch {
// The disconnect handler below owns supervisor-loss shutdown.
}
};
const exitAfterSupervisorLoss = (reason: string) => {
if (supervisorExitRequested) {
return;
@@ -272,10 +261,6 @@ async function main() {
});
process.on("disconnect", () => exitAfterSupervisorLoss("ipc_disconnect_event"));
sendWorkerHeartbeat();
const workerHeartbeat = setInterval(sendWorkerHeartbeat, 1_000);
workerHeartbeat.unref();
const timer = setInterval(() => {
const ipcConnected = typeof process.connected === "boolean" ? process.connected : true;
const heartbeatExpired = Date.now() - lastSupervisorHeartbeatAt > 3500;
@@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest";
import { DirectorySyncService } from "./index.js";
function project(projectId: string, name: string) {
return {
projectId,
projectDisplayName: name,
projectRootPath: `/${projectId}`,
projectKind: "git" as const,
};
}
describe("DirectorySyncService", () => {
test("owns sequencing and returns wire-ready latest project changes", () => {
const service = new DirectorySyncService("generation");
expect(service.synchronizeProjects([project("one", "One")], {}).sync).toMatchObject({
mode: "snapshot",
headSeq: 1,
});
service.sequenceProjectUpdate({ kind: "upsert", project: project("one", "Renamed") }, true);
service.sequenceProjectUpdate({ kind: "upsert", project: project("one", "Latest") }, true);
expect(
service.synchronizeProjects([project("one", "Latest")], {
generation: "generation",
afterSeq: 1,
}),
).toEqual({
projects: [{ ...project("one", "Latest"), syncSeq: 3 }],
sync: { generation: "generation", mode: "changes", headSeq: 3, removals: [] },
});
});
test("sequences a hard project removal once", () => {
const service = new DirectorySyncService("generation");
service.synchronizeProjects([project("one", "One")], {});
const update = { kind: "remove" as const, projectId: "one" };
expect(service.sequenceProjectUpdate(update, true)).toEqual({
...update,
generation: "generation",
seq: 2,
});
expect(service.sequenceProjectUpdate(update, true)).toEqual({
...update,
generation: "generation",
seq: 2,
});
expect(
service.synchronizeProjects([], { generation: "generation", afterSeq: 1 }).sync.removals,
).toEqual([{ id: "one", seq: 2 }]);
});
test("keeps one daemon-wide version for repeated observations", () => {
const service = new DirectorySyncService("generation");
const update = { kind: "upsert" as const, project: project("one", "One") };
const sequenced = { ...update, generation: "generation", seq: 1 };
expect(service.sequenceProjectUpdate(update, true)).toEqual(sequenced);
expect(service.sequenceProjectUpdate(update, true)).toEqual(sequenced);
});
test("sequences only the project whose effective icon changed", () => {
const service = new DirectorySyncService("generation");
const one = { ...project("one", "One"), projectIconRevision: "icon-a" };
const two = { ...project("two", "Two"), projectIconRevision: "icon-a" };
service.synchronizeProjects([one, two], {});
const changed = { ...one, projectIconRevision: "icon-b" };
service.sequenceProjectUpdate({ kind: "upsert", project: changed }, true);
expect(
service.synchronizeProjects([changed, two], {
generation: "generation",
afterSeq: 2,
}),
).toEqual({
projects: [{ ...changed, syncSeq: 3 }],
sync: { generation: "generation", mode: "changes", headSeq: 3, removals: [] },
});
});
test("falls back to a snapshot when the daemon generation changes", () => {
const service = new DirectorySyncService("new-generation");
const read = service.synchronizeProjects([project("one", "One")], {
generation: "old-generation",
afterSeq: 10,
});
expect(read.sync).toMatchObject({
mode: "snapshot",
reason: "generation_changed",
headSeq: 1,
});
});
});
@@ -0,0 +1,174 @@
import { randomUUID } from "node:crypto";
import type {
AgentSnapshotPayload,
ProjectPlacementPayload,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { type CollectionRead, VersionedCollection } from "./internal/versioned-collection.js";
interface AgentDirectoryEntry {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
}
interface DirectorySyncCursor {
generation?: string;
afterSeq?: number;
}
interface DirectoryVersion {
generation: string;
seq: number;
}
type ProjectUpdate = Extract<SessionOutboundMessage, { type: "project.update" }>;
type ProjectListResponse = Extract<
SessionOutboundMessage,
{ type: "project.list.response" }
>["payload"];
type FetchWorkspacesResponse = Extract<
SessionOutboundMessage,
{ type: "fetch_workspaces_response" }
>["payload"];
type FetchAgentsResponse = Extract<
SessionOutboundMessage,
{ type: "fetch_agents_response" }
>["payload"];
/** Daemon-global latest-state sequence owner for the active app directory. */
export class DirectorySyncService {
private readonly generation: string;
private readonly projects = new VersionedCollection<WorkspaceProjectDescriptorPayload>({
getId: (project) => project.projectId,
});
private readonly workspaces = new VersionedCollection<WorkspaceDescriptorPayload>({
getId: (workspace) => workspace.id,
});
private readonly agents = new VersionedCollection<AgentDirectoryEntry>({
getId: (entry) => entry.agent.id,
});
constructor(generation = randomUUID()) {
this.generation = generation;
}
synchronizeProjects(
snapshot: Iterable<WorkspaceProjectDescriptorPayload>,
cursor: DirectorySyncCursor,
): Pick<ProjectListResponse, "projects" | "sync"> {
this.projects.replaceAll(snapshot);
const read = this.read(this.projects, cursor);
return {
projects: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
sync: this.metadata(read),
};
}
synchronizeWorkspaces(
snapshot: Iterable<WorkspaceDescriptorPayload>,
cursor: DirectorySyncCursor,
): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> {
this.workspaces.replaceAll(snapshot);
const read = this.read(this.workspaces, cursor);
return {
entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
sync: this.metadata(read),
};
}
synchronizeAgents(
snapshot: Iterable<AgentDirectoryEntry>,
cursor: DirectorySyncCursor,
): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> {
this.agents.replaceAll(snapshot);
const read = this.read(this.agents, cursor);
return {
entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
sync: this.metadata(read),
};
}
sequenceProjectUpdate(
update: ProjectUpdate["payload"],
includeSequence: boolean,
): ProjectUpdate["payload"] {
if (update.kind === "upsert") {
this.projects.replace(update.project);
} else {
this.projects.remove(update.projectId);
}
const id = update.kind === "upsert" ? update.project.projectId : update.projectId;
return this.withVersion(update, includeSequence, this.version(this.projects, id));
}
sequenceWorkspaceUpdate<T extends object>(
payload: T,
value: WorkspaceDescriptorPayload | null,
id: string,
includeSequence: boolean,
): T & Partial<DirectoryVersion> {
if (value) {
this.workspaces.replace(value);
} else {
this.workspaces.remove(id);
}
return this.withVersion(payload, includeSequence, this.version(this.workspaces, id));
}
sequenceAgentUpdate<T extends object>(
payload: T,
value: AgentDirectoryEntry | null,
id: string,
includeSequence: boolean,
): T & Partial<DirectoryVersion> {
if (value) {
this.agents.replace(value);
} else {
this.agents.remove(id);
}
return this.withVersion(payload, includeSequence, this.version(this.agents, id));
}
private metadata(read: CollectionRead<unknown>) {
return {
generation: this.generation,
headSeq: read.headSeq,
mode: read.mode,
...(read.reason ? { reason: read.reason } : {}),
removals: read.removals,
};
}
private withVersion<T extends object>(
payload: T,
includeSequence: boolean,
version: DirectoryVersion | null,
): T & Partial<DirectoryVersion> {
return version && includeSequence ? { ...payload, ...version } : payload;
}
private read<T>(
collection: VersionedCollection<T>,
cursor: DirectorySyncCursor,
): CollectionRead<T> {
if (cursor.generation !== this.generation) {
return collection.readSnapshot(
cursor.generation === undefined ? "no_cursor" : "generation_changed",
);
}
if (cursor.afterSeq === undefined) {
return collection.readSnapshot("no_cursor");
}
return collection.readAfter(cursor.afterSeq);
}
private version<T>(collection: VersionedCollection<T>, id: string): DirectoryVersion | null {
const seq = collection.sequenceFor(id);
return seq === null ? null : { generation: this.generation, seq };
}
}
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { VersionedCollection } from "./versioned-collection.js";
describe("VersionedCollection", () => {
test("returns only each entity's latest visible snapshot after a sequence", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
collection.replace({ id: "one", title: "first" });
const cursor = collection.headSeq;
collection.replace({ id: "one", title: "second" });
collection.replace({ id: "one", title: "latest" });
collection.replace({ id: "two", title: "other" });
expect(collection.readAfter(cursor)).toEqual({
mode: "changes",
headSeq: 4,
values: [
{ seq: 3, value: { id: "one", title: "latest" } },
{ seq: 4, value: { id: "two", title: "other" } },
],
removals: [],
});
});
test("does not advance for an unchanged projection or repeated removal", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
expect(collection.replace({ id: "one", title: "same" })?.seq).toBe(1);
expect(collection.replace({ id: "one", title: "same" })).toBeNull();
expect(collection.remove("one")?.seq).toBe(2);
expect(collection.remove("one")).toBeNull();
expect(collection.headSeq).toBe(2);
});
test("returns tombstones and lets a later value replace them", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
collection.replace({ id: "one", title: "before" });
collection.remove("one");
expect(collection.readAfter(1)).toEqual({
mode: "changes",
headSeq: 2,
values: [],
removals: [{ id: "one", seq: 2 }],
});
collection.replace({ id: "one", title: "after" });
expect(collection.readAfter(2)).toEqual({
mode: "changes",
headSeq: 3,
values: [{ seq: 3, value: { id: "one", title: "after" } }],
removals: [],
});
});
test("falls back to a latest snapshot when a required tombstone expired", () => {
const collection = new VersionedCollection<{ id: string }>({
getId: (value) => value.id,
maxTombstones: 1,
});
collection.replace({ id: "one" });
collection.replace({ id: "two" });
collection.remove("one");
collection.remove("two");
expect(collection.readAfter(2)).toEqual({
mode: "snapshot",
reason: "cursor_expired",
headSeq: 4,
values: [],
removals: [],
});
});
});
@@ -0,0 +1,141 @@
export interface VersionedValue<T> {
seq: number;
value: T;
}
export interface VersionedRemoval {
id: string;
seq: number;
}
export interface CollectionRead<T> {
mode: "changes" | "snapshot";
reason?: "no_cursor" | "generation_changed" | "cursor_expired";
headSeq: number;
values: VersionedValue<T>[];
removals: VersionedRemoval[];
}
export type PublishedChange<T> =
| { kind: "upsert"; seq: number; value: T }
| { kind: "remove"; seq: number; id: string };
interface StoredValue<T> extends VersionedValue<T> {
fingerprint: string;
}
interface VersionedCollectionOptions<T> {
getId(value: T): string;
fingerprint?(value: T): string;
maxTombstones?: number;
}
/** Owns latest-state sequencing for one directory entity type. */
export class VersionedCollection<T> {
private readonly valuesById = new Map<string, StoredValue<T>>();
private readonly tombstonesById = new Map<string, number>();
private readonly getId: (value: T) => string;
private readonly fingerprint: (value: T) => string;
private readonly maxTombstones: number;
private expiredThroughSeq = 0;
private nextSeq = 0;
constructor(options: VersionedCollectionOptions<T>) {
this.getId = options.getId;
this.fingerprint = options.fingerprint ?? ((value) => JSON.stringify(value));
this.maxTombstones = options.maxTombstones ?? 5_000;
}
get headSeq(): number {
return this.nextSeq;
}
sequenceFor(id: string): number | null {
return this.valuesById.get(id)?.seq ?? this.tombstonesById.get(id) ?? null;
}
replace(value: T): Extract<PublishedChange<T>, { kind: "upsert" }> | null {
const id = this.getId(value);
const fingerprint = this.fingerprint(value);
const previous = this.valuesById.get(id);
if (previous?.fingerprint === fingerprint) return null;
const seq = this.advance();
this.valuesById.set(id, { seq, fingerprint, value });
this.tombstonesById.delete(id);
return { kind: "upsert", seq, value };
}
remove(id: string): Extract<PublishedChange<T>, { kind: "remove" }> | null {
if (!this.valuesById.has(id) && this.tombstonesById.has(id)) return null;
if (!this.valuesById.delete(id) && !this.tombstonesById.has(id)) return null;
const seq = this.advance();
this.tombstonesById.delete(id);
this.tombstonesById.set(id, seq);
this.trimTombstones();
return { kind: "remove", seq, id };
}
replaceAll(values: Iterable<T>): void {
const retainedIds = new Set<string>();
for (const value of values) {
const id = this.getId(value);
retainedIds.add(id);
this.replace(value);
}
for (const id of this.valuesById.keys()) {
if (!retainedIds.has(id)) this.remove(id);
}
}
readSnapshot(
reason: "no_cursor" | "generation_changed" | "cursor_expired" = "no_cursor",
): CollectionRead<T> {
return {
mode: "snapshot",
reason,
headSeq: this.headSeq,
values: this.sortedValues(),
removals: [],
};
}
readAfter(afterSeq: number): CollectionRead<T> {
if (afterSeq < this.expiredThroughSeq || afterSeq > this.headSeq) {
return this.readSnapshot("cursor_expired");
}
return {
mode: "changes",
headSeq: this.headSeq,
values: this.sortedValues().filter((entry) => entry.seq > afterSeq),
removals: Array.from(this.tombstonesById, ([id, seq]) => ({ id, seq }))
.filter((entry) => entry.seq > afterSeq)
.sort((left, right) => left.seq - right.seq || left.id.localeCompare(right.id)),
};
}
private advance(): number {
this.nextSeq += 1;
return this.nextSeq;
}
private sortedValues(): VersionedValue<T>[] {
return Array.from(this.valuesById.values())
.map(({ seq, value }) => ({ seq, value }))
.sort(
(left, right) =>
left.seq - right.seq || this.getId(left.value).localeCompare(this.getId(right.value)),
);
}
private trimTombstones(): void {
while (this.tombstonesById.size > this.maxTombstones) {
const oldest = this.tombstonesById.entries().next().value;
if (!oldest) return;
const [id, seq] = oldest;
this.tombstonesById.delete(id);
this.expiredThroughSeq = Math.max(this.expiredThroughSeq, seq);
}
}
}
@@ -1,10 +1,25 @@
import { appendFile, chmod, mkdtemp, rm, stat, truncate, writeFile } from "node:fs/promises";
import {
appendFile,
chmod,
mkdir,
mkdtemp,
readFile,
rm,
stat,
truncate,
writeFile,
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { runGitCommand } from "../../utils/run-git-command.js";
import {
createExplorerEntry,
deleteExplorerEntry,
duplicateExplorerEntry,
getExplorerFileVersion,
readExplorerFile,
renameExplorerEntry,
streamExplorerFile,
writeExplorerFile,
} from "./service.js";
@@ -373,4 +388,202 @@ describe("file explorer service", () => {
await rm(root, { recursive: true, force: true });
}
});
it("creates files and directories, refusing duplicates and separator names", async () => {
const root = await createTempDir("paseo-entry-create-");
try {
const file = await createExplorerEntry({
root,
parentPath: ".",
name: "notes.txt",
kind: "file",
});
expect(file).toEqual({ status: "ok", path: "notes.txt" });
expect((await stat(path.join(root, "notes.txt"))).isFile()).toBe(true);
const dir = await createExplorerEntry({
root,
parentPath: ".",
name: "docs",
kind: "directory",
});
expect(dir).toEqual({ status: "ok", path: "docs" });
const nested = await createExplorerEntry({
root,
parentPath: "docs",
name: "guide.md",
kind: "file",
});
expect(nested).toEqual({ status: "ok", path: "docs/guide.md" });
const duplicate = await createExplorerEntry({
root,
parentPath: ".",
name: "notes.txt",
kind: "file",
});
expect(duplicate.status).toBe("error");
const traversal = await createExplorerEntry({
root,
parentPath: ".",
name: "../escape",
kind: "directory",
});
expect(traversal.status).toBe("error");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("duplicates files and folders with collision-free sibling names", async () => {
const root = await createTempDir("paseo-entry-duplicate-");
try {
await writeFile(path.join(root, "notes.txt"), "original", "utf8");
const firstFileCopy = await duplicateExplorerEntry({
root,
relativePath: "notes.txt",
});
expect(firstFileCopy).toEqual({ status: "ok", path: "notes copy.txt" });
expect(await readFile(path.join(root, "notes copy.txt"), "utf8")).toBe("original");
const secondFileCopy = await duplicateExplorerEntry({
root,
relativePath: "notes.txt",
});
expect(secondFileCopy).toEqual({ status: "ok", path: "notes copy 2.txt" });
await mkdir(path.join(root, "docs"));
await writeFile(path.join(root, "docs", "guide.md"), "guide", "utf8");
const folderCopy = await duplicateExplorerEntry({ root, relativePath: "docs" });
expect(folderCopy).toEqual({ status: "ok", path: "docs copy" });
expect(await readFile(path.join(root, "docs copy", "guide.md"), "utf8")).toBe("guide");
await expect(duplicateExplorerEntry({ root, relativePath: "." })).resolves.toMatchObject({
status: "error",
});
await expect(duplicateExplorerEntry({ root, relativePath: "missing.txt" })).resolves.toEqual({
status: "error",
error: "File or folder no longer exists",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("renames tracked entries with Git and untracked entries on the filesystem", async () => {
const root = await createTempDir("paseo-entry-rename-");
try {
await runGitCommand(["init"], { cwd: root });
await writeFile(path.join(root, "tracked.txt"), "tracked", "utf8");
await mkdir(path.join(root, "tracked-folder"));
await writeFile(path.join(root, "tracked-folder", "inside.txt"), "tracked", "utf8");
await runGitCommand(["add", "."], { cwd: root });
await runGitCommand(
["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"],
{ cwd: root },
);
const tracked = await renameExplorerEntry({
root,
relativePath: "tracked.txt",
name: "renamed.txt",
});
expect(tracked).toEqual({ status: "ok", path: "renamed.txt" });
expect((await runGitCommand(["status", "--short"], { cwd: root })).stdout.trim()).toBe(
"R tracked.txt -> renamed.txt",
);
const trackedFolder = await renameExplorerEntry({
root,
relativePath: "tracked-folder",
name: "renamed-folder",
});
expect(trackedFolder).toEqual({ status: "ok", path: "renamed-folder" });
const gitStatus = (await runGitCommand(["status", "--short"], { cwd: root })).stdout;
expect(gitStatus).toContain("tracked-folder/inside.txt -> renamed-folder/inside.txt");
await writeFile(path.join(root, "untracked.txt"), "untracked", "utf8");
const untracked = await renameExplorerEntry({
root,
relativePath: "untracked.txt",
name: "moved.txt",
});
expect(untracked).toEqual({ status: "ok", path: "moved.txt" });
expect((await stat(path.join(root, "moved.txt"))).isFile()).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("supports case-only renames for tracked and untracked files", async () => {
const root = await createTempDir("paseo-entry-case-rename-");
try {
await runGitCommand(["init"], { cwd: root });
await writeFile(path.join(root, "Tracked.txt"), "tracked", "utf8");
await writeFile(path.join(root, "Loose.txt"), "untracked", "utf8");
await runGitCommand(["add", "Tracked.txt"], { cwd: root });
await runGitCommand(
["-c", "user.name=Paseo Test", "-c", "user.email=test@paseo.local", "commit", "-m", "base"],
{ cwd: root },
);
await expect(
renameExplorerEntry({ root, relativePath: "Tracked.txt", name: "tracked.txt" }),
).resolves.toEqual({ status: "ok", path: "tracked.txt" });
expect((await stat(path.join(root, "tracked.txt"))).isFile()).toBe(true);
await expect(
renameExplorerEntry({ root, relativePath: "Loose.txt", name: "loose.txt" }),
).resolves.toEqual({ status: "ok", path: "loose.txt" });
expect((await stat(path.join(root, "loose.txt"))).isFile()).toBe(true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("refuses invalid renames, existing targets, and the workspace root", async () => {
const root = await createTempDir("paseo-entry-rename-errors-");
try {
await writeFile(path.join(root, "source.txt"), "source", "utf8");
await writeFile(path.join(root, "existing.txt"), "existing", "utf8");
await expect(
renameExplorerEntry({ root, relativePath: "source.txt", name: "existing.txt" }),
).resolves.toEqual({ status: "error", error: '"existing.txt" already exists' });
await expect(
renameExplorerEntry({ root, relativePath: "source.txt", name: "../outside.txt" }),
).resolves.toEqual({ status: "error", error: "Name cannot contain path separators" });
await expect(
renameExplorerEntry({ root, relativePath: ".", name: "renamed-root" }),
).resolves.toEqual({ status: "error", error: "Cannot rename the workspace root" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("deletes files and directories but never the workspace root or outside paths", async () => {
const root = await createTempDir("paseo-entry-delete-");
try {
await writeFile(path.join(root, "doomed.txt"), "bye", "utf8");
const removedFile = await deleteExplorerEntry({ root, relativePath: "doomed.txt" });
expect(removedFile).toEqual({ status: "ok", path: "doomed.txt" });
await expect(stat(path.join(root, "doomed.txt"))).rejects.toThrow();
await createExplorerEntry({ root, parentPath: ".", name: "nested", kind: "directory" });
await writeFile(path.join(root, "nested", "inner.txt"), "hi", "utf8");
const removedDir = await deleteExplorerEntry({ root, relativePath: "nested" });
expect(removedDir).toEqual({ status: "ok", path: "nested" });
await expect(stat(path.join(root, "nested"))).rejects.toThrow();
const rootDelete = await deleteExplorerEntry({ root, relativePath: "." });
expect(rootDelete.status).toBe("error");
await expect(
deleteExplorerEntry({ root, relativePath: "../outside" }),
).resolves.toMatchObject({ status: "error" });
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
@@ -3,6 +3,7 @@ import type { FileHandle } from "fs/promises";
import path from "path";
import { randomUUID } from "crypto";
import { expandUserPath, resolvePathFromBase } from "../path-utils.js";
import { runGitCommand } from "../../utils/run-git-command.js";
export type ExplorerEntryKind = "file" | "directory";
export type ExplorerFileKind = "text" | "image" | "binary";
@@ -572,6 +573,215 @@ export async function getDownloadableFileInfo({ root, relativePath }: ReadFilePa
}
}
export interface ExplorerCreateEntryParams {
root: string;
parentPath: string;
name: string;
kind: ExplorerEntryKind;
}
export interface ExplorerRenameEntryParams {
root: string;
relativePath: string;
name: string;
}
export type ExplorerEntryMutationResult =
| { status: "ok"; path: string }
| { status: "error"; error: string };
export async function createExplorerEntry({
root,
parentPath,
name,
kind,
}: ExplorerCreateEntryParams): Promise<ExplorerEntryMutationResult> {
const trimmedName = name.trim();
if (!trimmedName || trimmedName === "." || trimmedName === "..") {
return { status: "error", error: "Invalid name" };
}
if (trimmedName.includes("/") || trimmedName.includes("\\")) {
return { status: "error", error: "Name cannot contain path separators" };
}
try {
const parent = await resolveScopedPath({ root, relativePath: parentPath });
const parentStats = await fs.stat(parent.resolvedPath);
if (!parentStats.isDirectory()) {
return { status: "error", error: "Parent path is not a directory" };
}
const targetPath = path.join(parent.resolvedPath, trimmedName);
if (kind === "directory") {
await fs.mkdir(targetPath);
} else {
const handle = await fs.open(targetPath, "wx", 0o644);
await handle.close();
}
return {
status: "ok",
path: normalizeRelativePath({
root,
targetPath: path.join(parent.requestedPath, trimmedName),
}),
};
} catch (error) {
if (isEntryExistsError(error)) {
return { status: "error", error: `"${trimmedName}" already exists` };
}
return { status: "error", error: error instanceof Error ? error.message : String(error) };
}
}
export async function duplicateExplorerEntry({
root,
relativePath,
}: ReadFileParams): Promise<ExplorerEntryMutationResult> {
try {
const source = await resolveScopedPath({ root, relativePath });
const realRoot = await fs.realpath(expandUserPath(root));
if (source.resolvedPath === realRoot) {
return { status: "error", error: "Cannot duplicate the workspace root" };
}
const stats = await fs.lstat(source.requestedPath);
const sourceName = path.basename(source.requestedPath);
const extension = stats.isDirectory() ? "" : path.extname(sourceName);
const baseName = extension ? sourceName.slice(0, -extension.length) : sourceName;
let targetPath = "";
for (let copyNumber = 1; ; copyNumber += 1) {
const suffix = copyNumber === 1 ? " copy" : ` copy ${copyNumber}`;
targetPath = path.join(
path.dirname(source.requestedPath),
`${baseName}${suffix}${extension}`,
);
try {
await fs.lstat(targetPath);
} catch (error) {
if (isMissingEntryError(error)) {
break;
}
throw error;
}
}
await fs.cp(source.requestedPath, targetPath, {
recursive: stats.isDirectory(),
errorOnExist: true,
force: false,
preserveTimestamps: true,
});
return { status: "ok", path: normalizeRelativePath({ root, targetPath }) };
} catch (error) {
if (isMissingEntryError(error)) {
return { status: "error", error: "File or folder no longer exists" };
}
return { status: "error", error: error instanceof Error ? error.message : String(error) };
}
}
export async function renameExplorerEntry({
root,
relativePath,
name,
}: ExplorerRenameEntryParams): Promise<ExplorerEntryMutationResult> {
const trimmedName = name.trim();
if (!trimmedName || trimmedName === "." || trimmedName === "..") {
return { status: "error", error: "Invalid name" };
}
if (trimmedName.includes("/") || trimmedName.includes("\\")) {
return { status: "error", error: "Name cannot contain path separators" };
}
try {
const source = await resolveScopedPath({ root, relativePath });
const realRoot = await fs.realpath(expandUserPath(root));
if (source.resolvedPath === realRoot) {
return { status: "error", error: "Cannot rename the workspace root" };
}
const targetPath = path.join(path.dirname(source.requestedPath), trimmedName);
const sourceStats = await fs.lstat(source.requestedPath);
const targetStats = await fs.lstat(targetPath).catch((error: unknown) => {
if (isMissingEntryError(error)) {
return null;
}
throw error;
});
if (
targetStats &&
(targetStats.dev !== sourceStats.dev || targetStats.ino !== sourceStats.ino)
) {
return { status: "error", error: `"${trimmedName}" already exists` };
}
const sourcePath = normalizeRelativePath({ root, targetPath: source.requestedPath });
const renamedPath = normalizeRelativePath({ root, targetPath });
if (sourcePath === renamedPath) {
return { status: "ok", path: renamedPath };
}
const repository = await runGitCommand(["rev-parse", "--is-inside-work-tree"], {
cwd: realRoot,
acceptExitCodes: [0, 128],
});
const tracked =
repository.exitCode === 0
? await runGitCommand(["ls-files", "-z", "--", sourcePath], { cwd: realRoot })
: null;
if (tracked?.stdout) {
await runGitCommand(["mv", "--", sourcePath, renamedPath], { cwd: realRoot });
} else {
await fs.rename(source.requestedPath, targetPath);
}
return { status: "ok", path: renamedPath };
} catch (error) {
if (isEntryExistsError(error)) {
return { status: "error", error: `"${trimmedName}" already exists` };
}
if (isMissingEntryError(error)) {
return { status: "error", error: "File or folder no longer exists" };
}
return { status: "error", error: error instanceof Error ? error.message : String(error) };
}
}
export async function deleteExplorerEntry({
root,
relativePath,
}: ReadFileParams): Promise<ExplorerEntryMutationResult> {
try {
const scoped = await resolveScopedPath({ root, relativePath });
const realRoot = await fs.realpath(expandUserPath(root));
if (scoped.resolvedPath === realRoot) {
return { status: "error", error: "Cannot delete the workspace root" };
}
// Remove the requested path, not the realpath: deleting a symlink must
// remove the link, never its target. lstat keeps the same guarantee.
const stats = await fs.lstat(scoped.requestedPath);
await fs.rm(scoped.requestedPath, {
recursive: stats.isDirectory(),
force: false,
});
return {
status: "ok",
path: normalizeRelativePath({ root, targetPath: scoped.requestedPath }),
};
} catch (error) {
if (isMissingEntryError(error)) {
return { status: "error", error: "File or folder no longer exists" };
}
return { status: "error", error: error instanceof Error ? error.message : String(error) };
}
}
function isEntryExistsError(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as NodeJS.ErrnoException).code === "EEXIST"
);
}
async function resolveScopedPath({
root,
relativePath = ".",
@@ -237,7 +237,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and
await Promise.all(paths.map((path, index) => writeFile(path, `${index}`)));
await expect
.poll(() => paths.filter((path) => !observed.has(path)), { timeout: 10_000 })
.poll(() => paths.filter((path) => !observed.has(path)), { timeout: 60_000 })
.toEqual([]);
const removedPaths = paths.slice(0, 100);
@@ -258,7 +258,7 @@ test("observes a thousand concurrent writes and remains healthy after delete and
await writeFile(sentinel, "alive");
await expect.poll(() => observed.has(sentinel)).toBe(true);
await subscription.unsubscribe();
}, 30_000);
}, 90_000);
test("unsubscribe cancels reconciliation queued by a write burst", async () => {
const root = await createRoot();
@@ -246,7 +246,7 @@ test("a sibling workspace keeps an archived execution's worktree directory alive
expect(await hub.ownedAgentArchivedAt(created.payload.agentId!)).toEqual(expect.any(String));
});
test("archiving an execution in a reused worktree leaves the existing workspace intact", async () => {
test("archiving a second same-slug execution leaves the first worktree intact", async () => {
const hub = await launchRelationship();
const worktree = {
mode: "branch-off" as const,
@@ -267,6 +267,7 @@ test("archiving an execution in a reused worktree leaves the existing workspace
prompt: "sleep 30",
});
const reused = await hub.ownedCreateResult("reused-worktree-create");
const secondWorktreeCwd = hub.latestCreatedCwd()!;
const reusedWorkspaceId = reused.payload.agent?.workspaceId;
await hub.ownedRunningUpdate(reused.payload.agentId!);
@@ -278,8 +279,10 @@ test("archiving an execution in a reused worktree leaves the existing workspace
expect(response).toMatchObject({ success: true, error: null });
expect(reusedWorkspaceId).toEqual(expect.any(String));
expect(reusedWorkspaceId).not.toBe(originalWorkspaceId);
expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, worktreeCwd)).toBe(true);
expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, worktreeCwd)).toBe(false);
expect(hub.pathsReferToSameLocation(reused.payload.agent!.cwd, secondWorktreeCwd)).toBe(true);
expect(await hub.worktreeState(worktreeCwd)).toEqual({ exists: true, listed: true });
expect(await hub.worktreeState(secondWorktreeCwd)).toEqual({ exists: false, listed: false });
expect(await hub.agentRemainsAvailable(original.payload.agentId!)).toBe(true);
expect(await hub.ownedAgentArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String));
expect(await hub.ownedWorkspaceArchivedAt(reused.payload.agentId!)).toEqual(expect.any(String));
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { platform } from "node:os";
import { hostname, platform } from "node:os";
import { afterEach, describe, expect, test } from "vitest";
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
@@ -88,6 +88,7 @@ describe("Hub relationship", () => {
.update(secret ?? "")
.digest("base64url"),
);
expect(enrollment.hostname).toBe(hostname());
relationship.completeEnrollment();
await connecting.result;
await relationship.socketDialed();
@@ -343,16 +344,18 @@ describe("Hub relationship", () => {
const credential = relationship.relationshipFile()?.credential?.secret;
relationship.loseEnrollmentResponse();
await connecting.result;
relationship.failRevocations(2);
relationship.failRevocations(1);
const disconnected = await relationship.disconnect();
expect(disconnected.state).toBe("disconnecting");
expect(relationship.relationshipFile()?.state).toBe("disconnecting");
const reconnected = await relationship.beginConnect("fresh-token").result;
await relationship.restartDaemon();
await relationship.retry();
expect(relationship.revocationAttempts()).toBe(3);
expect(disconnected).toMatchObject({
state: "not_connected",
hub: null,
warning: expect.stringContaining("server-side revocation may remain pending"),
});
expect(reconnected.state).toBe("connecting");
expect(relationship.revocationAttempts()).toBe(1);
expect(relationship.latestRevocation()).toEqual(
expect.objectContaining({
daemonId: enrollment.daemonId,
@@ -360,7 +363,10 @@ describe("Hub relationship", () => {
credential,
}),
);
expect(relationship.relationshipFile()).toBeNull();
expect(relationship.pendingRelationshipRetries()).toBe(0);
expect(relationship.loggableValues(disconnected)).toContain(
"Failed to notify Hub before removing local relationship",
);
});
test("disconnect revokes only after an in-flight enrollment settles", async () => {
@@ -370,7 +376,8 @@ describe("Hub relationship", () => {
const enrollment = await relationship.enrollmentBegins();
const disconnecting = relationship.beginDisconnect();
await relationship.relationshipStateBecomes("disconnecting");
await relationship.connectionStateBecomes("disconnecting");
expect(relationship.relationshipFile()?.state).toBe("disconnecting");
expect(relationship.revocationAttempts()).toBe(0);
relationship.completeEnrollment();
@@ -384,6 +391,40 @@ describe("Hub relationship", () => {
expect(relationship.relationshipFile()).toBeNull();
});
test("disconnect persists terminal intent only while remote notification is in flight", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.holdRevocation();
const disconnecting = relationship.beginDisconnect();
await relationship.relationshipStateBecomes("disconnecting");
expect(await relationship.status()).toMatchObject({ state: "disconnecting" });
expect(relationship.pendingRelationshipRetries()).toBe(0);
relationship.completeRevocation();
await expect(disconnecting.result).resolves.toMatchObject({ state: "not_connected" });
expect(relationship.relationshipFile()).toBeNull();
});
test("force disconnect does not wait for or notify an in-flight enrollment", async () => {
relationship = await HubRelationshipHarness.start();
relationship.holdEnrollment();
const connecting = relationship.beginConnect("one-time-token");
await relationship.enrollmentBegins();
const disconnected = await relationship.disconnect(true);
expect(disconnected).toMatchObject({ state: "not_connected", hub: null });
expect(relationship.revocationAttempts()).toBe(0);
expect(relationship.relationshipFile()).toBeNull();
relationship.completeEnrollment();
await connecting.result;
expect(relationship.socketAttempts()).toBe(0);
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
});
test("daemon restart reconnects the same durable relationship", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
@@ -728,48 +769,73 @@ describe("Hub relationship", () => {
expect(loggable).not.toContain(enrollment.idempotencyKey);
});
test("offline disconnect retries across runtime and restart without opening a socket", async () => {
test("offline disconnect removes local authority after one notify attempt", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.connectLatestSocket();
relationship.failRevocations(3);
const disconnecting = await relationship.disconnect();
await relationship.retry();
const disconnected = await relationship.disconnect();
await relationship.restartDaemon();
await relationship.retry();
expect(disconnecting.state).toBe("disconnecting");
expect(relationship.revocationAttempts()).toBe(4);
expect(disconnected).toMatchObject({
state: "not_connected",
hub: null,
warning: expect.stringContaining("server-side revocation may remain pending"),
});
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
expect(relationship.revocationAttempts()).toBe(1);
expect(relationship.socketAttempts()).toBe(1);
expect(relationship.pendingRelationshipRetries()).toBe(0);
expect(relationship.relationshipFile()).toBeNull();
});
test("successful disconnect clears a transient revocation error", async () => {
test("successful disconnect notifies the Hub and returns clean local status", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.failRevocations(1);
const disconnecting = await relationship.disconnect();
await relationship.retry();
const disconnected = await relationship.disconnect();
expect(disconnecting).toMatchObject({ state: "disconnecting", error: expect.any(String) });
expect(disconnected).toMatchObject({ state: "not_connected", hub: null, error: null });
expect(disconnected).not.toHaveProperty("warning");
expect(relationship.revocationAttempts()).toBe(1);
expect(await relationship.status()).toMatchObject({ state: "not_connected", error: null });
});
test("force disconnect removes local authority and reports the remote warning", async () => {
test("force disconnect removes local authority without notifying the Hub", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
relationship.failRevocations(1);
await relationship.disconnect();
const forced = await relationship.disconnect(true);
expect(forced.state).toBe("not_connected");
expect(forced.warning).toContain("remote revocation");
expect(forced).toMatchObject({ state: "not_connected", hub: null });
expect(forced).not.toHaveProperty("warning");
expect(relationship.revocationAttempts()).toBe(0);
expect(relationship.loggableValues(forced)).not.toContain(
"Failed to notify Hub before removing local relationship",
);
expect(relationship.relationshipFile()).toBeNull();
});
test("startup removes a legacy persisted disconnecting relationship", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
const active = relationship.relationshipFile();
await relationship.corruptRelationshipFile(
JSON.stringify({ ...active, state: "disconnecting" }),
);
await relationship.startStoppedDaemon();
expect(await relationship.status()).toMatchObject({ state: "not_connected", hub: null });
expect(relationship.relationshipFile()).toBeNull();
expect(relationship.revocationAttempts()).toBe(0);
expect(relationship.pendingRelationshipRetries()).toBe(0);
expect(relationship.loggableValues(await relationship.status())).toContain(
"Removed legacy disconnecting Hub relationship during startup",
);
});
test("disconnect leaves no intent artifacts and shutdown closes the owned agent", async () => {
relationship = await HubRelationshipHarness.start();
await relationship.beginConnect().result;
@@ -137,6 +137,7 @@ export interface HubRelationshipRetryPolicy {
export interface HubRelationshipControllerOptions {
paseoHome: string;
hostname: string;
serverId: string;
daemonPublicKey: string;
logger: pino.Logger;
@@ -196,11 +197,19 @@ export class HubRelationshipController implements HubRelationshipManagement {
this.clock = options.clock ?? systemClock;
this.retryPolicy = options.retryPolicy ?? new BoundedExponentialHubRetryPolicy();
this.record = this.load();
if (this.record?.state === "disconnecting") {
// COMPAT(hubUnilateralDisconnect): added in v0.4.0, remove after 2027-02-13 once legacy records have aged out.
this.options.logger.warn(
{ daemonId: this.record.relationship.daemonId },
"Removed legacy disconnecting Hub relationship during startup",
);
rmSync(this.filePath, { force: true });
this.record = null;
}
if (this.record?.state === "revoked") {
this.state = "revoked";
this.lastError = this.record.reason ?? null;
} else if (this.record?.state === "disconnecting") this.state = "disconnecting";
else if (this.record) this.state = "connecting";
} else if (this.record) this.state = "connecting";
}
async start(): Promise<void> {
@@ -217,7 +226,6 @@ export class HubRelationshipController implements HubRelationshipManagement {
);
}
}
if (this.record?.state === "disconnecting") await this.tryRevocation(this.record);
}
async stop(): Promise<void> {
@@ -290,14 +298,6 @@ export class HubRelationshipController implements HubRelationshipManagement {
await pendingCreateCleanup;
return { status: this.status() };
}
if (input.force) {
this.remove();
await pendingCreateCleanup;
return {
status: this.status(),
warning: "Local Hub credential removed; remote revocation may remain pending.",
};
}
const disconnecting: DisconnectingRecord = {
version: 1,
state: "disconnecting",
@@ -308,12 +308,29 @@ export class HubRelationshipController implements HubRelationshipManagement {
this.persist(disconnecting);
this.record = disconnecting;
this.state = "disconnecting";
if (waitForEnrollment) {
if (waitForEnrollment && !input.force) {
await Promise.all(this.inFlightEnrollments);
}
await this.tryRevocation(disconnecting);
let warning: string | undefined;
if (!input.force) {
try {
await this.options.remote.revoke({
daemonId: disconnecting.relationship.daemonId,
hubOrigin: disconnecting.relationship.hubOrigin,
credential: disconnecting.credential.secret,
});
} catch (error) {
this.options.logger.warn(
{ err: error, daemonId: disconnecting.relationship.daemonId },
"Failed to notify Hub before removing local relationship",
);
warning =
"Hub could not be reached; local relationship removed, but server-side revocation may remain pending.";
}
}
this.remove();
await pendingCreateCleanup;
return { status: this.status() };
return { status: this.status(), ...(warning ? { warning } : {}) };
}
private async tryEnrollment(pending: PendingRecord, enrollmentGeneration: number): Promise<void> {
@@ -324,6 +341,7 @@ export class HubRelationshipController implements HubRelationshipManagement {
idempotencyKey: pending.relationship.idempotencyKey,
hubOrigin: pending.relationship.hubOrigin,
token: pending.enrollment.token,
hostname: this.options.hostname,
serverId: pending.identity.serverId,
daemonPublicKey: pending.identity.daemonPublicKey,
credentialVerifier: verifier,
@@ -455,24 +473,6 @@ export class HubRelationshipController implements HubRelationshipManagement {
});
}
private async tryRevocation(record: DisconnectingRecord): Promise<void> {
const generation = this.generation;
try {
await this.options.remote.revoke({
daemonId: record.relationship.daemonId,
hubOrigin: record.relationship.hubOrigin,
credential: record.credential.secret,
});
if (generation !== this.generation) return;
this.remove();
} catch (error) {
if (generation !== this.generation) return;
this.lastError = error instanceof Error ? error.message : String(error);
this.state = "disconnecting";
this.schedule(() => void this.tryRevocation(record));
}
}
private schedule(task: () => void): void {
this.retry?.cancel();
const generation = this.generation;
@@ -61,6 +61,7 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
hostname: "test-daemon.local",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
@@ -74,7 +75,10 @@ test.each([408, 429])("transient enrollment status %s remains retryable", async
});
test("enrollment rejects a transport URL that cannot open a WebSocket", async () => {
const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon");
let enrolledHostname: string | undefined;
const hubOrigin = await startEnrollmentHub("ftp://hub.test/daemon", (enrollment) => {
enrolledHostname = enrollment.hostname;
});
const remote = new DirectHubRelationshipRemote();
await expect(
@@ -83,12 +87,14 @@ test("enrollment rejects a transport URL that cannot open a WebSocket", async ()
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
hostname: "test-daemon.local",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
scopes: ["hub.execution.*"],
}),
).rejects.toThrow("Hub WebSocket URL must use ws or wss");
expect(enrolledHostname).toBe("test-daemon.local");
});
test("enrollment rejects a WebSocket URL with a fragment", async () => {
@@ -101,6 +107,7 @@ test("enrollment rejects a WebSocket URL with a fragment", async () => {
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
hostname: "test-daemon.local",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
@@ -119,6 +126,7 @@ test("enrollment rejects a WebSocket outside the enrolled Hub authority", async
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
hostname: "test-daemon.local",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
@@ -138,6 +146,7 @@ test.each(["enrollment", "revocation"])("%s HTTP calls are bounded", async (oper
idempotencyKey: "ceremony-1",
hubOrigin,
token: "token",
hostname: "test-daemon.local",
serverId: "server-1",
daemonPublicKey: "public-key",
credentialVerifier: "verifier",
@@ -290,11 +299,15 @@ async function startHubReturning(status: number): Promise<string> {
return `http://127.0.0.1:${address.port}`;
}
async function startEnrollmentHub(webSocketUrl: string): Promise<string> {
async function startEnrollmentHub(
webSocketUrl: string,
onEnrollment?: (enrollment: { daemonId: string; hostname: string }) => void,
): Promise<string> {
const server = createServer(async (request, response) => {
let body = "";
for await (const chunk of request) body += chunk;
const enrollment = JSON.parse(body) as { daemonId: string };
const enrollment = JSON.parse(body) as { daemonId: string; hostname: string };
onEnrollment?.(enrollment);
response.writeHead(200, { "content-type": "application/json" }).end(
JSON.stringify({
daemonId: enrollment.daemonId,
@@ -523,6 +536,7 @@ async function connectController(
openPaseoHomes.push(paseoHome);
const controller = new HubRelationshipController({
paseoHome,
hostname: "test-daemon.local",
serverId: "server-1",
daemonPublicKey: "daemon-public-key",
logger: pino({ level: "silent" }),
@@ -7,6 +7,7 @@ export interface HubEnrollment {
idempotencyKey: string;
hubOrigin: string;
token: string;
hostname: string;
serverId: string;
daemonPublicKey: string;
credentialVerifier: string;
@@ -96,6 +97,7 @@ export class DirectHubRelationshipRemote implements HubRelationshipRemote {
body: JSON.stringify({
daemonId: input.daemonId,
idempotencyKey: input.idempotencyKey,
hostname: input.hostname,
serverId: input.serverId,
daemonPublicKey: input.daemonPublicKey,
credentialVerifier: input.credentialVerifier,
@@ -284,6 +284,7 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
private enrollmentRejection: 401 | 403 | null = null;
private enrollmentScopes = ["hub.execution.*"];
private revokeFailures = 0;
private revocationGate: Deferred<void> | null = null;
private readonly relationships = new Set<string>();
readonly enrollmentSnapshots: RelationshipInvocationSnapshot[] = [];
readonly socketSnapshots: RelationshipInvocationSnapshot[] = [];
@@ -353,8 +354,19 @@ class InMemoryHubRelationships implements HubRelationshipRemote {
this.revokeFailures = count;
}
holdRevocation(): void {
this.revocationGate = deferred<void>();
}
completeRevocation(): void {
if (!this.revocationGate) throw new Error("No revocation is waiting");
this.revocationGate.resolve();
this.revocationGate = null;
}
async revoke(input: HubRevocation): Promise<void> {
this.revocations.push({ ...input });
await this.revocationGate?.promise;
if (this.revokeFailures > 0) {
this.revokeFailures--;
throw new Error("Hub is offline");
@@ -510,6 +522,14 @@ export class HubRelationshipHarness {
}
}
async connectionStateBecomes(expected: string): Promise<void> {
for (let attempt = 0; attempt < 100; attempt++) {
if ((await this.status()).state === expected) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
throw new Error(`Hub connection state did not become ${expected}`);
}
async manageRelationshipFromExternalSocket(): Promise<SessionOutboundMessage[]> {
const address = Object.values(networkInterfaces())
.flat()
@@ -596,6 +616,14 @@ export class HubRelationshipHarness {
this.remote.failRevocations(count);
}
holdRevocation(): void {
this.remote.holdRevocation();
}
completeRevocation(): void {
this.remote.completeRevocation();
}
failProviderPromptStart(prompt = "Create through the Hub"): void {
this.promptsToFail.add(prompt);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -260,6 +260,39 @@ test("seeds an uncommitted exact-project config into the mapped worktree directo
expect(existsSync(path.join(result.worktree.worktreePath, "paseo.json"))).toBe(false);
});
test("does not overwrite a committed exact-project config with source checkout edits", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const sourceDir = path.join(repoDir, "packages", "app");
mkdirSync(sourceDir, { recursive: true });
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
const committedConfig = JSON.stringify({ worktree: { setup: ["npm ci"] } });
writeFileSync(path.join(sourceDir, "paseo.json"), committedConfig);
commitAll(repoDir, "add subproject config");
writeFileSync(
path.join(sourceDir, "paseo.json"),
JSON.stringify({ worktree: { setup: ["npm install"] } }),
);
const result = await createPaseoWorktree(
{
cwd: sourceDir,
worktreeSlug: "preserve-nested-config",
runSetup: false,
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
expect(readFileSync(path.join(result.workspace.cwd, "paseo.json"), "utf8")).toBe(committedConfig);
expect(
execFileSync("git", ["status", "--porcelain"], {
cwd: result.worktree.worktreePath,
encoding: "utf8",
}),
).toBe("");
});
test("removes a new worktree when its ref does not contain the selected project directory", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
@@ -459,14 +492,14 @@ test("an explicit project FK remains unchanged when its worktree comes from anot
});
});
// POSIX-only: Windows git worktree paths need separate canonicalization coverage.
test.skipIf(isPlatform("win32"))(
"keeps repeated worktree requests isolated by workspace identity",
"creates a suffixed worktree when the preferred slug is occupied",
async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const paseoHome = path.join(tempDir, ".paseo");
const firstDeps = createDeps();
const runtimeHome = path.join(tempDir, "runtime-home");
const firstDeps = createDeps({ runtimeHome });
const first = await createPaseoWorktree(
{
cwd: repoDir,
@@ -481,6 +514,7 @@ test.skipIf(isPlatform("win32"))(
events,
projects: firstDeps.projects,
workspaces: firstDeps.workspaces,
runtimeHome,
});
const second = await createPaseoWorktree(
@@ -495,9 +529,10 @@ test.skipIf(isPlatform("win32"))(
expect(second.created).toBe(true);
expect(second.worktree.worktreePath).not.toBe(first.worktree.worktreePath);
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
// Compatibility cwd is presentation data; repeated requests get distinct
// runtime-owned placement and identity.
expect(path.basename(second.worktree.worktreePath)).toBe("reuse-me-1");
expect(events).toContain(`workspace:${second.workspace.workspaceId}`);
expect(second.workspace.workspaceId).not.toBe(first.workspace.workspaceId);
},
);
@@ -933,7 +968,7 @@ test.skipIf(isPlatform("win32"))(
expect(worktreeList).toContain(created.worktreePath);
// Recreating without pruning fails with the stale registration pinning the
// branch — this is the case restore must heal.
// path — this is the case restore must heal.
await expect(
createWorktree({
cwd: repoDir,
@@ -942,7 +977,7 @@ test.skipIf(isPlatform("win32"))(
runSetup: false,
paseoHome,
}),
).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" });
).rejects.toThrow("missing but already registered worktree");
// The restore-side prune frees the stale registration; recreate then succeeds.
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
@@ -987,7 +1022,7 @@ test.skipIf(isPlatform("win32"))(
);
test.skipIf(isPlatform("win32"))(
"rejects with BranchAlreadyCheckedOutError when the kept branch is checked out elsewhere",
"creates a suffixed branch when the requested branch is checked out elsewhere",
async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
@@ -1003,15 +1038,16 @@ test.skipIf(isPlatform("win32"))(
});
expect(existsSync(first.worktreePath)).toBe(true);
await expect(
createWorktree({
cwd: repoDir,
worktreeSlug: "busy-branch-again",
source: { kind: "checkout-branch", branchName: "busy-branch" },
runSetup: false,
paseoHome,
}),
).rejects.toMatchObject({ name: "BranchAlreadyCheckedOutError" });
const second = await createWorktree({
cwd: repoDir,
worktreeSlug: "busy-branch-again",
source: { kind: "checkout-branch", branchName: "busy-branch" },
runSetup: false,
paseoHome,
});
expect(second.branchName).toBe("busy-branch-1");
expect(existsSync(second.worktreePath)).toBe(true);
},
);
@@ -1024,6 +1060,7 @@ function createDeps(options?: {
events?: string[];
projects?: Map<string, PersistedProjectRecord>;
workspaces?: Map<string, PersistedWorkspaceRecord>;
runtimeHome?: string;
}): TestDeps {
const events = options?.events ?? [];
const projects = options?.projects ?? new Map<string, PersistedProjectRecord>();
@@ -1088,8 +1125,9 @@ function createDeps(options?: {
workspaceGitService,
logger: createTestLogger(),
});
const runtimeHome = mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-runtime-"));
cleanupPaths.push(runtimeHome);
const runtimeHome =
options?.runtimeHome ?? mkdtempSync(path.join(tmpdir(), "paseo-worktree-service-runtime-"));
if (!options?.runtimeHome) cleanupPaths.push(runtimeHome);
const workspaceRuntime = createWorkspaceRuntimeService({
paseoHome: runtimeHome,
worktreesRoot: path.join(runtimeHome, "worktrees"),
@@ -296,6 +296,20 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
],
});
});
test("accepts a custom provider catalog refresh timeout", () => {
const parsed = PersistedConfigSchema.parse({
agents: { catalogRefreshTimeoutMs: 180_000 },
});
expect(parsed.agents?.catalogRefreshTimeoutMs).toBe(180_000);
});
test("rejects provider catalog refresh timeouts that overflow Node timers", () => {
expect(() =>
PersistedConfigSchema.parse({ agents: { catalogRefreshTimeoutMs: 2_147_483_648 } }),
).toThrow();
});
});
describe("provider overrides (new format)", () => {
@@ -9,7 +9,7 @@ import {
} from "./agent/provider-launch-config.js";
import type { AgentProviderRuntimeSettingsMap } from "./agent/provider-launch-config.js";
import { ensurePrivateFile, writePrivateFileAtomicSync } from "./private-files.js";
import { TerminalProfileSchema } from "@getpaseo/protocol/messages";
import { AgentProfileSchema, TerminalProfileSchema } from "@getpaseo/protocol/messages";
import { PaseoServicePortAllocationSchema } from "@getpaseo/protocol/paseo-config-schema";
export const LogLevelSchema = z.enum(["trace", "debug", "info", "warn", "error", "fatal"]);
@@ -276,6 +276,7 @@ export const PersistedConfigSchema = z
enableTerminalAgentHooks: z.boolean().optional(),
appendSystemPrompt: z.string().optional(),
terminalProfiles: z.array(TerminalProfileSchema).optional(),
agentProfiles: z.array(AgentProfileSchema).optional(),
cors: z
.object({
allowedOrigins: z.array(z.string()).optional(),
@@ -325,6 +326,7 @@ export const PersistedConfigSchema = z
agents: z
.object({
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
metadataGeneration: AgentMetadataGenerationSchema.optional(),
})
.strict()
@@ -0,0 +1,67 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type pino from "pino";
import { afterEach, describe, expect, test } from "vitest";
import { createPushNotifications } from "./index.js";
function createLogger(): pino.Logger {
const logger = {
child: () => logger,
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return logger as unknown as pino.Logger;
}
describe("push notifications", () => {
const homes: string[] = [];
afterEach(() => {
for (const home of homes.splice(0)) {
rmSync(home, { recursive: true, force: true });
}
});
test("an offline device stops receiving notifications after 48 hours", async () => {
const home = mkdtempSync(path.join(tmpdir(), "paseo-push-notifications-"));
homes.push(home);
const filePath = path.join(home, "push-tokens.json");
let now = Date.parse("2026-08-10T00:00:00.000Z");
const deliveries: string[][] = [];
const pushNotifications = createPushNotifications({
logger: createLogger(),
filePath,
now: () => now,
deliver: async (tokens) => deliveries.push(tokens),
});
pushNotifications.renew("ExponentPushToken[offline-device]");
now += 48 * 60 * 60 * 1000;
await pushNotifications.send({ title: "Agent finished", body: "Done" });
expect(deliveries).toEqual([]);
expect(JSON.parse(readFileSync(filePath, "utf8"))).toEqual({ subscriptions: [] });
});
test("online revocation stops notifications immediately", async () => {
const home = mkdtempSync(path.join(tmpdir(), "paseo-push-notifications-"));
homes.push(home);
const deliveries: string[][] = [];
const pushNotifications = createPushNotifications({
logger: createLogger(),
filePath: path.join(home, "push-tokens.json"),
now: () => Date.parse("2026-08-10T00:00:00.000Z"),
deliver: async (tokens) => deliveries.push(tokens),
});
pushNotifications.renew("ExponentPushToken[online-device]");
pushNotifications.revoke("ExponentPushToken[online-device]");
await pushNotifications.send({ title: "Agent finished", body: "Done" });
expect(deliveries).toEqual([]);
});
});
+45
View File
@@ -0,0 +1,45 @@
import type pino from "pino";
import { PushService, type PushPayload } from "./push-service.js";
import { PushTokenStore } from "./token-store.js";
export type { PushPayload };
const PUSH_TOKEN_LEASE_MS = 48 * 60 * 60 * 1000;
export interface PushNotifications {
renew(token: string): void;
revoke(token: string): void;
send(payload: PushPayload): Promise<void>;
}
export type PushNotificationSender = Pick<PushNotifications, "send">;
export function createPushNotifications(options: {
logger: pino.Logger;
filePath: string;
now?: () => number;
deliver?: (tokens: string[], payload: PushPayload) => Promise<void>;
}): PushNotifications {
const now = options.now ?? Date.now;
const store = new PushTokenStore(options.logger, options.filePath, now, PUSH_TOKEN_LEASE_MS);
const service = new PushService(options.logger, (token) => store.revokeToken(token));
const deliver =
options.deliver ??
((tokens: string[], payload: PushPayload) => service.sendPush(tokens, payload));
return {
renew(token) {
store.renewToken(token);
},
revoke(token) {
store.revokeToken(token);
},
async send(payload) {
const tokens = store.getActiveTokens();
options.logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length === 0) return;
await deliver(tokens, payload);
},
};
}
@@ -1,29 +0,0 @@
import type pino from "pino";
import { PushService, type PushPayload } from "./push-service.js";
import type { PushTokenStore } from "./token-store.js";
export type { PushPayload };
export interface PushNotificationSender {
send(payload: PushPayload): Promise<void>;
}
export function createPushNotificationSender(
logger: pino.Logger,
tokenStore: PushTokenStore,
): PushNotificationSender {
const pushService = new PushService(logger, tokenStore);
return {
async send(payload) {
const tokens = tokenStore.getAllTokens();
logger.info({ tokenCount: tokens.length }, "Sending push notification");
if (tokens.length === 0) {
return;
}
await pushService.sendPush(tokens, payload);
},
};
}
@@ -1,4 +1,3 @@
import type { PushTokenStore } from "./token-store.js";
import type pino from "pino";
export interface PushPayload {
@@ -31,11 +30,11 @@ const MAX_BATCH_SIZE = 100;
*/
export class PushService {
private readonly logger: pino.Logger;
private readonly tokenStore: PushTokenStore;
private readonly revokeToken: (token: string) => void;
constructor(logger: pino.Logger, tokenStore: PushTokenStore) {
constructor(logger: pino.Logger, revokeToken: (token: string) => void) {
this.logger = logger.child({ component: "push-service" });
this.tokenStore = tokenStore;
this.revokeToken = revokeToken;
}
async sendPush(tokens: string[], payload: PushPayload): Promise<void> {
@@ -102,7 +101,7 @@ export class PushService {
ticket.details?.error === "DeviceNotRegistered" ||
ticket.details?.error === "InvalidCredentials"
) {
this.tokenStore.removeToken(message.to);
this.revokeToken(message.to);
}
}
}
@@ -5,6 +5,7 @@ import type pino from "pino";
import { describe, expect, test } from "vitest";
import { PRIVATE_FILE_MODE } from "../private-files.js";
import { createPushNotifications } from "./index.js";
import { PushTokenStore } from "./token-store.js";
const MODE_MASK = 0o777;
@@ -16,6 +17,7 @@ function createLogger(): pino.Logger {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
return logger as unknown as pino.Logger;
}
@@ -29,9 +31,12 @@ describe.skipIf(process.platform === "win32")("PushTokenStore file permissions",
const home = mkdtempSync(path.join(tmpdir(), "paseo-push-tokens-"));
const tokenPath = path.join(home, "push-tokens.json");
try {
const store = new PushTokenStore(createLogger(), tokenPath);
const pushNotifications = createPushNotifications({
logger: createLogger(),
filePath: tokenPath,
});
store.addToken("ExponentPushToken[test]");
pushNotifications.renew("ExponentPushToken[test]");
expect(modeOf(tokenPath)).toBe(PRIVATE_FILE_MODE);
} finally {
@@ -39,19 +44,61 @@ describe.skipIf(process.platform === "win32")("PushTokenStore file permissions",
}
});
test("repairs existing push token file permissions when loading", () => {
test("repairs existing push token file permissions when loading", async () => {
const home = mkdtempSync(path.join(tmpdir(), "paseo-push-tokens-"));
const tokenPath = path.join(home, "push-tokens.json");
try {
writeFileSync(tokenPath, JSON.stringify({ tokens: ["ExponentPushToken[test]"] }));
chmodSync(tokenPath, PERMISSIVE_FILE_MODE);
const store = new PushTokenStore(createLogger(), tokenPath);
const deliveries: string[][] = [];
const pushNotifications = createPushNotifications({
logger: createLogger(),
filePath: tokenPath,
deliver: async (tokens) => deliveries.push(tokens),
});
await pushNotifications.send({ title: "Agent finished", body: "Done" });
expect(store.getAllTokens()).toEqual(["ExponentPushToken[test]"]);
expect(deliveries).toEqual([["ExponentPushToken[test]"]]);
expect(modeOf(tokenPath)).toBe(PRIVATE_FILE_MODE);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe("PushTokenStore persistence", () => {
test("does not apply a revocation until the updated subscriptions are durable", () => {
let rejectWrites = false;
let persisted = "";
const store = new PushTokenStore(
createLogger(),
path.join(tmpdir(), `missing-push-token-store-${process.pid}`, "push-tokens.json"),
() => Date.parse("2026-08-10T00:00:00.000Z"),
48 * 60 * 60 * 1000,
(_filePath, payload) => {
if (rejectWrites) throw new Error("disk full");
persisted = String(payload);
},
);
store.renewToken("ExponentPushToken[test]");
rejectWrites = true;
expect(() => store.revokeToken("ExponentPushToken[test]")).toThrow("disk full");
expect(store.getActiveTokens()).toEqual(["ExponentPushToken[test]"]);
expect(JSON.parse(persisted)).toEqual({
subscriptions: [
{
token: "ExponentPushToken[test]",
expiresAt: "2026-08-12T00:00:00.000Z",
},
],
});
rejectWrites = false;
store.revokeToken("ExponentPushToken[test]");
expect(store.getActiveTokens()).toEqual([]);
expect(JSON.parse(persisted)).toEqual({ subscriptions: [] });
});
});
+92 -24
View File
@@ -10,36 +10,69 @@ import { ensurePrivateFile, writePrivateFileAtomicSync } from "../private-files.
*/
export class PushTokenStore {
private readonly logger: pino.Logger;
private tokens: Set<string> = new Set();
private subscriptions = new Map<string, number>();
private readonly filePath: string;
private readonly now: () => number;
private readonly leaseMs: number;
private readonly write: typeof writePrivateFileAtomicSync;
constructor(logger: pino.Logger, filePath: string) {
constructor(
logger: pino.Logger,
filePath: string,
now: () => number,
leaseMs: number,
write: typeof writePrivateFileAtomicSync = writePrivateFileAtomicSync,
) {
this.logger = logger.child({ component: "token-store" });
this.filePath = filePath;
this.now = now;
this.leaseMs = leaseMs;
this.write = write;
this.loadFromDisk();
}
addToken(token: string): void {
renewToken(token: string): void {
const normalized = token.trim();
if (!normalized) return;
if (this.tokens.has(normalized)) return;
this.tokens.add(normalized);
this.persist();
this.logger.debug({ total: this.tokens.size }, "Added token");
const now = this.now();
const currentExpiry = this.subscriptions.get(normalized);
if (currentExpiry !== undefined && currentExpiry - now > this.leaseMs / 2) return;
const next = new Map(this.subscriptions);
next.set(normalized, now + this.leaseMs);
this.persist(next);
this.subscriptions = next;
this.logger.debug({ total: this.subscriptions.size }, "Renewed token");
}
removeToken(token: string): void {
revokeToken(token: string): void {
const normalized = token.trim();
if (!normalized) return;
const deleted = this.tokens.delete(normalized);
if (deleted) {
this.persist();
this.logger.debug({ total: this.tokens.size }, "Removed token");
if (!this.subscriptions.has(normalized)) return;
const next = new Map(this.subscriptions);
next.delete(normalized);
this.persist(next);
this.subscriptions = next;
this.logger.debug({ total: this.subscriptions.size }, "Revoked token");
}
getActiveTokens(): string[] {
const now = this.now();
const active = new Map(this.subscriptions);
for (const [token, expiresAt] of this.subscriptions) {
if (expiresAt <= now) {
active.delete(token);
}
}
}
getAllTokens(): string[] {
return Array.from(this.tokens);
if (active.size !== this.subscriptions.size) {
try {
this.persist(active);
this.subscriptions = active;
} catch {
// Keep the previous state so a later send retries pruning. Expired tokens
// are still excluded from this delivery.
}
}
return Array.from(active.keys());
}
private loadFromDisk(): void {
@@ -49,25 +82,60 @@ export class PushTokenStore {
}
ensurePrivateFile(this.filePath);
const raw = readFileSync(this.filePath, "utf-8");
const parsed = JSON.parse(raw) as { tokens?: unknown };
const tokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter((t): t is string => typeof t === "string" && t.trim().length > 0)
const parsed = JSON.parse(raw) as { subscriptions?: unknown; tokens?: unknown };
const loaded = new Map<string, number>();
const subscriptions = Array.isArray(parsed.subscriptions) ? parsed.subscriptions : [];
for (const value of subscriptions) {
if (!value || typeof value !== "object") continue;
const candidate = value as { token?: unknown; expiresAt?: unknown };
if (typeof candidate.token !== "string" || typeof candidate.expiresAt !== "string")
continue;
const token = candidate.token.trim();
const expiresAt = Date.parse(candidate.expiresAt);
if (token && Number.isFinite(expiresAt)) {
loaded.set(token, expiresAt);
}
}
this.subscriptions = loaded;
const legacyTokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter((token): token is string => typeof token === "string")
: [];
this.tokens = new Set(tokens.map((t) => t.trim()));
this.logger.info({ total: this.tokens.size }, "Loaded push tokens");
if (legacyTokens.length > 0) {
const migrated = new Map(loaded);
const expiresAt = this.now() + this.leaseMs;
for (const token of legacyTokens) {
const normalized = token.trim();
if (normalized) migrated.set(normalized, expiresAt);
}
this.persist(migrated);
this.subscriptions = migrated;
}
this.logger.info({ total: this.subscriptions.size }, "Loaded push tokens");
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn({ err }, "Failed to load push tokens");
}
}
private persist(): void {
private persist(subscriptions: ReadonlyMap<string, number>): void {
try {
const payload = JSON.stringify({ tokens: Array.from(this.tokens) }, null, 2) + "\n";
writePrivateFileAtomicSync(this.filePath, payload);
const payload =
JSON.stringify(
{
subscriptions: Array.from(subscriptions, ([token, expiresAt]) => ({
token,
expiresAt: new Date(expiresAt).toISOString(),
})),
},
null,
2,
) + "\n";
this.write(this.filePath, payload);
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn({ err }, "Failed to persist push tokens");
throw err;
}
}
}
+92 -56
View File
@@ -33,10 +33,8 @@ import {
asAgentManager,
asAgentStorage,
asDownloadTokenStore,
asPushTokenStore,
asChatService,
asPushNotifications,
asScheduleService,
asLoopService,
asCheckoutDiffManager,
asGitHubService,
asWorkspaceGitService,
@@ -321,6 +319,7 @@ interface SessionForTestOptions {
daemonVersion?: SessionOptions["daemonVersion"];
daemonRuntimeConfig?: SessionOptions["daemonRuntimeConfig"];
downloadTokenStore?: SessionOptions["downloadTokenStore"];
pushNotifications?: SessionOptions["pushNotifications"];
messages?: unknown[];
targetedMessages?: Array<{ source: object; message: SessionOutboundMessage }>;
binaryMessages?: Uint8Array[];
@@ -450,7 +449,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
onBinaryMessage: createBinaryMessageHandler(options.binaryMessages),
logger,
downloadTokenStore: options.downloadTokenStore ?? asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: options.pushNotifications ?? asPushNotifications(),
paseoHome: options.paseoHome ?? "/tmp/paseo-home",
agentManager: asAgentManager({
listAgents: vi.fn(() => []),
@@ -478,9 +477,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
},
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager),
github: asGitHubService(github),
workspaceGitService: asWorkspaceGitService(workspaceGitService),
@@ -796,6 +793,7 @@ describe("project command-center RPCs", () => {
projectDisplayName: "new-project",
projectCustomName: null,
projectCustomIconRevision: null,
projectIconRevision: "automatic:none:v1",
projectRootPath: directoryPath,
projectKind: "non_git",
},
@@ -1530,6 +1528,88 @@ describe("project config RPC authorization", () => {
});
});
test("push token registration can be revoked by the connected client", async () => {
const renewed: string[] = [];
const revoked: string[] = [];
const messages: unknown[] = [];
const session = createSessionForTest({
messages,
pushNotifications: asPushNotifications({
renew: (token: string) => renewed.push(token),
revoke: (token: string) => revoked.push(token),
}),
});
await session.handleMessage({
type: "register_push_token",
token: "ExponentPushToken[test-device]",
});
await session.handleMessage({
type: "push.unregister.request",
token: "ExponentPushToken[test-device]",
requestId: "revoke-1",
});
await session.handleMessage({
type: "client_heartbeat",
deviceType: "mobile",
focusedAgentId: null,
lastActivityAt: "2026-08-10T00:00:00.000Z",
appVisible: false,
});
expect(renewed).toEqual(["ExponentPushToken[test-device]"]);
expect(revoked).toEqual(["ExponentPushToken[test-device]"]);
expect(messages).toEqual([
{
type: "push.unregister.response",
payload: { requestId: "revoke-1" },
},
]);
});
test("push token revocation only acknowledges durable removal", async () => {
const renewed: string[] = [];
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
pushNotifications: asPushNotifications({
renew: (token: string) => renewed.push(token),
revoke: () => {
throw new Error("disk full");
},
}),
});
await session.handleMessage({
type: "register_push_token",
token: "ExponentPushToken[test-device]",
});
await session.handleMessage({
type: "push.unregister.request",
token: "ExponentPushToken[test-device]",
requestId: "revoke-failed",
});
await session.handleMessage({
type: "client_heartbeat",
deviceType: "mobile",
focusedAgentId: null,
lastActivityAt: "2026-08-10T00:00:00.000Z",
appVisible: false,
});
expect(renewed).toEqual(["ExponentPushToken[test-device]", "ExponentPushToken[test-device]"]);
expect(messages.some((message) => message.type === "push.unregister.response")).toBe(false);
expect(messages).toContainEqual({
type: "rpc_error",
payload: {
requestId: "revoke-failed",
requestType: "push.unregister.request",
error: "Request failed: disk full",
code: "handler_error",
},
});
});
describe("daemon status + pairing RPC", () => {
const tempDirs: string[] = [];
@@ -4698,57 +4778,12 @@ describe("session pull request timeline handling", () => {
});
});
describe("chat/schedule/loop dispatch routing (behavior preservation)", () => {
// Each chat/*, loop/*, and schedule/* type must reach its domain handler. The
// injected service stubs are unstubbed, so every handler's own try/catch fires
// and emits its domain rpc_error code — proving the message routed (a dropped
// case would silently no-op and emit nothing). schedule/* historically routed
// via the chat dispatcher's fall-through arm; this guards that path explicitly.
describe("schedule dispatch routing", () => {
// Each schedule/* type must reach its domain handler. The injected service stub
// is unstubbed, so every handler's own try/catch emits its domain rpc_error code.
// handleMessage receives already-parsed messages, so these fixtures only need to
// satisfy the TS union here — zod parsing happens upstream at the transport.
const routingCases: Array<{ msg: SessionInboundMessage; code: string }> = [
{
msg: { type: "chat/create", requestId: "rt-chat-create", name: "room" },
code: "chat_request_failed",
},
{ msg: { type: "chat/list", requestId: "rt-chat-list" }, code: "chat_request_failed" },
{
msg: { type: "chat/inspect", requestId: "rt-chat-inspect", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/delete", requestId: "rt-chat-delete", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/post", requestId: "rt-chat-post", room: "room", body: "hi" },
code: "chat_request_failed",
},
{
msg: { type: "chat/read", requestId: "rt-chat-read", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/wait", requestId: "rt-chat-wait", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "loop/run", requestId: "rt-loop-run", prompt: "p", cwd: "/tmp/loop" },
code: "loop_request_failed",
},
{ msg: { type: "loop/list", requestId: "rt-loop-list" }, code: "loop_request_failed" },
{
msg: { type: "loop/inspect", requestId: "rt-loop-inspect", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: { type: "loop/logs", requestId: "rt-loop-logs", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: { type: "loop/stop", requestId: "rt-loop-stop", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: {
type: "schedule/create",
@@ -5016,7 +5051,7 @@ test("keeps selective delivery scoped per socket when a retained session also ha
]);
});
test("sends project updates only to capable sockets in a retained session", () => {
test("sends project updates only to capable sockets in a retained session", async () => {
const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const session = createSessionForTest({ messages, targetedMessages });
@@ -5025,7 +5060,7 @@ test("sends project updates only to capable sockets in a retained session", () =
session.updateClientCapabilities(null, legacySocket);
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket);
session.emitProjectUpdate({
await session.emitProjectUpdate({
kind: "upsert",
project: createPersistedProjectRecord({
projectId: "project-capable-socket",
@@ -5088,6 +5123,7 @@ test("project.list returns every active project descriptor", async () => {
projectDisplayName: "acme/app",
projectCustomName: null,
projectCustomIconRevision: null,
projectIconRevision: "automatic:none:v1",
projectRootPath: "/tmp/project-active",
projectKind: "git",
},
+183 -119
View File
@@ -41,7 +41,6 @@ import {
} from "./persistence-hooks.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js";
import {
formatSystemNotificationPrompt,
sendPromptToAgent,
waitForAgentRunStartWithTimeout,
unarchiveAgentState,
@@ -75,6 +74,7 @@ import {
CLIENT_SHUTDOWN_RPC_REASON,
normalizeClientRestartRpcReason,
} from "./lifecycle-reasons.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js";
import { buildTimelinePromptIndex } from "./agent/timeline-prompt-index.js";
@@ -152,7 +152,7 @@ import type { ProviderProbeService } from "./provider-probe/index.js";
import { wrapSpokenInput } from "./voice-config.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import {
readProjectIcon,
ProjectIconReader,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "../utils/project-custom-icon.js";
@@ -166,7 +166,7 @@ import {
createAgentStructuredTextGeneration,
createGitMetadataGenerator,
} from "./session/checkout/git-metadata-generator.js";
import { ChatScheduleLoopSession } from "./session/chat/chat-schedule-loop-session.js";
import { ScheduleSession } from "./session/schedule/schedule-session.js";
import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js";
import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js";
import { AgentConfigSession } from "./session/agent-config/agent-config-session.js";
@@ -177,7 +177,7 @@ import type { HubRelationshipManagement } from "./hub/relationship-controller.js
import { HubExecutionController } from "./hub/execution-controller.js";
import type { HubExecutionAgents } from "./hub/daemon-executions.js";
import { DownloadTokenStore } from "./file-download/token-store.js";
import { PushTokenStore } from "./push/token-store.js";
import type { PushNotifications } from "./push/index.js";
import {
archivePersistedWorkspaceRecord,
archiveWorkspaceContents,
@@ -210,8 +210,6 @@ import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
import type pino from "pino";
import { FileBackedChatService } from "./chat/chat-service.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import {
createGitHubService,
@@ -404,6 +402,7 @@ type WorkspaceUpdatePayload = Extract<
>["payload"];
interface WorkspaceUpdatesSubscriptionState {
subscriptionId: string;
syncEnabled?: boolean;
filter?: WorkspaceUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
@@ -449,7 +448,7 @@ export interface SessionOptions {
onWorkspaceRecovered?: (workspace: PersistedWorkspaceRecord) => Promise<void>;
logger: pino.Logger;
downloadTokenStore: DownloadTokenStore;
pushTokenStore: PushTokenStore;
pushNotifications: PushNotifications;
paseoHome: string;
worktreesRoot?: string;
agentManager: AgentManager;
@@ -458,10 +457,9 @@ export interface SessionOptions {
workspaceRegistry: WorkspaceRegistry;
workspaceRuntime?: WorkspaceRuntimeService;
providerProbe?: ProviderProbeService;
directorySync?: DirectorySyncService;
filesystem?: SessionFileSystem;
chatService: FileBackedChatService;
scheduleService: ScheduleService;
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
github?: ForgeService;
createAgentMcpTransport?: AgentMcpTransportFactory;
@@ -590,6 +588,10 @@ interface WorkspaceUpdateOptions {
optimisticStatus?: WorkspaceDescriptorPayload["status"];
}
function resolveDirectorySync(service: DirectorySyncService | undefined): DirectorySyncService {
return service ?? new DirectorySyncService();
}
function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition {
if (!record) {
return "created";
@@ -623,6 +625,7 @@ export class Session {
| null;
private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string;
private readonly projectIcons: ProjectIconReader;
private readonly worktreesRoot: string | undefined;
private agentManager: AgentManager;
@@ -631,6 +634,7 @@ export class Session {
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly workspaceRuntime: WorkspaceRuntimeService | undefined;
private readonly providerProbe: ProviderProbeService | undefined;
private readonly directorySync: DirectorySyncService;
private readonly filesystem: SessionFileSystem;
private readonly github: ForgeService;
private readonly workspaceGitService: WorkspaceGitService;
@@ -640,12 +644,13 @@ export class Session {
private readonly workspaceProvisioning: WorkspaceProvisioningService;
private readonly workspaceRecovery: WorkspaceRecoveryService;
private readonly daemonConfigStore: DaemonConfigStore;
private readonly pushTokenStore: PushTokenStore;
private readonly pushNotifications: PushNotifications;
private unsubscribeAgentEvents: (() => void) | null = null;
private unsubscribeProjectMutations: (() => void) | null = null;
private unsubscribeWorkspaceMutations: (() => void) | null = null;
private registryMutationQueue: Promise<void> = Promise.resolve();
private readonly workspacesAwaitingInitialAgent = new Set<string>();
private projectUpdateQueue: Promise<void> = Promise.resolve();
private isCleanedUp = false;
private viewedTimelineAgentIds = new Set<string>();
private readonly viewedTimelineAgentIdsBySource = new Map<object, Set<string>>();
@@ -654,6 +659,7 @@ export class Session {
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
private readonly agentUpdates: AgentUpdatesService;
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null;
private projectSyncEnabled = false;
private readonly workspaceUpdateTails = new Map<string, Promise<void>>();
private clientActivity: {
deviceType: "web" | "mobile";
@@ -663,6 +669,7 @@ export class Session {
appVisible: boolean;
appVisibilityChangedAt: Date;
} | null = null;
private registeredPushToken: string | null = null;
private readonly terminalManager: TerminalManager | null;
private readonly providerSnapshotManager: ProviderSnapshotManager;
private readonly serviceProxy: ServiceProxySubsystem | null;
@@ -680,7 +687,7 @@ export class Session {
private readonly workspaceDirectory: WorkspaceDirectory;
private readonly voiceSession: VoiceSession;
private readonly checkoutSession: CheckoutSession;
private readonly chatScheduleLoopSession: ChatScheduleLoopSession;
private readonly scheduleSession: ScheduleSession;
private readonly providerCatalogSession: ProviderCatalogSession;
private readonly workspaceFilesSession: WorkspaceFilesSession;
private readonly agentConfigSession: AgentConfigSession;
@@ -705,7 +712,7 @@ export class Session {
onWorkspaceRecovered,
logger,
downloadTokenStore,
pushTokenStore,
pushNotifications,
paseoHome,
worktreesRoot,
agentManager,
@@ -714,10 +721,9 @@ export class Session {
workspaceRegistry,
workspaceRuntime,
providerProbe,
directorySync,
filesystem,
chatService,
scheduleService,
loopService,
checkoutDiffManager,
github,
workspaceGitService,
@@ -758,8 +764,9 @@ export class Session {
this.getTransportBufferedAmount = getTransportBufferedAmount ?? (() => 0);
this.onLifecycleIntent = onLifecycleIntent ?? null;
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
this.pushTokenStore = pushTokenStore;
this.pushNotifications = pushNotifications;
this.paseoHome = paseoHome;
this.projectIcons = new ProjectIconReader(paseoHome);
this.worktreesRoot = worktreesRoot;
this.sessionLogger = logger.child({
module: "session",
@@ -784,6 +791,7 @@ export class Session {
this.workspaceRegistry = workspaceRegistry;
this.workspaceRuntime = workspaceRuntime;
this.providerProbe = providerProbe;
this.directorySync = resolveDirectorySync(directorySync);
this.filesystem = filesystem ?? nodeSessionFileSystem;
this.github = github ?? createGitHubService();
this.workspaceGitService = workspaceGitService;
@@ -851,27 +859,9 @@ export class Session {
onBranchChanged,
logger: this.sessionLogger,
});
this.chatScheduleLoopSession = new ChatScheduleLoopSession({
host: {
emit: (msg) => this.emit(msg),
listStoredAgents: () => this.agentStorage.list(),
listLiveAgents: () => this.agentManager.listAgents(),
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: async (agentId, text) => {
await sendPromptToAgent({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentId,
prompt: formatSystemNotificationPrompt(text),
unarchive: false,
logger: this.sessionLogger,
});
},
},
chatService,
this.scheduleSession = new ScheduleSession({
host: { emit: (msg) => this.emit(msg) },
scheduleService,
loopService,
clientId: this.clientId,
logger: this.sessionLogger,
});
this.providerCatalogSession = new ProviderCatalogSession({
@@ -965,6 +955,13 @@ export class Session {
this.buildProjectPlacementForWorkspaceId(workspaceId),
emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) =>
this.directorySync.sequenceAgentUpdate(
payload,
agent && project ? { agent, project } : null,
agentId,
includeSequence,
),
logger: this.sessionLogger,
});
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
@@ -1185,13 +1182,22 @@ export class Session {
);
}
emitProjectUpdate(update: ProjectUpdate): void {
emitProjectUpdate(update: ProjectUpdate): Promise<void> {
const queued = this.projectUpdateQueue
.catch(() => undefined)
.then(() => this.publishProjectUpdate(update));
this.projectUpdateQueue = queued;
return queued;
}
private async publishProjectUpdate(update: ProjectUpdate): Promise<void> {
const projectedPayload =
update.kind === "upsert"
? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) }
: update;
const message: SessionOutboundMessage = {
type: "project.update",
payload:
update.kind === "upsert"
? { kind: "upsert", project: this.buildProjectDescriptor(update.project) }
: update,
payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled),
};
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message);
@@ -1890,7 +1896,7 @@ export class Session {
this.dispatchWorkspaceFileMessage(msg, source) ??
this.dispatchProviderMessage(msg) ??
this.dispatchTerminalMessage(msg) ??
this.dispatchChatScheduleLoopMessage(msg) ??
this.dispatchScheduleMessage(msg) ??
this.dispatchMiscMessage(msg);
if (promise) await promise;
}
@@ -2068,6 +2074,8 @@ export class Session {
return this.agentConfigSession.handleSetAgentFeatureRequest(msg);
case "set_agent_thinking_request":
return this.agentConfigSession.handleSetAgentThinkingRequest(msg);
case "agent.config.apply.request":
return this.agentConfigSession.handleAgentConfigApplyRequest(msg);
case "get_daemon_config_request":
this.emit({
type: "get_daemon_config_response",
@@ -2140,6 +2148,8 @@ export class Session {
return this.checkoutSession.handleCheckoutPushRequest(msg);
case "checkout.refresh.request":
return this.checkoutSession.handleRefreshRequest(msg);
case "checkout.discard_changes.request":
return this.checkoutSession.handleCheckoutDiscardChangesRequest(msg);
case "checkout_pr_create_request":
return this.checkoutSession.handleCheckoutPrCreateRequest(msg);
case "checkout_pr_merge_request":
@@ -2175,7 +2185,7 @@ export class Session {
case "fetch_workspaces_request":
return this.handleFetchWorkspacesRequest(msg);
case "project.list.request":
return this.handleProjectListRequest(msg.requestId);
return this.handleProjectListRequest(msg);
case "paseo_worktree_list_request":
return this.handlePaseoWorktreeListRequest(msg);
case "paseo_worktree_archive_request":
@@ -2240,6 +2250,14 @@ export class Session {
return this.workspaceFilesSession.handleFileUnsubscribeRequest(msg);
case "fs.file.write.request":
return this.workspaceFilesSession.handleFileWriteRequest(msg);
case "fs.entry.create.request":
return this.workspaceFilesSession.handleFileEntryCreateRequest(msg);
case "fs.entry.rename.request":
return this.workspaceFilesSession.handleFileEntryRenameRequest(msg);
case "fs.entry.duplicate.request":
return this.workspaceFilesSession.handleFileEntryDuplicateRequest(msg);
case "fs.entry.delete.request":
return this.workspaceFilesSession.handleFileEntryDeleteRequest(msg);
case "project_icon_request":
return this.workspaceFilesSession.handleProjectIconRequest(msg);
case "project.icon.get.request":
@@ -2303,51 +2321,26 @@ export class Session {
}
}
// eslint-disable-next-line complexity
private dispatchChatScheduleLoopMessage(msg: SessionInboundMessage): Promise<void> | undefined {
private dispatchScheduleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "chat/create":
return this.chatScheduleLoopSession.handleChatCreateRequest(msg);
case "chat/list":
return this.chatScheduleLoopSession.handleChatListRequest(msg);
case "chat/inspect":
return this.chatScheduleLoopSession.handleChatInspectRequest(msg);
case "chat/delete":
return this.chatScheduleLoopSession.handleChatDeleteRequest(msg);
case "chat/post":
return this.chatScheduleLoopSession.handleChatPostRequest(msg);
case "chat/read":
return this.chatScheduleLoopSession.handleChatReadRequest(msg);
case "chat/wait":
return this.chatScheduleLoopSession.handleChatWaitRequest(msg);
case "loop/run":
return this.chatScheduleLoopSession.handleLoopRunRequest(msg);
case "loop/list":
return this.chatScheduleLoopSession.handleLoopListRequest(msg);
case "loop/inspect":
return this.chatScheduleLoopSession.handleLoopInspectRequest(msg);
case "loop/logs":
return this.chatScheduleLoopSession.handleLoopLogsRequest(msg);
case "loop/stop":
return this.chatScheduleLoopSession.handleLoopStopRequest(msg);
case "schedule/create":
return this.chatScheduleLoopSession.handleScheduleCreateRequest(msg);
return this.scheduleSession.handleScheduleCreateRequest(msg);
case "schedule/list":
return this.chatScheduleLoopSession.handleScheduleListRequest(msg);
return this.scheduleSession.handleScheduleListRequest(msg);
case "schedule/inspect":
return this.chatScheduleLoopSession.handleScheduleInspectRequest(msg);
return this.scheduleSession.handleScheduleInspectRequest(msg);
case "schedule/logs":
return this.chatScheduleLoopSession.handleScheduleLogsRequest(msg);
return this.scheduleSession.handleScheduleLogsRequest(msg);
case "schedule/pause":
return this.chatScheduleLoopSession.handleSchedulePauseRequest(msg);
return this.scheduleSession.handleSchedulePauseRequest(msg);
case "schedule/resume":
return this.chatScheduleLoopSession.handleScheduleResumeRequest(msg);
return this.scheduleSession.handleScheduleResumeRequest(msg);
case "schedule/delete":
return this.chatScheduleLoopSession.handleScheduleDeleteRequest(msg);
return this.scheduleSession.handleScheduleDeleteRequest(msg);
case "schedule/run-once":
return this.chatScheduleLoopSession.handleScheduleRunOnceRequest(msg);
return this.scheduleSession.handleScheduleRunOnceRequest(msg);
case "schedule/update":
return this.chatScheduleLoopSession.handleScheduleUpdateRequest(msg);
return this.scheduleSession.handleScheduleUpdateRequest(msg);
default:
return undefined;
}
@@ -2361,6 +2354,16 @@ export class Session {
case "register_push_token":
this.handleRegisterPushToken(msg.token);
return;
case "push.unregister.request":
this.pushNotifications.revoke(msg.token);
if (this.registeredPushToken?.trim() === msg.token.trim()) {
this.registeredPushToken = null;
}
this.emit({
type: "push.unregister.response",
payload: { requestId: msg.requestId },
});
return;
}
}
@@ -2618,25 +2621,22 @@ export class Session {
didUnarchive: boolean;
originalArchivedAt: string | null;
} | null> {
const records = await this.agentStorage.list();
const matched = records
.filter(
(record) =>
record.persistence?.provider === handle.provider &&
record.persistence?.sessionId === handle.sessionId,
)
.reduce<StoredAgentRecord | null>((latest, candidate) => {
if (!latest) {
return candidate;
}
const updatedDelta =
Date.parse(resolveStoredAgentPayloadUpdatedAt(candidate)) -
Date.parse(resolveStoredAgentPayloadUpdatedAt(latest));
if (updatedDelta !== 0) {
return updatedDelta > 0 ? candidate : latest;
}
return Date.parse(candidate.createdAt) > Date.parse(latest.createdAt) ? candidate : latest;
}, null);
const records = await this.agentStorage.listByProviderSession(
handle.provider,
handle.sessionId,
);
const matched = records.reduce<StoredAgentRecord | null>((latest, candidate) => {
if (!latest) {
return candidate;
}
const updatedDelta =
Date.parse(resolveStoredAgentPayloadUpdatedAt(candidate)) -
Date.parse(resolveStoredAgentPayloadUpdatedAt(latest));
if (updatedDelta !== 0) {
return updatedDelta > 0 ? candidate : latest;
}
return Date.parse(candidate.createdAt) > Date.parse(latest.createdAt) ? candidate : latest;
}, null);
if (!matched) {
return null;
}
@@ -2766,7 +2766,7 @@ export class Session {
// Emit a project.update so clients that track the project as an empty
// project (no workspaces yet) receive the resolved name immediately.
this.emitProjectUpdate({ kind: "upsert", project: updated });
await this.emitProjectUpdate({ kind: "upsert", project: updated });
// Re-emit descriptors for every workspace under this project so the new
// resolved name lands in the UI immediately.
@@ -2820,7 +2820,7 @@ export class Session {
type: "project.icon.set.response",
payload: { requestId, projectId, accepted: true, error: null },
});
this.emitProjectUpdate({ kind: "upsert", project: updated });
await this.emitProjectUpdate({ kind: "upsert", project: updated });
const affectedWorkspaceIds = (await this.workspaceRegistry.list())
.filter((workspace) => workspace.projectId === projectId)
@@ -2846,7 +2846,7 @@ export class Session {
const project = await this.projectRegistry.get(projectId);
if (!project) throw new Error("Project not found");
const icon = await readProjectIcon({ paseoHome: this.paseoHome, project });
const icon = await this.projectIcons.read(project);
this.emit({
type: "project.icon.get.response",
payload: { projectId, icon, error: null, requestId },
@@ -3839,6 +3839,9 @@ export class Session {
if (msg.appVisible && focusedTerminalId) {
void this.clearFocusedTerminalAttention(focusedTerminalId);
}
if (this.registeredPushToken) {
this.pushNotifications.renew(this.registeredPushToken);
}
}
private async clearFocusedTerminalAttention(terminalId: string): Promise<void> {
@@ -3857,7 +3860,8 @@ export class Session {
* Handle push token registration
*/
private handleRegisterPushToken(token: string): void {
this.pushTokenStore.addToken(token);
this.registeredPushToken = token;
this.pushNotifications.renew(token);
this.sessionLogger.info("Registered push token");
}
@@ -4785,15 +4789,17 @@ export class Session {
}
}
private buildProjectDescriptor(
private async buildProjectDescriptor(
project: PersistedProjectRecord,
): WorkspaceProjectDescriptorPayload {
): Promise<WorkspaceProjectDescriptorPayload> {
const icon = await this.projectIcons.snapshot(project);
return {
projectId: project.projectId,
...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null,
projectCustomIconRevision: project.customIconRevision ?? null,
projectIconRevision: icon.revision,
projectRootPath: project.rootPath,
projectKind: project.kind,
};
@@ -5013,7 +5019,6 @@ export class Session {
continue;
}
this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace);
if (!nextWorkspace) {
if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) {
continue;
@@ -5022,17 +5027,28 @@ export class Session {
return;
}
subscription.lastEmittedByWorkspaceId.delete(workspaceId);
const removePayload = await this.buildWorkspaceRemoveUpdatePayload(
workspaceId,
options?.removedProjectId,
);
this.bufferOrEmitWorkspaceUpdate(
subscription,
await this.buildWorkspaceRemoveUpdatePayload(workspaceId, options?.removedProjectId),
this.directorySync.sequenceWorkspaceUpdate(
removePayload,
workspace ?? null,
workspaceId,
subscription.syncEnabled === true,
),
);
continue;
}
const nextPayload: WorkspaceUpdatePayload = {
kind: "upsert",
workspace: nextWorkspace,
};
const nextPayload: WorkspaceUpdatePayload = this.directorySync.sequenceWorkspaceUpdate(
{ kind: "upsert", workspace: nextWorkspace },
workspace ?? null,
workspaceId,
subscription.syncEnabled === true,
);
if (
lastEmitted &&
@@ -5136,10 +5152,13 @@ export class Session {
this.agentUpdates.beginSubscription({
subscriptionId,
filter: request.filter,
syncEnabled: Boolean(request.sync),
});
}
const payload = await this.listFetchAgentsEntries(request);
const payload = request.sync
? await this.readAgentDirectorySync(request)
: await this.listFetchAgentsEntries(request);
const snapshotUpdatedAtByAgentId = new Map<string, number>();
for (const entry of payload.entries) {
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
@@ -5270,6 +5289,7 @@ export class Session {
if (subscriptionId) {
this.workspaceUpdatesSubscription = {
subscriptionId,
syncEnabled: Boolean(request.sync),
filter: request.filter,
isBootstrapping: true,
pendingUpdatesByWorkspaceId: new Map(),
@@ -5278,7 +5298,9 @@ export class Session {
};
}
const payload = await this.listFetchWorkspacesEntries(request);
const payload = request.sync
? await this.readWorkspaceDirectorySync(request)
: await this.listFetchWorkspacesEntries(request);
this.workspaceGitObserver.syncObservers(payload.entries);
this.sessionLogger.debug(
{
@@ -5328,21 +5350,32 @@ export class Session {
}
}
private async handleProjectListRequest(requestId: string): Promise<void> {
private async handleProjectListRequest(
request: Extract<SessionInboundMessage, { type: "project.list.request" }>,
): Promise<void> {
try {
const projects = (await this.projectRegistry.list())
.filter((project) => !project.archivedAt)
.map((project) => this.buildProjectDescriptor(project));
const projects = await Promise.all(
(await this.projectRegistry.list())
.filter((project) => !project.archivedAt)
.map((project) => this.buildProjectDescriptor(project)),
);
const synchronized = request.sync
? this.directorySync.synchronizeProjects(projects, request.sync)
: null;
if (synchronized) this.projectSyncEnabled = true;
this.emit({
type: "project.list.response",
payload: { requestId, projects },
payload: {
requestId: request.requestId,
...(synchronized ?? { projects }),
},
});
} catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle project.list.request");
this.emit({
type: "rpc_error",
payload: {
requestId,
requestId: request.requestId,
requestType: "project.list.request",
error: error instanceof Error ? error.message : "Failed to list projects",
code: "project_list_failed",
@@ -5351,6 +5384,37 @@ export class Session {
}
}
private async readAgentDirectorySync(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
) {
if (request.scope !== "active" || request.filter) {
throw new SessionRequestError(
"invalid_request",
"Sequenced agent directory reads require scope=active and no filter.",
);
}
const snapshot = await this.listFetchAgentsEntries({
...request,
page: { limit: Number.MAX_SAFE_INTEGER },
});
return this.directorySync.synchronizeAgents(snapshot.entries, request.sync ?? {});
}
private async readWorkspaceDirectorySync(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
) {
if (request.filter) {
throw new SessionRequestError(
"invalid_request",
"Sequenced workspace directory reads do not support filters.",
);
}
return this.directorySync.synchronizeWorkspaces(
await this.workspaceDirectory.listDescriptors(),
request.sync ?? {},
);
}
// Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates`
// to decide which pending updates to drop. Captures the status,
// statusEnteredAt, and activityAt (parsed to ms) for each workspace entry
@@ -5818,7 +5882,7 @@ export class Session {
type: "project.add.response",
payload: {
requestId: request.requestId,
project: this.buildProjectDescriptor(project),
project: await this.buildProjectDescriptor(project),
error: null,
},
});
@@ -5852,7 +5916,7 @@ export class Session {
payload: {
requestId: request.requestId,
directoryPath: result.directoryPath,
project: this.buildProjectDescriptor(result.project),
project: await this.buildProjectDescriptor(result.project),
error: null,
errorCode: null,
},
@@ -6017,7 +6081,7 @@ export class Session {
requestId: request.requestId,
repo: repo.displayName,
checkoutPath,
project: this.buildProjectDescriptor(project),
project: await this.buildProjectDescriptor(project),
error: null,
},
});
@@ -205,7 +205,7 @@ function createSessionForWorkspaceGitWatchTests(options?: {
onMessage: (message) => emitted.push(message as { type: string; payload: unknown }),
logger: createStub<pino.Logger>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
pushNotifications: createStub<SessionOptions["pushNotifications"]>({}),
paseoHome: "/tmp/paseo-test",
agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {},
@@ -93,7 +93,7 @@ function createHarness(input: {
onMessage: (m) => emitted.push(m),
logger: createStub<SessionOptions["logger"]>(logger),
downloadTokenStore: createStub<SessionOptions["downloadTokenStore"]>({}),
pushTokenStore: createStub<SessionOptions["pushTokenStore"]>({}),
pushNotifications: createStub<SessionOptions["pushNotifications"]>({}),
paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")),
agentManager: createStub<SessionOptions["agentManager"]>({
subscribe: () => () => {},
@@ -159,9 +159,7 @@ function createHarness(input: {
},
}),
filesystem: { isDirectory: async () => true },
chatService: createStub<SessionOptions["chatService"]>({}),
scheduleService: createStub<SessionOptions["scheduleService"]>({}),
loopService: createStub<SessionOptions["loopService"]>({}),
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -62,10 +62,8 @@ import {
asAgentManager,
asAgentStorage,
asDownloadTokenStore,
asPushTokenStore,
asChatService,
asPushNotifications,
asScheduleService,
asLoopService,
asCheckoutDiffManager,
asDaemonConfigStore,
asTerminalManager,
@@ -667,7 +665,7 @@ function createSessionForWorkspaceTests(
onWorkspaceRecovered: options.onWorkspaceRecovered,
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: options.paseoHome ?? "/tmp/paseo-test",
worktreesRoot: options.worktreesRoot,
agentManager,
@@ -741,9 +739,7 @@ function createSessionForWorkspaceTests(
removeWorkspaceRecord: (workspaceId) => workspaceRegistry.remove(workspaceId),
}),
filesystem: { isDirectory: async () => true },
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -793,6 +789,55 @@ function createSessionForWorkspaceTests(
return session;
}
test("project.list.request catches up by sequence on the existing RPC", async () => {
const emitted: SessionOutboundMessage[] = [];
let project = createPersistedProjectRecord({
projectId: "project-sequenced",
rootPath: "/repo/sequenced",
kind: "git",
displayName: "Before",
createdAt: "2026-08-12T08:00:00.000Z",
updatedAt: "2026-08-12T08:00:00.000Z",
});
const session = createSessionForWorkspaceTests({
onMessage: (message) => emitted.push(message),
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [project],
get: async () => project,
getOrCreateActiveByRoot: async () => project,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
},
});
await session.handleMessage({
type: "project.list.request",
requestId: "initial",
sync: {},
});
const initial = findByType(emitted, "project.list.response")?.payload;
expect(initial?.sync).toMatchObject({ mode: "snapshot", headSeq: 1 });
project = { ...project, displayName: "After", updatedAt: "2026-08-12T08:01:00.000Z" };
await session.handleMessage({
type: "project.list.request",
requestId: "catch-up",
sync: {
generation: initial?.sync?.generation,
afterSeq: initial?.sync?.headSeq,
},
});
const responses = filterByType(emitted, "project.list.response");
expect(responses.at(-1)?.payload).toMatchObject({
requestId: "catch-up",
projects: [{ projectId: "project-sequenced", projectDisplayName: "After", syncSeq: 2 }],
sync: { mode: "changes", headSeq: 2, removals: [] },
});
});
test("agent updates preserve queued live transitions across stored metadata reads", async () => {
const running = makeManagedAgent({
id: "agent-coherent",
@@ -1008,15 +1053,13 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: child, files: [], error: null },
@@ -1194,16 +1237,14 @@ test("create_agent_request launches from an exact subdirectory in a created work
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
workspaceRuntime,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: child, files: [], error: null },
@@ -1335,15 +1376,13 @@ test("create_agent_request does not title an existing workspace from the agent p
onMessage: vi.fn(),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd, files: [], error: null },
@@ -1605,7 +1644,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -1672,9 +1711,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
remove: async () => {},
};
})(),
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: REPO_CWD, files: [], error: null },
@@ -1972,7 +2009,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(sessionLogger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -2034,9 +2071,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
remove: async () => {},
};
})(),
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -2142,7 +2177,7 @@ test("close_items_request archives stored agents that are not currently loaded",
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(sessionLogger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -2221,9 +2256,7 @@ test("close_items_request archives stored agents that are not currently loaded",
remove: async () => {},
};
})(),
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -2303,7 +2336,7 @@ test("close_items_request continues after an archive failure", async () => {
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(sessionLogger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -2370,9 +2403,7 @@ test("close_items_request continues after an archive failure", async () => {
remove: async () => {},
};
})(),
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -3566,7 +3597,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
pushNotifications: asPushNotifications(),
paseoHome: "/tmp/paseo-test",
agentManager: asAgentManager({
subscribe: () => () => {},
@@ -3616,9 +3647,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
archive: async () => {},
remove: async () => {},
},
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -7886,15 +7915,17 @@ const ICON_PNG_1X1 = Buffer.from([
test("project.icon.set.request publishes a custom icon that project.icon.get serves back", async () => {
const emitted: SessionOutboundMessage[] = [];
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
const session = asTestSession(
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }),
createSessionForWorkspaceTests({
paseoHome: path.join(tempDir, "paseo-home"),
onMessage: (message) => emitted.push(message),
}),
);
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true });
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
const projectRoot = path.join(tempDir, "project-without-icons");
mkdirSync(projectRoot, { recursive: true });
session.paseoHome = path.join(tempDir, "paseo-home");
const project = createPersistedProjectRecord({
projectId: "prj_icon",
@@ -14,6 +14,8 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
/** Cross-operation ordering, which the per-operation arrays above cannot show. */
readonly callLog: string[] = [];
modeNotice: AgentProviderNotice | null = null;
thinkingNotice: AgentProviderNotice | null = null;
loadFailure: Error | null = null;
@@ -26,17 +28,20 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
async setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
this.modeCalls.push({ agentId, modeId });
this.callLog.push("mode");
if (this.failWith) throw this.failWith;
return this.modeNotice;
}
async setModel(agentId: string, modelId: string | null): Promise<void> {
this.modelCalls.push({ agentId, modelId });
this.callLog.push("model");
if (this.failWith) throw this.failWith;
}
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
this.featureCalls.push({ agentId, featureId, value });
this.callLog.push(`feature:${featureId}`);
if (this.failWith) throw this.failWith;
}
@@ -45,6 +50,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
this.thinkingCalls.push({ agentId, thinkingOptionId });
this.callLog.push("thinking");
if (this.failWith) throw this.failWith;
return this.thinkingNotice;
}
@@ -302,4 +308,123 @@ describe("AgentConfigSession", () => {
payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "thinking boom" },
});
});
test("apply: sets model before thinking and features last, in one accepted response", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: {
modelId: "opus-5",
modeId: "plan",
thinkingOptionId: "high",
featureValues: { webSearch: true },
},
requestId: "req-1",
});
expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]);
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]);
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(operations.featureCalls).toEqual([
{ agentId: "agent-1", featureId: "webSearch", value: true },
]);
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
expect(emitted).toEqual([
{
type: "agent.config.apply.response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
notice: null,
},
},
]);
});
test("apply: leaves omitted fields alone", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modelId: "haiku-4.5" },
requestId: "req-1",
});
expect(operations.callLog).toEqual(["model"]);
});
test("apply: clears model and thinking when they are explicitly null", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modelId: null, thinkingOptionId: null },
requestId: "req-1",
});
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: null }]);
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: null }]);
});
test("apply: reports the first notice and still runs the rest of the bundle", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.modeNotice = { type: "info", message: "Switched to plan mode" };
operations.thinkingNotice = { type: "warning", message: "Thinking budget reduced" };
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } },
requestId: "req-1",
});
expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]);
expect(emitted).toEqual([
{
type: "agent.config.apply.response",
payload: {
requestId: "req-1",
agentId: "agent-1",
accepted: true,
error: null,
notice: { type: "info", message: "Switched to plan mode" },
},
},
]);
});
test("apply: a mid-bundle failure rejects and leaves the earlier steps applied", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
operations.failWith = new Error("apply boom");
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modelId: "opus-5", modeId: "plan" },
requestId: "req-1",
});
expect(operations.callLog).toEqual(["model"]);
expect(emitted.map((m) => m.type)).toEqual(["activity_log", "agent.config.apply.response"]);
expect(emitted[0]).toEqual({
type: "activity_log",
payload: {
id: expect.any(String),
timestamp: expect.any(Date),
type: "error",
content: "Failed to apply agent config: apply boom",
},
});
expect(emitted[1]).toEqual({
type: "agent.config.apply.response",
payload: { requestId: "req-1", agentId: "agent-1", accepted: false, error: "apply boom" },
});
});
});
@@ -1,12 +1,13 @@
import type pino from "pino";
import { v4 as uuidv4 } from "uuid";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import type { AgentConfigApply } from "@getpaseo/protocol/messages";
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
/**
* The four agent-config response messages share one payload shape; deriving the
* type keeps it pinned to the protocol schema instead of restating it.
* The agent-config response messages share one payload shape; deriving the type
* keeps it pinned to the protocol schema instead of restating it.
*/
type AgentActionResponsePayload = Extract<
SessionOutboundMessage,
@@ -51,11 +52,11 @@ interface ConfigChange {
}
/**
* A client's per-agent config surface: set mode, model, feature, and thinking
* option. Each request shares one envelope — log, run the mutation, then emit the
* accepted response, or on failure emit an activity_log error frame followed by
* the rejected response. Reaches no state beyond the injected operations and the
* outbound channel.
* A client's per-agent config surface: set mode, model, feature, or thinking
* option one at a time, or apply all of them together. Each request shares one
* envelope — log, run the mutation, then emit the accepted response, or on
* failure emit an activity_log error frame followed by the rejected response.
* Reaches no state beyond the injected operations and the outbound channel.
*/
export class AgentConfigSession {
private readonly host: AgentConfigSessionHost;
@@ -134,6 +135,67 @@ export class AgentConfigSession {
});
}
handleAgentConfigApplyRequest(
msg: Extract<SessionInboundMessage, { type: "agent.config.apply.request" }>,
): Promise<void> {
const { agentId, config, requestId } = msg;
return this.applyConfigChange({
agentId,
requestId,
logLabel: "agent.config.apply.request",
logFields: { agentId, requestId, config },
failureText: "Failed to apply agent config",
run: () => this.applyBundle(agentId, config),
emitResponse: (payload) => this.host.emit({ type: "agent.config.apply.response", payload }),
});
}
/**
* Model first, because a thinking option only means anything against the model
* that offers it; features last so they settle on top of the rest. An omitted
* field is left alone. The first notice produced is the one the client sees —
* later steps still run, so a notice never truncates the bundle.
*
* This is not a transaction. A step that rejects aborts the rest, and steps
* that already applied stay applied — provider setters validate against the
* agent's live model, so a bundle that omits `modelId` can have its mode land
* and its feature rejected. Nothing is hidden by that: every AgentManager
* setter emits agent state before returning, so what applied is already
* streamed to the client alongside the rejected response and the error frame.
*
* Rolling back would re-invoke the setters that just failed, most often
* against a session that is already gone. Pre-flight validation would restate
* each provider's model-dependent checks somewhere they would drift from the
* setters that own them. What sending the bundle in one request does remove is
* the multi-round-trip failure class: interruption between hops, and
* interleaving with another client's mutation.
*/
private async applyBundle(
agentId: string,
config: AgentConfigApply,
): Promise<AgentProviderNotice | null> {
let notice: AgentProviderNotice | null = null;
if (config.modelId !== undefined) {
await this.operations.setModel(agentId, config.modelId);
}
if (config.modeId !== undefined) {
// Await first, assign second: `notice ??= await ...` would skip the call
// entirely once an earlier step produced a notice.
const modeNotice = await this.operations.setMode(agentId, config.modeId);
notice ??= modeNotice;
}
if (config.thinkingOptionId !== undefined) {
const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId);
notice ??= thinkingNotice;
}
for (const [featureId, value] of Object.entries(config.featureValues ?? {})) {
await this.operations.setFeature(agentId, featureId, value);
}
return notice;
}
private async applyConfigChange(change: ConfigChange): Promise<void> {
const { agentId, requestId, logLabel, logFields, failureText, run, emitResponse } = change;
this.logger.info(logFields, `session: ${logLabel}`);
@@ -8,6 +8,7 @@ import type {
} from "../../messages.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
import { DirectorySyncService } from "../../directory-sync/index.js";
// No mocks — every dependency is an injected in-memory fake. The agent payloads
// are supplied through the fake builders, so each test fully controls the
@@ -100,6 +101,7 @@ function buildHarness() {
let providerVisible: (provider: string) => boolean = () => true;
let buildAgentPayloadError: Error | null = null;
let enrichProjectedPayload = false;
const directorySync = new DirectorySyncService("test-generation");
const service = createAgentUpdatesService({
emit: (message) => emitted.push(message),
@@ -133,6 +135,13 @@ function buildHarness() {
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
workspaceUpdates.push(workspaceId);
},
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) =>
directorySync.sequenceAgentUpdate(
payload,
agent && project ? { agent, project } : null,
agentId,
includeSequence,
),
logger: { error: (...args: unknown[]) => loggedErrors.push(args) } as unknown as pino.Logger,
});
@@ -16,6 +16,7 @@ type AgentUpdatesFilter = NonNullable<
interface AgentUpdatesSubscriptionState {
subscriptionId: string;
syncEnabled?: boolean;
filter?: AgentUpdatesFilter;
isBootstrapping: boolean;
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
@@ -37,7 +38,11 @@ interface AgentUpdatesSubscriptionState {
* stay consistent.
*/
export interface AgentUpdatesService {
beginSubscription(input: { subscriptionId: string; filter?: AgentUpdatesFilter }): void;
beginSubscription(input: {
subscriptionId: string;
filter?: AgentUpdatesFilter;
syncEnabled?: boolean;
}): void;
flushBootstrapped(
subscriptionId: string,
options?: { snapshotUpdatedAtByAgentId?: Map<string, number> },
@@ -57,6 +62,13 @@ export interface AgentUpdatesServiceDeps {
isProviderVisibleToClient(provider: string): boolean;
buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<ProjectPlacementPayload | null>;
emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void>;
sequenceAgentUpdate<T extends AgentUpdatePayload>(
payload: T,
agent: AgentSnapshotPayload | null,
project: ProjectPlacementPayload | null,
agentId: string,
includeSequence: boolean,
): T;
logger: pino.Logger;
}
@@ -151,6 +163,13 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string {
export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService {
let subscription: AgentUpdatesSubscriptionState | null = null;
const liveAgentUpdateTails = new Map<string, Promise<void>>();
const sequence = <T extends AgentUpdatePayload>(
sub: AgentUpdatesSubscriptionState,
payload: T,
agent: AgentSnapshotPayload | null,
project: ProjectPlacementPayload | null,
agentId: string,
) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true);
function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void {
if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) {
@@ -167,9 +186,14 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
});
}
function beginSubscription(input: { subscriptionId: string; filter?: AgentUpdatesFilter }): void {
function beginSubscription(input: {
subscriptionId: string;
filter?: AgentUpdatesFilter;
syncEnabled?: boolean;
}): void {
subscription = {
subscriptionId: input.subscriptionId,
syncEnabled: input.syncEnabled,
filter: input.filter,
isBootstrapping: true,
pendingUpdatesByAgentId: new Map(),
@@ -230,10 +254,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
if (!project) {
bufferOrEmit(sub, {
kind: "remove",
agentId: payload.id,
});
bufferOrEmit(
sub,
sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id),
);
return payload;
}
@@ -244,16 +268,22 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
});
bufferOrEmit(
sub,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId: payload.id,
},
sequence(
sub,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId: payload.id,
},
payload,
project,
payload.id,
),
);
return payload;
}
@@ -267,10 +297,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
if (!project) {
bufferOrEmit(sub, {
kind: "remove",
agentId: payload.id,
});
bufferOrEmit(
sub,
sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id),
);
} else {
const matches = matchesAgentUpdatesFilter({
agent: payload,
@@ -279,16 +309,34 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
});
if (matches) {
bufferOrEmit(sub, {
kind: "upsert",
agent: payload,
project,
});
bufferOrEmit(
sub,
sequence(
sub,
{
kind: "upsert",
agent: payload,
project,
},
payload,
project,
payload.id,
),
);
} else {
bufferOrEmit(sub, {
kind: "remove",
agentId: payload.id,
});
bufferOrEmit(
sub,
sequence(
sub,
{
kind: "remove",
agentId: payload.id,
},
payload,
project,
payload.id,
),
);
}
}
}
@@ -326,7 +374,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
function removeAgent(agentId: string): Promise<void> {
return enqueueAgentUpdate(agentId, () => {
if (subscription) {
bufferOrEmit(subscription, { kind: "remove", agentId });
bufferOrEmit(
subscription,
sequence(subscription, { kind: "remove", agentId }, null, null, agentId),
);
}
});
}
@@ -1,228 +0,0 @@
import { describe, expect, it } from "vitest";
import pino from "pino";
import {
ChatScheduleLoopSession,
type ChatScheduleLoopSessionHost,
} from "./chat-schedule-loop-session.js";
import { createStub } from "../../test-utils/class-mocks.js";
import { findByType } from "../../test-utils/session-stubs.js";
import type { SessionOutboundMessage } from "../../messages.js";
import type { FileBackedChatService } from "../../chat/chat-service.js";
import type { ScheduleService } from "../../schedule/service.js";
import type { LoopService } from "../../loop-service.js";
type ChatMessageFixture = Awaited<ReturnType<FileBackedChatService["dispatchMessage"]>>;
interface MakeOptions {
chat?: { [K in keyof FileBackedChatService]?: unknown };
schedule?: { [K in keyof ScheduleService]?: unknown };
loop?: { [K in keyof LoopService]?: unknown };
host?: Partial<ChatScheduleLoopSessionHost>;
}
function makeSubsystem(options: MakeOptions = {}) {
const emitted: SessionOutboundMessage[] = [];
const sentAgentMessages: Array<{ agentId: string; text: string }> = [];
let onSend: (() => void) | null = null;
const host: ChatScheduleLoopSessionHost = {
emit: (msg) => emitted.push(msg),
listStoredAgents: async () => [],
listLiveAgents: () => [],
resolveAgentIdentifier: async (identifier) => ({ ok: true, agentId: identifier }),
sendAgentMessage: async (agentId, text) => {
sentAgentMessages.push({ agentId, text });
onSend?.();
},
...options.host,
};
const subsystem = new ChatScheduleLoopSession({
host,
chatService: createStub<FileBackedChatService>(options.chat ?? {}),
scheduleService: createStub<ScheduleService>(options.schedule ?? {}),
loopService: createStub<LoopService>(options.loop ?? {}),
clientId: "client-1",
logger: pino({ level: "silent" }),
});
// notifyChatMentions is fire-and-forget; arm the signal before dispatching so the
// mentioned-agent send is observed deterministically without polling.
function waitForSend(): Promise<void> {
return new Promise((resolve) => {
onSend = resolve;
});
}
return { subsystem, emitted, sentAgentMessages, waitForSend };
}
describe("ChatScheduleLoopSession", () => {
it("chat/post emits the stored message and does not fan out without mentions", async () => {
const message: ChatMessageFixture = {
id: "m1",
roomId: "r1",
authorAgentId: "client-1",
body: "hello",
replyToMessageId: null,
mentionAgentIds: [],
createdAt: "2026-01-01T00:00:00.000Z",
};
const { subsystem, emitted, sentAgentMessages } = makeSubsystem({
chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] },
});
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p1",
room: "r1",
body: "hello",
});
const res = findByType(emitted, "chat/post/response");
expect(res?.payload.message).toEqual(message);
expect(res?.payload.error).toBeNull();
expect(sentAgentMessages).toEqual([]);
});
it("chat/post notifies a mentioned agent through the host send seam", async () => {
const message: ChatMessageFixture = {
id: "m2",
roomId: "r1",
authorAgentId: "client-1",
body: "@agent-2 ping",
replyToMessageId: null,
mentionAgentIds: ["agent-2"],
createdAt: "2026-01-01T00:00:00.000Z",
};
const { subsystem, emitted, sentAgentMessages, waitForSend } = makeSubsystem({
chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] },
});
const sent = waitForSend();
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p2",
room: "r1",
body: "@agent-2 ping",
});
await sent;
expect(findByType(emitted, "chat/post/response")?.payload.error).toBeNull();
expect(sentAgentMessages).toHaveLength(1);
expect(sentAgentMessages[0]?.agentId).toBe("agent-2");
expect(sentAgentMessages[0]?.text).toContain('in room "r1"');
});
it("chat/post rejects @everyone past the fanout limit with the chat error code", async () => {
const posters = Array.from({ length: 26 }, (_, i) => `poster-${i}`);
const { subsystem, emitted } = makeSubsystem({
chat: { listRoomPosterAgentIds: async () => posters },
});
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p3",
room: "r1",
body: "@everyone go",
});
const err = findByType(emitted, "rpc_error");
expect(err?.payload.code).toBe("chat_mention_fanout_limit_exceeded");
expect(err?.payload.requestId).toBe("p3");
});
it("schedule/create returns a summary with the runs stripped", async () => {
const stored = {
id: "s1",
name: null,
prompt: "p",
cadence: { type: "every" as const, everyMs: 1000 },
target: { type: "agent" as const, agentId: "a" },
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [
{
id: "run-1",
scheduledFor: "2026-01-01T00:00:00.000Z",
startedAt: "2026-01-01T00:00:00.000Z",
endedAt: null,
status: "running" as const,
agentId: null,
output: null,
error: null,
},
],
};
const { subsystem, emitted } = makeSubsystem({ schedule: { create: async () => stored } });
await subsystem.handleScheduleCreateRequest({
type: "schedule/create",
requestId: "sc1",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "agent", agentId: "a" },
});
const res = findByType(emitted, "schedule/create/response");
expect(res?.payload.schedule).toBeDefined();
expect(res?.payload.schedule).not.toHaveProperty("runs");
expect(res?.payload.schedule.id).toBe("s1");
});
it("schedule/create remaps a self target to an agent target before creating", async () => {
let received: Parameters<ScheduleService["create"]>[0] | undefined;
const stored = {
id: "s2",
name: null,
prompt: "p",
cadence: { type: "every" as const, everyMs: 1000 },
target: { type: "agent" as const, agentId: "agent-9" },
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
};
const { subsystem, emitted } = makeSubsystem({
schedule: {
create: async (input: Parameters<ScheduleService["create"]>[0]) => {
received = input;
return stored;
},
},
});
await subsystem.handleScheduleCreateRequest({
type: "schedule/create",
requestId: "sc2",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "self", agentId: "agent-9" },
});
expect(received?.target).toEqual({ type: "agent", agentId: "agent-9" });
expect(findByType(emitted, "schedule/create/response")?.payload.error).toBeNull();
});
it("loop/run emits the loop summary from the loop service", async () => {
const loop = { id: "loop-1", status: "running" };
const { subsystem, emitted } = makeSubsystem({ loop: { runLoop: async () => loop } });
await subsystem.handleLoopRunRequest({
type: "loop/run",
requestId: "l1",
prompt: "p",
cwd: "/tmp/loop",
});
const res = findByType(emitted, "loop/run/response");
expect(res?.payload.loop).toEqual(loop);
});
});
@@ -1,609 +0,0 @@
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import {
ChatServiceError,
type FileBackedChatService,
parseMentionAgentIds,
} from "../../chat/chat-service.js";
import { notifyChatMentions, prepareChatMentionFanout } from "../../chat/chat-mentions.js";
import type { LoopService } from "../../loop-service.js";
import type { ScheduleService } from "../../schedule/service.js";
/**
* The collaborators a chat command reaches that are NOT part of the chat/schedule/loop
* domain: the agent roster reads and the agent-message send used only by chat/post
* mention fanout. The Session shell owns the agent lifecycle; this subsystem orchestrates
* a notification through it but does not own it.
*/
export interface ChatScheduleLoopSessionHost {
emit(msg: SessionOutboundMessage): void;
listStoredAgents(): Promise<StoredAgentRecord[]>;
listLiveAgents(): ManagedAgent[];
resolveAgentIdentifier(
identifier: string,
): Promise<{ ok: true; agentId: string } | { ok: false; error: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
}
export interface ChatScheduleLoopSessionOptions {
host: ChatScheduleLoopSessionHost;
chatService: FileBackedChatService;
scheduleService: ScheduleService;
loopService: LoopService;
clientId: string;
logger: pino.Logger;
}
/**
* A client's chat, schedule, and loop request surface. The three families are the
* least-coupled in the session: each is a stateless request/response over its own
* service (chat rooms, cron routines, autonomous loops), with no shared observer,
* git, or voice state and no subscriptions to tear down. They live in one subsystem
* because they are dispatched together — schedule/* was historically reached through
* the chat dispatcher's fall-through arm. The three rpc-error emitters stay separate:
* they differ by default code, and only the chat one reads ChatServiceError.code.
*/
export class ChatScheduleLoopSession {
private readonly host: ChatScheduleLoopSessionHost;
private readonly chatService: FileBackedChatService;
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
private readonly clientId: string;
private readonly logger: pino.Logger;
constructor(options: ChatScheduleLoopSessionOptions) {
this.host = options.host;
this.chatService = options.chatService;
this.scheduleService = options.scheduleService;
this.loopService = options.loopService;
this.clientId = options.clientId;
this.logger = options.logger;
}
private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void {
const message = error instanceof Error ? error.message : "Chat request failed";
const code = error instanceof ChatServiceError ? error.code : "chat_request_failed";
this.logger.error({ err: error, requestType: request.type }, "Chat request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
async handleChatCreateRequest(
request: Extract<SessionInboundMessage, { type: "chat/create" }>,
): Promise<void> {
try {
const room = await this.chatService.createRoom({
name: request.name,
purpose: request.purpose,
});
this.host.emit({
type: "chat/create/response",
payload: {
requestId: request.requestId,
room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatListRequest(
request: Extract<SessionInboundMessage, { type: "chat/list" }>,
): Promise<void> {
try {
const rooms = await this.chatService.listRooms();
this.host.emit({
type: "chat/list/response",
payload: {
requestId: request.requestId,
rooms,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatInspectRequest(
request: Extract<SessionInboundMessage, { type: "chat/inspect" }>,
): Promise<void> {
try {
const result = await this.chatService.inspectRoom({
room: request.room,
});
this.host.emit({
type: "chat/inspect/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatDeleteRequest(
request: Extract<SessionInboundMessage, { type: "chat/delete" }>,
): Promise<void> {
try {
const result = await this.chatService.deleteRoom({
room: request.room,
});
this.host.emit({
type: "chat/delete/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatPostRequest(
request: Extract<SessionInboundMessage, { type: "chat/post" }>,
): Promise<void> {
try {
const authorAgentId = request.authorAgentId?.trim() || this.clientId;
const mentionAgentIds = parseMentionAgentIds(request.body);
const storedAgents = await this.host.listStoredAgents();
const liveAgents = this.host.listLiveAgents();
const fanout = await prepareChatMentionFanout({
authorAgentId,
mentionAgentIds,
storedAgents,
liveAgents,
listRoomPosterAgentIds: () =>
this.chatService.listRoomPosterAgentIds({ room: request.room }),
});
if (!fanout.ok) {
throw new ChatServiceError("chat_mention_fanout_limit_exceeded", fanout.error);
}
const message = await this.chatService.dispatchMessage({
room: request.room,
authorAgentId,
body: request.body,
replyToMessageId: request.replyToMessageId,
});
this.host.emit({
type: "chat/post/response",
payload: {
requestId: request.requestId,
message,
error: null,
},
});
void notifyChatMentions({
room: request.room,
authorAgentId,
body: request.body,
mentionAgentIds: message.mentionAgentIds,
logger: this.logger,
storedAgents,
liveAgents,
prepared: fanout.prepared,
resolveAgentIdentifier: (identifier) => this.host.resolveAgentIdentifier(identifier),
sendAgentMessage: (agentId, text) => this.host.sendAgentMessage(agentId, text),
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatReadRequest(
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> {
try {
const messages = await this.chatService.readMessages({
room: request.room,
limit: request.limit,
since: request.since,
authorAgentId: request.authorAgentId,
});
this.host.emit({
type: "chat/read/response",
payload: {
requestId: request.requestId,
messages,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatWaitRequest(
request: Extract<SessionInboundMessage, { type: "chat/wait" }>,
): Promise<void> {
try {
const messages = await this.chatService.waitForMessages({
room: request.room,
afterMessageId: request.afterMessageId,
timeoutMs: request.timeoutMs,
});
this.host.emit({
type: "chat/wait/response",
payload: {
requestId: request.requestId,
messages,
timedOut: messages.length === 0,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private toScheduleSummary(
schedule: Awaited<ReturnType<ScheduleService["inspect"]>>,
): Extract<
SessionOutboundMessage,
{ type: "schedule/list/response" }
>["payload"]["schedules"][number] {
const { runs: _runs, ...summary } = schedule;
return summary;
}
private emitScheduleRpcError(
request: Extract<
SessionInboundMessage,
{
type:
| "schedule/create"
| "schedule/list"
| "schedule/inspect"
| "schedule/logs"
| "schedule/pause"
| "schedule/resume"
| "schedule/delete"
| "schedule/run-once"
| "schedule/update";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, requestType: request.type }, "Schedule request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "schedule_request_failed",
},
});
}
async handleScheduleCreateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/create" }>,
): Promise<void> {
try {
const target =
request.target.type === "self"
? { type: "agent" as const, agentId: request.target.agentId }
: request.target;
const schedule = await this.scheduleService.create({
prompt: request.prompt,
name: request.name,
cadence: request.cadence,
target,
maxRuns: request.maxRuns,
expiresAt: request.expiresAt,
runOnCreate: request.runOnCreate,
});
this.host.emit({
type: "schedule/create/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleListRequest(
request: Extract<SessionInboundMessage, { type: "schedule/list" }>,
): Promise<void> {
try {
const schedules = await this.scheduleService.list();
this.host.emit({
type: "schedule/list/response",
payload: {
requestId: request.requestId,
schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleInspectRequest(
request: Extract<SessionInboundMessage, { type: "schedule/inspect" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.inspect(request.scheduleId);
this.host.emit({
type: "schedule/inspect/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleLogsRequest(
request: Extract<SessionInboundMessage, { type: "schedule/logs" }>,
): Promise<void> {
try {
const runs = await this.scheduleService.logs(request.scheduleId);
this.host.emit({
type: "schedule/logs/response",
payload: {
requestId: request.requestId,
runs,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleSchedulePauseRequest(
request: Extract<SessionInboundMessage, { type: "schedule/pause" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.pause(request.scheduleId);
this.host.emit({
type: "schedule/pause/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleResumeRequest(
request: Extract<SessionInboundMessage, { type: "schedule/resume" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.resume(request.scheduleId);
this.host.emit({
type: "schedule/resume/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleDeleteRequest(
request: Extract<SessionInboundMessage, { type: "schedule/delete" }>,
): Promise<void> {
try {
await this.scheduleService.delete(request.scheduleId);
this.host.emit({
type: "schedule/delete/response",
payload: {
requestId: request.requestId,
scheduleId: request.scheduleId,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleRunOnceRequest(
request: Extract<SessionInboundMessage, { type: "schedule/run-once" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.runOnce(request.scheduleId);
this.host.emit({
type: "schedule/run-once/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleUpdateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/update" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.update({
id: request.scheduleId,
...(request.name !== undefined ? { name: request.name } : {}),
...(request.prompt !== undefined ? { prompt: request.prompt } : {}),
...(request.cadence !== undefined ? { cadence: request.cadence } : {}),
...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}),
...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}),
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
});
this.host.emit({
type: "schedule/update/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private emitLoopRpcError(
request: Extract<
SessionInboundMessage,
{
type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, requestType: request.type }, "Loop request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "loop_request_failed",
},
});
}
async handleLoopRunRequest(
request: Extract<SessionInboundMessage, { type: "loop/run" }>,
): Promise<void> {
try {
const loop = await this.loopService.runLoop({
prompt: request.prompt,
cwd: request.cwd,
provider: request.provider,
model: request.model,
modeId: request.modeId,
workerProvider: request.workerProvider,
workerModel: request.workerModel,
verifierProvider: request.verifierProvider,
verifierModel: request.verifierModel,
verifierModeId: request.verifierModeId,
verifyPrompt: request.verifyPrompt,
verifyChecks: request.verifyChecks,
archive: request.archive,
name: request.name,
sleepMs: request.sleepMs,
maxIterations: request.maxIterations,
maxTimeMs: request.maxTimeMs,
});
this.host.emit({
type: "loop/run/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopListRequest(
request: Extract<SessionInboundMessage, { type: "loop/list" }>,
): Promise<void> {
try {
const loops = await this.loopService.listLoops();
this.host.emit({
type: "loop/list/response",
payload: {
requestId: request.requestId,
loops,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopInspectRequest(
request: Extract<SessionInboundMessage, { type: "loop/inspect" }>,
): Promise<void> {
try {
const loop = await this.loopService.inspectLoop(request.id);
this.host.emit({
type: "loop/inspect/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopLogsRequest(
request: Extract<SessionInboundMessage, { type: "loop/logs" }>,
): Promise<void> {
try {
const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0);
this.host.emit({
type: "loop/logs/response",
payload: {
requestId: request.requestId,
loop: result.loop,
entries: result.entries,
nextCursor: result.nextCursor,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopStopRequest(
request: Extract<SessionInboundMessage, { type: "loop/stop" }>,
): Promise<void> {
try {
const loop = await this.loopService.stopLoop(request.id);
this.host.emit({
type: "loop/stop/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
}
@@ -1,3 +1,7 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import pino from "pino";
import {
@@ -24,6 +28,7 @@ import {
createNoopWorkspaceGitService,
} from "../../test-utils/workspace-git-service-stub.js";
import { expandTilde } from "../../../utils/path.js";
import { discardChanges } from "../../../utils/checkout-git.js";
import type { GitMetadataGenerator } from "./git-metadata-generator.js";
function isCheckDetailsResponse(msg: SessionOutboundMessage): boolean {
@@ -454,6 +459,94 @@ describe("CheckoutSession", () => {
});
});
describe("discard changes", () => {
it("discards the paths, notifies git mutation, refreshes diffs, and confirms success", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-"));
const cwd = realpathSync(tempDir);
try {
execFileSync("git", ["init", "-q"], { cwd });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd });
execFileSync("git", ["config", "user.name", "Test User"], { cwd });
writeFileSync(join(cwd, "file.txt"), "original\n");
execFileSync("git", ["add", "file.txt"], { cwd });
execFileSync("git", ["commit", "-qm", "initial"], { cwd });
writeFileSync(join(cwd, "file.txt"), "changed\n");
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
cwd: "",
files: [],
error: null,
});
const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({
diff: subscriber,
git: { discardChanges },
});
await checkout.handleCheckoutDiscardChangesRequest({
type: "checkout.discard_changes.request",
cwd,
paths: ["file.txt"],
requestId: "discard-1",
});
expect(readFileSync(join(cwd, "file.txt"), "utf8").replaceAll("\r\n", "\n")).toBe(
"original\n",
);
expect(gitMutationCalls.notifyGitMutation).toEqual([
{ cwd, reason: "discard-changes", options: undefined },
]);
expect(refreshedCwds).toEqual([cwd]);
expect(emitted).toEqual([
{
type: "checkout.discard_changes.response",
payload: { cwd, success: true, error: null, requestId: "discard-1" },
},
]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
it("returns the checkout error without notifying git or refreshing diffs", async () => {
const tempDir = mkdtempSync(join(tmpdir(), "checkout-session-discard-error-"));
const cwd = realpathSync(tempDir);
try {
const { subscriber, refreshedCwds } = createFakeDiffSubscriber({
cwd: "",
files: [],
error: null,
});
const { checkout, emitted, gitMutationCalls } = makeCheckoutSession({
diff: subscriber,
git: { discardChanges },
});
await checkout.handleCheckoutDiscardChangesRequest({
type: "checkout.discard_changes.request",
cwd,
paths: ["file.txt"],
requestId: "discard-error",
});
expect(gitMutationCalls.notifyGitMutation).toEqual([]);
expect(refreshedCwds).toEqual([]);
expect(emitted).toEqual([
{
type: "checkout.discard_changes.response",
payload: {
cwd,
success: false,
error: { code: "NOT_GIT_REPO", message: `Not a git repository: ${cwd}` },
requestId: "discard-error",
},
},
]);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
describe("diff subscriptions", () => {
it("opens a subscription, streams updates tagged with the id, and tears down on unsubscribe", async () => {
const { subscriber, subscriptions } = createFakeDiffSubscriber({
@@ -578,6 +671,16 @@ describe("CheckoutSession", () => {
},
]);
});
it("does not emit the same checkout status twice", () => {
const { checkout, emitted } = makeCheckoutSession();
const snapshot = createGitSnapshot("/repo", "main");
checkout.emitStatusUpdate("workspace-1", "/repo", snapshot);
checkout.emitStatusUpdate("workspace-1", "/repo", snapshot);
expect(emitted).toHaveLength(1);
});
});
describe("switch branch", () => {
@@ -135,6 +135,7 @@ export class CheckoutSession {
private readonly gitMetadataGenerator: GitMetadataGenerator;
private readonly logger: pino.Logger;
private readonly diffSubscriptions = new Map<string, () => void>();
private readonly statusUpdateFingerprints = new Map<string, string>();
constructor(options: CheckoutSessionOptions) {
this.host = options.host;
@@ -479,21 +480,25 @@ export class CheckoutSession {
emitStatusUpdate(workspaceId: string, cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
try {
const requestId = `subscription:${workspaceId}`;
const payload = {
workspaceId,
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
};
const fingerprint = JSON.stringify(payload);
if (this.statusUpdateFingerprints.get(workspaceId) === fingerprint) return;
this.statusUpdateFingerprints.set(workspaceId, fingerprint);
this.host.emit({
type: "checkout_status_update",
payload: {
workspaceId,
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
},
payload,
});
} catch (error) {
this.logger.warn({ err: error, cwd }, "Failed to emit workspace checkout status update");
@@ -617,6 +622,27 @@ export class CheckoutSession {
}
}
async handleCheckoutDiscardChangesRequest(
msg: Extract<SessionInboundMessage, { type: "checkout.discard_changes.request" }>,
): Promise<void> {
const { cwd, paths, requestId } = msg;
try {
const workspaceGit = await this.resolveWorkspaceGit(msg);
await workspaceGit.discardChanges(paths);
await this.gitMutation.bind(workspaceGit).notify("discard-changes");
this.scheduleDiffRefresh(workspaceGit);
this.host.emit({
type: "checkout.discard_changes.response",
payload: { cwd, success: true, error: null, requestId },
});
} catch (error) {
this.host.emit({
type: "checkout.discard_changes.response",
payload: { cwd, success: false, error: toCheckoutError(error), requestId },
});
}
}
async handleStashSaveRequest(
msg: Extract<SessionInboundMessage, { type: "stash_save_request" }>,
): Promise<void> {
@@ -1426,6 +1452,7 @@ export class CheckoutSession {
unsubscribe();
}
this.diffSubscriptions.clear();
this.statusUpdateFingerprints.clear();
}
}

Some files were not shown because too many files have changed in this diff Show More