mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
@@ -34,6 +34,17 @@ function recordArr(val: unknown): StructuredContent[] {
|
||||
return z.array(z.record(z.unknown())).parse(val);
|
||||
}
|
||||
|
||||
function expectAgentFeatureValue(snapshot: StructuredContent, featureId: string, value: unknown) {
|
||||
expect(recordArr(snapshot.features)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: featureId,
|
||||
value,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function strArrOptional(val: unknown): string[] | undefined {
|
||||
return z.array(z.string()).optional().parse(val);
|
||||
}
|
||||
@@ -325,6 +336,60 @@ describe("Suite A: Core Fixes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent accepts provider features over MCP", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createTopLevelAgent({ features: { test_feature: true } });
|
||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
||||
|
||||
const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId });
|
||||
const snapshot = z.record(z.unknown()).parse(status.snapshot);
|
||||
expectAgentFeatureValue(snapshot, "test_feature", true);
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("agent-scoped create_agent accepts provider features over MCP", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createChildAgent({
|
||||
provider: "claude/claude-test-model",
|
||||
features: { test_feature: true },
|
||||
});
|
||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
||||
|
||||
const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId });
|
||||
const snapshot = z.record(z.unknown()).parse(status.snapshot);
|
||||
expectAgentFeatureValue(snapshot, "test_feature", true);
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("set_agent_feature updates provider features over MCP", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
agentId = await createTopLevelAgent({ features: { test_feature: false } });
|
||||
const updated = await callToolStructured(topLevelClient, "set_agent_feature", {
|
||||
agentId,
|
||||
featureId: "test_feature",
|
||||
value: true,
|
||||
});
|
||||
expect(updated.success).toBe(true);
|
||||
const internalSnapshot = daemonHandle.daemon.agentManager.getAgent(agentId);
|
||||
expect(internalSnapshot?.config.featureValues).toEqual({ test_feature: true });
|
||||
|
||||
const status = await callToolStructured(topLevelClient, "get_agent_status", { agentId });
|
||||
const snapshot = z.record(z.unknown()).parse(status.snapshot);
|
||||
expectAgentFeatureValue(snapshot, "test_feature", true);
|
||||
} finally {
|
||||
await archiveAgentIfPresent(agentId);
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent accepts labels param", async () => {
|
||||
let agentId: string | null = null;
|
||||
try {
|
||||
@@ -480,6 +545,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity schedule list",
|
||||
provider: "claude",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
|
||||
@@ -527,6 +593,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity inspect schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
|
||||
@@ -551,6 +618,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity pause schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
|
||||
@@ -577,6 +645,7 @@ describe("Suite C: Schedule Tools", () => {
|
||||
prompt: "say hello",
|
||||
every: "5m",
|
||||
name: "Parity delete schedule",
|
||||
provider: "claude",
|
||||
});
|
||||
scheduleId = str(created.id);
|
||||
|
||||
|
||||
@@ -47,6 +47,20 @@ interface LooseStructuredContent {
|
||||
|
||||
interface RegisteredMcpTool {
|
||||
inputSchema: LooseInputSchema;
|
||||
callback?: (
|
||||
input: unknown,
|
||||
extra?: unknown,
|
||||
) => Promise<{
|
||||
structuredContent: LooseStructuredContent;
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
}>;
|
||||
handler?: (input: unknown) => Promise<{
|
||||
structuredContent: LooseStructuredContent;
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface RegisteredMcpToolWithHandler extends RegisteredMcpTool {
|
||||
handler: (input: unknown) => Promise<{
|
||||
structuredContent: LooseStructuredContent;
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
@@ -64,12 +78,16 @@ function lookupTool(
|
||||
function registeredTool(
|
||||
server: Awaited<ReturnType<typeof createAgentMcpServer>>,
|
||||
name: string,
|
||||
): RegisteredMcpTool {
|
||||
): RegisteredMcpToolWithHandler {
|
||||
const tool = lookupTool(server, name);
|
||||
if (!tool) {
|
||||
throw new Error(`MCP tool not registered: ${name}`);
|
||||
}
|
||||
return tool;
|
||||
const handler = tool.handler ?? tool.callback;
|
||||
if (!handler) {
|
||||
throw new Error(`MCP tool has no callable handler: ${name}`);
|
||||
}
|
||||
return { ...tool, handler };
|
||||
}
|
||||
|
||||
function agentsOf(response: {
|
||||
@@ -96,6 +114,7 @@ function buildAgentManagerSpies() {
|
||||
waitForAgentEvent: vi.fn(),
|
||||
recordUserMessage: vi.fn(),
|
||||
setAgentMode: vi.fn(),
|
||||
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
archiveAgent: vi.fn().mockResolvedValue({ archivedAt: new Date().toISOString() }),
|
||||
@@ -467,6 +486,44 @@ describe("create_agent MCP tool", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts provider features and passes them through createAgent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "feature-agent",
|
||||
cwd: REPO_CWD,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Feature test", featureValues: { fast_mode: true } },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const input = {
|
||||
cwd: existingCwd,
|
||||
title: "Feature test",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
features: { fast_mode: true },
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
expect(parsed.success).toBe(true);
|
||||
|
||||
await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("requires provider as provider/model and rejects the old model field", async () => {
|
||||
const { agentManager, agentStorage } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
@@ -1269,6 +1326,58 @@ describe("create_agent MCP tool", () => {
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("accepts provider features from caller agents and passes them through createAgent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
cwd: existingCwd,
|
||||
provider: "claude",
|
||||
currentModeId: "bypassPermissions",
|
||||
} as ManagedAgent);
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "child-agent",
|
||||
cwd: existingCwd,
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Child", featureValues: { fast_mode: true } },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
callerAgentId: "parent-agent",
|
||||
logger,
|
||||
});
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const input = {
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: true,
|
||||
features: { fast_mode: true },
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
expect(parsed.success).toBe(true);
|
||||
|
||||
await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: "parent-agent",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("delegates MCP injection to AgentManager and passes through an undefined agent ID", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
@@ -1465,6 +1574,29 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("set_agent_feature MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
it("sets a provider feature on an existing agent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = registeredTool(server, "set_agent_feature");
|
||||
const input = {
|
||||
agentId: "agent-1",
|
||||
featureId: "fast_mode",
|
||||
value: true,
|
||||
};
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync(input);
|
||||
expect(parsed.success).toBe(true);
|
||||
|
||||
const response = await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
||||
expect(response.structuredContent).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("create_schedule MCP tool", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
|
||||
@@ -489,6 +489,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"Required provider/model pair, for example codex/gpt-5.4.",
|
||||
),
|
||||
thinking: z.string().optional().describe("Thinking option ID"),
|
||||
features: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
@@ -531,6 +535,10 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
"Required provider/model pair, for example codex/gpt-5.4.",
|
||||
),
|
||||
thinking: z.string().optional().describe("Thinking option ID"),
|
||||
features: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe("Provider-specific feature values, for example { fast_mode: true } for Codex."),
|
||||
labels: z.record(z.string(), z.string()).optional().describe("Labels to set on the agent"),
|
||||
initialPrompt: z
|
||||
.string()
|
||||
@@ -630,6 +638,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
normalizedTitle: string | null;
|
||||
model: string | undefined;
|
||||
thinking: string | undefined;
|
||||
features: Record<string, unknown> | undefined;
|
||||
labels: Record<string, string> | undefined;
|
||||
notifyOnFinish: boolean;
|
||||
resolvedCwd: string;
|
||||
@@ -699,6 +708,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
normalizedTitle: callerArgs.title.trim(),
|
||||
model: resolvedProviderModel.model,
|
||||
thinking: callerArgs.thinking,
|
||||
features: callerArgs.features,
|
||||
labels: callerArgs.labels,
|
||||
notifyOnFinish: callerArgs.notifyOnFinish ?? false,
|
||||
resolvedCwd,
|
||||
@@ -772,6 +782,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
normalizedTitle: topLevelArgs.title.trim(),
|
||||
model: resolvedProviderModel.model,
|
||||
thinking: topLevelArgs.thinking,
|
||||
features: topLevelArgs.features,
|
||||
labels: topLevelArgs.labels,
|
||||
notifyOnFinish: topLevelArgs.notifyOnFinish ?? false,
|
||||
resolvedCwd,
|
||||
@@ -815,6 +826,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
normalizedTitle,
|
||||
model,
|
||||
thinking,
|
||||
features,
|
||||
labels,
|
||||
notifyOnFinish,
|
||||
resolvedCwd,
|
||||
@@ -836,6 +848,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
title: normalizedTitle ?? undefined,
|
||||
model,
|
||||
thinkingOptionId: thinking,
|
||||
featureValues: features,
|
||||
},
|
||||
undefined,
|
||||
Object.keys(mergedLabels).length > 0 ? { labels: mergedLabels } : undefined,
|
||||
@@ -923,6 +936,29 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"set_agent_feature",
|
||||
{
|
||||
title: "Set agent feature",
|
||||
description: "Set a provider-specific feature on an existing agent, such as Codex fast_mode.",
|
||||
inputSchema: {
|
||||
agentId: z.string(),
|
||||
featureId: z.string().trim().min(1),
|
||||
value: z.unknown(),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
},
|
||||
},
|
||||
async ({ agentId, featureId, value }) => {
|
||||
await agentManager.setAgentFeature(agentId, featureId, value);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ success: true }),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"wait_for_agent",
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
@@ -33,6 +34,25 @@ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||
|
||||
type CodexFeaturesTestSession = AgentSession;
|
||||
|
||||
interface CapturedLogEntry {
|
||||
level?: number;
|
||||
msg?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function createCapturedLogger(): { logger: pino.Logger; entries: CapturedLogEntry[] } {
|
||||
const entries: CapturedLogEntry[] = [];
|
||||
const logger = pino(
|
||||
{ level: "debug" },
|
||||
{
|
||||
write(line: string) {
|
||||
entries.push(JSON.parse(line) as CapturedLogEntry);
|
||||
},
|
||||
},
|
||||
);
|
||||
return { logger, entries };
|
||||
}
|
||||
|
||||
function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSessionConfig {
|
||||
return {
|
||||
provider: CODEX_PROVIDER,
|
||||
@@ -43,7 +63,10 @@ function createConfig(overrides: Partial<AgentSessionConfig> = {}): AgentSession
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {}): {
|
||||
function createSessionHarness(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
options: { logger?: pino.Logger } = {},
|
||||
): {
|
||||
session: CodexFeaturesTestSession;
|
||||
appServer: FakeCodexAppServer;
|
||||
} {
|
||||
@@ -54,17 +77,20 @@ function createSessionHarness(configOverrides: Partial<AgentSessionConfig> = {})
|
||||
const session = new __codexAppServerInternals.CodexAppServerAgentSession(
|
||||
{ ...config, provider: CODEX_PROVIDER },
|
||||
null,
|
||||
createTestLogger(),
|
||||
options.logger ?? createTestLogger(),
|
||||
async () => appServer.child,
|
||||
) as CodexFeaturesTestSession;
|
||||
return { session, appServer };
|
||||
}
|
||||
|
||||
async function createConnectedSession(configOverrides: Partial<AgentSessionConfig> = {}): Promise<{
|
||||
async function createConnectedSession(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
options: { logger?: pino.Logger } = {},
|
||||
): Promise<{
|
||||
session: CodexFeaturesTestSession;
|
||||
appServer: FakeCodexAppServer;
|
||||
}> {
|
||||
const harness = createSessionHarness(configOverrides);
|
||||
const harness = createSessionHarness(configOverrides, options);
|
||||
await harness.session.connect();
|
||||
harness.appServer.assertNoErrors();
|
||||
return harness;
|
||||
@@ -136,6 +162,30 @@ describe("Codex app-server provider features", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("constructor ignores restored fast mode when model does not support it", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model: "gpt-3.5-turbo",
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({
|
||||
serviceTier: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', true) sets serviceTier to fast", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
@@ -160,6 +210,14 @@ describe("Codex app-server provider features", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', true) rejects models that do not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
||||
"Codex fast mode is not available for model 'gpt-3.5-turbo'",
|
||||
);
|
||||
});
|
||||
|
||||
test("setFeature invalidates runtime info", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
@@ -228,6 +286,30 @@ describe("Codex app-server provider features", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
|
||||
const capture = createCapturedLogger();
|
||||
const prompt = "secret prompt text should not be logged";
|
||||
const { session } = await createConnectedSession(
|
||||
{ featureValues: { fast_mode: true } },
|
||||
{ logger: capture.logger },
|
||||
);
|
||||
|
||||
await session.startTurn(prompt);
|
||||
|
||||
const entry = capture.entries.find(
|
||||
(candidate) => candidate.msg === "Starting Codex app-server turn",
|
||||
);
|
||||
expect(entry).toMatchObject({
|
||||
level: 30,
|
||||
msg: "Starting Codex app-server turn",
|
||||
model: "gpt-5.4",
|
||||
modeId: "auto",
|
||||
serviceTier: "fast",
|
||||
cwd: "/tmp/codex-fast-mode-test",
|
||||
});
|
||||
expect(JSON.stringify(entry)).not.toContain(prompt);
|
||||
});
|
||||
|
||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
|
||||
@@ -2714,7 +2714,7 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
this.currentMode = config.modeId;
|
||||
this.config = config;
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
if (this.config.featureValues?.fast_mode) {
|
||||
if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) {
|
||||
this.serviceTier = "fast";
|
||||
}
|
||||
if (this.config.featureValues?.plan_mode) {
|
||||
@@ -3109,50 +3109,18 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return args ? `$${commandName} ${args}` : `$${commandName}`;
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
return runProviderTurn({
|
||||
prompt,
|
||||
runOptions: options,
|
||||
startTurn: (p, o) => this.startTurn(p, o),
|
||||
subscribe: (callback) => this.subscribe(callback),
|
||||
getSessionId: async () => (await this.getRuntimeInfo()).sessionId ?? "",
|
||||
reduceFinalText: ({ current, item }) => {
|
||||
if (item.type === "assistant_message") {
|
||||
return item.text;
|
||||
}
|
||||
if (item.type === "tool_call" && item.detail.type === "plan") {
|
||||
return item.detail.text;
|
||||
}
|
||||
return current;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async startTurn(
|
||||
prompt: AgentPromptInput,
|
||||
private async buildTurnStartParams(
|
||||
prompt: CodexPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
if (this.activeForegroundTurnId) {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client not initialized");
|
||||
}
|
||||
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: prompt;
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
|
||||
const input = await this.buildUserInput(effectivePrompt);
|
||||
): Promise<{
|
||||
params: Record<string, unknown>;
|
||||
thinkingOptionId?: string;
|
||||
approvalPolicy: string;
|
||||
sandboxPolicyType: string;
|
||||
hasOutputSchema: boolean;
|
||||
hasCodexConfig: boolean;
|
||||
}> {
|
||||
const input = await this.buildUserInput(prompt);
|
||||
const preset = MODE_PRESETS[this.currentMode] ?? MODE_PRESETS[DEFAULT_CODEX_MODE_ID];
|
||||
const approvalPolicy = this.config.approvalPolicy ?? preset.approvalPolicy;
|
||||
const sandboxPolicyType = this.config.sandboxMode ?? preset.sandbox;
|
||||
@@ -3200,11 +3168,109 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
params.config = codexConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
params,
|
||||
thinkingOptionId,
|
||||
approvalPolicy,
|
||||
sandboxPolicyType,
|
||||
hasOutputSchema: Boolean(options?.outputSchema),
|
||||
hasCodexConfig: Boolean(codexConfig),
|
||||
};
|
||||
}
|
||||
|
||||
private logTurnStartSummary({
|
||||
turnId,
|
||||
thinkingOptionId,
|
||||
approvalPolicy,
|
||||
sandboxPolicyType,
|
||||
hasOutputSchema,
|
||||
hasCodexConfig,
|
||||
}: {
|
||||
turnId: string;
|
||||
thinkingOptionId?: string;
|
||||
approvalPolicy: string;
|
||||
sandboxPolicyType: string;
|
||||
hasOutputSchema: boolean;
|
||||
hasCodexConfig: boolean;
|
||||
}): void {
|
||||
this.logger.info(
|
||||
{
|
||||
turnId,
|
||||
threadId: this.currentThreadId,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.currentMode ?? null,
|
||||
effort: thinkingOptionId ?? null,
|
||||
serviceTier: this.serviceTier,
|
||||
cwd: this.config.cwd ?? null,
|
||||
approvalPolicy,
|
||||
sandboxPolicyType,
|
||||
hasCollaborationMode: Boolean(this.resolvedCollaborationMode),
|
||||
hasOutputSchema,
|
||||
hasDeveloperInstructions: Boolean(this.config.systemPrompt?.trim()),
|
||||
hasCodexConfig,
|
||||
},
|
||||
"Starting Codex app-server turn",
|
||||
);
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
return runProviderTurn({
|
||||
prompt,
|
||||
runOptions: options,
|
||||
startTurn: (p, o) => this.startTurn(p, o),
|
||||
subscribe: (callback) => this.subscribe(callback),
|
||||
getSessionId: async () => (await this.getRuntimeInfo()).sessionId ?? "",
|
||||
reduceFinalText: ({ current, item }) => {
|
||||
if (item.type === "assistant_message") {
|
||||
return item.text;
|
||||
}
|
||||
if (item.type === "tool_call" && item.detail.type === "plan") {
|
||||
return item.detail.text;
|
||||
}
|
||||
return current;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async startTurn(
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
if (this.activeForegroundTurnId) {
|
||||
throw new Error("A foreground turn is already active");
|
||||
}
|
||||
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client not initialized");
|
||||
}
|
||||
|
||||
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
|
||||
const effectivePrompt = slashCommand
|
||||
? await this.buildCommandPromptInput(slashCommand.commandName, slashCommand.args)
|
||||
: prompt;
|
||||
|
||||
if (this.currentThreadId) {
|
||||
await this.ensureThreadLoaded();
|
||||
} else {
|
||||
await this.ensureThread();
|
||||
}
|
||||
|
||||
const turnStart = await this.buildTurnStartParams(effectivePrompt, options);
|
||||
|
||||
const turnId = this.createTurnId();
|
||||
this.activeForegroundTurnId = turnId;
|
||||
|
||||
try {
|
||||
await this.client.request("turn/start", params, TURN_START_TIMEOUT_MS);
|
||||
this.logTurnStartSummary({
|
||||
turnId,
|
||||
thinkingOptionId: turnStart.thinkingOptionId,
|
||||
approvalPolicy: turnStart.approvalPolicy,
|
||||
sandboxPolicyType: turnStart.sandboxPolicyType,
|
||||
hasOutputSchema: turnStart.hasOutputSchema,
|
||||
hasCodexConfig: turnStart.hasCodexConfig,
|
||||
});
|
||||
await this.client.request("turn/start", turnStart.params, TURN_START_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
this.activeForegroundTurnId = null;
|
||||
throw error;
|
||||
@@ -3288,6 +3354,11 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (featureId === "fast_mode") {
|
||||
if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) {
|
||||
throw new Error(
|
||||
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
}
|
||||
this.applyFeatureValue("fast_mode", Boolean(value));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -32,6 +33,8 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
const TEST_FEATURE_ID = "test_feature";
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
@@ -325,6 +328,18 @@ class FakeAgentSession implements AgentSession {
|
||||
return this.providerName;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return [
|
||||
{
|
||||
type: "toggle",
|
||||
id: TEST_FEATURE_ID,
|
||||
label: "Test feature",
|
||||
description: "Deterministic provider feature used by MCP integration tests.",
|
||||
value: this.config.featureValues?.[TEST_FEATURE_ID] === true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private async appendHistoryEvent(event: AgentStreamEvent): Promise<void> {
|
||||
const folder = path.dirname(this.historyPath);
|
||||
await mkdir(folder, { recursive: true });
|
||||
@@ -786,6 +801,13 @@ class FakeAgentSession implements AgentSession {
|
||||
this.config.modeId = modeId;
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[featureId]: value,
|
||||
};
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return this.pendingPermissions;
|
||||
}
|
||||
|
||||
@@ -15,19 +15,20 @@ The MCP server itself is controlled by `daemon.mcp.enabled`. Existing agents may
|
||||
|
||||
### Agents
|
||||
|
||||
| Tool | Function |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt or a new git worktree. |
|
||||
| `wait_for_agent` | Block until an agent requests permission or finishes its current run. |
|
||||
| `send_agent_prompt` | Send a task to a running agent. |
|
||||
| `get_agent_status` | Return the latest snapshot for an agent. |
|
||||
| `list_agents` | List recent agents as compact metadata. |
|
||||
| `cancel_agent` | Abort an agent's current run but keep the agent alive. |
|
||||
| `archive_agent` | Soft-delete an agent and remove it from the active list. |
|
||||
| `kill_agent` | Terminate an agent session permanently. |
|
||||
| `update_agent` | Update an agent name or labels. |
|
||||
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
|
||||
| `set_agent_mode` | Switch an agent's session mode. |
|
||||
| Tool | Function |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `create_agent` | Create an agent tied to a working directory, optionally with an initial prompt, provider features, or a new git worktree. |
|
||||
| `wait_for_agent` | Block until an agent requests permission or finishes its current run. |
|
||||
| `send_agent_prompt` | Send a task to a running agent. |
|
||||
| `get_agent_status` | Return the latest snapshot for an agent. |
|
||||
| `list_agents` | List recent agents as compact metadata. |
|
||||
| `cancel_agent` | Abort an agent's current run but keep the agent alive. |
|
||||
| `archive_agent` | Soft-delete an agent and remove it from the active list. |
|
||||
| `kill_agent` | Terminate an agent session permanently. |
|
||||
| `update_agent` | Update an agent name or labels. |
|
||||
| `get_agent_activity` | Return recent agent timeline entries as a curated summary. |
|
||||
| `set_agent_mode` | Switch an agent's session mode. |
|
||||
| `set_agent_feature` | Set a provider-specific feature on an existing agent, for example Codex `fast_mode`. |
|
||||
|
||||
### Terminals
|
||||
|
||||
|
||||
@@ -20,12 +20,16 @@ Returns `{ branchName, worktreePath }`. Pass `cwd` to target a specific repo.
|
||||
|
||||
## Agents
|
||||
|
||||
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`. Returns `{ agentId, … }`.
|
||||
**`create_agent`** — required: `title`, `provider` (`claude/opus`, `codex/gpt-5.4`, …), `initialPrompt`. Common: `cwd` (often a `worktreePath`), `background` (default `false` — blocks until completion or permission), `notifyOnFinish`, `features`. Returns `{ agentId, … }`.
|
||||
|
||||
Provider features are provider-specific. For Codex fast mode, pass `features: { "fast_mode": true }` when creating the agent.
|
||||
|
||||
Compose: call `create_worktree` first, then `create_agent` with `cwd` set to the returned `worktreePath`.
|
||||
|
||||
**`send_agent_prompt`** — `{ agentId, prompt }`. Blocks by default; pass `background: true` to fire-and-forget.
|
||||
|
||||
**`set_agent_feature`** — `{ agentId, featureId, value }`. Use for provider-specific toggles on an existing agent, for example `{ agentId, featureId: "fast_mode", value: true }` for Codex.
|
||||
|
||||
**`list_agents`** — filter by `cwd`, `statuses`, `sinceHours`, `includeArchived`.
|
||||
|
||||
**`archive_agent`** — `{ agentId }`. Interrupts if running, removes from active list.
|
||||
|
||||
Reference in New Issue
Block a user