Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
createAgentTextThoughtChunk,
createUserMessageChunk,
} from "./ContentChunks";
import { getSubAgentActivityTracker } from "./SubAgentActivityTracker";

export interface ThreadGoalSnapshot {
objective: string;
Expand Down Expand Up @@ -178,7 +179,7 @@ export class CodexAcpServer {
this.getRecentStderr = getRecentStderr ?? (() => "");
this.clientInfo = null;
this.clientCapabilities = null;
this.terminalOutputMode = "terminal_output_delta";
this.terminalOutputMode = "content";
this.booleanConfigOptionsSupported = false;
this.availableCommands = new CodexCommands(
connection,
Expand Down Expand Up @@ -999,9 +1000,10 @@ export class CodexAcpServer {
case "userMessage":
return this.createUserMessageUpdates(item);
case "hookPrompt":
case "subAgentActivity":
case "sleep":
return [];
case "subAgentActivity":
return getSubAgentActivityTracker(sessionState).mapSubAgentActivity(item, "completed");
case "agentMessage": {
const meta = createCodexMessagePhaseMeta(item.phase);
return [{
Expand All @@ -1016,7 +1018,7 @@ export class CodexAcpServer {
case "fileChange":
return [await createFileChangeUpdate(item)];
case "commandExecution": {
const updates = [await createCommandExecutionUpdate(item)];
const updates = [await createCommandExecutionUpdate(item, sessionState.terminalOutputMode)];
const completeUpdate = createCommandExecutionCompleteUpdate(item, sessionState.terminalOutputMode);
if (completeUpdate) {
updates.push(completeUpdate);
Expand All @@ -1028,7 +1030,7 @@ export class CodexAcpServer {
case "dynamicToolCall":
return [await createDynamicToolCallUpdate(item)];
case "collabAgentToolCall":
return [createCollabAgentToolCallUpdate(item)];
return getSubAgentActivityTracker(sessionState).mapCollabAgentToolCall(item, "completed");
case "webSearch":
return [this.createWebSearchUpdate(item)];
case "imageView":
Expand Down Expand Up @@ -1691,9 +1693,9 @@ function mergeHistoryUpdates(
const seen = new Set<string>();
let fallbackIndex = 0;

const pushUpdate = (update: UpdateSessionEvent) => {
const pushUpdate = (update: UpdateSessionEvent, dedupe: boolean = true) => {
const key = historyUpdateKey(update);
if (key && seen.has(key)) {
if (dedupe && key && seen.has(key)) {
return;
}
if (key) {
Expand Down Expand Up @@ -1729,7 +1731,10 @@ function mergeHistoryUpdates(

for (const update of threadUpdates) {
flushFallbackBeforeMatchingDuplicate(update);
pushUpdate(update);
// Thread history is authoritative and can legitimately contain several
// progress updates for the same tool call. Only fallback records are
// deduplicated against those updates.
pushUpdate(update, false);
}

while (fallbackIndex < responseItemFallbackUpdates.length) {
Expand Down
44 changes: 44 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ export class CodexAppServerClient {
private readonly threadGoalUpdateCaptures = new Map<string, Set<(event: ThreadGoalUpdatedNotification) => void>>();
private readonly threadGoalClearedCaptures = new Map<string, Set<() => void>>();
private readonly staleTurnIds = new Map<string, Set<string>>();
private readonly childThreadParents = new Map<string, string>();

constructor(connection: MessageConnection) {
this.connection = connection;
Expand Down Expand Up @@ -173,6 +174,7 @@ export class CodexAppServerClient {
if (this.handleStaleTurnNotification(serverNotification, routing)) {
return;
}
this.recordChildThreadParent(serverNotification);
this.recordTurnRouting(routing);
if (this.handleStaleTurnNotification(serverNotification, routing)) {
return;
Expand Down Expand Up @@ -251,6 +253,12 @@ export class CodexAppServerClient {
this.notificationHandlers.delete(threadId);
this.approvalHandlers.delete(threadId);
this.elicitationHandlers.delete(threadId);
this.childThreadParents.delete(threadId);
for (const [childThreadId, parentThreadId] of this.childThreadParents) {
if (parentThreadId === threadId) {
this.childThreadParents.delete(childThreadId);
}
}
}

async initialize(params: InitializeParams): Promise<InitializeResponse> {
Expand Down Expand Up @@ -654,13 +662,41 @@ export class CodexAppServerClient {
if (handler) {
handler(notification);
}
const parentThreadId = this.childThreadParents.get(threadId);
const parentHandler = parentThreadId ? this.notificationHandlers.get(parentThreadId) : undefined;
if (
parentHandler
&& parentHandler !== handler
&& isChildActivityNotification(notification)
) {
parentHandler(notification);
}
return;
}
for (const notificationHandler of this.notificationHandlers.values()) {
notificationHandler(notification);
}
}

private recordChildThreadParent(notification: ServerNotification): void {
if (notification.method !== "item/started" && notification.method !== "item/completed") {
return;
}
const item = notification.params.item;
if (item.type === "subAgentActivity") {
this.childThreadParents.set(item.agentThreadId, notification.params.threadId);
return;
}
if (
item.type === "collabAgentToolCall"
&& (item.tool === "spawnAgent" || item.tool === "resumeAgent")
) {
for (const childThreadId of item.receiverThreadIds) {
this.childThreadParents.set(childThreadId, notification.params.threadId);
}
}
}

private recordTurnCompleted(event: TurnCompletedNotification): void {
const threadResolvers = this.pendingTurnCompletionResolvers.get(event.threadId);
const resolve = threadResolvers?.get(event.turn.id);
Expand Down Expand Up @@ -973,6 +1009,14 @@ function isTurnCompletedNotification(notification: ServerNotification): notifica
return notification.method === "turn/completed";
}

function isChildActivityNotification(notification: ServerNotification): boolean {
if (notification.method === "turn/completed") {
return true;
}
return notification.method === "item/completed"
&& notification.params.item.type === "agentMessage";
}

function isThreadStatusChangedNotification(notification: ServerNotification): notification is {
method: "thread/status/changed";
params: ThreadStatusChangedNotification;
Expand Down
92 changes: 51 additions & 41 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,8 @@ import type { McpStartupCompleteEvent } from "./app-server";
import {toTokenCount} from "./TokenCount";
import {
commandExecutionUsesTerminalOutput,
createCollabAgentToolCallCompleteUpdate,
createCollabAgentToolCallUpdate,
createCommandExecutionUpdate,
createCommandExecutionCompleteUpdate,
createContextCompactionCompleteUpdate,
createContextCompactionStartUpdate,
createDynamicToolCallUpdate,
Expand All @@ -62,9 +61,12 @@ import {
createAgentTextMessageChunk,
createAgentTextThoughtChunk,
} from "./ContentChunks";
import { getSubAgentActivityTracker } from "./SubAgentActivityTracker";

export { stripShellPrefix };

type UpdateResult = UpdateSessionEvent | UpdateSessionEvent[] | null;

export class CodexEventHandler {

private readonly connection: AcpClientConnection;
Expand All @@ -90,13 +92,14 @@ export class CodexEventHandler {

async handleNotification(notification: ServerNotification) {
const session = new ACPSessionConnection(this.connection, this.sessionState.sessionId);
const updateEvent = await this.createUpdateEvent(notification);
if (updateEvent) {
const result = await this.createUpdateEvent(notification);
const updateEvents = Array.isArray(result) ? result : result ? [result] : [];
for (const updateEvent of updateEvents) {
await session.update(updateEvent);
}
}

private async createUpdateEvent(notification: ServerNotification): Promise<UpdateSessionEvent | null> {
private async createUpdateEvent(notification: ServerNotification): Promise<UpdateResult> {
/*
TODO split UpdateSessionEvent to improve completion
createUpdateEvent({
Expand All @@ -119,6 +122,12 @@ export class CodexEventHandler {
this.sessionState.currentTurnId = notification.params.turn.id;
return null;
case "turn/completed":
if (notification.params.threadId !== this.sessionState.sessionId) {
return getSubAgentActivityTracker(this.sessionState).completeChildTurn(
notification.params.threadId,
notification.params.turn,
);
}
this.sessionState.currentTurnId = null;
return null;
case "thread/tokenUsage/updated":
Expand Down Expand Up @@ -309,18 +318,21 @@ export class CodexEventHandler {
return createAgentTextThoughtChunk(text, messageId);
}

private async createItemEvent(event: ItemStartedNotification): Promise<UpdateSessionEvent | null> {
private async createItemEvent(event: ItemStartedNotification): Promise<UpdateResult> {
switch (event.item.type) {
case "fileChange":
return await createFileChangeUpdate(event.item);
case "commandExecution": {
if (commandExecutionUsesTerminalOutput(event.item)) {
if (
this.sessionState.terminalOutputMode !== "content"
&& commandExecutionUsesTerminalOutput(event.item)
) {
this.terminalCommandIds.add(event.item.id);
} else {
this.terminalCommandIds.delete(event.item.id);
this.terminalCommandOutputIds.delete(event.item.id);
}
return await createCommandExecutionUpdate(event.item);
return await createCommandExecutionUpdate(event.item, this.sessionState.terminalOutputMode);
}
case "mcpToolCall":
return await createMcpToolCallUpdate(event.item);
Expand All @@ -335,13 +347,14 @@ export class CodexEventHandler {
this.activeImageGenerationItems.add(event.item.id);
return createImageGenerationStartUpdate(event.item);
case "collabAgentToolCall":
return createCollabAgentToolCallUpdate(event.item);
return getSubAgentActivityTracker(this.sessionState).mapCollabAgentToolCall(event.item, "started");
case "agentMessage":
this.rememberAgentMessagePhase(event.item);
return null;
case "contextCompaction":
return createContextCompactionStartUpdate(event.item);
case "subAgentActivity":
return getSubAgentActivityTracker(this.sessionState).mapSubAgentActivity(event.item, "started");
case "sleep":
case "userMessage":
case "hookPrompt":
Expand All @@ -353,7 +366,7 @@ export class CodexEventHandler {
}
}

private async completeItemEvent(event: ItemCompletedNotification): Promise<UpdateSessionEvent | null> {
private async completeItemEvent(event: ItemCompletedNotification): Promise<UpdateResult> {
switch (event.item.type) {
case "fileChange":
case "dynamicToolCall":
Expand Down Expand Up @@ -390,8 +403,15 @@ export class CodexEventHandler {
case "webSearch":
return createWebSearchCompleteUpdate(event.item);
case "collabAgentToolCall":
return createCollabAgentToolCallCompleteUpdate(event.item);
return getSubAgentActivityTracker(this.sessionState).mapCollabAgentToolCall(event.item, "completed");
case "agentMessage":
if (event.threadId !== this.sessionState.sessionId) {
getSubAgentActivityTracker(this.sessionState).recordChildMessage(
event.threadId,
event.item.text,
);
return null;
}
this.rememberAgentMessagePhase(event.item);
return null;
case "exitedReviewMode":
Expand All @@ -400,6 +420,7 @@ export class CodexEventHandler {
return createContextCompactionCompleteUpdate(event.item);
//ignored types
case "subAgentActivity":
return getSubAgentActivityTracker(this.sessionState).mapSubAgentActivity(event.item, "completed");
case "sleep":
case "userMessage":
case "hookPrompt":
Expand Down Expand Up @@ -436,7 +457,7 @@ export class CodexEventHandler {
}

private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent {
if (this.terminalCommandIds.has(event.itemId) && event.delta.length > 0) {
if (event.delta.length > 0) {
this.terminalCommandOutputIds.add(event.itemId);
}
return this.createCommandOutputEvent(event.itemId, event.delta, this.commandOutputMode(event.itemId));
Expand All @@ -447,6 +468,16 @@ export class CodexEventHandler {
data: string,
terminalOutputMode: TerminalOutputMode
): UpdateSessionEvent {
if (terminalOutputMode === "content") {
return {
sessionUpdate: "tool_call_update",
toolCallId: itemId,
content: [{
type: "content",
content: { type: "text", text: data },
}],
};
}
return {
sessionUpdate: "tool_call_update",
toolCallId: itemId,
Expand Down Expand Up @@ -518,37 +549,16 @@ export class CodexEventHandler {
}

private completeCommandExecutionEvent(item: ThreadItem & { "type": "commandExecution" }): UpdateSessionEvent {
const update: UpdateSessionEvent = {
sessionUpdate: "tool_call_update",
toolCallId: item.id,
status: item.status === "completed" ? "completed" : "failed",
rawOutput: {
formatted_output: item.aggregatedOutput ?? "",
exit_code: item.exitCode
},
};

const commandHadTerminal = this.terminalCommandIds.delete(item.id);
const commandHadOutput = this.terminalCommandOutputIds.delete(item.id);
if (!commandHadTerminal) {
return update;
}
const terminalMeta: Record<string, unknown> = {};
if (!commandHadOutput && item.aggregatedOutput) {
Object.assign(
terminalMeta,
createTerminalOutputMeta(this.sessionState.terminalOutputMode, item.id, item.aggregatedOutput)
);
}
terminalMeta["terminal_exit"] = {
exit_code: item.exitCode,
signal: null,
terminal_id: item.id
};
return {
...update,
_meta: terminalMeta,
};
return createCommandExecutionCompleteUpdate(
item,
this.sessionState.terminalOutputMode,
{
includeOutputContent: !commandHadOutput,
includeTerminalMeta: commandHadTerminal,
},
)!;
}

private async updatePlan(event: TurnPlanUpdatedNotification): Promise<UpdateSessionEvent> {
Expand Down
Loading