Add provider/model selection for schedules and workspace fetch debug logging
- Unify resolveProviderAndModel across CLI and server, supporting provider/model syntax (e.g. codex/gpt-5.4) for agent runs and schedules - Add --provider flag to schedule create CLI and both MCP schedule tools - Add structured debug logging to workspace fetch hydrate, sidebar refresh, and real-time update paths - Update orchestrate skill references
This commit is contained in:
@@ -53,6 +53,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { reconcilePreviousAgentStatuses } from "@/contexts/session-status-tracking";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { summarizeWorkspaceCollection } from "@/utils/workspace-fetch-debug";
|
||||
|
||||
// Re-export types from session-store and draft-store for backward compatibility
|
||||
export type { DraftInput } from "@/stores/draft-store";
|
||||
@@ -333,11 +334,24 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
let includeSubscribe = options?.subscribe ?? false;
|
||||
|
||||
while (true) {
|
||||
console.log("[WorkspaceFetch][hydrate] request", {
|
||||
serverId,
|
||||
cursor,
|
||||
includeSubscribe,
|
||||
existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()),
|
||||
});
|
||||
const payload = await client.fetchWorkspaces({
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
...(includeSubscribe ? { subscribe: {} } : {}),
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
console.log("[WorkspaceFetch][hydrate] response", {
|
||||
serverId,
|
||||
cursor,
|
||||
includeSubscribe,
|
||||
pageInfo: payload.pageInfo,
|
||||
payload: summarizeWorkspaceCollection(payload.entries),
|
||||
});
|
||||
if (options?.isCancelled?.()) {
|
||||
return;
|
||||
}
|
||||
@@ -366,6 +380,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
|
||||
setWorkspaces(serverId, workspaces);
|
||||
setHasHydratedWorkspaces(serverId, true);
|
||||
console.log("[WorkspaceFetch][hydrate] applied", {
|
||||
serverId,
|
||||
subscribed: options?.subscribe ?? false,
|
||||
nextWorkspaces: summarizeWorkspaceCollection(workspaces.values()),
|
||||
});
|
||||
},
|
||||
[client, isConnected, serverId, setHasHydratedWorkspaces, setWorkspaces],
|
||||
);
|
||||
@@ -1153,10 +1172,23 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
|
||||
if (message.type !== "workspace_update") return;
|
||||
if (message.payload.kind === "remove") {
|
||||
console.log("[WorkspaceFetch][update] remove", {
|
||||
serverId,
|
||||
workspaceId: message.payload.id,
|
||||
});
|
||||
removeWorkspace(serverId, message.payload.id);
|
||||
return;
|
||||
}
|
||||
mergeWorkspaces(serverId, [normalizeWorkspaceDescriptor(message.payload.workspace)]);
|
||||
const workspace = normalizeWorkspaceDescriptor(message.payload.workspace);
|
||||
const existingWorkspace = useSessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.workspaces.get(workspace.id);
|
||||
console.log("[WorkspaceFetch][update] upsert", {
|
||||
serverId,
|
||||
existedInStore: Boolean(existingWorkspace),
|
||||
workspace: summarizeWorkspaceCollection([workspace]),
|
||||
});
|
||||
mergeWorkspaces(serverId, [workspace]);
|
||||
});
|
||||
|
||||
const unsubStatus = client.on("status", (message) => {
|
||||
|
||||
@@ -9,6 +9,10 @@ import { useSidebarOrderStore } from "@/stores/sidebar-order-store";
|
||||
import type { WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import type { WorkspaceDescriptorPayload } from "@server/shared/messages";
|
||||
import { projectDisplayNameFromProjectId } from "@/utils/project-display-name";
|
||||
import {
|
||||
summarizeSidebarProjects,
|
||||
summarizeWorkspaceCollection,
|
||||
} from "@/utils/workspace-fetch-debug";
|
||||
|
||||
const EMPTY_ORDER: string[] = [];
|
||||
const EMPTY_PROJECTS: SidebarProjectEntry[] = [];
|
||||
@@ -284,6 +288,20 @@ export function useSidebarWorkspacesList(options?: {
|
||||
});
|
||||
}, [persistedProjectOrder, persistedWorkspaceOrderByScope, serverId, sessionWorkspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[WorkspaceFetch][sidebar] model", {
|
||||
serverId,
|
||||
connectionStatus,
|
||||
hasHydratedWorkspaces,
|
||||
sessionWorkspaces: summarizeWorkspaceCollection(sessionWorkspaces?.values()),
|
||||
projects: summarizeSidebarProjects(projects),
|
||||
});
|
||||
}, [connectionStatus, hasHydratedWorkspaces, projects, serverId, sessionWorkspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || projects.length === 0) {
|
||||
return;
|
||||
@@ -327,10 +345,21 @@ export function useSidebarWorkspacesList(options?: {
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
console.log("[WorkspaceFetch][sidebar-refresh] request", {
|
||||
serverId,
|
||||
cursor,
|
||||
existingWorkspaces: summarizeWorkspaceCollection(existingWorkspaces?.values()),
|
||||
});
|
||||
const payload = await client.fetchWorkspaces({
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
page: cursor ? { limit: 200, cursor } : { limit: 200 },
|
||||
});
|
||||
console.log("[WorkspaceFetch][sidebar-refresh] response", {
|
||||
serverId,
|
||||
cursor,
|
||||
pageInfo: payload.pageInfo,
|
||||
payload: summarizeWorkspaceCollection(payload.entries),
|
||||
});
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = toWorkspaceDescriptor(entry);
|
||||
next.set(
|
||||
@@ -349,7 +378,16 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(serverId, next);
|
||||
store.setHasHydratedWorkspaces(serverId, true);
|
||||
} catch {
|
||||
console.log("[WorkspaceFetch][sidebar-refresh] applied", {
|
||||
serverId,
|
||||
nextWorkspaces: summarizeWorkspaceCollection(next.values()),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[WorkspaceFetch][sidebar-refresh] failed", {
|
||||
serverId,
|
||||
cursor,
|
||||
error,
|
||||
});
|
||||
// ignore explicit refresh failures; hook keeps existing data
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
type WorkspaceLike = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName?: string;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceKind: string;
|
||||
};
|
||||
|
||||
type SidebarWorkspaceLike = {
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
statusBucket: string;
|
||||
workspaceKind: string;
|
||||
};
|
||||
|
||||
type SidebarProjectLike = {
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
statusBucket: string;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: SidebarWorkspaceLike[];
|
||||
};
|
||||
|
||||
function countByValue(values: Iterable<string>): Record<string, number> {
|
||||
const counts = new Map<string, number>();
|
||||
for (const value of values) {
|
||||
counts.set(value, (counts.get(value) ?? 0) + 1);
|
||||
}
|
||||
return Object.fromEntries(counts);
|
||||
}
|
||||
|
||||
export function summarizeWorkspaceCollection(
|
||||
workspaces: Iterable<WorkspaceLike> | null | undefined,
|
||||
): {
|
||||
count: number;
|
||||
projectIds: string[];
|
||||
statusCounts: Record<string, number>;
|
||||
workspaces: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string | null;
|
||||
name: string;
|
||||
status: string;
|
||||
workspaceKind: string;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(workspaces ?? [], (workspace) => ({
|
||||
id: workspace.id,
|
||||
projectId: workspace.projectId,
|
||||
projectDisplayName: workspace.projectDisplayName?.trim() || null,
|
||||
name: workspace.name,
|
||||
status: workspace.status,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
}));
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
projectIds: [...new Set(entries.map((workspace) => workspace.projectId))],
|
||||
statusCounts: countByValue(entries.map((workspace) => workspace.status)),
|
||||
workspaces: entries,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSidebarProjects(
|
||||
projects: Iterable<SidebarProjectLike> | null | undefined,
|
||||
): {
|
||||
count: number;
|
||||
projectKeys: string[];
|
||||
projects: Array<{
|
||||
projectKey: string;
|
||||
projectName: string;
|
||||
statusBucket: string;
|
||||
activeCount: number;
|
||||
totalWorkspaces: number;
|
||||
workspaces: Array<{
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
statusBucket: string;
|
||||
workspaceKind: string;
|
||||
}>;
|
||||
}>;
|
||||
} {
|
||||
const entries = Array.from(projects ?? [], (project) => ({
|
||||
projectKey: project.projectKey,
|
||||
projectName: project.projectName,
|
||||
statusBucket: project.statusBucket,
|
||||
activeCount: project.activeCount,
|
||||
totalWorkspaces: project.totalWorkspaces,
|
||||
workspaces: project.workspaces.map((workspace) => ({
|
||||
workspaceId: workspace.workspaceId,
|
||||
name: workspace.name,
|
||||
statusBucket: workspace.statusBucket,
|
||||
workspaceKind: workspace.workspaceKind,
|
||||
})),
|
||||
}));
|
||||
|
||||
return {
|
||||
count: entries.length,
|
||||
projectKeys: entries.map((project) => project.projectKey),
|
||||
projects: entries,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { resolve } from "node:path";
|
||||
import { lookup } from "mime-types";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import { collectMultiple } from "../../utils/command-options.js";
|
||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
|
||||
export function addRunOptions(cmd: Command): Command {
|
||||
return cmd
|
||||
@@ -98,11 +99,6 @@ export interface AgentRunOptions extends CommandOptions {
|
||||
outputSchema?: string;
|
||||
}
|
||||
|
||||
interface ResolvedProviderModel {
|
||||
provider: string;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
function toRunResult(
|
||||
agent: AgentSnapshotPayload,
|
||||
statusOverride?: AgentRunResult["status"],
|
||||
@@ -222,54 +218,6 @@ function structuredRunSchema(output: Record<string, unknown>): OutputSchema<Agen
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(
|
||||
options: Pick<AgentRunOptions, "provider" | "model">,
|
||||
): ResolvedProviderModel {
|
||||
const providerInput = options.provider?.trim() || "claude";
|
||||
const modelInput = options.model?.trim();
|
||||
|
||||
if (options.model !== undefined && !modelInput) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_MODEL",
|
||||
message: "--model cannot be empty",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: "Invalid --provider value",
|
||||
details: "Use --provider <provider> or --provider <provider>/<model>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "CONFLICTING_MODEL_OPTIONS",
|
||||
message: "Conflicting model values provided",
|
||||
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runRunCommand(
|
||||
prompt: string,
|
||||
options: AgentRunOptions,
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
|
||||
cron?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
}
|
||||
@@ -30,6 +31,7 @@ export async function runCreateCommand(
|
||||
cron: options.cron,
|
||||
name: options.name,
|
||||
target: options.target,
|
||||
provider: options.provider,
|
||||
maxRuns: options.maxRuns,
|
||||
expiresIn: options.expiresIn,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,10 @@ export function createScheduleCommand(): Command {
|
||||
.option("--cron <expr>", "Cron cadence expression")
|
||||
.option("--name <name>", "Optional schedule name")
|
||||
.option("--target <self|new-agent|agent-id>", "Run target")
|
||||
.option(
|
||||
"--provider <provider>",
|
||||
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
|
||||
)
|
||||
.option("--max-runs <n>", "Maximum number of runs")
|
||||
.option("--expires-in <duration>", "Time to live for the schedule"),
|
||||
).action(withOutput(runCreateCommand));
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ScheduleTarget,
|
||||
} from "./types.js";
|
||||
import { parseDuration } from "../../utils/duration.js";
|
||||
import { resolveProviderAndModel } from "../../utils/provider-model.js";
|
||||
|
||||
export interface ScheduleCommandOptions extends CommandOptions {
|
||||
host?: string;
|
||||
@@ -58,7 +59,8 @@ export function formatTarget(target: ScheduleTarget | ScheduleListItem["target"]
|
||||
if (target.type === "agent") {
|
||||
return `agent:${target.agentId.slice(0, 7)}`;
|
||||
}
|
||||
return `new-agent:${target.config.provider}`;
|
||||
const modelSuffix = target.config.model ? `/${target.config.model}` : "";
|
||||
return `new-agent:${target.config.provider}${modelSuffix}`;
|
||||
}
|
||||
|
||||
export function formatDurationMs(durationMs: number): string {
|
||||
@@ -87,6 +89,7 @@ export function parseScheduleCreateInput(options: {
|
||||
cron?: string;
|
||||
name?: string;
|
||||
target?: string;
|
||||
provider?: string;
|
||||
maxRuns?: string;
|
||||
expiresIn?: string;
|
||||
}): CreateScheduleInput {
|
||||
@@ -111,29 +114,53 @@ export function parseScheduleCreateInput(options: {
|
||||
: { type: "cron", expression: options.cron!.trim() };
|
||||
|
||||
const targetValue = options.target?.trim();
|
||||
const hasExplicitProviderSelection = options.provider !== undefined;
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: options.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
const newAgentTarget: ScheduleTarget = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
},
|
||||
};
|
||||
let target: ScheduleTarget;
|
||||
if (!targetValue || targetValue === "self") {
|
||||
if (!targetValue) {
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (currentAgentId) {
|
||||
if (currentAgentId && !hasExplicitProviderSelection) {
|
||||
target = { type: "self", agentId: currentAgentId };
|
||||
} else {
|
||||
target = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
target = newAgentTarget;
|
||||
}
|
||||
} else if (targetValue === "self") {
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
|
||||
if (!currentAgentId) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--target self requires running inside a Paseo agent",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
target = { type: "self", agentId: currentAgentId };
|
||||
} else if (targetValue === "new-agent") {
|
||||
target = {
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
};
|
||||
target = newAgentTarget;
|
||||
} else {
|
||||
if (hasExplicitProviderSelection) {
|
||||
throw {
|
||||
code: "INVALID_TARGET",
|
||||
message: "--provider can only be used with a new-agent target",
|
||||
details: "Use --target new-agent or omit --target to create a new agent schedule",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
target = {
|
||||
type: "agent",
|
||||
agentId: targetValue,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CommandError } from "../output/index.js";
|
||||
|
||||
export interface ResolveProviderAndModelOptions {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
defaultProvider?: string;
|
||||
}
|
||||
|
||||
export interface ResolvedProviderModel {
|
||||
provider: string;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(
|
||||
options: ResolveProviderAndModelOptions,
|
||||
): ResolvedProviderModel {
|
||||
const providerInput = options.provider?.trim() || options.defaultProvider || "claude";
|
||||
const modelInput = options.model?.trim();
|
||||
|
||||
if (options.model !== undefined && !modelInput) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_MODEL",
|
||||
message: "--model cannot be empty",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_PROVIDER",
|
||||
message: "Invalid --provider value",
|
||||
details: "Use --provider <provider> or --provider <provider>/<model>",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
const error: CommandError = {
|
||||
code: "CONFLICTING_MODEL_OPTIONS",
|
||||
message: "Conflicting model values provided",
|
||||
details: `--provider specifies model ${modelFromProvider}, but --model specifies ${modelInput}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
provider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
@@ -23,6 +23,11 @@ try {
|
||||
const createdJson = JSON.parse(created.stdout);
|
||||
assert.strictEqual(createdJson.name, "review-prs");
|
||||
assert.strictEqual(createdJson.cadence, "every:5m");
|
||||
assert(
|
||||
typeof createdJson.target === "string" &&
|
||||
(createdJson.target.startsWith("agent:") || createdJson.target === "new-agent:claude"),
|
||||
created.stdout,
|
||||
);
|
||||
|
||||
const listed = await ctx.paseo(["schedule", "ls", "--json"]);
|
||||
assert.strictEqual(listed.exitCode, 0, listed.stderr);
|
||||
@@ -53,6 +58,61 @@ try {
|
||||
console.log("schedule commands work\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 1b: schedule create accepts provider/model syntax for new-agent runs");
|
||||
const created = await ctx.paseo(
|
||||
[
|
||||
"schedule",
|
||||
"create",
|
||||
"Refactor the API layer",
|
||||
"--every",
|
||||
"10m",
|
||||
"--provider",
|
||||
"codex/gpt-5.4",
|
||||
"--json",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
assert.strictEqual(created.exitCode, 0, created.stderr);
|
||||
const createdJson = JSON.parse(created.stdout);
|
||||
assert.strictEqual(createdJson.target, "new-agent:codex/gpt-5.4");
|
||||
|
||||
const inspected = await ctx.paseo(["schedule", "inspect", createdJson.id, "--json"]);
|
||||
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
|
||||
const inspectedJson = JSON.parse(inspected.stdout);
|
||||
assert.strictEqual(inspectedJson.target.config.provider, "codex");
|
||||
assert.strictEqual(inspectedJson.target.config.model, "gpt-5.4");
|
||||
|
||||
const deleted = await ctx.paseo(["schedule", "delete", createdJson.id, "--json"]);
|
||||
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
|
||||
console.log("schedule provider/model syntax works\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 1c: schedule create rejects provider with self target");
|
||||
const result = await ctx.paseo(
|
||||
[
|
||||
"schedule",
|
||||
"create",
|
||||
"Conflicting schedule",
|
||||
"--every",
|
||||
"5m",
|
||||
"--target",
|
||||
"self",
|
||||
"--provider",
|
||||
"codex/gpt-5.4",
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
assert.notStrictEqual(result.exitCode, 0, "should fail for self target with provider");
|
||||
const output = result.stdout + result.stderr;
|
||||
assert(
|
||||
output.includes("--provider can only be used with a new-agent target"),
|
||||
"should explain provider target mismatch",
|
||||
);
|
||||
console.log("schedule rejects provider with self target\n");
|
||||
}
|
||||
|
||||
{
|
||||
console.log("Test 2: loop run/ls/inspect/logs/stop work");
|
||||
const run = await ctx.paseo(
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
AgentStatusEnum,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
sanitizePermissionRequest,
|
||||
serializeSnapshotWithMetadata,
|
||||
startAgentRun,
|
||||
@@ -81,13 +82,20 @@ export async function createAgentManagementMcpServer(
|
||||
component: "agent-management-mcp",
|
||||
});
|
||||
const waitTracker = new WaitForAgentTracker(logger);
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: AgentProvider; cwd?: string }) => ({
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: params?.provider ?? ("claude" as AgentProvider),
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
},
|
||||
});
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const server = new McpServer({
|
||||
name: "paseo-agent-management",
|
||||
@@ -656,7 +664,9 @@ export async function createAgentManagementMcpServer(
|
||||
cron: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
|
||||
),
|
||||
cwd: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
|
||||
@@ -478,6 +478,28 @@ describe("MCP parity end-to-end", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule accepts provider/model syntax", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(topLevelClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity provider schedule",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("inspect_schedule returns details", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
@@ -568,6 +590,27 @@ describe("MCP parity end-to-end", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule on agent MCP accepts provider/model override for new-agent", async () => {
|
||||
let scheduleId: string | null = null;
|
||||
try {
|
||||
const created = await callToolStructured(agentScopedClient, "create_schedule", {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
provider: "codex/gpt-5.4",
|
||||
});
|
||||
scheduleId = created.id as string;
|
||||
expect(created.target).toMatchObject({
|
||||
type: "new-agent",
|
||||
config: {
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await deleteScheduleIfPresent(scheduleId);
|
||||
}
|
||||
});
|
||||
|
||||
test("create_schedule target self without callerAgentId throws", async () => {
|
||||
await expectToolError(
|
||||
topLevelClient,
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
AgentStatusEnum,
|
||||
ProviderSummarySchema,
|
||||
parseDurationString,
|
||||
resolveProviderAndModel,
|
||||
sanitizePermissionRequest,
|
||||
setupFinishNotification,
|
||||
serializeSnapshotWithMetadata,
|
||||
@@ -230,16 +231,36 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
return expandUserPath(trimmedCwd);
|
||||
};
|
||||
|
||||
const resolveNewAgentScheduleTarget = () => {
|
||||
const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => {
|
||||
const callerAgent = resolveCallerAgent();
|
||||
if (callerAgent) {
|
||||
const hasProviderOverride = params?.provider !== undefined;
|
||||
const resolvedProviderModel = hasProviderOverride
|
||||
? resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: callerAgent.provider,
|
||||
})
|
||||
: null;
|
||||
const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider;
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: callerAgent.provider,
|
||||
cwd: callerAgent.cwd,
|
||||
...(callerAgent.currentModeId ? { modeId: callerAgent.currentModeId } : {}),
|
||||
...(callerAgent.config.model ? { model: callerAgent.config.model } : {}),
|
||||
provider: resolvedProvider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd,
|
||||
...(callerAgent.currentModeId
|
||||
? {
|
||||
modeId: mapModeAcrossProviders(
|
||||
callerAgent.currentModeId,
|
||||
callerAgent.provider,
|
||||
resolvedProvider,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(resolvedProviderModel?.model
|
||||
? { model: resolvedProviderModel.model }
|
||||
: !hasProviderOverride && callerAgent.config.model
|
||||
? { model: callerAgent.config.model }
|
||||
: {}),
|
||||
...(callerAgent.config.thinkingOptionId
|
||||
? { thinkingOptionId: callerAgent.config.thinkingOptionId }
|
||||
: {}),
|
||||
@@ -267,10 +288,17 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
|
||||
return {
|
||||
type: "new-agent" as const,
|
||||
config: {
|
||||
provider: "claude" as AgentProvider,
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
config: (() => {
|
||||
const resolvedProviderModel = resolveProviderAndModel({
|
||||
provider: params?.provider,
|
||||
defaultProvider: "claude",
|
||||
});
|
||||
return {
|
||||
provider: resolvedProviderModel.provider,
|
||||
cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : process.cwd(),
|
||||
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
|
||||
};
|
||||
})(),
|
||||
};
|
||||
};
|
||||
const agentToAgentInputSchema = {
|
||||
@@ -1181,12 +1209,16 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
cron: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
target: z.enum(["self", "new-agent"]).optional(),
|
||||
provider: AgentProviderEnum.optional().describe(
|
||||
"Provider, or provider/model (for example: codex or codex/gpt-5.4).",
|
||||
),
|
||||
cwd: z.string().optional(),
|
||||
maxRuns: z.number().int().positive().optional(),
|
||||
expiresIn: z.string().optional(),
|
||||
},
|
||||
outputSchema: ScheduleSummarySchema.shape,
|
||||
},
|
||||
async ({ prompt, every, cron, name, target, maxRuns, expiresIn }) => {
|
||||
async ({ prompt, every, cron, name, target, provider, cwd, maxRuns, expiresIn }) => {
|
||||
if (!scheduleService) {
|
||||
throw new Error("Schedule service is not configured");
|
||||
}
|
||||
@@ -1202,9 +1234,12 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
if (!callerAgentId) {
|
||||
throw new Error("target=self requires a caller agent");
|
||||
}
|
||||
if (provider !== undefined || cwd !== undefined) {
|
||||
throw new Error("provider and cwd can only be used with target=new-agent");
|
||||
}
|
||||
return { type: "agent" as const, agentId: callerAgentId };
|
||||
})()
|
||||
: resolveNewAgentScheduleTarget();
|
||||
: resolveNewAgentScheduleTarget({ provider, cwd });
|
||||
|
||||
const schedule = await scheduleService.create({
|
||||
prompt: prompt.trim(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { curateAgentActivity } from "./activity-curator.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "../schedule/types.js";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
|
||||
export const AgentProviderEnum = z.string();
|
||||
|
||||
@@ -48,6 +49,49 @@ export const AgentModelSchema = z.object({
|
||||
// 30 seconds - surface friendly message before SDK tool timeout (~60s)
|
||||
export const AGENT_WAIT_TIMEOUT_MS = 30000;
|
||||
|
||||
export interface ResolvedProviderModel {
|
||||
provider: AgentProvider;
|
||||
model: string | undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderAndModel(params: {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
defaultProvider: AgentProvider;
|
||||
}): ResolvedProviderModel {
|
||||
const providerInput = params.provider?.trim() || params.defaultProvider;
|
||||
const modelInput = params.model?.trim();
|
||||
|
||||
if (params.model !== undefined && !modelInput) {
|
||||
throw new Error("model cannot be empty");
|
||||
}
|
||||
|
||||
const slashIndex = providerInput.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return {
|
||||
provider: providerInput as AgentProvider,
|
||||
model: modelInput,
|
||||
};
|
||||
}
|
||||
|
||||
const provider = providerInput.slice(0, slashIndex).trim();
|
||||
const modelFromProvider = providerInput.slice(slashIndex + 1).trim();
|
||||
if (!provider || !modelFromProvider) {
|
||||
throw new Error("provider must be <provider> or <provider>/<model>");
|
||||
}
|
||||
|
||||
if (modelInput && modelInput !== modelFromProvider) {
|
||||
throw new Error(
|
||||
`Conflicting model values provided: provider specifies ${modelFromProvider}, but model specifies ${modelInput}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
provider: provider as AgentProvider,
|
||||
model: modelInput ?? modelFromProvider,
|
||||
};
|
||||
}
|
||||
|
||||
export type StartAgentRunOptions = {
|
||||
replaceRunning?: boolean;
|
||||
};
|
||||
|
||||
@@ -335,6 +335,42 @@ type FetchWorkspacesCursor = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
function summarizeFetchWorkspacesEntries(entries: Iterable<FetchWorkspacesResponseEntry>): {
|
||||
count: number;
|
||||
projectIds: string[];
|
||||
statusCounts: Record<string, number>;
|
||||
workspaces: Array<{
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectDisplayName: string;
|
||||
name: string;
|
||||
status: FetchWorkspacesResponseEntry["status"];
|
||||
workspaceKind: FetchWorkspacesResponseEntry["workspaceKind"];
|
||||
activityAt: string | null;
|
||||
}>;
|
||||
} {
|
||||
const workspaces = Array.from(entries, (entry) => ({
|
||||
id: entry.id,
|
||||
projectId: entry.projectId,
|
||||
projectDisplayName: entry.projectDisplayName,
|
||||
name: entry.name,
|
||||
status: entry.status,
|
||||
workspaceKind: entry.workspaceKind,
|
||||
activityAt: entry.activityAt,
|
||||
}));
|
||||
const statusCounts = new Map<string, number>();
|
||||
for (const workspace of workspaces) {
|
||||
statusCounts.set(workspace.status, (statusCounts.get(workspace.status) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
count: workspaces.length,
|
||||
projectIds: [...new Set(workspaces.map((workspace) => workspace.projectId))],
|
||||
statusCounts: Object.fromEntries(statusCounts),
|
||||
workspaces,
|
||||
};
|
||||
}
|
||||
|
||||
class SessionRequestError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
@@ -5730,7 +5766,9 @@ export class Session {
|
||||
const filter = request.filter;
|
||||
const sort = this.normalizeFetchWorkspacesSort(request.sort);
|
||||
let entries = await this.listWorkspaceDescriptors();
|
||||
const listedCount = entries.length;
|
||||
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }));
|
||||
const filteredCount = entries.length;
|
||||
entries.sort((left, right) => this.compareFetchWorkspacesEntries(left, right, sort));
|
||||
|
||||
const cursorToken = request.page?.cursor;
|
||||
@@ -5749,6 +5787,21 @@ export class Session {
|
||||
? this.encodeFetchWorkspacesCursor(pagedEntries[pagedEntries.length - 1], sort)
|
||||
: null;
|
||||
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
filter: request.filter ?? null,
|
||||
sort,
|
||||
page: request.page ?? null,
|
||||
listedCount,
|
||||
filteredCount,
|
||||
returnedCount: pagedEntries.length,
|
||||
hasMore,
|
||||
nextCursor,
|
||||
},
|
||||
"fetch_workspaces_entries_listed",
|
||||
);
|
||||
|
||||
return {
|
||||
entries: pagedEntries,
|
||||
pageInfo: {
|
||||
@@ -6031,6 +6084,16 @@ export class Session {
|
||||
: null;
|
||||
|
||||
try {
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
subscribeRequested: Boolean(request.subscribe),
|
||||
filter: request.filter ?? null,
|
||||
sort: request.sort ?? null,
|
||||
page: request.page ?? null,
|
||||
},
|
||||
"fetch_workspaces_request_received",
|
||||
);
|
||||
if (subscriptionId) {
|
||||
this.workspaceUpdatesSubscription = {
|
||||
subscriptionId,
|
||||
@@ -6042,6 +6105,15 @@ export class Session {
|
||||
}
|
||||
|
||||
const payload = await this.listFetchWorkspacesEntries(request);
|
||||
this.sessionLogger.debug(
|
||||
{
|
||||
requestId: request.requestId,
|
||||
subscriptionId,
|
||||
pageInfo: payload.pageInfo,
|
||||
payload: summarizeFetchWorkspacesEntries(payload.entries),
|
||||
},
|
||||
"fetch_workspaces_response_ready",
|
||||
);
|
||||
const snapshotLatestActivityByWorkspaceId = new Map<string, number>();
|
||||
for (const entry of payload.entries) {
|
||||
const parsedLatestActivity = entry.activityAt
|
||||
|
||||
Reference in New Issue
Block a user