diff --git a/packages/agent/agent.ts b/packages/agent/agent.ts deleted file mode 100644 index 0f1a92ba22..0000000000 --- a/packages/agent/agent.ts +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env node - -import { runAcp } from "./src/adapters/claude/claude.js"; - -runAcp(); diff --git a/packages/agent/example-client.ts b/packages/agent/example-client.ts index d796f8a1f5..41e1f3b847 100644 --- a/packages/agent/example-client.ts +++ b/packages/agent/example-client.ts @@ -1,10 +1,9 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import "dotenv/config"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import * as readline from "node:readline/promises"; -import { Readable, Writable } from "node:stream"; import { fileURLToPath } from "node:url"; import { @@ -20,8 +19,10 @@ import { type WriteTextFileRequest, type WriteTextFileResponse, } from "@agentclientprotocol/sdk"; +import { Agent } from "./src/agent.js"; import { PostHogAPIClient } from "./src/posthog-api.js"; import type { SessionPersistenceConfig } from "./src/session-store.js"; +import { Logger } from "./src/utils/logger.js"; // PostHog configuration - set via env vars const POSTHOG_CONFIG = { @@ -30,6 +31,8 @@ const POSTHOG_CONFIG = { projectId: parseInt(process.env.POSTHOG_PROJECT_ID || "0", 10), }; +const logger = new Logger({ debug: true, prefix: "[example-client]" }); + // Simple file-based storage for session -> persistence mapping const SESSION_STORE_PATH = join( dirname(fileURLToPath(import.meta.url)), @@ -111,6 +114,9 @@ class ExampleClient implements Client { console.log(`[${update.content.type}]`); } break; + case "user_message_chunk": + // Skip rendering user messages live - the user already sees what they typed + break; case "tool_call": console.log(`\nšŸ”§ ${update.title} (${update.status})`); break; @@ -120,10 +126,13 @@ class ExampleClient implements Client { ); break; case "plan": - case "agent_thought_chunk": - case "user_message_chunk": console.log(`[${update.sessionUpdate}]`); break; + case "agent_thought_chunk": + if (update.content.type === "text") { + process.stdout.write(`šŸ’­ ${update.content.text}`); + } + break; default: break; } @@ -142,7 +151,7 @@ class ExampleClient implements Client { case "user_message_chunk": if (update.content.type === "text") { process.stdout.write( - `\n${dim}šŸ’¬ You: ${update.content.text}${reset}`, + `\n${dim}šŸ’¬ You: ${update.content.text}${reset}\n`, ); } break; @@ -219,10 +228,6 @@ async function prompt(message: string): Promise { } async function main() { - const __filename = fileURLToPath(import.meta.url); - const __dirname = dirname(__filename); - const agentPath = join(__dirname, "agent.ts"); - // Check for session ID argument: npx tsx example-client.ts [sessionId] const existingSessionId = process.argv[2]; @@ -286,28 +291,33 @@ async function main() { console.log(" Starting fresh without persistence...\n"); } - // Spawn the agent as a subprocess using tsx - // Pass PostHog config as env vars so agent can create its own SessionStore - const agentProcess = spawn("npx", ["tsx", agentPath], { - stdio: ["pipe", "pipe", "inherit"], - env: { - ...process.env, - POSTHOG_API_URL: POSTHOG_CONFIG.apiUrl, - POSTHOG_API_KEY: POSTHOG_CONFIG.apiKey, - POSTHOG_PROJECT_ID: String(POSTHOG_CONFIG.projectId), + // Create Agent and get in-process ACP connection + const agent = new Agent({ + workingDirectory: process.cwd(), + debug: true, + onLog: (level, scope, message, data) => { + logger.log(level, message, data, scope); }, + ...(POSTHOG_CONFIG.apiUrl && { posthogApiUrl: POSTHOG_CONFIG.apiUrl }), + ...(POSTHOG_CONFIG.apiKey && { posthogApiKey: POSTHOG_CONFIG.apiKey }), + ...(POSTHOG_CONFIG.projectId && { posthogProjectId: POSTHOG_CONFIG.projectId }), }); - // Create streams to communicate with the agent - const input = Writable.toWeb(agentProcess.stdin!); - const output = Readable.toWeb( - agentProcess.stdout!, - ) as unknown as ReadableStream; + if (!persistence) { + logger.error("PostHog configuration required for runTaskV2"); + process.exit(1); + } + + const { clientStreams } = await agent.runTaskV2( + persistence.taskId, + persistence.runId, + { skipGitBranch: true }, + ); - // Create the client connection + // Create the client connection using the in-memory streams const client = new ExampleClient(); - const stream = ndJsonStream(input, output); - const connection = new ClientSideConnection((_agent) => client, stream); + const clientStream = ndJsonStream(clientStreams.writable, clientStreams.readable); + const connection = new ClientSideConnection((_agent) => client, clientStream); try { // Initialize the connection @@ -414,7 +424,6 @@ async function main() { } catch (error) { console.error("[Client] Error:", error); } finally { - agentProcess.kill(); process.exit(0); } } diff --git a/packages/agent/example.ts b/packages/agent/example.ts index 24efdac156..e89f0b6f93 100644 --- a/packages/agent/example.ts +++ b/packages/agent/example.ts @@ -90,12 +90,6 @@ async function testAgent() { posthogProjectId: process.env.POSTHOG_PROJECT_ID ? parseInt(process.env.POSTHOG_PROJECT_ID, 10) : 1, - onEvent: (event) => { - if (event.type === "token") { - return; - } - console.log(`[event:${event.type}]`, event); - }, debug: true, }); diff --git a/packages/agent/index.ts b/packages/agent/index.ts index 0ba7eb46f2..d55cb2cc7b 100644 --- a/packages/agent/index.ts +++ b/packages/agent/index.ts @@ -1,17 +1,22 @@ // Main entry point - re-exports from src +// ACP connection utilities +export type { + AcpConnectionConfig, + InProcessAcpConnection, +} from "./src/adapters/claude/claude.js"; +export { createAcpConnection } from "./src/adapters/claude/claude.js"; + // Session persistence export type { SessionPersistenceConfig } from "./src/session-store.js"; export { SessionStore } from "./src/session-store.js"; -// TODO: Refactor - legacy adapter removed -// export { ClaudeAdapter } from "./src/adapters/claude-legacy/claude-adapter.js"; -// export type { ProviderAdapter } from "./src/adapters/types.js"; -// export { Agent } from "./src/agent.js"; -export type { TodoItem, TodoList } from "./src/todo-manager.js"; + // Todo management +export type { TodoItem, TodoList } from "./src/todo-manager.js"; export { TodoManager } from "./src/todo-manager.js"; -export { ToolRegistry } from "./src/tools/registry.js"; + // Tool types +export { ToolRegistry } from "./src/tools/registry.js"; export type { BashOutputTool, BashTool, @@ -32,49 +37,46 @@ export type { WebSearchTool, WriteTool, } from "./src/tools/types.js"; + +// Core types export type { AgentConfig, - AgentEvent, - // Individual event types for creating events - ArtifactEvent, - CompactBoundaryEvent, - ConsoleEvent, - ContentBlockStartEvent, - ContentBlockStopEvent, - DoneEvent, - ErrorEvent, ExecutionResult, - InitEvent, LogLevel as LogLevelType, McpServerConfig, - MessageDeltaEvent, - MessageStartEvent, - MessageStopEvent, - MetricEvent, OnLogCallback, - RawSDKEvent, ResearchEvaluation, - StatusEvent, + SessionNotification, + StoredEntry, + StoredNotification, + StoredSessionNotification, SupportingFile, Task, TaskRun, - TokenEvent, - ToolCallEvent, - ToolResultEvent, - UserMessageEvent, WorktreeInfo, } from "./src/types.js"; -export { - AgentEventSchema, - PermissionMode, - parseAgentEvent, - parseAgentEvents, -} from "./src/types.js"; +export { PermissionMode } from "./src/types.js"; + +// ACP extensions (PostHog-specific notification types) +export { POSTHOG_NOTIFICATIONS } from "./src/acp-extensions.js"; +export type { + ArtifactNotificationPayload, + BranchCreatedPayload, + ConsoleNotificationPayload, + ErrorNotificationPayload, + PhaseNotificationPayload, + PostHogNotificationPayload, + PostHogNotificationType, + PrCreatedPayload, + RunStartedPayload, + SdkSessionPayload, + TaskCompletePayload, +} from "./src/acp-extensions.js"; + +// Logging export type { LoggerConfig } from "./src/utils/logger.js"; -export { - Logger, - LogLevel, -} from "./src/utils/logger.js"; -export type { WorktreeConfig } from "./src/worktree-manager.js"; +export { Logger, LogLevel } from "./src/utils/logger.js"; + // Worktree management +export type { WorktreeConfig } from "./src/worktree-manager.js"; export { WorktreeManager } from "./src/worktree-manager.js"; diff --git a/packages/agent/package.json b/packages/agent/package.json index 9d08247900..8341199646 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -40,6 +40,7 @@ "devDependencies": { "@changesets/cli": "^2.27.8", "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^15.2.3", "@types/bun": "latest", "minimatch": "^10.0.3", diff --git a/packages/agent/rollup.config.mjs b/packages/agent/rollup.config.mjs index 3393b12209..b59bff0fec 100644 --- a/packages/agent/rollup.config.mjs +++ b/packages/agent/rollup.config.mjs @@ -1,6 +1,7 @@ import { builtinModules } from "node:module"; import path from "node:path"; import commonjs from "@rollup/plugin-commonjs"; +import json from "@rollup/plugin-json"; import { nodeResolve } from "@rollup/plugin-node-resolve"; import { defineConfig } from "rollup"; import copy from "rollup-plugin-copy"; @@ -31,6 +32,7 @@ export default defineConfig({ nodeResolve({ extensions: [".ts", ".js", ".json"], }), + json(), commonjs(), typescript({ tsconfig: path.resolve("tsconfig.rollup.json"), diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts new file mode 100644 index 0000000000..fd4ab0d324 --- /dev/null +++ b/packages/agent/src/acp-extensions.ts @@ -0,0 +1,122 @@ +/** + * PostHog-specific ACP extensions. + * + * These follow the ACP extensibility model: + * - Custom notification methods are prefixed with `_posthog/` + * - Custom data can be attached via `_meta` fields + * + * See: https://agentclientprotocol.com/docs/extensibility + */ + +/** + * Custom notification methods for PostHog-specific events. + * Used with AgentSideConnection.extNotification() or Client.extNotification() + */ +export const POSTHOG_NOTIFICATIONS = { + /** Artifact produced during task execution (research, plan, etc.) */ + ARTIFACT: "_posthog/artifact", + /** Phase has started (research, plan, build, etc.) */ + PHASE_START: "_posthog/phase_start", + /** Phase has completed */ + PHASE_COMPLETE: "_posthog/phase_complete", + /** Git branch was created */ + BRANCH_CREATED: "_posthog/branch_created", + /** Pull request was created */ + PR_CREATED: "_posthog/pr_created", + /** Task run has started */ + RUN_STARTED: "_posthog/run_started", + /** Task has completed */ + TASK_COMPLETE: "_posthog/task_complete", + /** Error occurred during task execution */ + ERROR: "_posthog/error", + /** Console/log output */ + CONSOLE: "_posthog/console", + /** SDK session ID notification (for resumption) */ + SDK_SESSION: "_posthog/sdk_session", +} as const; + +export type PostHogNotificationType = + (typeof POSTHOG_NOTIFICATIONS)[keyof typeof POSTHOG_NOTIFICATIONS]; + + +export interface ArtifactNotificationPayload { + sessionId: string; + kind: + | "research_evaluation" + | "research_questions" + | "plan" + | "pr_body" + | string; + content: unknown; +} + + +export interface PhaseNotificationPayload { + sessionId: string; + phase: "research" | "plan" | "build" | "finalize" | string; + [key: string]: unknown; +} + + +export interface BranchCreatedPayload { + sessionId: string; + branch: string; +} + +/** + * Payload for PR created notification + */ +export interface PrCreatedPayload { + sessionId: string; + prUrl: string; +} + + +export interface RunStartedPayload { + sessionId: string; + runId: string; + taskId?: string; +} + +/** + * Payload for task complete notification + */ +export interface TaskCompletePayload { + sessionId: string; + taskId: string; +} + +export interface ErrorNotificationPayload { + sessionId: string; + message: string; + error?: unknown; +} + +/** + * Console output for a session + */ +export interface ConsoleNotificationPayload { + sessionId: string; + level: "debug" | "info" | "warn" | "error"; + message: string; +} + +/** + * Maps a session ID to a SDKs session ID + */ +export interface SdkSessionPayload { + sessionId: string; + sdkSessionId: string; +} + + +export type PostHogNotificationPayload = + | ArtifactNotificationPayload + | PhaseNotificationPayload + | BranchCreatedPayload + | PrCreatedPayload + | RunStartedPayload + | TaskCompletePayload + | ErrorNotificationPayload + | ConsoleNotificationPayload + | SdkSessionPayload; diff --git a/packages/agent/src/adapters/claude/claude.ts b/packages/agent/src/adapters/claude/claude.ts index 49d161ff3f..15a3a33872 100644 --- a/packages/agent/src/adapters/claude/claude.ts +++ b/packages/agent/src/adapters/claude/claude.ts @@ -63,10 +63,9 @@ import { toolUpdateFromToolResult, } from "./tools.js"; import { - nodeToWebReadable, - nodeToWebWritable, - Pushable, - unreachable, + createBidirectionalStreams, Pushable, + type StreamPair, + unreachable } from "./utils.js"; type Session = { @@ -180,7 +179,7 @@ export class ClaudeAcpAgent implements Agent { // In-memory (always) this.sessions[sessionId]?.notificationHistory.push(notification); // Persist via store (if registered) - this.sessionStore?.append(sessionId, notification); + this.sessionStore?.appendSessionNotification(sessionId, notification); } async initialize(request: InitializeRequest): Promise { @@ -466,6 +465,19 @@ export class ClaudeAcpAgent implements Agent { const { query, input } = this.sessions[params.sessionId]; + // Capture and store user message for replay + for (const chunk of params.prompt) { + const userNotification: SessionNotification = { + sessionId: params.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: chunk, + }, + }; + await this.client.sessionUpdate(userNotification); + this.appendNotification(params.sessionId, userNotification); + } + input.push(promptToClaude(params)); while (true) { const { value: message, done } = await query.next(); @@ -475,6 +487,8 @@ export class ClaudeAcpAgent implements Agent { } break; } + this.logger.debug("SDK message received", { type: message.type, subtype: (message as any).subtype }); + switch (message.type) { case "system": switch (message.subtype) { @@ -541,6 +555,7 @@ export class ClaudeAcpAgent implements Agent { break; } case "stream_event": { + this.logger.debug("Stream event", { eventType: message.event?.type }); for (const notification of streamEventToAcpNotifications( message, params.sessionId, @@ -599,13 +614,9 @@ export class ClaudeAcpAgent implements Agent { throw RequestError.authRequired(); } - const content = - message.type === "assistant" - ? // Handled by stream events above - message.message.content.filter( - (item) => !["text", "thinking"].includes(item.type), - ) - : message.message.content; + // For assistant messages, text/thinking are normally streamed via stream_event. + // But some gateways (like LiteLLM) don't stream, so we process all content. + const content = message.message.content; for (const notification of toAcpNotifications( content, @@ -710,7 +721,7 @@ export class ClaudeAcpAgent implements Agent { let loadedHistory: SessionNotification[] = []; if (!this.sessions[sessionId] && persistence?.logUrl && this.sessionStore) { try { - loadedHistory = await this.sessionStore.load(persistence.logUrl); + loadedHistory = await this.sessionStore.loadSessionNotifications(persistence.logUrl); } catch (error) { this.logger.error("Failed to load session from S3:", error); } @@ -1362,15 +1373,16 @@ export function streamEventToAcpNotifications( } } -export function runAcp() { - const input = nodeToWebWritable(process.stdout); - const output = nodeToWebReadable( - process.stdin, - ) as unknown as ReadableStream; +export type AcpConnectionConfig = { + sessionStore?: SessionStore; +}; + +export type InProcessAcpConnection = { + agentConnection: AgentSideConnection; + clientStreams: StreamPair; +}; - // Create SessionStore if PostHog env vars are provided - // This will move somewhere else later. - let sessionStore: SessionStore | undefined; +function createSessionStoreFromEnv(): SessionStore | undefined { const apiUrl = process.env.POSTHOG_API_URL; const apiKey = process.env.POSTHOG_API_KEY; const projectId = process.env.POSTHOG_PROJECT_ID; @@ -1381,12 +1393,40 @@ export function runAcp() { apiKey, projectId: parseInt(projectId, 10), }); - sessionStore = new SessionStore(posthogClient); + return new SessionStore(posthogClient); } + return undefined; +} + +export function createAcpConnection( + config: AcpConnectionConfig = {}, +): InProcessAcpConnection { + const streams = createBidirectionalStreams(); + const sessionStore = config.sessionStore ?? createSessionStoreFromEnv(); - const stream = ndJsonStream(input, output); - new AgentSideConnection( + const agentStream = ndJsonStream(streams.agent.writable, streams.agent.readable); + const agentConnection = new AgentSideConnection( (client) => new ClaudeAcpAgent(client, sessionStore), - stream, + agentStream, ); + + return { + agentConnection, + clientStreams: streams.client, + }; } + +// export function runAcp(): AgentSideConnection { +// const input = nodeToWebWritable(process.stdout); +// const output = nodeToWebReadable( +// process.stdin, +// ) as unknown as ReadableStream; + +// const sessionStore = createSessionStoreFromEnv(); + +// const stream = ndJsonStream(input, output); +// return new AgentSideConnection( +// (client) => new ClaudeAcpAgent(client, sessionStore), +// stream, +// ); +// } diff --git a/packages/agent/src/adapters/claude/utils.ts b/packages/agent/src/adapters/claude/utils.ts index 46cd6e1374..5c07803a37 100644 --- a/packages/agent/src/adapters/claude/utils.ts +++ b/packages/agent/src/adapters/claude/utils.ts @@ -139,6 +139,69 @@ export function applyEnvironmentSettings(settings: ManagedSettings): void { } } +export type StreamPair = { + readable: globalThis.ReadableStream; + writable: globalThis.WritableStream; +}; + +export type BidirectionalStreamPair = { + client: StreamPair; + agent: StreamPair; +}; + +function pushableToReadableStream( + pushable: Pushable, +): globalThis.ReadableStream { + const iterator = pushable[Symbol.asyncIterator](); + return new ReadableStream({ + async pull(controller) { + const { value, done } = await iterator.next(); + if (done) { + controller.close(); + } else { + controller.enqueue(value); + } + }, + }) as unknown as globalThis.ReadableStream; +} + +export function createBidirectionalStreams(): BidirectionalStreamPair { + const clientToAgentPushable = new Pushable(); + const agentToClientPushable = new Pushable(); + + const clientToAgentReadable = pushableToReadableStream(clientToAgentPushable); + const agentToClientReadable = pushableToReadableStream(agentToClientPushable); + + const clientToAgentWritable = new WritableStream({ + write(chunk) { + clientToAgentPushable.push(chunk); + }, + close() { + clientToAgentPushable.end(); + }, + }) as unknown as globalThis.WritableStream; + + const agentToClientWritable = new WritableStream({ + write(chunk) { + agentToClientPushable.push(chunk); + }, + close() { + agentToClientPushable.end(); + }, + }) as unknown as globalThis.WritableStream; + + return { + client: { + readable: agentToClientReadable, + writable: clientToAgentWritable, + }, + agent: { + readable: clientToAgentReadable, + writable: agentToClientWritable, + }, + }; +} + export interface ExtractLinesResult { content: string; wasLimited: boolean; diff --git a/packages/agent/src/adapters/types.ts b/packages/agent/src/adapters/types.ts deleted file mode 100644 index 1493f7beb2..0000000000 --- a/packages/agent/src/adapters/types.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { AgentEvent, ArtifactEvent, StatusEvent } from "../types.js"; - -/** - * Provider adapter interface for transforming provider-specific messages - * into our standardized AgentEvent format. - * - * This allows us to support multiple AI providers (Claude, Gemini, OpenAI, etc.) - * while maintaining a consistent event interface for consumers. - */ -export interface ProviderAdapter { - /** Provider name (e.g., 'claude', 'gemini') */ - name: string; - - /** - * Transform a provider-specific SDK message into one or more AgentEvents. - * Returns an array of events (can be empty if the message should be ignored). - */ - transform(sdkMessage: unknown): AgentEvent[]; - - /** - * Create a standardized status event. - * Used for task phase transitions and other status updates. - */ - createStatusEvent(phase: string, additionalData?: unknown): StatusEvent; - - /** - * Create an artifact event for custom task artifacts (todos, etc). - * Used to emit structured artifacts for UI consumption. - */ - createArtifactEvent(kind: string, content: unknown): ArtifactEvent; - - /** - * Create a raw SDK event for debugging purposes. - * Wraps the original SDK message in a raw_sdk_event. - */ - createRawSDKEvent(sdkMessage: any): AgentEvent; -} diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 913f4c44c9..4301759ed3 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -1,44 +1,38 @@ -import { query } from "@anthropic-ai/claude-agent-sdk"; -// @ts-expect-error -import { ClaudeAdapter } from "./adapters/claude-legacy/claude-adapter.js"; -import type { ProviderAdapter } from "./adapters/types.js"; +import { v7 as uuidv7 } from "uuid"; +import { POSTHOG_NOTIFICATIONS } from "./acp-extensions.js"; +import { + createAcpConnection, + type InProcessAcpConnection, +} from "./adapters/claude/claude.js"; import { PostHogFileManager } from "./file-manager.js"; import { GitManager } from "./git-manager.js"; import { PostHogAPIClient } from "./posthog-api.js"; import { PromptBuilder } from "./prompt-builder.js"; import { TaskManager } from "./task-manager.js"; -import { TaskRunProgressReporter } from "./task-run-progress-reporter.js"; import { TemplateManager } from "./template-manager.js"; -import type { - AgentConfig, - AgentEvent, - CanUseTool, - ExecutionResult, - Task, -} from "./types.js"; +import type { AgentConfig, CanUseTool, Task } from "./types.js"; import { Logger } from "./utils/logger.js"; import { TASK_WORKFLOW } from "./workflow/config.js"; -import type { WorkflowRuntime } from "./workflow/types.js"; +import type { SendNotification, WorkflowRuntime } from "./workflow/types.js"; export class Agent { private workingDirectory: string; - private onEvent?: (event: AgentEvent) => void; private taskManager: TaskManager; private posthogAPI?: PostHogAPIClient; private fileManager: PostHogFileManager; private gitManager: GitManager; private templateManager: TemplateManager; - private adapter: ProviderAdapter; private logger: Logger; - private progressReporter: TaskRunProgressReporter; + private acpConnection?: InProcessAcpConnection; private promptBuilder: PromptBuilder; private mcpServers?: Record; private canUseTool?: CanUseTool; + private currentSessionId?: string; + private currentRunId?: string; public debug: boolean; constructor(config: AgentConfig) { this.workingDirectory = config.workingDirectory || process.cwd(); - this.onEvent = config.onEvent; this.canUseTool = config.canUseTool; this.debug = config.debug || false; @@ -73,8 +67,6 @@ export class Agent { onLog: config.onLog, }); this.taskManager = new TaskManager(); - // Hardcode Claude adapter for now - extensible for other providers later - this.adapter = new ClaudeAdapter(); this.fileManager = new PostHogFileManager( this.workingDirectory, @@ -83,11 +75,10 @@ export class Agent { this.gitManager = new GitManager({ repositoryPath: this.workingDirectory, logger: this.logger.child("GitManager"), - // TODO: Add author config from environment or config }); this.templateManager = new TemplateManager(); - if (config.posthogApiUrl && config.posthogApiKey) { + if (config.posthogApiUrl && config.posthogApiKey && config.posthogProjectId) { this.posthogAPI = new PostHogAPIClient({ apiUrl: config.posthogApiUrl, apiKey: config.posthogApiKey, @@ -101,10 +92,6 @@ export class Agent { posthogClient: this.posthogAPI, logger: this.logger.child("PromptBuilder"), }); - this.progressReporter = new TaskRunProgressReporter( - this.posthogAPI, - this.logger, - ); } /** @@ -125,54 +112,75 @@ export class Agent { try { const gatewayUrl = this.posthogAPI.getLlmGatewayUrl(); + this.logger.info("Gateway URL", { gatewayUrl }); const apiKey = this.posthogAPI.getApiKey(); + this.logger.info("API Key", { apiKey }); process.env.ANTHROPIC_BASE_URL = gatewayUrl; process.env.ANTHROPIC_AUTH_TOKEN = apiKey; this.ensureOpenAIGatewayEnv(gatewayUrl, apiKey); - this.logger.debug("Configured LLM gateway", { gatewayUrl }); + this.logger.info("Configured LLM gateway", { gatewayUrl }); } catch (error) { this.logger.error("Failed to configure LLM gateway", error); throw error; } } + private getOrCreateConnection(): InProcessAcpConnection { + if (!this.acpConnection) { + this.acpConnection = createAcpConnection(); + } + return this.acpConnection; + } + // Adaptive task execution orchestrated via workflow steps async runTask( taskId: string, taskRunId: string, options: import("./types.js").TaskExecutionOptions = {}, ): Promise { - await this._configureLlmGateway(); + // await this._configureLlmGateway(); const task = await this.fetchTask(taskId); const cwd = options.repositoryPath || this.workingDirectory; const isCloudMode = options.isCloudMode ?? false; const taskSlug = (task as any).slug || task.id; + // Create a session for this task run + const sessionId = uuidv7(); + this.currentSessionId = sessionId; + this.currentRunId = taskRunId; + this.logger.info("Starting adaptive task execution", { taskId: task.id, taskSlug, taskRunId, + sessionId, isCloudMode, }); - await this.progressReporter.start(taskId, taskRunId, { - totalSteps: TASK_WORKFLOW.length, - }); + const connection = this.getOrCreateConnection(); - this.emitEvent( - this.adapter.createStatusEvent("run_started", { runId: taskRunId }), - ); + // Create sendNotification using ACP connection's extNotification + const sendNotification: SendNotification = async (method, params) => { + this.logger.debug(`Notification: ${method}`, params); + await connection.agentConnection.extNotification?.(method, params); + }; - await this.prepareTaskBranch(taskSlug, isCloudMode); + await sendNotification(POSTHOG_NOTIFICATIONS.RUN_STARTED, { + sessionId, + runId: taskRunId, + }); + + await this.prepareTaskBranch(taskSlug, isCloudMode, sendNotification); let taskError: Error | undefined; try { const workflowContext: WorkflowRuntime = { task, taskSlug, + runId: taskRunId, cwd, isCloudMode, options, @@ -180,11 +188,11 @@ export class Agent { fileManager: this.fileManager, gitManager: this.gitManager, promptBuilder: this.promptBuilder, - progressReporter: this.progressReporter, - adapter: this.adapter, + connection: connection.agentConnection, + sessionId, + sendNotification, mcpServers: this.mcpServers, posthogAPI: this.posthogAPI, - emitEvent: (event: any) => this.emitEvent(event), stepResults: {}, }; @@ -197,78 +205,88 @@ export class Agent { const shouldCreatePR = options.createPR ?? isCloudMode; if (shouldCreatePR) { - await this.ensurePullRequest(task, workflowContext.stepResults); + await this.ensurePullRequest(task, workflowContext.stepResults, sendNotification); } this.logger.info("Task execution complete", { taskId: task.id }); - this.emitEvent( - this.adapter.createStatusEvent("task_complete", { taskId: task.id }), - ); + await sendNotification(POSTHOG_NOTIFICATIONS.TASK_COMPLETE, { + sessionId, + taskId: task.id, + }); } catch (error) { taskError = error instanceof Error ? error : new Error(String(error)); this.logger.error("Task execution failed", { taskId: task.id, error: taskError.message, }); - } finally { - if (taskError) { - await this.progressReporter.fail(taskError); - // biome-ignore lint/correctness/noUnsafeFinally: we actually want to throw the error - throw taskError; - } else { - await this.progressReporter.complete(); - } + await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { + sessionId, + message: taskError.message, + }); + throw taskError; } } - // Direct prompt execution - still supported for low-level usage - async run( - prompt: string, - options: { - repositoryPath?: string; - permissionMode?: import("./types.js").PermissionMode; - queryOverrides?: Record; - canUseTool?: CanUseTool; - } = {}, - ): Promise { + /** + * Creates an in-process ACP connection for client communication. + * Sets up git branch for the task, configures LLM gateway. + * The client handles all prompting/querying via the returned streams. + * + * @returns InProcessAcpConnection with clientStreams for the client to use + */ + async runTaskV2( + taskId: string, + taskRunId: string, + options: import("./types.js").TaskExecutionOptions = {}, + ): Promise { await this._configureLlmGateway(); - const baseOptions: Record = { - model: "claude-sonnet-4-5-20250929", - cwd: options.repositoryPath || this.workingDirectory, - permissionMode: (options.permissionMode as any) || "default", - settingSources: ["local"], - mcpServers: this.mcpServers, - }; - // Add canUseTool hook if provided (options take precedence over instance config) - const canUseTool = options.canUseTool || this.canUseTool; - if (canUseTool) { - baseOptions.canUseTool = canUseTool; - } + const task = await this.fetchTask(taskId); + const taskSlug = (task as any).slug || task.id; + const isCloudMode = options.isCloudMode ?? false; + const cwd = options.repositoryPath || this.workingDirectory; + + const sessionId = uuidv7(); + this.currentSessionId = sessionId; + this.currentRunId = taskRunId; - const response = query({ - prompt, - options: { ...baseOptions, ...(options.queryOverrides || {}) }, + this.logger.info("Starting task session", { + taskId: task.id, + taskSlug, + taskRunId, + sessionId, + cwd, }); - const results = []; - try { - for await (const message of response) { - this.logger.debug("Received message in direct run", message); - // Emit raw SDK event - this.emitEvent(this.adapter.createRawSDKEvent(message)); - const transformedEvents = this.adapter.transform(message); - for (const event of transformedEvents) { - this.emitEvent(event); - } - results.push(message); + this.acpConnection = createAcpConnection(); + + const sendNotification: SendNotification = async (method, params) => { + this.logger.debug(`Notification: ${method}`, params); + await this.acpConnection?.agentConnection.extNotification?.(method, params); + }; + + await sendNotification(POSTHOG_NOTIFICATIONS.RUN_STARTED, { + sessionId, + runId: taskRunId, + }); + + if (!options.skipGitBranch) { + try { + await this.prepareTaskBranch(taskSlug, isCloudMode, sendNotification); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error("Failed to prepare task branch", { error: errorMessage }); + await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { + sessionId, + message: errorMessage, + }); + throw error; } - } catch (error) { - this.logger.error("Error during direct run", error); - throw error; + } else { + this.logger.info("Skipping git branch creation"); } - return { results }; + return this.acpConnection; } // PostHog task operations @@ -288,44 +306,6 @@ export class Agent { return this.posthogAPI; } - async listTasks(filters?: { - repository?: string; - organization?: string; - origin_product?: string; - }): Promise { - if (!this.posthogAPI) { - throw new Error( - "PostHog API not configured. Provide posthogApiUrl and posthogApiKey in constructor.", - ); - } - return this.posthogAPI.listTasks(filters); - } - - // File system operations for task artifacts - async writeTaskFile( - taskId: string, - fileName: string, - content: string, - type: "plan" | "context" | "reference" | "output" = "reference", - ): Promise { - this.logger.debug("Writing task file", { - taskId, - fileName, - type, - contentLength: content.length, - }); - await this.fileManager.writeTaskFile(taskId, { - name: fileName, - content, - type, - }); - } - - async readTaskFile(taskId: string, fileName: string): Promise { - this.logger.debug("Reading task file", { taskId, fileName }); - return await this.fileManager.readTaskFile(taskId, fileName); - } - async getTaskFiles(taskId: string): Promise { this.logger.debug("Getting task files", { taskId }); const files = await this.fileManager.getTaskFiles(taskId); @@ -333,62 +313,6 @@ export class Agent { return files; } - async writePlan(taskId: string, plan: string): Promise { - this.logger.info("Writing plan", { taskId, planLength: plan.length }); - await this.fileManager.writePlan(taskId, plan); - } - - async readPlan(taskId: string): Promise { - this.logger.debug("Reading plan", { taskId }); - return await this.fileManager.readPlan(taskId); - } - - // Git operations for task execution - async createPlanningBranch(taskId: string): Promise { - this.logger.info("Creating planning branch", { taskId }); - const branchName = await this.gitManager.createTaskPlanningBranch(taskId); - this.logger.debug("Planning branch created", { taskId, branchName }); - return branchName; - } - - async commitPlan(taskId: string, taskTitle: string): Promise { - this.logger.info("Committing plan", { taskId, taskTitle }); - const commitHash = await this.gitManager.commitPlan(taskId, taskTitle); - this.logger.debug("Plan committed", { taskId, commitHash }); - return commitHash; - } - - async createImplementationBranch( - taskId: string, - planningBranchName?: string, - ): Promise { - this.logger.info("Creating implementation branch", { - taskId, - fromBranch: planningBranchName, - }); - const branchName = await this.gitManager.createTaskImplementationBranch( - taskId, - planningBranchName, - ); - this.logger.debug("Implementation branch created", { taskId, branchName }); - return branchName; - } - - async commitImplementation( - taskId: string, - taskTitle: string, - planSummary?: string, - ): Promise { - this.logger.info("Committing implementation", { taskId, taskTitle }); - const commitHash = await this.gitManager.commitImplementation( - taskId, - taskTitle, - planSummary, - ); - this.logger.debug("Implementation committed", { taskId, commitHash }); - return commitHash; - } - async createPullRequest( taskId: string, branchName: string, @@ -429,7 +353,7 @@ Generated by PostHog Agent`; ): Promise { this.logger.info("Attaching PR to task run", { taskId, prUrl, branchName }); - if (!this.posthogAPI || !this.progressReporter.runId) { + if (!this.posthogAPI || !this.currentRunId) { const error = new Error( "PostHog API not configured or no active run. Cannot attach PR to task.", ); @@ -444,14 +368,10 @@ Generated by PostHog Agent`; updates.branch = branchName; } - await this.posthogAPI.updateTaskRun( - taskId, - this.progressReporter.runId, - updates, - ); + await this.posthogAPI.updateTaskRun(taskId, this.currentRunId, updates); this.logger.debug("PR attached to task run", { taskId, - runId: this.progressReporter.runId, + runId: this.currentRunId, prUrl, }); } @@ -459,7 +379,7 @@ Generated by PostHog Agent`; async updateTaskBranch(taskId: string, branchName: string): Promise { this.logger.info("Updating task run branch", { taskId, branchName }); - if (!this.posthogAPI || !this.progressReporter.runId) { + if (!this.posthogAPI || !this.currentRunId) { const error = new Error( "PostHog API not configured or no active run. Cannot update branch.", ); @@ -467,12 +387,12 @@ Generated by PostHog Agent`; throw error; } - await this.posthogAPI.updateTaskRun(taskId, this.progressReporter.runId, { + await this.posthogAPI.updateTaskRun(taskId, this.currentRunId, { branch: branchName, }); this.logger.debug("Task run branch updated", { taskId, - runId: this.progressReporter.runId, + runId: this.currentRunId, branchName, }); } @@ -501,6 +421,7 @@ Generated by PostHog Agent`; private async prepareTaskBranch( taskSlug: string, isCloudMode: boolean, + sendNotification: SendNotification, ): Promise { if (await this.gitManager.hasChanges()) { throw new Error( @@ -516,11 +437,9 @@ Generated by PostHog Agent`; this.logger.info("Running in worktree, using existing branch", { branch: currentBranch, }); - this.emitEvent( - this.adapter.createStatusEvent("branch_created", { - branch: currentBranch, - }), - ); + await sendNotification(POSTHOG_NOTIFICATIONS.BRANCH_CREATED, { + branch: currentBranch, + }); return; } @@ -529,11 +448,9 @@ Generated by PostHog Agent`; const existingBranch = await this.gitManager.getTaskBranch(taskSlug); if (!existingBranch) { const branchName = await this.gitManager.createTaskBranch(taskSlug); - this.emitEvent( - this.adapter.createStatusEvent("branch_created", { - branch: branchName, - }), - ); + await sendNotification(POSTHOG_NOTIFICATIONS.BRANCH_CREATED, { + branch: branchName, + }); await this.gitManager.addAllPostHogFiles(); @@ -573,6 +490,7 @@ Generated by PostHog Agent`; private async ensurePullRequest( task: Task, stepResults: Record, + sendNotification: SendNotification, ): Promise { const latestRun = task.latest_run; const existingPr = @@ -609,7 +527,7 @@ Generated by PostHog Agent`; prBody, ); - this.emitEvent(this.adapter.createStatusEvent("pr_created", { prUrl })); + await sendNotification(POSTHOG_NOTIFICATIONS.PR_CREATED, { prUrl }); try { await this.attachPullRequestToTask(task.id, prUrl, branchName); @@ -624,24 +542,6 @@ Generated by PostHog Agent`; } } - private emitEvent(event: AgentEvent): void { - // Log events (skip tokens and raw_sdk_event - too verbose) - if ( - event.type !== "token" && - (this.debug || event.type !== "raw_sdk_event") - ) { - this.logger.info(`Event: ${event.type}`, event); - } - const persistPromise = this.progressReporter.recordEvent(event); - if (persistPromise && typeof persistPromise.then === "function") { - persistPromise.catch((error: Error) => - this.logger.debug("Failed to persist agent event", { - message: error.message, - }), - ); - } - this.onEvent?.(event); - } } export type { diff --git a/packages/agent/src/posthog-api.ts b/packages/agent/src/posthog-api.ts index 60c256d285..9b0f4b1b2c 100644 --- a/packages/agent/src/posthog-api.ts +++ b/packages/agent/src/posthog-api.ts @@ -1,7 +1,7 @@ import type { - AgentEvent, PostHogAPIConfig, PostHogResource, + StoredEntry, Task, TaskArtifactUploadPayload, TaskRun, @@ -211,7 +211,7 @@ export class PostHogAPIClient { async appendTaskRunLog( taskId: string, runId: string, - entries: AgentEvent[], + entries: StoredEntry[], ): Promise { const teamId = this.getTeamId(); return this.apiRequest( @@ -247,9 +247,9 @@ export class PostHogAPIClient { /** * Fetch logs from S3 using presigned URL from TaskRun * @param taskRun - The task run containing the log_url - * @returns Array of agent events, or empty array if no logs available + * @returns Array of stored entries, or empty array if no logs available */ - async fetchTaskRunLogs(taskRun: TaskRun): Promise { + async fetchTaskRunLogs(taskRun: TaskRun): Promise { if (!taskRun.log_url) { return []; } @@ -273,7 +273,7 @@ export class PostHogAPIClient { return content .trim() .split("\n") - .map((line) => JSON.parse(line) as AgentEvent); + .map((line) => JSON.parse(line) as StoredEntry); } catch (error) { throw new Error( `Failed to fetch task run logs: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/agent/src/schemas.ts b/packages/agent/src/schemas.ts deleted file mode 100644 index 8447cc5d4d..0000000000 --- a/packages/agent/src/schemas.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { z } from "zod"; - -// Base event schema with timestamp -const BaseEventSchema = z.object({ - ts: z.number(), -}); - -// Streaming content events -export const TokenEventSchema = BaseEventSchema.extend({ - type: z.literal("token"), - content: z.string(), - contentType: z.enum(["text", "thinking", "tool_input"]).optional(), -}); - -export const ContentBlockStartEventSchema = BaseEventSchema.extend({ - type: z.literal("content_block_start"), - index: z.number(), - contentType: z.enum(["text", "tool_use", "thinking"]), - toolName: z.string().optional(), - toolId: z.string().optional(), -}); - -export const ContentBlockStopEventSchema = BaseEventSchema.extend({ - type: z.literal("content_block_stop"), - index: z.number(), -}); - -// Tool events -export const ToolCallEventSchema = BaseEventSchema.extend({ - type: z.literal("tool_call"), - toolName: z.string(), - callId: z.string(), - args: z.record(z.string(), z.unknown()), - parentToolUseId: z.string().nullable().optional(), - tool: z.unknown().optional(), - category: z.unknown().optional(), -}); - -export const ToolResultEventSchema = BaseEventSchema.extend({ - type: z.literal("tool_result"), - toolName: z.string(), - callId: z.string(), - result: z.unknown(), - isError: z.boolean().optional(), - parentToolUseId: z.string().nullable().optional(), - tool: z.unknown().optional(), - category: z.unknown().optional(), -}); - -// Message lifecycle events -export const MessageStartEventSchema = BaseEventSchema.extend({ - type: z.literal("message_start"), - messageId: z.string().optional(), - model: z.string().optional(), -}); - -export const MessageDeltaEventSchema = BaseEventSchema.extend({ - type: z.literal("message_delta"), - stopReason: z.string().optional(), - stopSequence: z.string().optional(), - usage: z - .object({ - outputTokens: z.number(), - }) - .optional(), -}); - -export const MessageStopEventSchema = BaseEventSchema.extend({ - type: z.literal("message_stop"), -}); - -// User message events -export const UserMessageEventSchema = BaseEventSchema.extend({ - type: z.literal("user_message"), - content: z.string(), - isSynthetic: z.boolean().optional(), -}); - -// System events -export const StatusEventSchema = BaseEventSchema.extend({ - type: z.literal("status"), - phase: z.string(), - kind: z.string().optional(), - branch: z.string().optional(), - prUrl: z.string().optional(), - taskId: z.string().optional(), - messageId: z.string().optional(), - model: z.string().optional(), -}).passthrough(); // Allow additional fields - -export const InitEventSchema = BaseEventSchema.extend({ - type: z.literal("init"), - model: z.string(), - tools: z.array(z.string()), - permissionMode: z.string(), - cwd: z.string(), - apiKeySource: z.string(), - agents: z.array(z.string()).optional(), - slashCommands: z.array(z.string()).optional(), - outputStyle: z.string().optional(), - mcpServers: z - .array(z.object({ name: z.string(), status: z.string() })) - .optional(), -}); - -// Console event for log-style output -export const ConsoleEventSchema = BaseEventSchema.extend({ - type: z.literal("console"), - level: z.enum(["debug", "info", "warn", "error"]), - message: z.string(), -}); - -export const CompactBoundaryEventSchema = BaseEventSchema.extend({ - type: z.literal("compact_boundary"), - trigger: z.enum(["manual", "auto"]), - preTokens: z.number(), -}); - -// Result events -export const DoneEventSchema = BaseEventSchema.extend({ - type: z.literal("done"), - result: z.string().optional(), - durationMs: z.number().optional(), - durationApiMs: z.number().optional(), - numTurns: z.number().optional(), - totalCostUsd: z.number().optional(), - usage: z.unknown().optional(), - modelUsage: z - .record( - z.string(), - z.object({ - inputTokens: z.number(), - outputTokens: z.number(), - cacheReadInputTokens: z.number(), - cacheCreationInputTokens: z.number(), - webSearchRequests: z.number(), - costUSD: z.number(), - contextWindow: z.number(), - }), - ) - .optional(), - permissionDenials: z - .array( - z.object({ - tool_name: z.string(), - tool_use_id: z.string(), - tool_input: z.record(z.string(), z.unknown()), - }), - ) - .optional(), -}); - -export const ErrorEventSchema = BaseEventSchema.extend({ - type: z.literal("error"), - message: z.string(), - error: z.unknown().optional(), - errorType: z.string().optional(), - context: z.record(z.string(), z.unknown()).optional(), - sdkError: z.unknown().optional(), -}); - -// Metric and artifact events -export const MetricEventSchema = BaseEventSchema.extend({ - type: z.literal("metric"), - key: z.string(), - value: z.number(), - unit: z.string().optional(), -}); - -export const ArtifactEventSchema = BaseEventSchema.extend({ - type: z.literal("artifact"), - kind: z.string(), - content: z.unknown(), -}); - -export const RawSDKEventSchema = BaseEventSchema.extend({ - type: z.literal("raw_sdk_event"), - sdkMessage: z.unknown(), -}); - -export const AgentEventSchema = z.discriminatedUnion("type", [ - TokenEventSchema, - ContentBlockStartEventSchema, - ContentBlockStopEventSchema, - ToolCallEventSchema, - ToolResultEventSchema, - MessageStartEventSchema, - MessageDeltaEventSchema, - MessageStopEventSchema, - UserMessageEventSchema, - StatusEventSchema, - InitEventSchema, - ConsoleEventSchema, - CompactBoundaryEventSchema, - DoneEventSchema, - ErrorEventSchema, - MetricEventSchema, - ArtifactEventSchema, - RawSDKEventSchema, -]); - -export type TokenEvent = z.infer; -export type ContentBlockStartEvent = z.infer< - typeof ContentBlockStartEventSchema ->; -export type ContentBlockStopEvent = z.infer; -export type ToolCallEvent = z.infer; -export type ToolResultEvent = z.infer; -export type MessageStartEvent = z.infer; -export type MessageDeltaEvent = z.infer; -export type MessageStopEvent = z.infer; -export type UserMessageEvent = z.infer; -export type StatusEvent = z.infer; -export type InitEvent = z.infer; -export type ConsoleEvent = z.infer; -export type CompactBoundaryEvent = z.infer; -export type DoneEvent = z.infer; -export type ErrorEvent = z.infer; -export type MetricEvent = z.infer; -export type ArtifactEvent = z.infer; -export type RawSDKEvent = z.infer; -export type AgentEvent = z.infer; - -/** - * Parse and validate an AgentEvent from unknown input. - * Returns the parsed event if valid, or null if invalid. - */ -export function parseAgentEvent(input: unknown): AgentEvent | null { - const result = AgentEventSchema.safeParse(input); - return result.success ? result.data : null; -} - -/** - * Parse and validate multiple AgentEvents from an array of unknown inputs. - * Invalid entries are discarded. - */ -export function parseAgentEvents(inputs: unknown[]): AgentEvent[] { - return inputs - .map((input) => parseAgentEvent(input)) - .filter((event): event is AgentEvent => event !== null); -} diff --git a/packages/agent/src/session-store.ts b/packages/agent/src/session-store.ts index ca1aee3be2..2753a69071 100644 --- a/packages/agent/src/session-store.ts +++ b/packages/agent/src/session-store.ts @@ -1,6 +1,11 @@ import type { SessionNotification } from "@agentclientprotocol/sdk"; -import type { PostHogAPIClient } from "./posthog-api.js"; -import type { AgentEvent } from "./types.js"; +import type { PostHogAPIClient, TaskRunUpdate } from "./posthog-api.js"; +import type { + StoredEntry, + StoredNotification, + StoredSessionNotification, + TaskRun, +} from "./types.js"; import { Logger } from "./utils/logger.js"; export interface SessionPersistenceConfig { @@ -10,21 +15,16 @@ export interface SessionPersistenceConfig { sdkSessionId?: string; } -interface StoredNotification { - type: "acp_session_notification"; - timestamp: string; - notification: SessionNotification; -} - export class SessionStore { - private posthogAPI: PostHogAPIClient; - private pendingNotifications: Map = new Map(); + private posthogAPI?: PostHogAPIClient; + private pendingNotifications: Map = new Map(); private flushTimeouts: Map = new Map(); private configs: Map = new Map(); - private logger = new Logger({ debug: false, prefix: "[SessionStore]" }); + private logger: Logger; - constructor(posthogAPI: PostHogAPIClient) { + constructor(posthogAPI?: PostHogAPIClient, logger?: Logger) { this.posthogAPI = posthogAPI; + this.logger = logger ?? new Logger({ debug: false, prefix: "[SessionStore]" }); // Flush all pending notifications on process exit const flushAllAndExit = async () => { @@ -63,29 +63,67 @@ export class SessionStore { return this.configs.has(sessionId); } - /** Queue a notification for batched persistence */ - append(sessionId: string, notification: SessionNotification): void { + /** + * Add a custom notification following ACP extensibility model. + * Method names should start with underscore (e.g., `_posthog/phase_start`). + */ + addNotification( + sessionId: string, + method: string, + params: Record, + ): void { const config = this.configs.get(sessionId); if (!config) { this.logger.error(`Session ${sessionId} not registered for persistence`); return; } - const stored: StoredNotification = { + const notification: StoredNotification = { + type: "notification", + timestamp: new Date().toISOString(), + notification: { + jsonrpc: "2.0", + method, + params, + }, + }; + + const pending = this.pendingNotifications.get(sessionId) ?? []; + pending.push(notification); + this.pendingNotifications.set(sessionId, pending); + + this.scheduleFlush(sessionId); + } + + /** + * Append an ACP session notification for persistence. + * Used to store session updates like tool_call, agent_message_chunk, etc. + */ + appendSessionNotification( + sessionId: string, + notification: SessionNotification, + ): void { + const config = this.configs.get(sessionId); + if (!config) { + // Session not registered for persistence, silently skip + return; + } + + const entry: StoredSessionNotification = { type: "acp_session_notification", timestamp: new Date().toISOString(), notification, }; const pending = this.pendingNotifications.get(sessionId) ?? []; - pending.push(stored); + pending.push(entry); this.pendingNotifications.set(sessionId, pending); this.scheduleFlush(sessionId); } - /** Load notifications from S3 */ - async load(logUrl: string): Promise { + /** Load entries from S3 */ + async load(logUrl: string): Promise { const response = await fetch(logUrl); // Handle S3 errors (e.g., file doesn't exist yet) @@ -109,8 +147,19 @@ export class SessionStore { } }) .filter( - (entry): entry is StoredNotification => + (entry): entry is StoredEntry => + entry?.type === "notification" || entry?.type === "acp_session_notification", + ); + } + + /** Load and extract SessionNotifications from S3 */ + async loadSessionNotifications(logUrl: string): Promise { + const entries = await this.load(logUrl); + return entries + .filter( + (entry): entry is StoredSessionNotification => + entry.type === "acp_session_notification", ) .map((entry) => entry.notification); } @@ -129,12 +178,13 @@ export class SessionStore { this.flushTimeouts.delete(sessionId); } + if (!this.posthogAPI) { + this.logger.debug("No PostHog API configured, skipping flush"); + return; + } + try { - await this.posthogAPI.appendTaskRunLog( - config.taskId, - config.runId, - pending as unknown as AgentEvent[], - ); + await this.posthogAPI.appendTaskRunLog(config.taskId, config.runId, pending); } catch (error) { this.logger.error("Failed to persist session notifications:", error); } @@ -146,4 +196,104 @@ export class SessionStore { const timeout = setTimeout(() => this.flush(sessionId), 500); this.flushTimeouts.set(sessionId, timeout); } + + /** Get the persistence config for a session */ + getConfig(sessionId: string): SessionPersistenceConfig | undefined { + return this.configs.get(sessionId); + } + + /** + * Start a session for persistence. + * Loads the task run and updates status to "in_progress". + */ + async start( + sessionId: string, + taskId: string, + runId: string, + ): Promise { + if (!this.posthogAPI) { + this.logger.debug("No PostHog API configured, registering session without persistence"); + this.register(sessionId, { + taskId, + runId, + logUrl: "", + }); + return undefined; + } + + const taskRun = await this.posthogAPI.getTaskRun(taskId, runId); + + this.register(sessionId, { + taskId, + runId, + logUrl: taskRun.log_url, + }); + + await this.updateTaskRun(sessionId, { status: "in_progress" }); + + this.logger.info("Session started for persistence", { + sessionId, + taskId, + runId, + logUrl: taskRun.log_url, + }); + + return taskRun; + } + + /** + * Mark a session as completed. + * Flushes pending notifications and updates task run status. + */ + async complete(sessionId: string): Promise { + await this.flush(sessionId); + await this.updateTaskRun(sessionId, { status: "completed" }); + this.logger.info("Session completed", { sessionId }); + } + + /** + * Mark a session as failed. + * Flushes pending notifications and updates task run status with error. + */ + async fail(sessionId: string, error: Error | string): Promise { + await this.flush(sessionId); + const message = typeof error === "string" ? error : error.message; + await this.updateTaskRun(sessionId, { + status: "failed", + error_message: message, + }); + this.logger.error("Session failed", { sessionId, error: message }); + } + + /** + * Update the task run associated with a session. + */ + async updateTaskRun( + sessionId: string, + update: TaskRunUpdate, + ): Promise { + const config = this.configs.get(sessionId); + if (!config) { + this.logger.error( + `Cannot update task run: session ${sessionId} not registered`, + ); + return undefined; + } + + if (!this.posthogAPI) { + this.logger.debug("No PostHog API configured, skipping task run update"); + return undefined; + } + + try { + return await this.posthogAPI.updateTaskRun( + config.taskId, + config.runId, + update, + ); + } catch (error) { + this.logger.error("Failed to update task run:", error); + return undefined; + } + } } diff --git a/packages/agent/src/task-run-progress-reporter.ts b/packages/agent/src/task-run-progress-reporter.ts deleted file mode 100644 index 603b17b06e..0000000000 --- a/packages/agent/src/task-run-progress-reporter.ts +++ /dev/null @@ -1,268 +0,0 @@ -import type { PostHogAPIClient, TaskRunUpdate } from "./posthog-api.js"; -import type { AgentEvent, ConsoleEvent, TaskRun, TokenEvent } from "./types.js"; -import type { Logger } from "./utils/logger.js"; - -interface ProgressMetadata { - totalSteps?: number; -} - -/** - * Persists task run execution progress to PostHog so clients can poll for updates. - * - * The reporter is intentionally best-effort – failures are logged but never - * allowed to break the agent execution flow. - */ -export class TaskRunProgressReporter { - private posthogAPI?: PostHogAPIClient; - private logger: Logger; - private taskRun?: TaskRun; - private taskId?: string; - private totalSteps?: number; - private lastLogEntry?: string; - private tokenBuffer: string = ""; - private tokenCount: number = 0; - private tokenFlushTimer?: NodeJS.Timeout; - private readonly TOKEN_BATCH_SIZE = 100; - private readonly TOKEN_FLUSH_INTERVAL_MS = 1000; - private logWriteQueue: Promise = Promise.resolve(); - private readonly LOG_APPEND_MAX_RETRIES = 3; - private readonly LOG_APPEND_RETRY_BASE_DELAY_MS = 200; - - constructor(posthogAPI: PostHogAPIClient | undefined, logger: Logger) { - this.posthogAPI = posthogAPI; - this.logger = logger.child("TaskRunProgressReporter"); - } - - get runId(): string | undefined { - return this.taskRun?.id; - } - - async start( - taskId: string, - taskRunId: string, - metadata: ProgressMetadata = {}, - ): Promise { - if (!this.posthogAPI) { - return; - } - - this.taskId = taskId; - this.totalSteps = metadata.totalSteps; - - try { - const run = await this.posthogAPI.getTaskRun(taskId, taskRunId); - this.taskRun = run; - this.logger.info("Loaded task run", { - taskId, - runId: run.id, - logUrl: run.log_url ?? "(not yet available)", - }); - - await this.update({ status: "in_progress" }, "Task execution started"); - } catch (error) { - this.logger.warn("Failed to load task run", { - taskId, - error: (error as Error).message, - }); - throw error; - } - } - - async complete(): Promise { - await this.flushTokens(); // Flush any remaining tokens before completion - try { - await this.logWriteQueue; - } catch (error) { - this.logger.debug("Pending logs failed to write during completion", { - error, - }); - } - - if (this.tokenFlushTimer) { - clearTimeout(this.tokenFlushTimer); - this.tokenFlushTimer = undefined; - } - await this.update({ status: "completed" }, "Task execution completed"); - } - - async fail(error: Error | string): Promise { - try { - await this.logWriteQueue; - } catch (logError) { - this.logger.debug("Pending logs failed to write during fail", { - error: logError, - }); - } - - const message = typeof error === "string" ? error : error.message; - await this.update( - { status: "failed", error_message: message }, - `Task execution failed: ${message}`, - ); - } - - async appendLog(line: string): Promise { - await this.update({}, line); - } - - private async flushTokens(): Promise { - if (!this.tokenBuffer || this.tokenCount === 0) { - return; - } - - const buffer = this.tokenBuffer; - this.tokenBuffer = ""; - this.tokenCount = 0; - - const tokenEvent: TokenEvent = { - type: "token", - ts: Date.now(), - content: buffer, - }; - await this.appendEvent(tokenEvent); - } - - private scheduleTokenFlush(): void { - if (this.tokenFlushTimer) { - return; - } - - this.tokenFlushTimer = setTimeout(() => { - this.tokenFlushTimer = undefined; - this.flushTokens().catch((err) => { - this.logger.warn("Failed to flush tokens", { error: err }); - }); - }, this.TOKEN_FLUSH_INTERVAL_MS); - } - - private appendEvent(event: AgentEvent): Promise { - if (!this.posthogAPI || !this.runId || !this.taskId) { - return Promise.resolve(); - } - - const taskId = this.taskId; - const runId = this.runId; - - this.logWriteQueue = this.logWriteQueue - .catch((error) => { - this.logger.debug("Previous log append failed", { - taskId, - runId, - error: error instanceof Error ? error.message : String(error), - }); - }) - .then(() => this.writeEvent(taskId, runId, event)); - - return this.logWriteQueue; - } - - private async writeEvent( - taskId: string, - runId: string, - event: AgentEvent, - ): Promise { - if (!this.posthogAPI) { - return; - } - - for (let attempt = 1; attempt <= this.LOG_APPEND_MAX_RETRIES; attempt++) { - try { - await this.posthogAPI.appendTaskRunLog(taskId, runId, [event]); - return; - } catch (error) { - this.logger.warn("Failed to append event", { - taskId, - runId, - attempt, - maxAttempts: this.LOG_APPEND_MAX_RETRIES, - error: (error as Error).message, - }); - - if (attempt === this.LOG_APPEND_MAX_RETRIES) { - return; - } - - const delayMs = - this.LOG_APPEND_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1); - await new Promise((resolve) => setTimeout(resolve, delayMs)); - } - } - } - - async recordEvent(event: AgentEvent): Promise { - if (!this.posthogAPI || !this.runId || !this.taskId) { - return; - } - - // Skip raw SDK events - too verbose for persistence - if (event.type === "raw_sdk_event") { - return; - } - - // Batch tokens for efficiency - if (event.type === "token") { - this.tokenBuffer += event.content; - this.tokenCount++; - - if (this.tokenCount >= this.TOKEN_BATCH_SIZE) { - await this.flushTokens(); - if (this.tokenFlushTimer) { - clearTimeout(this.tokenFlushTimer); - this.tokenFlushTimer = undefined; - } - } else { - this.scheduleTokenFlush(); - } - return; - } - - // Append all other events directly - await this.appendEvent(event); - } - - private async update(update: TaskRunUpdate, logLine?: string): Promise { - if (!this.posthogAPI || !this.runId || !this.taskId) { - return; - } - - // If there's a log line, append it as a console event - if (logLine && logLine !== this.lastLogEntry) { - const consoleEvent: ConsoleEvent = { - type: "console", - ts: Date.now(), - level: "info", - message: logLine, - }; - try { - await this.posthogAPI.appendTaskRunLog(this.taskId, this.runId, [ - consoleEvent, - ]); - this.lastLogEntry = logLine; - } catch (error) { - this.logger.warn("Failed to append console event", { - taskId: this.taskId, - runId: this.runId, - error: (error as Error).message, - }); - } - } - - // Update other fields if provided - if (Object.keys(update).length > 0) { - try { - const run = await this.posthogAPI.updateTaskRun( - this.taskId, - this.runId, - update, - ); - this.taskRun = run; - } catch (error) { - this.logger.warn("Failed to update task run", { - taskId: this.taskId, - runId: this.runId, - error: (error as Error).message, - }); - } - } - } -} diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index d06e291d7f..3e94b1c8e6 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -3,7 +3,42 @@ import type { CanUseTool, PermissionResult, } from "@anthropic-ai/claude-agent-sdk"; -export type { CanUseTool, PermissionResult }; +import type { SessionNotification } from "@agentclientprotocol/sdk"; +export type { CanUseTool, PermissionResult, SessionNotification }; + +/** + * Stored custom notification following ACP extensibility model. + * Custom notifications use underscore-prefixed methods (e.g., `_posthog/phase_start`). + * See: https://agentclientprotocol.com/docs/extensibility + */ +export interface StoredNotification { + type: "notification"; + /** When this notification was stored */ + timestamp: string; + /** JSON-RPC 2.0 notification (no id field = notification, not request) */ + notification: { + jsonrpc: "2.0"; + method: string; + params?: Record; + }; +} + +/** + * Stored ACP session notification (from the ACP SDK). + * These are session updates like tool_call, agent_message_chunk, etc. + */ +export interface StoredSessionNotification { + type: "acp_session_notification"; + /** When this notification was stored */ + timestamp: string; + /** The ACP session notification */ + notification: SessionNotification; +} + +/** + * Union type for all stored log entries. + */ +export type StoredEntry = StoredNotification | StoredSessionNotification; // PostHog Task model (matches Array's OpenAPI schema) export interface Task { @@ -116,56 +151,9 @@ export interface TaskExecutionOptions { // Fine-grained permission control (only applied to build phase) // See: https://docs.claude.com/en/api/agent-sdk/permissions canUseTool?: CanUseTool; + skipGitBranch?: boolean; // Skip creating a task-specific git branch } -export type { - AgentEvent, - ArtifactEvent, - CompactBoundaryEvent, - ConsoleEvent, - ContentBlockStartEvent, - ContentBlockStopEvent, - DoneEvent, - ErrorEvent, - InitEvent, - MessageDeltaEvent, - MessageStartEvent, - MessageStopEvent, - MetricEvent, - RawSDKEvent, - StatusEvent, - TokenEvent, - ToolCallEvent, - ToolResultEvent, - UserMessageEvent, -} from "./schemas.js"; -// Re-export event types and schemas from schemas.ts -export { - AgentEventSchema, - ArtifactEventSchema, - CompactBoundaryEventSchema, - ConsoleEventSchema, - ContentBlockStartEventSchema, - ContentBlockStopEventSchema, - DoneEventSchema, - ErrorEventSchema, - InitEventSchema, - MessageDeltaEventSchema, - MessageStartEventSchema, - MessageStopEventSchema, - MetricEventSchema, - parseAgentEvent, - parseAgentEvents, - RawSDKEventSchema, - StatusEventSchema, - TokenEventSchema, - ToolCallEventSchema, - ToolResultEventSchema, - UserMessageEventSchema, -} from "./schemas.js"; - -import type { AgentEvent } from "./schemas.js"; - export interface ExecutionResult { // biome-ignore lint/suspicious/noExplicitAny: Results array contains varying SDK response types results: any[]; @@ -217,12 +205,11 @@ export type OnLogCallback = ( export interface AgentConfig { workingDirectory?: string; - onEvent?: (event: AgentEvent) => void; - // PostHog API configuration - posthogApiUrl: string; - posthogApiKey: string; - posthogProjectId: number; + // PostHog API configuration (optional - enables PostHog integration when provided) + posthogApiUrl?: string; + posthogApiKey?: string; + posthogProjectId?: number; // PostHog MCP configuration posthogMcpUrl?: string; diff --git a/packages/agent/src/utils/logger.ts b/packages/agent/src/utils/logger.ts index b74f9bfe78..db0e19cdd6 100644 --- a/packages/agent/src/utils/logger.ts +++ b/packages/agent/src/utils/logger.ts @@ -87,6 +87,15 @@ export class Logger { this.emitLog("debug", message, data); } + log(level: LogLevelType, message: string, data?: unknown, scope?: string) { + const originalScope = this.scope; + if (scope) { + this.scope = scope; + } + this.emitLog(level, message, data); + this.scope = originalScope; + } + /** * Create a child logger with additional prefix and scope */ diff --git a/packages/agent/src/workflow/steps/build.ts b/packages/agent/src/workflow/steps/build.ts index f3dbce8bea..f6bc1c189c 100644 --- a/packages/agent/src/workflow/steps/build.ts +++ b/packages/agent/src/workflow/steps/build.ts @@ -1,4 +1,5 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; +import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions.js"; import { EXECUTION_SYSTEM_PROMPT } from "../../agents/execution.js"; import { TodoManager } from "../../todo-manager.js"; import { PermissionMode } from "../../types.js"; @@ -11,10 +12,10 @@ export const buildStep: WorkflowStepRunner = async ({ step, context }) => { options, logger, promptBuilder, - adapter, + sessionId, mcpServers, gitManager, - emitEvent, + sendNotification, } = context; const stepLogger = logger.child("BuildStep"); @@ -33,7 +34,10 @@ export const buildStep: WorkflowStepRunner = async ({ step, context }) => { } stepLogger.info("Starting build phase", { taskId: task.id }); - emitEvent(adapter.createStatusEvent("phase_start", { phase: "build" })); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_START, { + sessionId, + phase: "build", + }); const executionPrompt = await promptBuilder.buildExecutionPrompt(task, cwd); const fullPrompt = `${EXECUTION_SYSTEM_PROMPT}\n\n${executionPrompt}`; @@ -89,18 +93,16 @@ export const buildStep: WorkflowStepRunner = async ({ step, context }) => { try { for await (const message of response) { - emitEvent(adapter.createRawSDKEvent(message)); - const transformedEvents = adapter.transform(message); - for (const event of transformedEvents) { - emitEvent(event); - } - const todoList = await todoManager.checkAndPersistFromMessage( message, task.id, ); if (todoList) { - emitEvent(adapter.createArtifactEvent("todos", todoList)); + await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { + sessionId, + kind: "todos", + content: todoList, + }); } } } catch (error) { @@ -125,6 +127,9 @@ export const buildStep: WorkflowStepRunner = async ({ step, context }) => { }); } - emitEvent(adapter.createStatusEvent("phase_complete", { phase: "build" })); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { + sessionId, + phase: "build", + }); return { status: "completed" }; }; diff --git a/packages/agent/src/workflow/steps/finalize.ts b/packages/agent/src/workflow/steps/finalize.ts index ff2ba7b3df..d79082f4fb 100644 --- a/packages/agent/src/workflow/steps/finalize.ts +++ b/packages/agent/src/workflow/steps/finalize.ts @@ -12,14 +12,14 @@ export const finalizeStep: WorkflowStepRunner = async ({ step, context }) => { fileManager, gitManager, posthogAPI, - progressReporter, + runId, } = context; const stepLogger = logger.child("FinalizeStep"); const artifacts = await fileManager.collectTaskArtifacts(task.id); let uploadedArtifacts: TaskRunArtifact[] | undefined; - if (artifacts.length && posthogAPI && progressReporter.runId) { + if (artifacts.length && posthogAPI && runId) { try { const payload = artifacts.map((artifact) => ({ name: artifact.name, @@ -29,7 +29,7 @@ export const finalizeStep: WorkflowStepRunner = async ({ step, context }) => { })); uploadedArtifacts = await posthogAPI.uploadTaskArtifacts( task.id, - progressReporter.runId, + runId, payload, ); stepLogger.info("Uploaded task artifacts to PostHog", { @@ -46,7 +46,7 @@ export const finalizeStep: WorkflowStepRunner = async ({ step, context }) => { stepLogger.debug("Skipping artifact upload", { hasArtifacts: artifacts.length > 0, hasPostHogApi: Boolean(posthogAPI), - runId: progressReporter.runId, + runId, }); } diff --git a/packages/agent/src/workflow/steps/plan.ts b/packages/agent/src/workflow/steps/plan.ts index fc39b568c9..194a98e840 100644 --- a/packages/agent/src/workflow/steps/plan.ts +++ b/packages/agent/src/workflow/steps/plan.ts @@ -1,4 +1,5 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; +import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions.js"; import { PLANNING_SYSTEM_PROMPT } from "../../agents/planning.js"; import { TodoManager } from "../../todo-manager.js"; import type { WorkflowStepRunner } from "../types.js"; @@ -14,9 +15,9 @@ export const planStep: WorkflowStepRunner = async ({ step, context }) => { fileManager, gitManager, promptBuilder, - adapter, + sessionId, mcpServers, - emitEvent, + sendNotification, } = context; const stepLogger = logger.child("PlanStep"); @@ -32,16 +33,18 @@ export const planStep: WorkflowStepRunner = async ({ step, context }) => { stepLogger.info("Waiting for answered research questions", { taskId: task.id, }); - emitEvent( - adapter.createStatusEvent("phase_complete", { - phase: "research_questions", - }), - ); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { + sessionId, + phase: "research_questions", + }); return { status: "skipped", halt: true }; } stepLogger.info("Starting planning phase", { taskId: task.id }); - emitEvent(adapter.createStatusEvent("phase_start", { phase: "planning" })); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_START, { + sessionId, + phase: "planning", + }); let researchContext = ""; if (researchData) { researchContext += `## Research Context\n\n${researchData.context}\n\n`; @@ -112,18 +115,16 @@ export const planStep: WorkflowStepRunner = async ({ step, context }) => { let planContent = ""; try { for await (const message of response) { - emitEvent(adapter.createRawSDKEvent(message)); - const transformedEvents = adapter.transform(message); - for (const event of transformedEvents) { - emitEvent(event); - } - const todoList = await todoManager.checkAndPersistFromMessage( message, task.id, ); if (todoList) { - emitEvent(adapter.createArtifactEvent("todos", todoList)); + await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { + sessionId, + kind: "todos", + content: todoList, + }); } // Extract text content for plan @@ -151,12 +152,16 @@ export const planStep: WorkflowStepRunner = async ({ step, context }) => { }); if (!isCloudMode) { - emitEvent( - adapter.createStatusEvent("phase_complete", { phase: "planning" }), - ); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { + sessionId, + phase: "planning", + }); return { status: "completed", halt: true }; } - emitEvent(adapter.createStatusEvent("phase_complete", { phase: "planning" })); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { + sessionId, + phase: "planning", + }); return { status: "completed" }; }; diff --git a/packages/agent/src/workflow/steps/research.ts b/packages/agent/src/workflow/steps/research.ts index 4f3598f112..aa53f2c665 100644 --- a/packages/agent/src/workflow/steps/research.ts +++ b/packages/agent/src/workflow/steps/research.ts @@ -1,4 +1,5 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; +import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions.js"; import { RESEARCH_SYSTEM_PROMPT } from "../../agents/research.js"; import type { ResearchEvaluation } from "../../types.js"; import type { WorkflowStepRunner } from "../types.js"; @@ -14,9 +15,9 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { fileManager, gitManager, promptBuilder, - adapter, + sessionId, mcpServers, - emitEvent, + sendNotification, } = context; const stepLogger = logger.child("ResearchStep"); @@ -36,18 +37,18 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { questionCount: existingResearch.questions.length, }); - emitEvent({ - type: "artifact", - ts: Date.now(), + await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { + sessionId, kind: "research_questions", content: existingResearch.questions, }); // In local mode, halt to allow user to answer if (!isCloudMode) { - emitEvent( - adapter.createStatusEvent("phase_complete", { phase: "research" }), - ); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { + sessionId, + phase: "research", + }); return { status: "skipped", halt: true }; } } @@ -56,7 +57,10 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { } stepLogger.info("Starting research phase", { taskId: task.id }); - emitEvent(adapter.createStatusEvent("phase_start", { phase: "research" })); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_START, { + sessionId, + phase: "research", + }); const researchPrompt = await promptBuilder.buildResearchPrompt(task, cwd); const fullPrompt = `${RESEARCH_SYSTEM_PROMPT}\n\n${researchPrompt}`; @@ -89,11 +93,7 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { let jsonContent = ""; try { for await (const message of response) { - emitEvent(adapter.createRawSDKEvent(message)); - const transformedEvents = adapter.transform(message); - for (const event of transformedEvents) { - emitEvent(event); - } + // Extract text content from assistant messages if (message.type === "assistant" && message.message?.content) { for (const c of message.message.content) { if (c.type === "text" && c.text) { @@ -109,9 +109,8 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { if (!jsonContent.trim()) { stepLogger.error("No JSON output from research agent", { taskId: task.id }); - emitEvent({ - type: "error", - ts: Date.now(), + await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { + sessionId, message: "Research agent returned no output", }); return { status: "completed", halt: true }; @@ -137,9 +136,8 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { error: error instanceof Error ? error.message : String(error), content: jsonContent.substring(0, 500), }); - emitEvent({ - type: "error", - ts: Date.now(), + await sendNotification(POSTHOG_NOTIFICATIONS.ERROR, { + sessionId, message: `Failed to parse research JSON: ${ error instanceof Error ? error.message : String(error) }`, @@ -161,9 +159,8 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { hasQuestions: !!evaluation.questions, }); - emitEvent({ - type: "artifact", - ts: Date.now(), + await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { + sessionId, kind: "research_evaluation", content: evaluation, }); @@ -185,9 +182,8 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { questionCount: evaluation.questions.length, }); - emitEvent({ - type: "artifact", - ts: Date.now(), + await sendNotification(POSTHOG_NOTIFICATIONS.ARTIFACT, { + sessionId, kind: "research_questions", content: evaluation.questions, }); @@ -200,9 +196,10 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { // In local mode, always halt after research for user review if (!isCloudMode) { - emitEvent( - adapter.createStatusEvent("phase_complete", { phase: "research" }), - ); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { + sessionId, + phase: "research", + }); return { status: "completed", halt: true }; } @@ -210,13 +207,17 @@ export const researchStep: WorkflowStepRunner = async ({ step, context }) => { const researchData = await fileManager.readResearch(task.id); if (researchData?.questions && !researchData.answered) { // Questions need answering - halt for user input in cloud mode too - emitEvent( - adapter.createStatusEvent("phase_complete", { phase: "research" }), - ); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { + sessionId, + phase: "research", + }); return { status: "completed", halt: true }; } // No questions or questions already answered - proceed to planning - emitEvent(adapter.createStatusEvent("phase_complete", { phase: "research" })); + await sendNotification(POSTHOG_NOTIFICATIONS.PHASE_COMPLETE, { + sessionId, + phase: "research", + }); return { status: "completed" }; }; diff --git a/packages/agent/src/workflow/types.ts b/packages/agent/src/workflow/types.ts index 04035fa867..7eb0d5d57e 100644 --- a/packages/agent/src/workflow/types.ts +++ b/packages/agent/src/workflow/types.ts @@ -1,15 +1,24 @@ -import type { ProviderAdapter } from "../adapters/types.js"; +import type { AgentSideConnection } from "@agentclientprotocol/sdk"; import type { PostHogFileManager } from "../file-manager.js"; import type { GitManager } from "../git-manager.js"; import type { PostHogAPIClient } from "../posthog-api.js"; import type { PromptBuilder } from "../prompt-builder.js"; -import type { TaskRunProgressReporter } from "../task-run-progress-reporter.js"; import type { PermissionMode, Task, TaskExecutionOptions } from "../types.js"; import type { Logger } from "../utils/logger.js"; +/** + * Function type for sending custom PostHog notifications via ACP extNotification. + * Used by workflow steps to emit artifacts, phase updates, etc. + */ +export type SendNotification = ( + method: string, + params: Record, +) => Promise; + export interface WorkflowRuntime { task: Task; taskSlug: string; + runId: string; cwd: string; isCloudMode: boolean; options: TaskExecutionOptions; @@ -17,11 +26,11 @@ export interface WorkflowRuntime { fileManager: PostHogFileManager; gitManager: GitManager; promptBuilder: PromptBuilder; - progressReporter: TaskRunProgressReporter; - adapter: ProviderAdapter; + connection: AgentSideConnection; + sessionId: string; mcpServers?: Record; posthogAPI?: PostHogAPIClient; - emitEvent: (event: unknown) => void; + sendNotification: SendNotification; stepResults: Record; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a15de37a7d..d6a0162e01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -370,6 +370,9 @@ importers: '@rollup/plugin-commonjs': specifier: ^25.0.7 version: 25.0.8(rollup@4.53.3) + '@rollup/plugin-json': + specifier: ^6.1.0 + version: 6.1.0(rollup@4.53.3) '@rollup/plugin-node-resolve': specifier: ^15.2.3 version: 15.3.1(rollup@4.53.3) @@ -2579,6 +2582,15 @@ packages: rollup: optional: true + '@rollup/plugin-json@6.1.0': + resolution: {integrity: sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + '@rollup/plugin-node-resolve@15.3.1': resolution: {integrity: sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==} engines: {node: '>=14.0.0'} @@ -9409,6 +9421,12 @@ snapshots: optionalDependencies: rollup: 4.53.3 + '@rollup/plugin-json@6.1.0(rollup@4.53.3)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + optionalDependencies: + rollup: 4.53.3 + '@rollup/plugin-node-resolve@15.3.1(rollup@4.53.3)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.53.3)