diff --git a/packages/server/src/server/acp/activity-curator.ts b/packages/server/src/server/acp/activity-curator.ts index 4253783bc..194790883 100644 --- a/packages/server/src/server/acp/activity-curator.ts +++ b/packages/server/src/server/acp/activity-curator.ts @@ -9,6 +9,9 @@ export function curateAgentActivity(updates: AgentUpdate[]): string { let thoughtBuffer = ""; for (const update of updates) { + if (!update.notification.update) { + continue; + } const updateType = update.notification.update.sessionUpdate; const content = (update.notification.update as any).content; diff --git a/packages/server/src/server/acp/agent-manager-permissions.test.ts b/packages/server/src/server/acp/agent-manager-permissions.test.ts new file mode 100644 index 000000000..15f62ee01 --- /dev/null +++ b/packages/server/src/server/acp/agent-manager-permissions.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { AgentManager } from "./agent-manager.js"; +import type { AgentUpdate } from "./types.js"; +import { mkdtemp, rm } from "fs/promises"; +import { tmpdir } from "os"; +import { join } from "path"; + +/** + * Vitest Test Suite for ACP Permission Flow + * + * This test validates that permission requests (like ExitPlanMode) are properly + * forwarded to the UI and can be responded to by the user. + * + * Run with: npx vitest run src/server/acp/agent-manager-permissions.test.ts + */ + +describe("AgentManager - Permission Flow", () => { + let manager: AgentManager; + let tmpDir: string; + + beforeAll(async () => { + manager = new AgentManager(); + tmpDir = await mkdtemp(join(tmpdir(), "acp-permission-test-")); + }); + + afterAll(async () => { + // Kill all agents + const agents = manager.listAgents(); + for (const agent of agents) { + await manager.killAgent(agent.id); + } + // Remove temp directory + await rm(tmpDir, { recursive: true, force: true }); + }); + + it( + "should emit permission request when agent calls ExitPlanMode", + async () => { + // Track all updates + const updates: AgentUpdate[] = []; + let permissionRequest: any = null; + + // Create agent in plan mode + const agentId = await manager.createAgent({ + cwd: tmpDir, + initialMode: "plan", + }); + + expect(agentId).toBeDefined(); + + // Subscribe to updates + const unsubscribe = manager.subscribeToUpdates(agentId, (update) => { + updates.push(update); + + // Check if this is a permission request + const notification = update.notification as any; + if (notification.type === "permissionRequest") { + permissionRequest = notification.permissionRequest; + console.log("[Test] Permission request received:", permissionRequest); + } + }); + + try { + // Send a prompt that will trigger ExitPlanMode + await manager.sendPrompt( + agentId, + "Create a dummy file called test.txt with the content 'hello'" + ); + + // Wait a bit for the agent to process and call ExitPlanMode + await new Promise((resolve) => setTimeout(resolve, 10000)); + + // Verify we got a permission request + expect(permissionRequest).toBeDefined(); + expect(permissionRequest.agentId).toBe(agentId); + expect(permissionRequest.requestId).toBeDefined(); + expect(permissionRequest.options).toBeDefined(); + expect(permissionRequest.options.length).toBeGreaterThan(0); + + // Verify the options include the expected plan mode options + const optionIds = permissionRequest.options.map((o: any) => o.optionId); + console.log("[Test] Option IDs:", optionIds); + + // Verify we have at least 2 options + expect(optionIds.length).toBeGreaterThanOrEqual(2); + + // The actual option IDs vary, but we should have some allow and reject options + // Common patterns: ['allow_always', 'allow', 'reject'] or ['acceptEdits', 'default', 'plan'] + const hasAllowOption = optionIds.some((id: string) => + id.includes('allow') || id.includes('default') || id.includes('Edits') + ); + const hasRejectOption = optionIds.some((id: string) => + id.includes('reject') || id.includes('plan') + ); + expect(hasAllowOption).toBe(true); + expect(hasRejectOption).toBe(true); + + // Verify the toolCall exists + expect(permissionRequest.toolCall).toBeDefined(); + console.log("[Test] Tool call:", JSON.stringify(permissionRequest.toolCall, null, 2)); + + // The toolCall structure can vary, but it should have either rawInput or other properties + if (permissionRequest.toolCall.rawInput) { + console.log("[Test] Plan:", permissionRequest.toolCall.rawInput.plan || permissionRequest.toolCall.rawInput); + } + + // Now simulate user approval (use first non-reject option) + const approveOption = permissionRequest.options.find((o: any) => + !o.optionId.includes('reject') && !o.optionId.includes('plan') + ); + expect(approveOption).toBeDefined(); + console.log("[Test] Simulating user approval with option:", approveOption.optionId); + manager.respondToPermission( + agentId, + permissionRequest.requestId, + approveOption.optionId + ); + + // Wait for agent to proceed after approval + await new Promise((resolve) => setTimeout(resolve, 15000)); + + // Verify agent proceeded (status should be processing or completed) + const status = manager.getAgentStatus(agentId); + console.log("[Test] Agent status after approval:", status); + expect(["processing", "completed", "ready"]).toContain(status); + + // The permission flow worked! The agent received permission and can proceed. + // We don't need to verify additional updates since the core permission mechanism works. + console.log("[Test] Permission flow verified successfully!"); + } finally { + unsubscribe(); + await manager.killAgent(agentId); + } + }, + 60000 // 60 second timeout + ); + + it( + "should handle permission rejection (keep planning)", + async () => { + let permissionRequest: any = null; + + // Create agent in plan mode + const agentId = await manager.createAgent({ + cwd: tmpDir, + initialMode: "plan", + }); + + expect(agentId).toBeDefined(); + + // Subscribe to updates + const unsubscribe = manager.subscribeToUpdates(agentId, (update) => { + const notification = update.notification as any; + if (notification.type === "permissionRequest") { + permissionRequest = notification.permissionRequest; + console.log("[Test] Permission request received for rejection test"); + } + }); + + try { + // Send a prompt + await manager.sendPrompt( + agentId, + "Create a file called reject-test.txt" + ); + + // Wait for permission request + await new Promise((resolve) => setTimeout(resolve, 10000)); + + expect(permissionRequest).toBeDefined(); + + // Reject the permission (choose reject/plan option) + const rejectOption = permissionRequest.options.find((o: any) => + o.optionId.includes('reject') || o.optionId.includes('plan') + ); + expect(rejectOption).toBeDefined(); + console.log("[Test] Simulating user rejection with option:", rejectOption.optionId); + manager.respondToPermission( + agentId, + permissionRequest.requestId, + rejectOption.optionId + ); + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 5000)); + + // Agent should remain ready or processing (not failed) + const status = manager.getAgentStatus(agentId); + console.log("[Test] Agent status after rejection:", status); + expect(["ready", "processing", "completed"]).toContain(status); + } finally { + unsubscribe(); + await manager.killAgent(agentId); + } + }, + 60000 + ); +}); diff --git a/packages/server/src/server/acp/agent-manager.ts b/packages/server/src/server/acp/agent-manager.ts index 294723b0c..4bc37a36c 100644 --- a/packages/server/src/server/acp/agent-manager.ts +++ b/packages/server/src/server/acp/agent-manager.ts @@ -22,8 +22,17 @@ import type { CreateAgentOptions, AgentUpdateCallback, SessionMode, + EnrichedSessionNotification, } from "./types.js"; +interface PendingPermission { + requestId: string; + sessionId: string; + params: RequestPermissionRequest; + resolve: (response: RequestPermissionResponse) => void; + reject: (error: Error) => void; +} + interface ManagedAgent { id: string; status: AgentStatus; @@ -36,6 +45,13 @@ interface ManagedAgent { availableModes?: SessionMode[]; subscribers: Set; updates: AgentUpdate[]; + title?: string; + cwd: string; + titleGenerationTriggered: boolean; + pendingPermissions: Map; + // Message ID tracking for stable deduplication + currentAssistantMessageId: string | null; + currentThoughtId: string | null; } /** @@ -44,20 +60,15 @@ interface ManagedAgent { class ACPClient implements Client { constructor( private agentId: string, - private onUpdate: (agentId: string, update: SessionNotification) => void + private onUpdate: (agentId: string, update: SessionNotification) => void, + private onPermissionRequest: (agentId: string, params: RequestPermissionRequest) => Promise ) {} async requestPermission(params: RequestPermissionRequest): Promise { - // TODO: Forward permission requests to the UI for user approval - // For now, auto-approve all permissions - console.log(`[Agent ${this.agentId}] Permission requested (auto-approving):`, params); + console.log(`[Agent ${this.agentId}] Permission requested:`, params); - return { - outcome: { - outcome: "selected" as const, - optionId: params.options[0]?.optionId || "", - }, - }; + // Forward to agent manager which will handle the permission flow + return this.onPermissionRequest(this.agentId, params); } async sessionUpdate(params: SessionNotification): Promise { @@ -128,6 +139,9 @@ export class AgentManager { agentId, (id, update) => { this.handleSessionNotification(id, update); + }, + (id, params) => { + return this.handlePermissionRequest(id, params); } ); const connection = new ClientSideConnection(() => client, stream); @@ -141,6 +155,11 @@ export class AgentManager { connection, subscribers: new Set(), updates: [], + cwd, + titleGenerationTriggered: false, + pendingPermissions: new Map(), + currentAssistantMessageId: null, + currentThoughtId: null, }; this.agents.set(agentId, agent); @@ -397,12 +416,14 @@ export class AgentManager { return Array.from(this.agents.values()).map((agent) => ({ id: agent.id, status: agent.status, - createdAt: agent.createdAt, + createdAt: agent.createdAt.toISOString(), type: "claude" as const, sessionId: agent.sessionId ?? null, error: agent.error ?? null, currentModeId: agent.currentModeId ?? null, availableModes: agent.availableModes ?? null, + title: agent.title ?? null, + cwd: agent.cwd, })); } @@ -499,15 +520,54 @@ export class AgentManager { /** * Handle session notifications from the ACP connection + * Augments agent message and thought chunks with stable message IDs */ private handleSessionNotification(agentId: string, update: SessionNotification): void { const agent = this.agents.get(agentId); if (!agent) return; + // Augment update with stable message IDs for deduplication + let enrichedUpdate: EnrichedSessionNotification = update; + + const updateType = update.update.sessionUpdate; + + // Agent message chunks - add stable message ID + if (updateType === 'agent_message_chunk') { + if (!agent.currentAssistantMessageId) { + agent.currentAssistantMessageId = uuidv4(); + } + enrichedUpdate = { + ...update, + update: { + ...update.update, + messageId: agent.currentAssistantMessageId, + }, + }; + } + // Agent thought chunks - add stable message ID + else if (updateType === 'agent_thought_chunk') { + if (!agent.currentThoughtId) { + agent.currentThoughtId = uuidv4(); + } + enrichedUpdate = { + ...update, + update: { + ...update.update, + messageId: agent.currentThoughtId, + }, + }; + } + // Reset message IDs on new turn (user message or tool call starts new turn) + else if (updateType === 'tool_call' || updateType === 'user_message_chunk') { + agent.currentAssistantMessageId = null; + agent.currentThoughtId = null; + } + + // Create agent update with enriched notification const agentUpdate: AgentUpdate = { agentId, timestamp: new Date(), - notification: update as any, + notification: enrichedUpdate, }; // Store the update in history @@ -520,7 +580,6 @@ export class AgentManager { } // Log update for debugging - const updateType = update.update.sessionUpdate; console.log( `[Agent ${agentId}] Session update:`, updateType @@ -606,4 +665,204 @@ export class AgentManager { } } } + + /** + * Set the title for an agent + */ + setAgentTitle(agentId: string, title: string): void { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + + agent.title = title; + console.log(`[Agent ${agentId}] Title set to: "${title}"`); + } + + /** + * Get the title for an agent + */ + getAgentTitle(agentId: string): string | null { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + return agent.title ?? null; + } + + /** + * Mark that title generation has been triggered for this agent + */ + markTitleGenerationTriggered(agentId: string): void { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + agent.titleGenerationTriggered = true; + } + + /** + * Check if title generation has been triggered for this agent + */ + isTitleGenerationTriggered(agentId: string): boolean { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + return agent.titleGenerationTriggered; + } + + /** + * Handle permission request from ACP + * Creates a pending permission and emits it via session notifications + */ + private handlePermissionRequest( + agentId: string, + params: RequestPermissionRequest + ): Promise { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + + // Generate unique request ID + const requestId = uuidv4(); + + console.log(`[Agent ${agentId}] Creating pending permission request: ${requestId}`); + + // Create a promise that will be resolved when user responds + return new Promise((resolve, reject) => { + // Store the pending permission + const pendingPermission: PendingPermission = { + requestId, + sessionId: params.sessionId, + params, + resolve, + reject, + }; + + agent.pendingPermissions.set(requestId, pendingPermission); + + // Emit permission request via session notification + // This will be picked up by subscribers (Session) and forwarded to UI + const permissionNotification: any = { + type: "permissionRequest", + permissionRequest: { + agentId, + requestId, + sessionId: params.sessionId, + toolCall: params.toolCall, + options: params.options, + }, + }; + + const agentUpdate: AgentUpdate = { + agentId, + timestamp: new Date(), + notification: permissionNotification, + }; + + // Store the update in history + agent.updates.push(agentUpdate); + + // Notify all subscribers + for (const subscriber of agent.subscribers) { + try { + subscriber(agentUpdate); + } catch (error) { + console.error(`[Agent ${agentId}] Subscriber error:`, error); + } + } + + console.log(`[Agent ${agentId}] Permission request emitted: ${requestId}`); + + // Set a timeout to auto-reject after 5 minutes if no response + setTimeout(() => { + if (agent.pendingPermissions.has(requestId)) { + console.warn(`[Agent ${agentId}] Permission request ${requestId} timed out`); + agent.pendingPermissions.delete(requestId); + reject(new Error("Permission request timed out")); + } + }, 5 * 60 * 1000); // 5 minutes + }); + } + + /** + * Respond to a pending permission request + * Called when user makes a choice in the UI + */ + respondToPermission( + agentId: string, + requestId: string, + optionId: string + ): void { + const agent = this.agents.get(agentId); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + + const pendingPermission = agent.pendingPermissions.get(requestId); + if (!pendingPermission) { + throw new Error( + `Permission request ${requestId} not found for agent ${agentId}` + ); + } + + console.log( + `[Agent ${agentId}] Resolving permission ${requestId} with option: ${optionId}` + ); + + // Resolve the promise with the user's choice + pendingPermission.resolve({ + outcome: { + outcome: "selected" as const, + optionId, + }, + }); + + // Remove from pending + agent.pendingPermissions.delete(requestId); + } + + /** + * Get all pending permission requests across all agents + * Used by orchestrator to see what permissions are waiting + */ + getPendingPermissions(): Array<{ + agentId: string; + requestId: string; + sessionId: string; + toolCall: any; + options: Array<{ + kind: string; + name: string; + optionId: string; + }>; + }> { + const allPermissions: Array<{ + agentId: string; + requestId: string; + sessionId: string; + toolCall: any; + options: Array<{ + kind: string; + name: string; + optionId: string; + }>; + }> = []; + + for (const [agentId, agent] of this.agents) { + for (const [requestId, permission] of agent.pendingPermissions) { + allPermissions.push({ + agentId, + requestId, + sessionId: permission.sessionId, + toolCall: permission.params.toolCall, + options: permission.params.options, + }); + } + } + + return allPermissions; + } } diff --git a/packages/server/src/server/acp/mcp-server.ts b/packages/server/src/server/acp/mcp-server.ts index f661b29bf..893d0bd3e 100644 --- a/packages/server/src/server/acp/mcp-server.ts +++ b/packages/server/src/server/acp/mcp-server.ts @@ -180,7 +180,7 @@ export async function createAgentMcpServer( .object({ id: z.string(), status: z.string(), - createdAt: z.date(), + createdAt: z.string(), type: z.literal("claude"), sessionId: z.string().nullable(), error: z.string().nullable(), @@ -228,7 +228,7 @@ export async function createAgentMcpServer( z.object({ id: z.string(), status: z.string(), - createdAt: z.date(), + createdAt: z.string(), type: z.literal("claude"), sessionId: z.string().nullable(), error: z.string().nullable(), @@ -432,5 +432,75 @@ export async function createAgentMcpServer( } ); + // Tool: list_pending_permissions + server.registerTool( + "list_pending_permissions", + { + title: "List Pending Permission Requests", + description: + "Get all pending permission requests from agents. When an agent in plan mode calls ExitPlanMode, or when an agent needs permission for file operations or commands, it creates a permission request. This tool shows all requests waiting for approval across all agents. Use respond_to_permission to approve or reject them.", + inputSchema: {}, + outputSchema: { + permissions: z.array( + z.object({ + agentId: z.string().describe("Agent that requested permission"), + requestId: z.string().describe("Unique identifier for this permission request"), + sessionId: z.string().describe("Agent's session ID"), + toolCall: z.any().describe("The tool call that triggered the permission request (contains details like plan, file_path, command, etc.)"), + options: z.array( + z.object({ + kind: z.string().describe("Option kind: 'allow_always', 'allow_once', 'reject_once'"), + name: z.string().describe("Human-readable option name"), + optionId: z.string().describe("Option ID to use when responding"), + }) + ).describe("Available response options"), + }) + ).describe("List of all pending permission requests"), + }, + }, + async () => { + const permissions = agentManager.getPendingPermissions(); + + const result = { + permissions, + }; + + return { + content: [], + structuredContent: result, + }; + } + ); + + // Tool: respond_to_permission + server.registerTool( + "respond_to_permission", + { + title: "Respond to Permission Request", + description: + "Approve or reject a pending permission request from an agent. When an agent requests permission (e.g., to exit plan mode, create a file, or run a command), you can use this tool to respond with your decision. The agent will proceed based on your response.", + inputSchema: { + agentId: z.string().describe("Agent ID (from list_pending_permissions)"), + requestId: z.string().describe("Permission request ID (from list_pending_permissions)"), + optionId: z.string().describe("Option ID to select (e.g., 'allow', 'reject', 'plan'). Get available options from list_pending_permissions."), + }, + outputSchema: { + success: z.boolean().describe("Whether the response was successfully sent to the agent"), + }, + }, + async ({ agentId, requestId, optionId }) => { + agentManager.respondToPermission(agentId, requestId, optionId); + + const result = { + success: true, + }; + + return { + content: [], + structuredContent: result, + }; + } + ); + return server; } diff --git a/packages/server/src/server/acp/types.ts b/packages/server/src/server/acp/types.ts index e78d0ab9f..bbc532d56 100644 --- a/packages/server/src/server/acp/types.ts +++ b/packages/server/src/server/acp/types.ts @@ -1,5 +1,20 @@ import type { SessionNotification } from "@agentclientprotocol/sdk"; +/** + * Extended update types with messageId for proper deduplication + */ +type AgentMessageChunkWithId = Extract & { messageId: string }; +type AgentThoughtChunkWithId = Extract & { messageId: string }; + +export type EnrichedSessionUpdate = + | AgentMessageChunkWithId + | AgentThoughtChunkWithId + | Exclude; + +export interface EnrichedSessionNotification extends Omit { + update: EnrichedSessionUpdate; +} + /** * Status of an agent */ @@ -17,22 +32,24 @@ export type AgentStatus = export interface AgentInfo { id: string; status: AgentStatus; - createdAt: Date; + createdAt: string; type: "claude"; sessionId: string | null; error: string | null; currentModeId: string | null; availableModes: SessionMode[] | null; + title: string | null; + cwd: string; } /** * Update from an agent session - * Wraps ACP SessionNotification with additional metadata + * Wraps enriched SessionNotification with additional metadata */ export interface AgentUpdate { agentId: string; timestamp: Date; - notification: SessionNotification; + notification: EnrichedSessionNotification; } /** diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index a9479604d..91864a3ef 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -10,6 +10,7 @@ import { deleteConversation, } from "./persistence.js"; import { AgentManager } from "./acp/agent-manager.js"; +import { initializeTitleGenerator } from "../services/agent-title-generator.js"; function createServer() { const app = express(); @@ -95,6 +96,9 @@ function main() { model: ttsModel, responseFormat: "pcm", }); + + // Initialize agent title generator + initializeTitleGenerator(apiKey); } else { console.warn( "⚠ OPENAI_API_KEY not set - LLM, STT, and TTS features will not work" diff --git a/packages/server/src/server/messages.ts b/packages/server/src/server/messages.ts index ceded7bf2..22a784877 100644 --- a/packages/server/src/server/messages.ts +++ b/packages/server/src/server/messages.ts @@ -71,6 +71,13 @@ export const SetAgentModeMessageSchema = z.object({ modeId: z.string(), }); +export const AgentPermissionResponseMessageSchema = z.object({ + type: z.literal("agent_permission_response"), + agentId: z.string(), + requestId: z.string(), + optionId: z.string(), +}); + export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ UserTextMessageSchema, RealtimeAudioChunkMessageSchema, @@ -84,6 +91,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SendAgentAudioSchema, CreateAgentRequestMessageSchema, SetAgentModeMessageSchema, + AgentPermissionResponseMessageSchema, ]); export type SessionInboundMessage = z.infer; @@ -178,6 +186,8 @@ export const AgentCreatedMessageSchema = z.object({ name: z.string(), description: z.string().nullable().optional(), })).optional(), + title: z.string().optional(), + cwd: z.string(), }), }); @@ -208,6 +218,8 @@ export const AgentStatusMessageSchema = z.object({ name: z.string(), description: z.string().nullable().optional(), })).optional(), + title: z.string().optional(), + cwd: z.string(), }), }), }); @@ -229,6 +241,8 @@ export const SessionStateMessageSchema = z.object({ name: z.string(), description: z.string().nullable().optional(), })).nullable(), + title: z.string().nullable(), + cwd: z.string(), }) ), commands: z.array( @@ -266,6 +280,26 @@ export const DeleteConversationResponseMessageSchema = z.object({ }), }); +export const AgentPermissionRequestMessageSchema = z.object({ + type: z.literal("agent_permission_request"), + payload: z.object({ + agentId: z.string(), + requestId: z.string(), + sessionId: z.string(), + toolCall: z.object({ + toolCallId: z.string(), + rawInput: z.object({ + plan: z.string(), + }), + }), + options: z.array(z.object({ + kind: z.enum(["allow_always", "allow_once", "reject_once"]), + name: z.string(), + optionId: z.string(), + })), + }), +}); + export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ActivityLogMessageSchema, AssistantChunkMessageSchema, @@ -280,6 +314,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ SessionStateMessageSchema, ListConversationsResponseMessageSchema, DeleteConversationResponseMessageSchema, + AgentPermissionRequestMessageSchema, ]); export type SessionOutboundMessage = z.infer< @@ -300,6 +335,7 @@ export type AgentStatusMessage = z.infer; export type SessionStateMessage = z.infer; export type ListConversationsResponseMessage = z.infer; export type DeleteConversationResponseMessage = z.infer; +export type AgentPermissionRequestMessage = z.infer; // Type exports for payload types export type ActivityLogPayload = z.infer; @@ -311,6 +347,7 @@ export type SendAgentMessage = z.infer; export type SendAgentAudio = z.infer; export type CreateAgentRequestMessage = z.infer; export type SetAgentModeMessage = z.infer; +export type AgentPermissionResponseMessage = z.infer; // ============================================================================ // WebSocket Level Messages (wraps session messages) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index b791907b0..556e8cb09 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -22,6 +22,7 @@ import { createTerminalMcpServer } from "./terminal-mcp/index.js"; import { AgentManager } from "./acp/agent-manager.js"; import { createAgentMcpServer } from "./acp/mcp-server.js"; import type { AgentUpdate } from "./acp/types.js"; +import { generateAgentTitle, isTitleGeneratorInitialized } from "../services/agent-title-generator.js"; const execAsync = promisify(exec); @@ -148,6 +149,27 @@ export class Session { const unsubscribe = this.agentManager.subscribeToUpdates( agentId, (update: AgentUpdate) => { + // Check if this is a permission request + const notification = update.notification as any; + console.log(`[Session ${this.clientId}] Agent update notification type:`, notification.type); + + if (notification.type === "permissionRequest" && notification.permissionRequest) { + // Forward permission request as a dedicated message type + const permissionRequest = notification.permissionRequest; + this.emit({ + type: "agent_permission_request", + payload: { + agentId: permissionRequest.agentId, + requestId: permissionRequest.requestId, + sessionId: permissionRequest.sessionId, + toolCall: permissionRequest.toolCall, + options: permissionRequest.options, + }, + }); + console.log(`[Session ${this.clientId}] Forwarded permission request ${permissionRequest.requestId} for agent ${agentId}`); + return; + } + // Forward agent updates to WebSocket this.emit({ type: "agent_update", @@ -158,9 +180,11 @@ export class Session { }, }); + // Trigger title generation after first meaningful update + this.maybeTriggerTitleGeneration(agentId); + // Check if this is a status change notification // The agent manager sends custom notifications with status field - const notification = update.notification as any; if (notification && notification.sessionUpdate && notification.sessionUpdate.status) { const status = notification.sessionUpdate.status; @@ -183,6 +207,8 @@ export class Session { error: info.error ?? undefined, currentModeId: info.currentModeId ?? undefined, availableModes: info.availableModes ?? undefined, + title: info.title ?? undefined, + cwd: info.cwd, }, }, }); @@ -199,6 +225,74 @@ export class Session { console.log(`[Session ${this.clientId}] Subscribed to agent ${agentId} updates`); } + /** + * Maybe trigger title generation for an agent + * Only generates title once after first meaningful activity + */ + private maybeTriggerTitleGeneration(agentId: string): void { + // Skip if title generator not initialized + if (!isTitleGeneratorInitialized()) { + return; + } + + // Skip if already triggered + if (this.agentManager.isTitleGenerationTriggered(agentId)) { + return; + } + + // Get agent updates + const updates = this.agentManager.getAgentUpdates(agentId); + + // Need at least a few updates before generating title + if (updates.length < 2) { + return; + } + + // Mark as triggered to prevent duplicate generation + this.agentManager.markTitleGenerationTriggered(agentId); + + // Generate title in background + const info = this.agentManager.listAgents().find(a => a.id === agentId); + if (!info) { + return; + } + + console.log(`[Session ${this.clientId}] Triggering title generation for agent ${agentId}`); + + generateAgentTitle(updates, info.cwd) + .then((title) => { + // Set the title + this.agentManager.setAgentTitle(agentId, title); + + // Emit agent_status to sync the new title to client + const updatedInfo = this.agentManager.listAgents().find(a => a.id === agentId); + if (updatedInfo) { + this.emit({ + type: "agent_status", + payload: { + agentId, + status: updatedInfo.status, + info: { + id: updatedInfo.id, + status: updatedInfo.status, + createdAt: updatedInfo.createdAt, + type: updatedInfo.type, + sessionId: updatedInfo.sessionId ?? undefined, + error: updatedInfo.error ?? undefined, + currentModeId: updatedInfo.currentModeId ?? undefined, + availableModes: updatedInfo.availableModes ?? undefined, + title: updatedInfo.title ?? undefined, + cwd: updatedInfo.cwd, + }, + }, + }); + } + }) + .catch((error) => { + console.error(`[Session ${this.clientId}] Failed to generate title for agent ${agentId}:`, error); + }); + } + /** * Initialize Terminal MCP client for this session */ @@ -329,6 +423,10 @@ export class Session { case "set_agent_mode": await this.handleSetAgentMode(msg.agentId, msg.modeId); break; + + case "agent_permission_response": + await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.optionId); + break; } } catch (error: any) { console.error( @@ -454,7 +552,7 @@ export class Session { type: "agent_update", payload: { agentId, - timestamp: new Date().toISOString(), + timestamp: new Date(), notification: { type: "sessionUpdate", update: { @@ -584,8 +682,10 @@ export class Session { agentId, status: agentInfo?.status || "initializing", type: "claude", - currentModeId: agentInfo?.currentModeId, - availableModes: agentInfo?.availableModes, + currentModeId: agentInfo?.currentModeId ?? undefined, + availableModes: agentInfo?.availableModes ?? undefined, + title: agentInfo?.title ?? undefined, + cwd: agentInfo?.cwd || cwd, }, }); console.log(`[Session ${this.clientId}] Emitted agent_created with currentModeId:`, agentInfo?.currentModeId); @@ -630,6 +730,8 @@ export class Session { status: info.status, createdAt: info.createdAt, type: info.type, + title: info.title ?? undefined, + cwd: info.cwd, sessionId: info.sessionId ?? undefined, error: info.error ?? undefined, currentModeId: info.currentModeId ?? undefined, @@ -653,6 +755,30 @@ export class Session { } } + /** + * Handle agent permission response from user + */ + private async handleAgentPermissionResponse(agentId: string, requestId: string, optionId: string): Promise { + console.log(`[Session ${this.clientId}] Handling permission response for agent ${agentId}, request ${requestId}, option ${optionId}`); + + try { + this.agentManager.respondToPermission(agentId, requestId, optionId); + console.log(`[Session ${this.clientId}] Permission response forwarded to agent ${agentId}`); + } catch (error: any) { + console.error(`[Session ${this.clientId}] Failed to respond to permission:`, error); + this.emit({ + type: "activity_log", + payload: { + id: uuidv4(), + timestamp: new Date(), + type: "error", + content: `Failed to respond to permission: ${error.message}`, + }, + }); + throw error; + } + } + /** * Send current session state (live agents and commands) to client */ @@ -677,7 +803,7 @@ export class Session { type: "agent_update", payload: { agentId: update.agentId, - timestamp: update.timestamp.toISOString(), + timestamp: update.timestamp instanceof Date ? update.timestamp : new Date(update.timestamp), notification: update.notification, }, }); @@ -1072,24 +1198,26 @@ export class Session { const result = chunk.output as any; if (result.structuredContent?.agentId) { const agentId = result.structuredContent.agentId; - const status = result.structuredContent.status; - const currentModeId = result.structuredContent.currentModeId; - const availableModes = result.structuredContent.availableModes; // Subscribe to agent updates this.subscribeToAgent(agentId); - // Emit agent_created message - this.emit({ - type: "agent_created", - payload: { - agentId, - status, - type: "claude", - currentModeId, - availableModes, - }, - }); + // Get full agent info and emit agent_created message + const agentInfo = this.agentManager.listAgents().find(a => a.id === agentId); + if (agentInfo) { + this.emit({ + type: "agent_created", + payload: { + agentId, + status: agentInfo.status, + type: "claude", + currentModeId: agentInfo.currentModeId ?? undefined, + availableModes: agentInfo.availableModes ?? undefined, + title: agentInfo.title || undefined, + cwd: agentInfo.cwd, + }, + }); + } } } diff --git a/packages/server/src/services/agent-title-generator.ts b/packages/server/src/services/agent-title-generator.ts new file mode 100644 index 000000000..4c0a264a0 --- /dev/null +++ b/packages/server/src/services/agent-title-generator.ts @@ -0,0 +1,103 @@ +import { generateObject } from "ai"; +import { createOpenAI } from "@ai-sdk/openai"; +import { z } from "zod"; +import type { AgentUpdate } from "../server/acp/types.js"; + +let openai: ReturnType | null = null; + +export function initializeTitleGenerator(apiKey: string): void { + openai = createOpenAI({ apiKey }); + console.log("✓ Agent title generator initialized"); +} + +export function isTitleGeneratorInitialized(): boolean { + return openai !== null; +} + +/** + * Extract text context from agent updates for title generation + */ +function extractActivityContext(updates: AgentUpdate[]): string { + const lines: string[] = []; + + for (const update of updates.slice(0, 10)) { // Only use first 10 updates for context + const notification = update.notification as any; + + if (notification?.update?.sessionUpdate) { + const sessionUpdate = notification.update.sessionUpdate; + + // Extract user messages + if (sessionUpdate.userMessage?.text) { + lines.push(`User: ${sessionUpdate.userMessage.text}`); + } + + // Extract assistant messages + if (sessionUpdate.assistantMessage?.text) { + lines.push(`Assistant: ${sessionUpdate.assistantMessage.text}`); + } + + // Extract tool calls (showing what actions were taken) + if (sessionUpdate.toolCall) { + const toolCall = sessionUpdate.toolCall; + lines.push(`Tool: ${toolCall.toolName || 'unknown'}`); + } + + // Extract plan entries + if (sessionUpdate.plan?.entries) { + for (const entry of sessionUpdate.plan.entries.slice(0, 3)) { + if (entry.content) { + lines.push(`Plan: ${entry.content}`); + } + } + } + } + } + + return lines.join('\n'); +} + +/** + * Generate a concise title for an agent based on its activity + * Returns a 3-5 word title similar to ChatGPT/Claude.ai + */ +export async function generateAgentTitle( + agentUpdates: AgentUpdate[], + cwd: string +): Promise { + if (!openai) { + throw new Error("Title generator not initialized"); + } + + if (agentUpdates.length === 0) { + return "New Agent"; + } + + const activityContext = extractActivityContext(agentUpdates); + + if (!activityContext.trim()) { + return "New Agent"; + } + + try { + const { object } = await generateObject({ + model: openai("gpt-5-nano"), + schema: z.object({ + title: z.string().describe("A concise 3-5 word title describing what the agent is working on"), + }), + prompt: `Generate a concise title for this agent session based on the activity. The title should describe what the agent is working on. Be specific but brief. Examples: "Fix Authentication Bug", "Build Dashboard Component", "Refactor API Routes". + +Working directory: ${cwd} + +Activity: +${activityContext}`, + temperature: 0.7, + }); + + console.log(`[TitleGenerator] Generated title: "${object.title}"`); + + return object.title; + } catch (error: any) { + console.error("[TitleGenerator] Failed to generate title:", error); + return "New Agent"; + } +}