mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 18:35:17 +00:00
chore(merge): integrate main into workspace runtimes
This commit is contained in:
@@ -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;
|
||||
|
||||
+68
-36
@@ -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) {
|
||||
|
||||
+16
-4
@@ -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",
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user