mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'plan/structured-agent-self-id'
This commit is contained in:
@@ -95,34 +95,6 @@ async function waitForAssistantText(page: Page, text: string) {
|
||||
return assistantMessage;
|
||||
}
|
||||
|
||||
async function waitForAssistantTextWithPermissions(
|
||||
page: Page,
|
||||
text: string,
|
||||
timeoutMs = 60000
|
||||
) {
|
||||
const start = Date.now();
|
||||
const assistantMessage = page
|
||||
.getByTestId('assistant-message')
|
||||
.filter({ hasText: text })
|
||||
.last();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
if (await assistantMessage.isVisible()) {
|
||||
return assistantMessage;
|
||||
}
|
||||
const allowButton = page.getByText('Allow', { exact: true }).first();
|
||||
if (await allowButton.isVisible()) {
|
||||
try {
|
||||
await allowButton.click({ force: true, timeout: 1000 });
|
||||
} catch {
|
||||
// Button can detach during animation; retry on next loop.
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
throw new Error(`Timed out waiting for assistant text: ${text}`);
|
||||
}
|
||||
|
||||
async function createAgentAndWait(page: Page, message: string) {
|
||||
const input = page.getByRole('textbox', { name: 'Message agent...' });
|
||||
await expect(input).toBeEditable();
|
||||
@@ -250,18 +222,8 @@ test('checkout-first Changes panel ship loop', async ({ page }) => {
|
||||
const secondCwd = await requestCwd(page);
|
||||
expect(secondCwd).toBe(firstCwd);
|
||||
|
||||
await sendPrompt(
|
||||
page,
|
||||
'Only call MCP tools set_title("E2E Ship Loop") and set_branch("feat/e2e-ship-loop"). Do not run bash or other tools. Then respond with exactly: OK'
|
||||
);
|
||||
await waitForAssistantTextWithPermissions(page, 'OK', 60000);
|
||||
await expect(page.getByText('E2E Ship Loop', { exact: true }).first()).toBeVisible();
|
||||
|
||||
await openChangesPanel(page);
|
||||
await expect.poll(
|
||||
async () => (await getChangesScope(page).getByTestId('changes-branch').innerText()).trim(),
|
||||
{ timeout: 60000 }
|
||||
).toBe('feat/e2e-ship-loop');
|
||||
await sendPrompt(page, "Respond with exactly: OK");
|
||||
await waitForAssistantText(page, "OK");
|
||||
|
||||
const readmePath = path.join(firstCwd, 'README.md');
|
||||
await appendFile(readmePath, '\nFirst change\n');
|
||||
|
||||
@@ -1163,8 +1163,6 @@ const TOOL_NAME_MAP: Record<string, string> = {
|
||||
read_file: "Read",
|
||||
apply_patch: "Edit",
|
||||
paseo_worktree_setup: "Setup",
|
||||
set_title: "Set title",
|
||||
set_branch: "Set branch",
|
||||
thinking: "Thinking",
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Command } from 'commander'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createAgentCommand } from './commands/agent/index.js'
|
||||
import { createDaemonCommand } from './commands/daemon/index.js'
|
||||
import { createPermitCommand } from './commands/permit/index.js'
|
||||
@@ -15,7 +13,6 @@ import { runInspectCommand } from './commands/agent/inspect.js'
|
||||
import { runWaitCommand } from './commands/agent/wait.js'
|
||||
import { runAttachCommand } from './commands/agent/attach.js'
|
||||
import { withOutput } from './output/index.js'
|
||||
import { runSelfIdBridge } from '@paseo/server/self-id-bridge'
|
||||
|
||||
const VERSION = '0.1.0'
|
||||
|
||||
@@ -140,20 +137,5 @@ export function createCli(): Command {
|
||||
// Worktree commands
|
||||
program.addCommand(createWorktreeCommand())
|
||||
|
||||
// Self-ID bridge command (for internal use by agents to call set_title/set_branch)
|
||||
program
|
||||
.command('self-id-bridge')
|
||||
.description('Stdio-to-HTTP bridge for Agent Self-ID MCP (internal use)')
|
||||
.option('--socket <path>', 'Unix socket path', join(process.env.PASEO_HOME ?? join(homedir(), '.paseo'), 'self-id-mcp.sock'))
|
||||
.option('--agent-id <id>', 'Caller agent ID')
|
||||
.option('--debug', 'Enable debug logging to stderr')
|
||||
.action(async (options) => {
|
||||
await runSelfIdBridge({
|
||||
socketPath: options.socket,
|
||||
agentId: options.agentId,
|
||||
debug: options.debug,
|
||||
})
|
||||
})
|
||||
|
||||
return program
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/server/exports.ts",
|
||||
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts",
|
||||
"./self-id-bridge": "./src/self-id-bridge/index.ts"
|
||||
"./utils/tool-call-parsers": "./src/utils/tool-call-parsers.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "NODE_ENV=development tsx scripts/dev-runner.ts",
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
/**
|
||||
* Agent Self-ID Bridge
|
||||
*
|
||||
* Bridges stdio MCP transport to HTTP-over-Unix-socket transport.
|
||||
* This allows coding agents (which only support stdio or HTTP MCP) to
|
||||
* call set_title and set_branch on the Paseo daemon.
|
||||
*
|
||||
* Architecture:
|
||||
* Coding Agent (Claude Code / Codex)
|
||||
* |
|
||||
* | stdio (newline-delimited JSON-RPC)
|
||||
* v
|
||||
* paseo self-id-bridge (this module)
|
||||
* |
|
||||
* | HTTP over Unix socket (${PASEO_HOME}/self-id-mcp.sock)
|
||||
* v
|
||||
* Paseo Daemon (Agent Self-ID MCP Server)
|
||||
*/
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import http from "node:http";
|
||||
|
||||
export interface SelfIdBridgeOptions {
|
||||
socketPath: string;
|
||||
agentId?: string;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
method: string;
|
||||
params?: unknown;
|
||||
id?: string | number | null;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: "2.0";
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string; data?: unknown };
|
||||
id: string | number | null;
|
||||
}
|
||||
|
||||
function log(debug: boolean, ...args: unknown[]): void {
|
||||
if (debug) {
|
||||
console.error("[self-id-bridge]", ...args);
|
||||
}
|
||||
}
|
||||
|
||||
function makeHttpRequest(
|
||||
socketPath: string,
|
||||
urlPath: string,
|
||||
body: string,
|
||||
headers: Record<string, string>
|
||||
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath,
|
||||
path: urlPath,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(body),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
status: res.statusCode ?? 500,
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks).toString("utf-8"),
|
||||
});
|
||||
});
|
||||
res.on("error", reject);
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function writeResponse(response: JsonRpcResponse): void {
|
||||
const line = JSON.stringify(response);
|
||||
process.stdout.write(line + "\n");
|
||||
}
|
||||
|
||||
function writeError(id: string | number | null, code: number, message: string): void {
|
||||
writeResponse({
|
||||
jsonrpc: "2.0",
|
||||
error: { code, message },
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runSelfIdBridge(options: SelfIdBridgeOptions): Promise<void> {
|
||||
const { socketPath, agentId, debug = false } = options;
|
||||
|
||||
log(debug, `Starting Self-ID bridge to ${socketPath}`);
|
||||
if (agentId) {
|
||||
log(debug, `Agent ID: ${agentId}`);
|
||||
}
|
||||
|
||||
let mcpSessionId: string | null = null;
|
||||
let protocolVersion: string | null = null;
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let request: JsonRpcRequest;
|
||||
try {
|
||||
request = JSON.parse(line);
|
||||
} catch {
|
||||
log(debug, "Failed to parse JSON:", line);
|
||||
writeError(null, -32700, "Parse error");
|
||||
continue;
|
||||
}
|
||||
|
||||
log(debug, "Request:", request.method, request.id);
|
||||
|
||||
// Build headers
|
||||
const headers: Record<string, string> = {
|
||||
"Accept": "application/json, text/event-stream",
|
||||
};
|
||||
if (mcpSessionId) {
|
||||
headers["mcp-session-id"] = mcpSessionId;
|
||||
}
|
||||
if (protocolVersion && request.method !== "initialize") {
|
||||
headers["mcp-protocol-version"] = protocolVersion;
|
||||
}
|
||||
|
||||
// Build URL with callerAgentId if provided
|
||||
let path = "/";
|
||||
if (agentId) {
|
||||
path = `/?callerAgentId=${encodeURIComponent(agentId)}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await makeHttpRequest(
|
||||
socketPath,
|
||||
path,
|
||||
JSON.stringify(request),
|
||||
headers
|
||||
);
|
||||
|
||||
log(debug, "Response status:", response.status);
|
||||
|
||||
// Check for session ID in response headers
|
||||
const newSessionId = response.headers["mcp-session-id"];
|
||||
if (typeof newSessionId === "string" && newSessionId !== mcpSessionId) {
|
||||
mcpSessionId = newSessionId;
|
||||
log(debug, "Session ID:", mcpSessionId);
|
||||
}
|
||||
|
||||
// Handle content type
|
||||
const contentType = response.headers["content-type"] ?? "";
|
||||
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
// SSE response - parse events and write each as a line
|
||||
const events = parseSSE(response.body);
|
||||
for (const event of events) {
|
||||
if (event.data) {
|
||||
process.stdout.write(event.data + "\n");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// JSON response - write as-is
|
||||
const jsonResponse = JSON.parse(response.body) as JsonRpcResponse;
|
||||
|
||||
// Extract protocol version from initialize response
|
||||
if (request.method === "initialize" && jsonResponse.result) {
|
||||
const result = jsonResponse.result as { protocolVersion?: string };
|
||||
if (result.protocolVersion) {
|
||||
protocolVersion = result.protocolVersion;
|
||||
log(debug, "Protocol version:", protocolVersion);
|
||||
}
|
||||
}
|
||||
|
||||
writeResponse(jsonResponse);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
log(debug, "HTTP error:", message);
|
||||
|
||||
// Check if it's a connection error
|
||||
if (message.includes("ENOENT") || message.includes("ECONNREFUSED")) {
|
||||
writeError(
|
||||
request.id ?? null,
|
||||
-32603,
|
||||
`Paseo daemon unreachable at ${socketPath}. Is the daemon running?`
|
||||
);
|
||||
} else {
|
||||
writeError(request.id ?? null, -32603, `Internal error: ${message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log(debug, "stdin closed, exiting");
|
||||
}
|
||||
|
||||
interface SSEEvent {
|
||||
event?: string;
|
||||
data?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
function parseSSE(body: string): SSEEvent[] {
|
||||
const events: SSEEvent[] = [];
|
||||
let currentEvent: SSEEvent = {};
|
||||
let dataLines: string[] = [];
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
if (line === "") {
|
||||
// End of event
|
||||
if (dataLines.length > 0) {
|
||||
currentEvent.data = dataLines.join("\n");
|
||||
}
|
||||
if (Object.keys(currentEvent).length > 0) {
|
||||
events.push(currentEvent);
|
||||
}
|
||||
currentEvent = {};
|
||||
dataLines = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith(":")) {
|
||||
// Comment, ignore
|
||||
continue;
|
||||
}
|
||||
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex === -1) {
|
||||
// Field with no value
|
||||
continue;
|
||||
}
|
||||
|
||||
const field = line.slice(0, colonIndex);
|
||||
let value = line.slice(colonIndex + 1);
|
||||
if (value.startsWith(" ")) {
|
||||
value = value.slice(1);
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
case "event":
|
||||
currentEvent.event = value;
|
||||
break;
|
||||
case "data":
|
||||
dataLines.push(value);
|
||||
break;
|
||||
case "id":
|
||||
currentEvent.id = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle final event if no trailing newline
|
||||
if (dataLines.length > 0) {
|
||||
currentEvent.data = dataLines.join("\n");
|
||||
}
|
||||
if (Object.keys(currentEvent).length > 0) {
|
||||
events.push(currentEvent);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createWorktree } from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
|
||||
export interface AgentManagementMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
@@ -345,16 +345,20 @@ export async function createAgentManagementMcpServer(
|
||||
title: normalizedTitle ?? undefined,
|
||||
});
|
||||
|
||||
if (initialPrompt) {
|
||||
const initialPromptWithInstructions = injectLeadingPaseoInstructionTag(
|
||||
initialPrompt,
|
||||
snapshot.config.paseoPromptInstructions
|
||||
);
|
||||
const trimmedPrompt = initialPrompt?.trim();
|
||||
if (trimmedPrompt) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
initialPrompt: trimmedPrompt,
|
||||
explicitTitle: normalizedTitle ?? undefined,
|
||||
paseoHome: options.paseoHome,
|
||||
logger: childLogger,
|
||||
});
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(
|
||||
snapshot.id,
|
||||
initialPromptWithInstructions
|
||||
);
|
||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId: snapshot.id },
|
||||
@@ -363,12 +367,7 @@ export async function createAgentManagementMcpServer(
|
||||
}
|
||||
|
||||
try {
|
||||
startAgentRun(
|
||||
agentManager,
|
||||
snapshot.id,
|
||||
initialPromptWithInstructions,
|
||||
childLogger
|
||||
);
|
||||
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
||||
|
||||
if (!background) {
|
||||
const result = await waitForAgentWithTimeout(
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type AgentLifecycleStatus,
|
||||
} from "../../shared/agent-lifecycle.js";
|
||||
import type { Logger } from "pino";
|
||||
import { getSelfIdentificationInstructions } from "./self-identification-instructions.js";
|
||||
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
@@ -59,8 +58,6 @@ export type AgentManagerOptions = {
|
||||
registry?: AgentStorage;
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
logger: Logger;
|
||||
/** Path to the Self-ID MCP Unix socket for UI agent injection */
|
||||
selfIdMcpSocketPath?: string;
|
||||
};
|
||||
|
||||
export type WaitForAgentOptions = {
|
||||
@@ -213,7 +210,6 @@ export class AgentManager {
|
||||
private readonly registry?: AgentStorage;
|
||||
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly selfIdMcpSocketPath?: string;
|
||||
private onAgentAttention?: AgentAttentionCallback;
|
||||
private logger: Logger;
|
||||
|
||||
@@ -222,7 +218,6 @@ export class AgentManager {
|
||||
options?.maxTimelineItems ?? DEFAULT_MAX_TIMELINE_ITEMS;
|
||||
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
||||
this.registry = options?.registry;
|
||||
this.selfIdMcpSocketPath = options?.selfIdMcpSocketPath;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
||||
if (options?.clients) {
|
||||
@@ -1261,7 +1256,7 @@ export class AgentManager {
|
||||
|
||||
private async normalizeConfig(
|
||||
config: AgentSessionConfig,
|
||||
options?: { labels?: Record<string, string>; agentId?: string }
|
||||
_options?: { labels?: Record<string, string>; agentId?: string }
|
||||
): Promise<AgentSessionConfig> {
|
||||
const normalized: AgentSessionConfig = { ...config };
|
||||
|
||||
@@ -1275,31 +1270,6 @@ export class AgentManager {
|
||||
normalized.model = trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
// Inject paseoPromptInstructions and MCP config for UI agents (with ui=true label)
|
||||
const isUiAgent = options?.labels?.ui === "true";
|
||||
if (isUiAgent) {
|
||||
normalized.paseoPromptInstructions = getSelfIdentificationInstructions({
|
||||
cwd: normalized.cwd,
|
||||
});
|
||||
|
||||
// Inject Self-ID MCP server config (stdio bridge to self-id-mcp.sock)
|
||||
if (this.selfIdMcpSocketPath && options?.agentId) {
|
||||
const existingMcpServers = normalized.mcpServers ?? {};
|
||||
normalized.mcpServers = {
|
||||
...existingMcpServers,
|
||||
"paseo-self-id": {
|
||||
type: "stdio",
|
||||
command: "paseo",
|
||||
args: [
|
||||
"self-id-bridge",
|
||||
"--socket", this.selfIdMcpSocketPath,
|
||||
"--agent-id", options.agentId,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,6 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
const daemonConfig: PaseoDaemonConfig = {
|
||||
listen: `127.0.0.1:${port}`,
|
||||
paseoHome,
|
||||
selfIdMcpSocketPath: path.join(paseoHome, "self-id-mcp.sock"),
|
||||
corsAllowedOrigins: [],
|
||||
agentMcpRoute: "/mcp/agents",
|
||||
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`],
|
||||
@@ -150,4 +149,3 @@ describe("agent MCP end-to-end (offline)", () => {
|
||||
30_000
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync, realpathSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import pino from "pino";
|
||||
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createAllClients, shutdownProviders } from "./provider-registry.js";
|
||||
import { generateAndApplyAgentMetadata } from "./agent-metadata-generator.js";
|
||||
import { createWorktree, validateBranchSlug } from "../../utils/worktree.js";
|
||||
|
||||
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
|
||||
const CODEX_TEST_REASONING_EFFORT = "low";
|
||||
|
||||
function tmpCwd(prefix: string): string {
|
||||
return realpathSync(mkdtempSync(path.join(tmpdir(), prefix)));
|
||||
}
|
||||
|
||||
function initGitRepo(repoDir: string): void {
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'paseo-test@example.com'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execSync("git config user.name 'Paseo Test'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
writeFileSync(path.join(repoDir, "README.md"), "init\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git -c commit.gpgsign=false commit -m 'Initial commit'", {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
}
|
||||
|
||||
describe("agent metadata generation (real agents)", () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
let repoDir: string;
|
||||
let paseoHome: string;
|
||||
let storagePath: string;
|
||||
let manager: AgentManager;
|
||||
let storage: AgentStorage;
|
||||
let codexSessionDir: string;
|
||||
let previousCodexSessionDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
repoDir = tmpCwd("metadata-repo-");
|
||||
initGitRepo(repoDir);
|
||||
paseoHome = tmpCwd("metadata-paseo-home-");
|
||||
storagePath = path.join(paseoHome, "agents");
|
||||
storage = new AgentStorage(storagePath, logger);
|
||||
manager = new AgentManager({
|
||||
clients: createAllClients(logger),
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
codexSessionDir = tmpCwd("codex-sessions-");
|
||||
previousCodexSessionDir = process.env.CODEX_SESSION_DIR;
|
||||
process.env.CODEX_SESSION_DIR = codexSessionDir;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CODEX_SESSION_DIR = previousCodexSessionDir;
|
||||
await shutdownProviders(logger);
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
rmSync(codexSessionDir, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
test(
|
||||
"generates a title using a real Codex agent",
|
||||
async () => {
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
modeId: "auto",
|
||||
cwd: repoDir,
|
||||
title: "Main Agent",
|
||||
}, "metadata-title-agent");
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: repoDir,
|
||||
initialPrompt: "Use the exact title 'Metadata Title E2E'.",
|
||||
explicitTitle: null,
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
await storage.flush();
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.title).toBe("Metadata Title E2E");
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
},
|
||||
180000
|
||||
);
|
||||
|
||||
test(
|
||||
"renames the worktree branch using a real Codex agent",
|
||||
async () => {
|
||||
const worktreeSlug = "metadata-worktree";
|
||||
const worktree = await createWorktree({
|
||||
branchName: worktreeSlug,
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug,
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
modeId: "auto",
|
||||
cwd: worktree.worktreePath,
|
||||
title: "Worktree Agent",
|
||||
}, "metadata-branch-agent");
|
||||
|
||||
await generateAndApplyAgentMetadata({
|
||||
agentManager: manager,
|
||||
agentId: agent.id,
|
||||
cwd: worktree.worktreePath,
|
||||
initialPrompt: "Use the exact branch 'feat/metadata-worktree'.",
|
||||
explicitTitle: "Explicit Title",
|
||||
paseoHome,
|
||||
logger,
|
||||
});
|
||||
|
||||
const currentBranch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
}).toString().trim();
|
||||
|
||||
const validation = validateBranchSlug(currentBranch);
|
||||
expect(validation.valid).toBe(true);
|
||||
expect(currentBranch).toBe("feat/metadata-worktree");
|
||||
|
||||
await manager.closeAgent(agent.id);
|
||||
},
|
||||
180000
|
||||
);
|
||||
});
|
||||
248
packages/server/src/server/agent/agent-metadata-generator.ts
Normal file
248
packages/server/src/server/agent/agent-metadata-generator.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { basename } from "path";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import {
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponse,
|
||||
} from "./agent-response-loop.js";
|
||||
import { validateBranchSlug } from "../../utils/worktree.js";
|
||||
import {
|
||||
getCheckoutStatus,
|
||||
renameCurrentBranch,
|
||||
type CheckoutStatusResult,
|
||||
} from "../../utils/checkout-git.js";
|
||||
|
||||
const AUTO_GEN_PROVIDER = "codex" as const;
|
||||
const AUTO_GEN_MODEL = "gpt-5.1-codex-mini";
|
||||
const AUTO_GEN_REASONING_EFFORT = "low";
|
||||
|
||||
export type AgentMetadataGeneratorDeps = {
|
||||
generateStructuredAgentResponse?: typeof generateStructuredAgentResponse;
|
||||
getCheckoutStatus?: typeof getCheckoutStatus;
|
||||
renameCurrentBranch?: typeof renameCurrentBranch;
|
||||
};
|
||||
|
||||
export type AgentMetadataGenerationOptions = {
|
||||
agentManager: AgentManager;
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
initialPrompt?: string | null;
|
||||
explicitTitle?: string | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
deps?: AgentMetadataGeneratorDeps;
|
||||
};
|
||||
|
||||
type AgentMetadataNeeds = {
|
||||
prompt: string | null;
|
||||
needsTitle: boolean;
|
||||
needsBranch: boolean;
|
||||
};
|
||||
|
||||
function hasExplicitTitle(title?: string | null): boolean {
|
||||
return Boolean(title && title.trim().length > 0);
|
||||
}
|
||||
|
||||
async function canRenameBranch(
|
||||
cwd: string,
|
||||
paseoHome: string | undefined,
|
||||
getCheckoutStatusImpl: typeof getCheckoutStatus
|
||||
): Promise<boolean> {
|
||||
let status: CheckoutStatusResult;
|
||||
try {
|
||||
status = await getCheckoutStatusImpl(cwd, { paseoHome });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!status.isGit || !status.isPaseoOwnedWorktree) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!status.currentBranch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const worktreeDirName = basename(status.repoRoot);
|
||||
return status.currentBranch === worktreeDirName;
|
||||
}
|
||||
|
||||
export async function determineAgentMetadataNeeds(
|
||||
options: Pick<AgentMetadataGenerationOptions, "initialPrompt" | "explicitTitle" | "cwd" | "paseoHome" | "deps">
|
||||
): Promise<AgentMetadataNeeds> {
|
||||
const prompt = options.initialPrompt?.trim();
|
||||
if (!prompt) {
|
||||
return { prompt: null, needsTitle: false, needsBranch: false };
|
||||
}
|
||||
|
||||
const needsTitle = !hasExplicitTitle(options.explicitTitle);
|
||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||
const needsBranch = await canRenameBranch(
|
||||
options.cwd,
|
||||
options.paseoHome,
|
||||
getCheckoutStatusImpl
|
||||
);
|
||||
|
||||
return {
|
||||
prompt,
|
||||
needsTitle,
|
||||
needsBranch,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMetadataSchema(needs: AgentMetadataNeeds): z.ZodObject<any> | null {
|
||||
if (!needs.needsTitle && !needs.needsBranch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
if (needs.needsTitle) {
|
||||
shape.title = z.string().min(1).max(60);
|
||||
}
|
||||
if (needs.needsBranch) {
|
||||
shape.branch = z.string().min(1).max(100);
|
||||
}
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
function buildPrompt(needs: AgentMetadataNeeds): string {
|
||||
const fields = [needs.needsTitle ? "title" : null, needs.needsBranch ? "branch" : null].filter(
|
||||
Boolean
|
||||
) as string[];
|
||||
|
||||
const instructions: string[] = [
|
||||
"Generate metadata for a coding agent based on the user prompt.",
|
||||
];
|
||||
|
||||
if (needs.needsTitle) {
|
||||
instructions.push("Title: short descriptive label (<= 60 chars).");
|
||||
}
|
||||
if (needs.needsBranch) {
|
||||
instructions.push(
|
||||
"Branch: lowercase slug using letters, numbers, hyphens, and slashes only; no spaces, no uppercase, no leading/trailing hyphen, no consecutive hyphens."
|
||||
);
|
||||
}
|
||||
|
||||
if (fields.length === 1) {
|
||||
instructions.push(`Return JSON only with a single field '${fields[0]}'.`);
|
||||
} else {
|
||||
instructions.push(`Return JSON only with fields '${fields.join("' and '")}'.`);
|
||||
}
|
||||
|
||||
instructions.push("", "User prompt:", needs.prompt ?? "");
|
||||
return instructions.join("\n");
|
||||
}
|
||||
|
||||
export async function generateAndApplyAgentMetadata(
|
||||
options: AgentMetadataGenerationOptions
|
||||
): Promise<void> {
|
||||
const needs = await determineAgentMetadataNeeds(options);
|
||||
if (!needs.prompt) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = buildMetadataSchema(needs);
|
||||
if (!schema) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generator = options.deps?.generateStructuredAgentResponse ?? generateStructuredAgentResponse;
|
||||
const getCheckoutStatusImpl = options.deps?.getCheckoutStatus ?? getCheckoutStatus;
|
||||
const renameCurrentBranchImpl = options.deps?.renameCurrentBranch ?? renameCurrentBranch;
|
||||
|
||||
let result: { title?: string; branch?: string };
|
||||
|
||||
try {
|
||||
result = await generator({
|
||||
manager: options.agentManager,
|
||||
agentConfig: {
|
||||
provider: AUTO_GEN_PROVIDER,
|
||||
model: AUTO_GEN_MODEL,
|
||||
reasoningEffort: AUTO_GEN_REASONING_EFFORT,
|
||||
cwd: options.cwd,
|
||||
title: "Agent metadata generator",
|
||||
internal: true,
|
||||
},
|
||||
prompt: buildPrompt(needs),
|
||||
schema,
|
||||
schemaName: "AgentMetadata",
|
||||
maxRetries: 2,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof StructuredAgentResponseError) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Structured metadata generation failed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
options.logger.error(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Agent metadata generation failed"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (needs.needsTitle && typeof result.title === "string") {
|
||||
const normalizedTitle = result.title.trim();
|
||||
if (normalizedTitle.length > 0) {
|
||||
await options.agentManager.setTitle(options.agentId, normalizedTitle);
|
||||
}
|
||||
}
|
||||
|
||||
if (needs.needsBranch && typeof result.branch === "string") {
|
||||
const normalizedBranch = result.branch.trim();
|
||||
const validation = validateBranchSlug(normalizedBranch);
|
||||
if (!validation.valid) {
|
||||
options.logger.warn(
|
||||
{ agentId: options.agentId, branch: normalizedBranch, error: validation.error },
|
||||
"Generated branch name is invalid"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let status: CheckoutStatusResult;
|
||||
try {
|
||||
status = await getCheckoutStatusImpl(options.cwd, { paseoHome: options.paseoHome });
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Failed to re-check branch eligibility"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!status.isGit || !status.isPaseoOwnedWorktree || !status.currentBranch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const worktreeDirName = basename(status.repoRoot);
|
||||
if (status.currentBranch !== worktreeDirName) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await renameCurrentBranchImpl(options.cwd, normalizedBranch);
|
||||
} catch (error) {
|
||||
options.logger.warn(
|
||||
{ err: error, agentId: options.agentId, branch: normalizedBranch },
|
||||
"Failed to rename branch"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleAgentMetadataGeneration(
|
||||
options: AgentMetadataGenerationOptions
|
||||
): void {
|
||||
queueMicrotask(() => {
|
||||
void generateAndApplyAgentMetadata(options).catch((error) => {
|
||||
options.logger.error(
|
||||
{ err: error, agentId: options.agentId },
|
||||
"Agent metadata generation crashed"
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -234,14 +234,6 @@ export type AgentSessionConfig = {
|
||||
networkAccess?: boolean;
|
||||
webSearch?: boolean;
|
||||
reasoningEffort?: string;
|
||||
/**
|
||||
* Paseo-owned instructions injected into the first user prompt via
|
||||
* <paseo-instructions>...</paseo-instructions>.
|
||||
*
|
||||
* These MUST NOT be sent via provider system/developer instructions (those are
|
||||
* reserved for provider/session behaviors like resuming).
|
||||
*/
|
||||
paseoPromptInstructions?: string;
|
||||
extra?: {
|
||||
codex?: AgentMetadata;
|
||||
claude?: Partial<ClaudeAgentOptions>;
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
/**
|
||||
* Agent Self-ID MCP Server
|
||||
*
|
||||
* Purpose: Agents identifying themselves (title, branch)
|
||||
* Transport: Stdio bridge → Unix socket (${PASEO_HOME}/self-id-mcp.sock)
|
||||
* Server name: "paseo-agent-self-id"
|
||||
*
|
||||
* Tools:
|
||||
* - set_title - Set agent's display title
|
||||
* - set_branch - Rename git branch (Paseo worktrees only)
|
||||
*
|
||||
* Requires callerAgentId - must know which agent is calling.
|
||||
*/
|
||||
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { basename } from "path";
|
||||
import { z } from "zod";
|
||||
import { ensureValidJson } from "../json-utils.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import {
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
validateBranchSlug,
|
||||
} from "../../utils/worktree.js";
|
||||
import {
|
||||
NotGitRepoError,
|
||||
renameCurrentBranch,
|
||||
getCheckoutStatus,
|
||||
} from "../../utils/checkout-git.js";
|
||||
|
||||
export interface AgentSelfIdMcpOptions {
|
||||
agentManager: AgentManager;
|
||||
paseoHome?: string;
|
||||
/**
|
||||
* ID of the agent that is connecting to this MCP server.
|
||||
* Required - this server only works for managed agents.
|
||||
*/
|
||||
callerAgentId: string;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
type ToolErrorCode = "NOT_ALLOWED" | "NOT_GIT_REPO" | "INVALID_BRANCH";
|
||||
|
||||
class AgentSelfIdToolError extends Error {
|
||||
readonly code: ToolErrorCode;
|
||||
|
||||
constructor(code: ToolErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "AgentSelfIdToolError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createAgentSelfIdMcpServer(
|
||||
options: AgentSelfIdMcpOptions
|
||||
): Promise<McpServer> {
|
||||
const { agentManager, callerAgentId, logger } = options;
|
||||
const childLogger = logger.child({
|
||||
module: "agent",
|
||||
component: "agent-self-id-mcp",
|
||||
callerAgentId,
|
||||
});
|
||||
|
||||
const server = new McpServer({
|
||||
name: "paseo-agent-self-id",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
"set_title",
|
||||
{
|
||||
title: "Set Agent Title",
|
||||
description: "Update the agent's title in the registry.",
|
||||
inputSchema: {
|
||||
title: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.describe("Short descriptive title (<= 60 chars)."),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
title: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ title }) => {
|
||||
const agent = agentManager.getAgent(callerAgentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${callerAgentId} not found`);
|
||||
}
|
||||
|
||||
const normalizedTitle = title.trim();
|
||||
if (!normalizedTitle) {
|
||||
throw new AgentSelfIdToolError("NOT_ALLOWED", "Title cannot be empty");
|
||||
}
|
||||
if (normalizedTitle.length > 60) {
|
||||
throw new AgentSelfIdToolError(
|
||||
"NOT_ALLOWED",
|
||||
"Title must be 60 characters or fewer"
|
||||
);
|
||||
}
|
||||
|
||||
childLogger.debug({ title: normalizedTitle }, "Setting agent title");
|
||||
await agentManager.setTitle(agent.id, normalizedTitle);
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
success: true,
|
||||
title: normalizedTitle,
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"set_branch",
|
||||
{
|
||||
title: "Set Agent Branch",
|
||||
description:
|
||||
"Rename the current git branch. Allowed only inside Paseo-owned worktrees.",
|
||||
inputSchema: {
|
||||
name: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"Git branch name (lowercase letters, numbers, hyphens, slashes)."
|
||||
),
|
||||
},
|
||||
outputSchema: {
|
||||
success: z.boolean(),
|
||||
branch: z.string(),
|
||||
},
|
||||
},
|
||||
async ({ name }) => {
|
||||
const agent = agentManager.getAgent(callerAgentId);
|
||||
if (!agent) {
|
||||
throw new Error(`Agent ${callerAgentId} not found`);
|
||||
}
|
||||
|
||||
const validation = validateBranchSlug(name);
|
||||
if (!validation.valid) {
|
||||
throw new AgentSelfIdToolError(
|
||||
"INVALID_BRANCH",
|
||||
validation.error ?? "Invalid branch name"
|
||||
);
|
||||
}
|
||||
|
||||
let ownership;
|
||||
try {
|
||||
ownership = await isPaseoOwnedWorktreeCwd(agent.cwd, {
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
} catch (error) {
|
||||
const notGitError =
|
||||
error instanceof NotGitRepoError
|
||||
? error
|
||||
: new NotGitRepoError(agent.cwd);
|
||||
throw new AgentSelfIdToolError("NOT_GIT_REPO", notGitError.message);
|
||||
}
|
||||
|
||||
if (!ownership.allowed) {
|
||||
throw new AgentSelfIdToolError(
|
||||
"NOT_ALLOWED",
|
||||
"Branch renames are only allowed inside Paseo-owned worktrees"
|
||||
);
|
||||
}
|
||||
|
||||
const status = await getCheckoutStatus(agent.cwd, {
|
||||
paseoHome: options.paseoHome,
|
||||
});
|
||||
if (!status.isGit || !status.currentBranch) {
|
||||
throw new AgentSelfIdToolError("NOT_GIT_REPO", "Unable to determine current branch");
|
||||
}
|
||||
|
||||
const worktreeDirName = basename(status.repoRoot);
|
||||
if (status.currentBranch !== worktreeDirName) {
|
||||
throw new AgentSelfIdToolError(
|
||||
"NOT_ALLOWED",
|
||||
"Branch has already been renamed. Use git commands for subsequent renames."
|
||||
);
|
||||
}
|
||||
|
||||
childLogger.debug({ branch: name }, "Renaming branch");
|
||||
const result = await renameCurrentBranch(agent.cwd, name);
|
||||
if (result.currentBranch !== name) {
|
||||
throw new Error(
|
||||
`Branch rename failed (expected ${name}, got ${result.currentBranch ?? "unknown"})`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({
|
||||
success: true,
|
||||
branch: name,
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createAgentSelfIdMcpServer } from "./agent-self-id-mcp.js";
|
||||
import { createWorktree } from "../../utils/worktree.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentRunResult,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "./agent-sdk-types.js";
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
supportsStreaming: false,
|
||||
supportsSessionPersistence: false,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
} as const;
|
||||
|
||||
class TestAgentClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
||||
return new TestAgentSession({
|
||||
provider: "codex",
|
||||
cwd: config?.cwd ?? process.cwd(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class TestAgentSession implements AgentSession {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
readonly id = randomUUID();
|
||||
|
||||
constructor(private readonly config: AgentSessionConfig) {}
|
||||
|
||||
async run(): Promise<AgentRunResult> {
|
||||
return {
|
||||
sessionId: this.id ?? this.config.provider,
|
||||
finalText: "",
|
||||
timeline: [],
|
||||
};
|
||||
}
|
||||
|
||||
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: this.provider };
|
||||
yield { type: "turn_completed", provider: this.provider };
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async setMode(): Promise<void> {}
|
||||
|
||||
getPendingPermissions() {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(): Promise<void> {}
|
||||
|
||||
describePersistence() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
};
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {}
|
||||
}
|
||||
|
||||
function initGitRepo(repoDir: string): void {
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "ignore" });
|
||||
execSync('git config user.email "paseo-test@example.com"', {
|
||||
cwd: repoDir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync('git config user.name "Paseo Test"', {
|
||||
cwd: repoDir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
writeFileSync(path.join(repoDir, "README.md"), "init\n");
|
||||
execSync("git add README.md", { cwd: repoDir, stdio: "ignore" });
|
||||
execSync('git commit -m "init"', { cwd: repoDir, stdio: "ignore" });
|
||||
}
|
||||
|
||||
describe("self-identification MCP tools", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("set_branch renames branch for Paseo worktree", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
const paseoHome = path.join(repoDir, "paseo-home");
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
const worktree = await createWorktree({
|
||||
branchName: "self-ident",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "self-ident",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-self-ident",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const server = await createAgentSelfIdMcpServer({
|
||||
agentManager: manager,
|
||||
paseoHome,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
await tool.callback({ name: "self-ident-ready" });
|
||||
|
||||
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(branch).toBe("self-ident-ready");
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("set_branch allows agents running in a subdirectory of a Paseo worktree", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
const paseoHome = path.join(repoDir, "paseo-home");
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
const worktree = await createWorktree({
|
||||
branchName: "self-ident-subdir",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "self-ident-subdir",
|
||||
paseoHome,
|
||||
});
|
||||
const nestedDir = path.join(worktree.worktreePath, "nested");
|
||||
execSync(`mkdir -p "${nestedDir}"`, { stdio: "ignore" });
|
||||
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-self-ident-subdir",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: nestedDir,
|
||||
});
|
||||
|
||||
const server = await createAgentSelfIdMcpServer({
|
||||
agentManager: manager,
|
||||
paseoHome,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
await tool.callback({ name: "self-ident-subdir-ready" });
|
||||
|
||||
const branch = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: nestedDir,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
|
||||
expect(branch).toBe("self-ident-subdir-ready");
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("set_branch rejects subsequent renames after initial rename", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
const paseoHome = path.join(repoDir, "paseo-home");
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
const worktree = await createWorktree({
|
||||
branchName: "initial-branch",
|
||||
cwd: repoDir,
|
||||
baseBranch: "main",
|
||||
worktreeSlug: "initial-branch",
|
||||
paseoHome,
|
||||
});
|
||||
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-subsequent-rename",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: worktree.worktreePath,
|
||||
});
|
||||
|
||||
const server = await createAgentSelfIdMcpServer({
|
||||
agentManager: manager,
|
||||
paseoHome,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
// First rename should succeed
|
||||
await tool.callback({ name: "first-rename" });
|
||||
|
||||
const branchAfterFirst = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(branchAfterFirst).toBe("first-rename");
|
||||
|
||||
// Second rename should fail
|
||||
await expect(tool.callback({ name: "second-rename" })).rejects.toMatchObject({
|
||||
code: "NOT_ALLOWED",
|
||||
message: expect.stringContaining("already been renamed"),
|
||||
});
|
||||
|
||||
// Branch should still be first-rename
|
||||
const branchAfterSecond = execSync("git rev-parse --abbrev-ref HEAD", {
|
||||
cwd: worktree.worktreePath,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(branchAfterSecond).toBe("first-rename");
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("set_branch rejects non-Paseo checkouts", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-non-worktree",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const server = await createAgentSelfIdMcpServer({
|
||||
agentManager: manager,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
await expect(tool.callback({ name: "should-fail" })).rejects.toMatchObject({
|
||||
code: "NOT_ALLOWED",
|
||||
});
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("set_branch rejects non-git directories", async () => {
|
||||
const repoDir = mkdtempSync(path.join(tmpdir(), "paseo-self-ident-"));
|
||||
|
||||
try {
|
||||
const storagePath = path.join(repoDir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "agent-non-git",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: repoDir,
|
||||
});
|
||||
|
||||
const server = await createAgentSelfIdMcpServer({
|
||||
agentManager: manager,
|
||||
callerAgentId: agent.id,
|
||||
logger,
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_branch"];
|
||||
|
||||
await expect(tool.callback({ name: "nope" })).rejects.toMatchObject({
|
||||
code: "NOT_GIT_REPO",
|
||||
});
|
||||
} finally {
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { createAgentMcpServer } from "./mcp-server.js";
|
||||
import { createAgentSelfIdMcpServer } from "./agent-self-id-mcp.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
|
||||
@@ -104,24 +103,27 @@ describe("create_agent MCP tool", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("set_title trims and persists titles for caller agent", async () => {
|
||||
const { agentManager, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "agent-1",
|
||||
it("trims caller-provided titles before createAgent", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.createAgent.mockResolvedValue({
|
||||
id: "agent-456",
|
||||
cwd: "/tmp/repo",
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentSelfIdMcpServer({
|
||||
agentManager,
|
||||
logger,
|
||||
callerAgentId: "agent-1",
|
||||
const server = await createAgentMcpServer({ agentManager, agentStorage, logger });
|
||||
const tool = (server as any)._registeredTools["create_agent"];
|
||||
await tool.callback({
|
||||
cwd: "/tmp/repo",
|
||||
title: " Fix auth ",
|
||||
});
|
||||
const tool = (server as any)._registeredTools["set_title"];
|
||||
|
||||
await tool.callback({ title: " Fix auth " });
|
||||
|
||||
expect(spies.agentManager.setTitle).toHaveBeenCalledWith(
|
||||
"agent-1",
|
||||
"Fix auth"
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: "Fix auth",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ import { AGENT_PROVIDER_DEFINITIONS } from "./provider-registry.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createWorktree } from "../../utils/worktree.js";
|
||||
import { WaitForAgentTracker } from "./wait-for-agent-tracker.js";
|
||||
import { injectLeadingPaseoInstructionTag } from "./paseo-instructions-tag.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
|
||||
export interface AgentMcpServerOptions {
|
||||
agentManager: AgentManager;
|
||||
@@ -440,13 +440,20 @@ export async function createAgentMcpServer(
|
||||
title: normalizedTitle ?? undefined,
|
||||
});
|
||||
|
||||
if (initialPrompt) {
|
||||
const initialPromptWithInstructions = injectLeadingPaseoInstructionTag(
|
||||
initialPrompt,
|
||||
snapshot.config.paseoPromptInstructions
|
||||
);
|
||||
const trimmedPrompt = initialPrompt?.trim();
|
||||
if (trimmedPrompt) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
initialPrompt: trimmedPrompt,
|
||||
explicitTitle: snapshot.config.title,
|
||||
paseoHome: options.paseoHome,
|
||||
logger: childLogger,
|
||||
});
|
||||
|
||||
try {
|
||||
agentManager.recordUserMessage(snapshot.id, initialPromptWithInstructions);
|
||||
agentManager.recordUserMessage(snapshot.id, trimmedPrompt);
|
||||
} catch (error) {
|
||||
childLogger.error(
|
||||
{ err: error, agentId: snapshot.id },
|
||||
@@ -455,7 +462,7 @@ export async function createAgentMcpServer(
|
||||
}
|
||||
|
||||
try {
|
||||
startAgentRun(agentManager, snapshot.id, initialPromptWithInstructions, childLogger);
|
||||
startAgentRun(agentManager, snapshot.id, trimmedPrompt, childLogger);
|
||||
|
||||
// If not running in background, wait for completion
|
||||
if (!background) {
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
formatPaseoInstructionTag,
|
||||
hasLeadingPaseoInstructionTag,
|
||||
injectLeadingPaseoInstructionTag,
|
||||
stripLeadingPaseoInstructionTag,
|
||||
} from "./paseo-instructions-tag.js";
|
||||
|
||||
describe("paseo instruction tags", () => {
|
||||
test("formatPaseoInstructionTag wraps content", () => {
|
||||
expect(formatPaseoInstructionTag("hello")).toBe(
|
||||
"<paseo-instructions>\nhello\n</paseo-instructions>"
|
||||
);
|
||||
});
|
||||
|
||||
test("hasLeadingPaseoInstructionTag detects leading tag", () => {
|
||||
expect(hasLeadingPaseoInstructionTag("<paseo-instructions>\nX\n</paseo-instructions>")).toBe(
|
||||
true
|
||||
);
|
||||
expect(hasLeadingPaseoInstructionTag("nope <paseo-instructions>")).toBe(false);
|
||||
});
|
||||
|
||||
test("stripLeadingPaseoInstructionTag strips leading tag content", () => {
|
||||
const input = [
|
||||
"<paseo-instructions>",
|
||||
"do the thing",
|
||||
"</paseo-instructions>",
|
||||
"",
|
||||
"Hello world",
|
||||
].join("\n");
|
||||
expect(stripLeadingPaseoInstructionTag(input)).toBe("Hello world");
|
||||
});
|
||||
|
||||
test("injectLeadingPaseoInstructionTag prepends instructions when missing", () => {
|
||||
expect(injectLeadingPaseoInstructionTag("Hello", "do the thing")).toBe(
|
||||
"<paseo-instructions>\ndo the thing\n</paseo-instructions>\n\nHello"
|
||||
);
|
||||
});
|
||||
|
||||
test("injectLeadingPaseoInstructionTag is idempotent when tag already present", () => {
|
||||
const input = "<paseo-instructions>\nX\n</paseo-instructions>\n\nHello";
|
||||
expect(injectLeadingPaseoInstructionTag(input, "do the thing")).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
const OPEN_TAG = "<paseo-instructions>";
|
||||
const CLOSE_TAG = "</paseo-instructions>";
|
||||
|
||||
export function formatPaseoInstructionTag(instructions: string): string {
|
||||
return `${OPEN_TAG}\n${instructions}\n${CLOSE_TAG}`;
|
||||
}
|
||||
|
||||
export function hasLeadingPaseoInstructionTag(text: string): boolean {
|
||||
return /^\s*<paseo-instructions>/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend paseo instructions to a prompt exactly once (idempotent by content).
|
||||
* This is intended for agent creation / initial prompt only.
|
||||
*/
|
||||
export function injectLeadingPaseoInstructionTag(
|
||||
prompt: string,
|
||||
instructions: string | null | undefined
|
||||
): string {
|
||||
const normalizedInstructions = instructions?.trim() ?? "";
|
||||
if (!normalizedInstructions) {
|
||||
return prompt;
|
||||
}
|
||||
if (hasLeadingPaseoInstructionTag(prompt)) {
|
||||
return prompt;
|
||||
}
|
||||
return `${formatPaseoInstructionTag(normalizedInstructions)}\n\n${prompt}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a leading <paseo-instructions>...</paseo-instructions> block, if present.
|
||||
* The content is treated as internal metadata and is discarded.
|
||||
*/
|
||||
export function stripLeadingPaseoInstructionTag(text: string): string {
|
||||
const leadingMatch = text.match(/^\s*<paseo-instructions>/);
|
||||
if (!leadingMatch || leadingMatch.index !== 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const openEnd = leadingMatch[0].length;
|
||||
const closeStart = text.indexOf(CLOSE_TAG, openEnd);
|
||||
if (closeStart === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const closeEnd = closeStart + CLOSE_TAG.length;
|
||||
return text.slice(closeEnd).trimStart();
|
||||
}
|
||||
@@ -147,8 +147,6 @@ describe("Codex app-server provider (integration)", () => {
|
||||
cwd,
|
||||
modeId: "auto",
|
||||
approvalPolicy: "on-request",
|
||||
paseoPromptInstructions:
|
||||
"You must use the shell tool for command execution tasks. Do not answer without running the command.",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
@@ -229,8 +227,6 @@ describe("Codex app-server provider (integration)", () => {
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
paseoPromptInstructions:
|
||||
"You must use shell for commands and apply_patch for file edits. Do not skip tool usage.",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
@@ -552,8 +548,6 @@ describe("Codex app-server provider (integration)", () => {
|
||||
cwd,
|
||||
modeId: "full-access",
|
||||
approvalPolicy: "on-request",
|
||||
paseoPromptInstructions:
|
||||
"You must use the apply_patch tool for file edits. Do not use the shell tool for file changes.",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
});
|
||||
@@ -616,9 +610,6 @@ describe("Codex app-server provider (integration)", () => {
|
||||
expect(sawPermissionResolved).toBe(true);
|
||||
}
|
||||
expect(sawPermission || timelineItems.length > 0).toBe(true);
|
||||
expect(
|
||||
timelineItems.some((item) => item.type === "tool_call" && item.name === "apply_patch")
|
||||
).toBe(true);
|
||||
expect(readFileSync(targetPath, "utf8").trim()).toBe("ok");
|
||||
} finally {
|
||||
cleanup();
|
||||
|
||||
@@ -33,7 +33,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import readline from "node:readline";
|
||||
|
||||
import { injectLeadingPaseoInstructionTag } from "../paseo-instructions-tag.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const CODEX_PROVIDER = "codex" as const;
|
||||
@@ -1335,37 +1334,15 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private buildUserInput(prompt: AgentPromptInput): unknown[] {
|
||||
if (typeof prompt === "string") {
|
||||
const text = this.paseoInstructionsInjected
|
||||
? prompt
|
||||
: injectLeadingPaseoInstructionTag(prompt, this.config.paseoPromptInstructions);
|
||||
this.paseoInstructionsInjected = true;
|
||||
return [{ type: "text", text }];
|
||||
return [{ type: "text", text: prompt }];
|
||||
}
|
||||
const blocks = prompt as AgentPromptContentBlock[];
|
||||
if (this.paseoInstructionsInjected) {
|
||||
return blocks;
|
||||
}
|
||||
this.paseoInstructionsInjected = true;
|
||||
if (blocks.length === 0) {
|
||||
return blocks;
|
||||
}
|
||||
const first = blocks[0];
|
||||
if (first && typeof first === "object" && (first as { type?: string }).type === "text") {
|
||||
const textBlock = first as { type: "text"; text: string };
|
||||
const text = injectLeadingPaseoInstructionTag(
|
||||
textBlock.text ?? "",
|
||||
this.config.paseoPromptInstructions
|
||||
);
|
||||
return [{ ...textBlock, text }, ...blocks.slice(1)];
|
||||
}
|
||||
const injected = injectLeadingPaseoInstructionTag(
|
||||
"",
|
||||
this.config.paseoPromptInstructions
|
||||
);
|
||||
if (injected.trim().length === 0) {
|
||||
return blocks;
|
||||
}
|
||||
return [{ type: "text", text: injected }, ...blocks];
|
||||
return blocks;
|
||||
}
|
||||
|
||||
private emitEvent(event: AgentStreamEvent): void {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { __test__ } from "./codex-mcp-agent.js";
|
||||
import type { AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
|
||||
describe("codex developer-instructions vs paseo prompt instructions", () => {
|
||||
test("does not inject Paseo self-identification into developer-instructions", () => {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "codex-rollout-"));
|
||||
const rolloutPath = path.join(dir, "rollout.jsonl");
|
||||
|
||||
const entry = {
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ input_text: "hello from history" }],
|
||||
},
|
||||
};
|
||||
|
||||
writeFileSync(rolloutPath, JSON.stringify(entry) + "\n", "utf8");
|
||||
|
||||
const config: AgentSessionConfig = {
|
||||
provider: "codex",
|
||||
cwd: dir,
|
||||
modeId: "auto",
|
||||
};
|
||||
|
||||
const payload = __test__.buildCodexMcpConfig(
|
||||
config,
|
||||
"Hello world",
|
||||
"auto",
|
||||
undefined,
|
||||
rolloutPath
|
||||
);
|
||||
|
||||
const dev = payload["developer-instructions"] ?? "";
|
||||
expect(dev).toContain("<previous_conversation>");
|
||||
expect(dev).toContain("hello from history");
|
||||
expect(dev.toLowerCase()).not.toContain("set_title");
|
||||
expect(dev.toLowerCase()).not.toContain("set_branch");
|
||||
expect(dev.toLowerCase()).not.toContain("you are running under paseo");
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,28 +0,0 @@
|
||||
export interface SelfIdentificationContext {
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
function looksLikePaseoWorktree(cwd?: string): boolean {
|
||||
if (!cwd) return false;
|
||||
// Simple heuristic: if cwd contains .paseo/worktrees, it's likely a Paseo worktree
|
||||
return cwd.includes(".paseo/worktrees") || cwd.includes(".paseo\\worktrees");
|
||||
}
|
||||
|
||||
export function getSelfIdentificationInstructions(
|
||||
context?: SelfIdentificationContext
|
||||
): string {
|
||||
const inWorktree = looksLikePaseoWorktree(context?.cwd);
|
||||
|
||||
const lines = [
|
||||
"You are running under Paseo, an agent orchestration tool.",
|
||||
"You MUST call set_title immediately after understanding the task. Call it exactly once per task—do not repeat.",
|
||||
];
|
||||
|
||||
if (inWorktree) {
|
||||
lines.push(
|
||||
"You are running inside a Paseo-owned worktree. Call set_branch once (alongside set_title) to name your branch."
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -46,7 +46,6 @@ import { AgentManager } from "./agent/agent-manager.js";
|
||||
import { AgentStorage } from "./agent/agent-storage.js";
|
||||
import { attachAgentStoragePersistence } from "./persistence-hooks.js";
|
||||
import { createAgentMcpServer } from "./agent/mcp-server.js";
|
||||
import { createAgentSelfIdMcpServer } from "./agent/agent-self-id-mcp.js";
|
||||
import { createAllClients, shutdownProviders } from "./agent/provider-registry.js";
|
||||
import { createTerminalManager, type TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import {
|
||||
@@ -73,7 +72,6 @@ export type PaseoOpenAIConfig = {
|
||||
export type PaseoDaemonConfig = {
|
||||
listen: string;
|
||||
paseoHome: string;
|
||||
selfIdMcpSocketPath: string;
|
||||
corsAllowedOrigins: string[];
|
||||
agentMcpRoute: string;
|
||||
agentMcpAllowedHosts: string[];
|
||||
@@ -214,7 +212,6 @@ export async function createPaseoDaemon(
|
||||
...config.agentClients,
|
||||
},
|
||||
registry: agentStorage,
|
||||
selfIdMcpSocketPath: config.selfIdMcpSocketPath,
|
||||
logger,
|
||||
});
|
||||
|
||||
@@ -231,7 +228,6 @@ export async function createPaseoDaemon(
|
||||
);
|
||||
|
||||
const agentMcpTransports: AgentMcpTransportMap = new Map();
|
||||
const selfIdMcpTransports: AgentMcpTransportMap = new Map();
|
||||
const allowedHosts = config.agentMcpAllowedHosts;
|
||||
|
||||
const createAgentMcpTransport = async (callerAgentId?: string) => {
|
||||
@@ -357,116 +353,6 @@ export async function createPaseoDaemon(
|
||||
app.delete(agentMcpRoute, handleAgentMcpRequest);
|
||||
logger.info({ route: agentMcpRoute }, "Agent MCP server mounted on main app");
|
||||
|
||||
// Create dedicated Self-ID MCP server on Unix socket for agent self-identification
|
||||
// This only provides set_title and set_branch tools for coding agents
|
||||
// Host validation is disabled since Unix sockets don't have HTTP hosts
|
||||
const selfIdMcpSocketPath = config.selfIdMcpSocketPath;
|
||||
|
||||
const createSelfIdMcpTransport = async (callerAgentId: string) => {
|
||||
const selfIdMcpServer = await createAgentSelfIdMcpServer({
|
||||
agentManager,
|
||||
paseoHome: config.paseoHome,
|
||||
callerAgentId,
|
||||
logger,
|
||||
});
|
||||
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => randomUUID(),
|
||||
onsessioninitialized: (sessionId) => {
|
||||
selfIdMcpTransports.set(sessionId, transport);
|
||||
logger.debug({ sessionId, callerAgentId }, "Self-ID MCP session initialized");
|
||||
},
|
||||
onsessionclosed: (sessionId) => {
|
||||
selfIdMcpTransports.delete(sessionId);
|
||||
logger.debug({ sessionId }, "Self-ID MCP session closed");
|
||||
},
|
||||
// Disable host validation for Unix socket
|
||||
enableDnsRebindingProtection: false,
|
||||
});
|
||||
transport.onclose = () => {
|
||||
if (transport.sessionId) {
|
||||
selfIdMcpTransports.delete(transport.sessionId);
|
||||
}
|
||||
};
|
||||
transport.onerror = (err) => {
|
||||
logger.error({ err }, "Self-ID MCP transport error");
|
||||
};
|
||||
|
||||
await selfIdMcpServer.connect(transport);
|
||||
|
||||
return transport;
|
||||
};
|
||||
|
||||
const handleSelfIdMcpRequest: express.RequestHandler = async (req, res) => {
|
||||
if (config.mcpDebug) {
|
||||
logger.debug(
|
||||
{
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
sessionId: req.header("mcp-session-id"),
|
||||
body: req.body,
|
||||
},
|
||||
"Self-ID MCP request"
|
||||
);
|
||||
}
|
||||
try {
|
||||
const sessionId = req.header("mcp-session-id");
|
||||
let transport = sessionId ? selfIdMcpTransports.get(sessionId) : undefined;
|
||||
|
||||
if (!transport) {
|
||||
if (req.method !== "POST") {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Missing or invalid MCP session" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!isInitializeRequest(req.body)) {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "Initialization request expected" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const callerAgentIdRaw = req.query.callerAgentId;
|
||||
const callerAgentId =
|
||||
typeof callerAgentIdRaw === "string"
|
||||
? callerAgentIdRaw
|
||||
: Array.isArray(callerAgentIdRaw) && typeof callerAgentIdRaw[0] === "string"
|
||||
? callerAgentIdRaw[0]
|
||||
: undefined;
|
||||
if (!callerAgentId) {
|
||||
res.status(400).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32000, message: "callerAgentId query parameter is required for Self-ID MCP" },
|
||||
id: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
transport = await createSelfIdMcpTransport(callerAgentId);
|
||||
}
|
||||
|
||||
await transport.handleRequest(req as any, res as any, req.body);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Failed to handle Self-ID MCP request");
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({
|
||||
jsonrpc: "2.0",
|
||||
error: { code: -32603, message: "Internal MCP server error" },
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selfIdMcpApp = express();
|
||||
selfIdMcpApp.use(express.json());
|
||||
selfIdMcpApp.post("/", handleSelfIdMcpRequest);
|
||||
selfIdMcpApp.get("/", handleSelfIdMcpRequest);
|
||||
selfIdMcpApp.delete("/", handleSelfIdMcpRequest);
|
||||
const selfIdMcpSocketServer = createHTTPServer(selfIdMcpApp);
|
||||
|
||||
let sttService: OpenAISTT | null = null;
|
||||
let ttsService: OpenAITTS | null = null;
|
||||
@@ -525,28 +411,7 @@ export async function createPaseoDaemon(
|
||||
|
||||
const start = async () => {
|
||||
// Acquire PID lock
|
||||
await acquirePidLock(config.paseoHome, selfIdMcpSocketPath);
|
||||
|
||||
// Start Self-ID MCP socket server first
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => {
|
||||
selfIdMcpSocketServer.off("listening", onListening);
|
||||
reject(err);
|
||||
};
|
||||
const onListening = () => {
|
||||
selfIdMcpSocketServer.off("error", onError);
|
||||
logger.info({ path: selfIdMcpSocketPath }, `Self-ID MCP server listening on ${selfIdMcpSocketPath}`);
|
||||
resolve();
|
||||
};
|
||||
selfIdMcpSocketServer.once("error", onError);
|
||||
selfIdMcpSocketServer.once("listening", onListening);
|
||||
|
||||
// Remove stale socket file if it exists
|
||||
if (existsSync(selfIdMcpSocketPath)) {
|
||||
unlinkSync(selfIdMcpSocketPath);
|
||||
}
|
||||
selfIdMcpSocketServer.listen(selfIdMcpSocketPath);
|
||||
});
|
||||
await acquirePidLock(config.paseoHome, config.listen);
|
||||
|
||||
// Start main HTTP server
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
@@ -626,16 +491,10 @@ export async function createPaseoDaemon(
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
selfIdMcpSocketServer.close(() => resolve());
|
||||
});
|
||||
// Clean up socket files
|
||||
if (listenTarget.type === "socket" && existsSync(listenTarget.path)) {
|
||||
unlinkSync(listenTarget.path);
|
||||
}
|
||||
if (existsSync(selfIdMcpSocketPath)) {
|
||||
unlinkSync(selfIdMcpSocketPath);
|
||||
}
|
||||
// Release PID lock
|
||||
await releasePidLock(config.paseoHome);
|
||||
};
|
||||
|
||||
@@ -15,9 +15,6 @@ function getDefaultListen(): string {
|
||||
return `127.0.0.1:${DEFAULT_PORT}`;
|
||||
}
|
||||
|
||||
function getSelfIdMcpSocketPath(paseoHome: string): string {
|
||||
return path.join(paseoHome, "self-id-mcp.sock");
|
||||
}
|
||||
|
||||
function parseOpenAIConfig(env: NodeJS.ProcessEnv) {
|
||||
const apiKey = env.OPENAI_API_KEY;
|
||||
@@ -82,7 +79,6 @@ export function loadConfig(
|
||||
// Default is TCP at 127.0.0.1:6767
|
||||
const listen = env.PASEO_LISTEN ?? persisted.listen ?? getDefaultListen();
|
||||
const mcpListen = getListenForMcp(listen);
|
||||
const selfIdMcpSocketPath = getSelfIdMcpSocketPath(paseoHome);
|
||||
|
||||
const envCorsOrigins = env.PASEO_CORS_ORIGINS
|
||||
? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim())
|
||||
@@ -91,7 +87,6 @@ export function loadConfig(
|
||||
return {
|
||||
listen,
|
||||
paseoHome,
|
||||
selfIdMcpSocketPath,
|
||||
corsAllowedOrigins: [...persisted.cors.allowedOrigins, ...envCorsOrigins],
|
||||
agentMcpRoute: DEFAULT_AGENT_MCP_ROUTE,
|
||||
agentMcpAllowedHosts: [mcpListen, `localhost:${mcpListen.split(":")[1]}`],
|
||||
|
||||
@@ -3,8 +3,6 @@ import { mkdtempSync, writeFileSync, rmSync, existsSync, realpathSync } from "fs
|
||||
import { tmpdir } from "os";
|
||||
import path from "path";
|
||||
import { execSync } from "child_process";
|
||||
import { experimental_createMCPClient } from "ai";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
@@ -30,63 +28,6 @@ function hasGitHubCliAuth(): boolean {
|
||||
|
||||
const testWithGitHubCliAuth = hasGitHubCliAuth() ? test : test.skip;
|
||||
|
||||
type McpToolResult = {
|
||||
structuredContent?: Record<string, unknown>;
|
||||
content?: Array<{ structuredContent?: Record<string, unknown> } | Record<string, unknown>>;
|
||||
toolResult?: unknown;
|
||||
isError?: boolean;
|
||||
};
|
||||
|
||||
type McpClient = {
|
||||
callTool: (input: { name: string; args?: Record<string, unknown> }) => Promise<unknown>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
function getStructuredContent(result: McpToolResult): Record<string, unknown> | null {
|
||||
if (result.structuredContent && typeof result.structuredContent === "object") {
|
||||
return result.structuredContent;
|
||||
}
|
||||
const content = result.content?.[0];
|
||||
if (content && "structuredContent" in content && content.structuredContent) {
|
||||
return content.structuredContent;
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
return content;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getToolResultText(result: McpToolResult): string {
|
||||
const chunks: string[] = [];
|
||||
if (result.structuredContent) {
|
||||
chunks.push(JSON.stringify(result.structuredContent));
|
||||
}
|
||||
if (result.toolResult !== undefined) {
|
||||
chunks.push(JSON.stringify(result.toolResult));
|
||||
}
|
||||
for (const entry of result.content ?? []) {
|
||||
if (entry && typeof entry === "object") {
|
||||
if ("text" in entry && typeof (entry as { text?: unknown }).text === "string") {
|
||||
chunks.push(String((entry as { text?: unknown }).text));
|
||||
}
|
||||
if (
|
||||
"structuredContent" in entry &&
|
||||
(entry as { structuredContent?: unknown }).structuredContent
|
||||
) {
|
||||
chunks.push(JSON.stringify((entry as { structuredContent?: unknown }).structuredContent));
|
||||
}
|
||||
}
|
||||
}
|
||||
return chunks.join(" ").trim();
|
||||
}
|
||||
|
||||
async function createMcpClient(port: number, agentId: string): Promise<McpClient> {
|
||||
const url = new URL(`http://127.0.0.1:${port}/mcp/agents`);
|
||||
url.searchParams.set("callerAgentId", agentId);
|
||||
const transport = new StreamableHTTPClientTransport(url);
|
||||
return (await experimental_createMCPClient({ transport })) as McpClient;
|
||||
}
|
||||
|
||||
function initGitRepo(repoDir: string): void {
|
||||
execSync("git init -b main", { cwd: repoDir, stdio: "pipe" });
|
||||
execSync("git config user.email 'paseo-test@example.com'", {
|
||||
@@ -439,49 +380,4 @@ describe("daemon checkout ship loop", () => {
|
||||
60000
|
||||
);
|
||||
|
||||
test(
|
||||
"set_branch is rejected outside Paseo-owned worktrees",
|
||||
async () => {
|
||||
const repoDir = tmpCwd("checkout-ship-non-paseo-");
|
||||
let agentId: string | null = null;
|
||||
let mcpClient: McpClient | null = null;
|
||||
|
||||
try {
|
||||
initGitRepo(repoDir);
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
|
||||
cwd: repoDir,
|
||||
title: "Checkout Non-Paseo",
|
||||
});
|
||||
agentId = agent.id;
|
||||
|
||||
mcpClient = await createMcpClient(ctx.daemon.port, agent.id);
|
||||
let errorMessage = "";
|
||||
try {
|
||||
const result = (await mcpClient.callTool({
|
||||
name: "set_branch",
|
||||
args: { name: "not-allowed" },
|
||||
})) as McpToolResult;
|
||||
errorMessage = getToolResultText(result);
|
||||
} catch (error) {
|
||||
errorMessage = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(errorMessage).toMatch(
|
||||
/NOT_ALLOWED|Branch renames are only allowed|Tool set_branch|MCP error -32602/
|
||||
);
|
||||
} finally {
|
||||
if (mcpClient) {
|
||||
await mcpClient.close().catch(() => undefined);
|
||||
}
|
||||
if (agentId) {
|
||||
await ctx.client.deleteAgent(agentId).catch(() => undefined);
|
||||
}
|
||||
rmSync(repoDir, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60000
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
createDaemonTestContext,
|
||||
type DaemonTestContext,
|
||||
} from "../test-utils/index.js";
|
||||
|
||||
describe("self-id MCP e2e", () => {
|
||||
let ctx: DaemonTestContext;
|
||||
|
||||
beforeEach(async () => {
|
||||
ctx = await createDaemonTestContext();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx.cleanup();
|
||||
}, 60000);
|
||||
|
||||
test("UI agent can call set_title to change its title", async () => {
|
||||
// Create a Claude agent with ui=true label (triggers MCP injection)
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: "/tmp",
|
||||
title: "Initial Title",
|
||||
labels: { ui: "true" },
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.title).toBe("Initial Title");
|
||||
|
||||
// Send a message asking the agent to call set_title
|
||||
await ctx.client.sendMessage(
|
||||
agent.id,
|
||||
"Use the set_title MCP tool to change your title to 'Updated via MCP'. Only call set_title, nothing else."
|
||||
);
|
||||
|
||||
// Wait for permission request (default mode requires permission for MCP tools)
|
||||
const state = await ctx.client.waitForFinish(agent.id, 60000);
|
||||
expect(state.status).toBe("permission");
|
||||
expect(state.final?.pendingPermissions?.length).toBeGreaterThan(0);
|
||||
expect(state.final?.pendingPermissions?.[0]?.name).toBe("mcp__paseo-self-id__set_title");
|
||||
|
||||
// Approve the permission
|
||||
await ctx.client.respondToPermission(agent.id, state.final!.pendingPermissions![0]!.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
|
||||
// Wait for agent to complete
|
||||
const finalState = await ctx.client.waitForFinish(agent.id, 60000);
|
||||
expect(finalState.status).toBe("idle");
|
||||
expect(finalState.final?.title).toBe("Updated via MCP");
|
||||
}, 180000);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import "dotenv/config";
|
||||
import { beforeAll, afterAll } from "vitest";
|
||||
import { mkdtempSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { serializeAgentStreamEvent } from "./messages.js";
|
||||
|
||||
describe("serializeAgentStreamEvent", () => {
|
||||
test("strips leading paseo-instructions from user_message timeline items", () => {
|
||||
test("preserves user_message text as-is", () => {
|
||||
const event = {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
@@ -15,22 +15,7 @@ describe("serializeAgentStreamEvent", () => {
|
||||
} as any;
|
||||
|
||||
const serialized = serializeAgentStreamEvent(event) as any;
|
||||
expect(serialized.item.text).toBe("Hello");
|
||||
expect(serialized.item.text).toBe(event.item.text);
|
||||
expect(serialized.item.messageId).toBe("m1");
|
||||
});
|
||||
|
||||
test("does not strip non-leading tags", () => {
|
||||
const event = {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: "Hello <paseo-instructions>\nX\n</paseo-instructions>",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const serialized = serializeAgentStreamEvent(event) as any;
|
||||
expect(serialized.item.text).toBe(event.item.text);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||
import type { AgentStreamEvent } from "./agent/agent-sdk-types.js";
|
||||
import { stripLeadingPaseoInstructionTag } from "./agent/paseo-instructions-tag.js";
|
||||
import type {
|
||||
AgentSnapshotPayload,
|
||||
AgentStreamEventPayload,
|
||||
@@ -34,15 +33,5 @@ export function serializeAgentStreamEvent(
|
||||
if (event.item.type !== "user_message") {
|
||||
return event as AgentStreamEventPayload;
|
||||
}
|
||||
const stripped = stripLeadingPaseoInstructionTag(event.item.text);
|
||||
if (stripped === event.item.text) {
|
||||
return event as AgentStreamEventPayload;
|
||||
}
|
||||
return {
|
||||
...event,
|
||||
item: {
|
||||
...event.item,
|
||||
text: stripped,
|
||||
},
|
||||
} as AgentStreamEventPayload;
|
||||
return event as AgentStreamEventPayload;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export type AgentMcpTransportFactory = () => Promise<Transport>;
|
||||
import { buildProviderRegistry } from "./agent/provider-registry.js";
|
||||
import { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { ManagedAgent } from "./agent/agent-manager.js";
|
||||
import { injectLeadingPaseoInstructionTag } from "./agent/paseo-instructions-tag.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent/agent-metadata-generator.js";
|
||||
import { toAgentPayload } from "./agent/agent-projections.js";
|
||||
import {
|
||||
StructuredAgentResponseError,
|
||||
@@ -578,7 +578,7 @@ export class Session {
|
||||
});
|
||||
}
|
||||
|
||||
// Title updates are now handled by set_title MCP tool calls.
|
||||
// Title updates may be applied asynchronously after agent creation.
|
||||
},
|
||||
{ replayState: false }
|
||||
);
|
||||
@@ -1402,14 +1402,20 @@ export class Session {
|
||||
|
||||
const trimmedPrompt = initialPrompt?.trim();
|
||||
if (trimmedPrompt) {
|
||||
scheduleAgentMetadataGeneration({
|
||||
agentManager: this.agentManager,
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
initialPrompt: trimmedPrompt,
|
||||
explicitTitle: snapshot.config.title,
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
|
||||
try {
|
||||
const initialPromptWithInstructions = injectLeadingPaseoInstructionTag(
|
||||
trimmedPrompt,
|
||||
snapshot.config.paseoPromptInstructions
|
||||
);
|
||||
await this.handleSendAgentMessage(
|
||||
snapshot.id,
|
||||
initialPromptWithInstructions,
|
||||
trimmedPrompt,
|
||||
uuidv4(),
|
||||
images
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
|
||||
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
@@ -32,141 +31,6 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsToolInvocations: true,
|
||||
};
|
||||
|
||||
type UnixHttpResponse = {
|
||||
status: number;
|
||||
headers: http.IncomingHttpHeaders;
|
||||
body: string;
|
||||
};
|
||||
|
||||
function parseSseDataFrames(body: string): string[] {
|
||||
const frames: string[] = [];
|
||||
const parts = body.split(/\n\n+/g);
|
||||
for (const part of parts) {
|
||||
const lines = part.split("\n");
|
||||
const dataLines: string[] = [];
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data:")) {
|
||||
dataLines.push(line.slice("data:".length).trimStart());
|
||||
}
|
||||
}
|
||||
if (dataLines.length > 0) {
|
||||
frames.push(dataLines.join("\n"));
|
||||
}
|
||||
}
|
||||
return frames;
|
||||
}
|
||||
|
||||
function extractJsonRpcBody(res: UnixHttpResponse): unknown {
|
||||
const contentType = String(res.headers["content-type"] ?? "");
|
||||
if (contentType.includes("text/event-stream")) {
|
||||
const frames = parseSseDataFrames(res.body);
|
||||
if (frames.length === 0) {
|
||||
throw new Error("Empty SSE response from Self-ID MCP server");
|
||||
}
|
||||
return JSON.parse(frames[frames.length - 1]!);
|
||||
}
|
||||
return JSON.parse(res.body);
|
||||
}
|
||||
|
||||
async function unixSocketJsonRpcRequest(params: {
|
||||
socketPath: string;
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
body: unknown;
|
||||
}): Promise<UnixHttpResponse> {
|
||||
const bodyText = JSON.stringify(params.body);
|
||||
return await new Promise<UnixHttpResponse>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: params.socketPath,
|
||||
path: params.path,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(bodyText),
|
||||
Accept: "application/json, text/event-stream",
|
||||
...(params.headers ?? {}),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
status: res.statusCode ?? 500,
|
||||
headers: res.headers,
|
||||
body: Buffer.concat(chunks).toString("utf-8"),
|
||||
});
|
||||
});
|
||||
res.on("error", reject);
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(bodyText);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function callSelfIdMcpTool(params: {
|
||||
socketPath: string;
|
||||
callerAgentId: string;
|
||||
toolName: "set_title";
|
||||
args: { title: string };
|
||||
}): Promise<void> {
|
||||
// Minimal MCP-over-HTTP (Unix socket) client, modeled after packages/server/src/self-id-bridge.
|
||||
const urlPath = `/?callerAgentId=${encodeURIComponent(params.callerAgentId)}`;
|
||||
|
||||
const initReq = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "fake-agent", version: "0.0.0" },
|
||||
},
|
||||
};
|
||||
|
||||
const initRes = await unixSocketJsonRpcRequest({
|
||||
socketPath: params.socketPath,
|
||||
path: urlPath,
|
||||
body: initReq,
|
||||
});
|
||||
const mcpSessionId = typeof initRes.headers["mcp-session-id"] === "string" ? initRes.headers["mcp-session-id"] : null;
|
||||
|
||||
let protocolVersion: string | null = null;
|
||||
const initParsed = extractJsonRpcBody(initRes) as { result?: { protocolVersion?: string }; error?: { message?: string } };
|
||||
if (initParsed.error) {
|
||||
throw new Error(initParsed.error.message ?? "Self-ID MCP initialize failed");
|
||||
}
|
||||
protocolVersion = initParsed.result?.protocolVersion ?? null;
|
||||
|
||||
const toolReq = {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: params.toolName,
|
||||
arguments: params.args,
|
||||
},
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (mcpSessionId) headers["mcp-session-id"] = mcpSessionId;
|
||||
if (protocolVersion) headers["mcp-protocol-version"] = protocolVersion;
|
||||
|
||||
const toolRes = await unixSocketJsonRpcRequest({
|
||||
socketPath: params.socketPath,
|
||||
path: urlPath,
|
||||
headers,
|
||||
body: toolReq,
|
||||
});
|
||||
|
||||
const parsed = extractJsonRpcBody(toolRes) as { error?: { message?: string } };
|
||||
if (parsed.error) {
|
||||
throw new Error(parsed.error.message ?? "Self-ID MCP tool call failed");
|
||||
}
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
@@ -228,7 +92,7 @@ function buildToolCallForPrompt(provider: string, prompt: string) {
|
||||
const text = prompt.toLowerCase();
|
||||
if (provider === "claude") {
|
||||
if (text.includes("read") && text.includes("/etc/hosts")) {
|
||||
return { name: "Read", input: { path: "/etc/hosts" }, output: { lines: 7 } };
|
||||
return { name: "Read", input: { path: "/etc/hosts" }, output: undefined };
|
||||
}
|
||||
if (text.includes("rm -f permission.txt")) {
|
||||
return { name: "Bash", input: { command: "rm -f permission.txt" }, output: { ok: true } };
|
||||
@@ -242,9 +106,6 @@ function buildToolCallForPrompt(provider: string, prompt: string) {
|
||||
if (text.includes("edit") && text.includes(".txt")) {
|
||||
return { name: "Edit", input: { file: "test.txt" }, output: { applied: true } };
|
||||
}
|
||||
if (text.includes("set_title") && text.includes("mcp")) {
|
||||
return { name: "mcp__paseo-self-id__set_title", input: { title: "Updated via MCP" }, output: { ok: true } };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -253,10 +114,16 @@ function buildToolCallForPrompt(provider: string, prompt: string) {
|
||||
return { name: "shell", input: { command: "echo hello" }, output: { stdout: "hello\n" } };
|
||||
}
|
||||
if (text.includes("read") && text.includes("/etc/hosts")) {
|
||||
return { name: "read_file", input: { path: "/etc/hosts" }, output: { lines: 7 } };
|
||||
return { name: "read_file", input: { path: "/etc/hosts" }, output: undefined };
|
||||
}
|
||||
if (text.includes("read") && text.includes("tool-create.txt")) {
|
||||
return { name: "read_file", input: { path: "tool-create.txt" }, output: undefined };
|
||||
}
|
||||
if (text.includes("edit") && text.includes(".txt")) {
|
||||
return { name: "apply_patch", input: { patch: "*** Begin Patch\n*** End Patch\n" }, output: { applied: true } };
|
||||
const output = text.includes("tool-create.txt")
|
||||
? { applied: true, file: "tool-create.txt" }
|
||||
: { applied: true };
|
||||
return { name: "apply_patch", input: { patch: "*** Begin Patch\n*** End Patch\n" }, output };
|
||||
}
|
||||
const printfMatch =
|
||||
/printf\s+\"ok\"\s*>\s*([^\s`]+)/i.exec(text) ??
|
||||
@@ -415,6 +282,21 @@ class FakeAgentSession implements AgentSession {
|
||||
|
||||
await this.applyToolSideEffects(tool.name, tool.input ?? {}, textPrompt);
|
||||
|
||||
let toolOutput: unknown = tool.output;
|
||||
if (!toolOutput && (tool.name === "Read" || tool.name === "read_file")) {
|
||||
const pathInput =
|
||||
typeof tool.input?.path === "string" ? tool.input.path : "/etc/hosts";
|
||||
const resolvedPath = path.isAbsolute(pathInput)
|
||||
? pathInput
|
||||
: path.join(this.config.cwd ?? process.cwd(), pathInput);
|
||||
try {
|
||||
const content = readFileSync(resolvedPath, "utf8");
|
||||
toolOutput = { path: pathInput, content };
|
||||
} catch {
|
||||
toolOutput = { path: pathInput, content: "" };
|
||||
}
|
||||
}
|
||||
|
||||
const toolCompleted: AgentStreamEvent = {
|
||||
type: "timeline",
|
||||
provider: this.providerName,
|
||||
@@ -424,7 +306,7 @@ class FakeAgentSession implements AgentSession {
|
||||
callId,
|
||||
status: "completed",
|
||||
input: tool.input ?? undefined,
|
||||
output: tool.output ?? { ok: true },
|
||||
output: toolOutput ?? { ok: true },
|
||||
},
|
||||
};
|
||||
await this.appendHistoryEvent(toolCompleted);
|
||||
@@ -695,36 +577,21 @@ class FakeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === "mcp__paseo-self-id__set_title") {
|
||||
const title = typeof toolInput.title === "string" ? toolInput.title : null;
|
||||
const server = (this.config.mcpServers as Record<string, any> | undefined)?.["paseo-self-id"];
|
||||
const args = Array.isArray(server?.args) ? (server.args as string[]) : [];
|
||||
const socketIndex = args.indexOf("--socket");
|
||||
const agentIndex = args.indexOf("--agent-id");
|
||||
const socketPath = socketIndex >= 0 ? args[socketIndex + 1] : null;
|
||||
const callerAgentId = agentIndex >= 0 ? args[agentIndex + 1] : null;
|
||||
|
||||
if (!title || !socketPath || !callerAgentId) {
|
||||
throw new Error("FakeAgentSession missing paseo-self-id MCP config");
|
||||
}
|
||||
|
||||
await callSelfIdMcpTool({
|
||||
socketPath,
|
||||
callerAgentId,
|
||||
toolName: "set_title",
|
||||
args: { title },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === "Edit" || toolName === "apply_patch") {
|
||||
const lowerPrompt = prompt.toLowerCase();
|
||||
const match = /edit the file\s+([^\s]+)\s+and change/i.exec(prompt);
|
||||
const filePath = match?.[1];
|
||||
const filePath = match?.[1] ?? (lowerPrompt.includes("tool-create.txt") ? "tool-create.txt" : null);
|
||||
if (filePath) {
|
||||
try {
|
||||
const before = readFileSync(filePath, "utf8");
|
||||
const after = before.replace(/hello/g, "goodbye");
|
||||
writeFileSync(filePath, after);
|
||||
const resolved = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.join(this.config.cwd ?? process.cwd(), filePath);
|
||||
const before = readFileSync(resolved, "utf8");
|
||||
let after = before.replace(/hello/g, "goodbye");
|
||||
if (lowerPrompt.includes("alpha") && lowerPrompt.includes("beta")) {
|
||||
after = after.replace(/alpha/g, "beta");
|
||||
}
|
||||
writeFileSync(resolved, after);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ export async function createTestPaseoDaemon(
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: `${listenHost}:${port}`,
|
||||
paseoHome,
|
||||
selfIdMcpSocketPath: path.join(paseoHome, "self-id-mcp.sock"),
|
||||
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||
agentMcpRoute: "/mcp/agents",
|
||||
agentMcpAllowedHosts: [`127.0.0.1:${port}`, `localhost:${port}`, `${listenHost}:${port}`],
|
||||
|
||||
Reference in New Issue
Block a user