Skip to content
Merged
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
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@

- Codex app-server usage: see https://github.com/openai/codex/blob/main/codex-rs/app-server/README.md when touching protocol/transport details, adding or consuming JSON-RPC methods, handling approvals/turn events, or updating generated schema/clients.
- App-server events: prefer `thread/*`, `turn/*`, and `item/*` event surfaces; avoid the deprecated `codex/event/*` API (planned removal). Keep implementations aligned with generated types in `src/app-server` (including `v2` exports).
- Codex reasoning summaries can echo trailing empty HTML comments from model instructions. Keep
that provider-specific cleanup in `src/ReasoningText.ts` across live deltas and history replay;
do not filter assistant text, raw reasoning, or HTML globally in the client renderer.
5 changes: 3 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import {
import packageJson from "../package.json";
import {isJetBrains2026_1Client} from "./JBUtils";
import {resolveTerminalOutputMode, type TerminalOutputMode} from "./TerminalOutputMode";
import {sanitizeReasoningParts} from "./ReasoningText";
import {
createCodexMessagePhaseMeta,
createAgentTextMessageChunk,
Expand Down Expand Up @@ -1082,9 +1083,9 @@ export class CodexAcpServer {
}

private createReasoningUpdates(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent[] {
const parts = item.summary.length > 0 ? item.summary : item.content;
const messageId = item.id;
return parts.map((text) => createAgentTextThoughtChunk(text, messageId));
return sanitizeReasoningParts(item.summary, item.content)
.map((text) => createAgentTextThoughtChunk(text, messageId));
}

private createWebSearchUpdate(
Expand Down
41 changes: 32 additions & 9 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
} from "./CodexToolCallMapper";
import { stripShellPrefix } from "./CommandUtils";
import {createTerminalOutputMeta, type TerminalOutputMode} from "./TerminalOutputMode";
import {ReasoningSummaryFilter, sanitizeReasoningParts} from "./ReasoningText";
import {
createCodexMessagePhaseMeta,
createAgentTextMessageChunk,
Expand All @@ -83,6 +84,7 @@ export class CodexEventHandler {
private readonly activeImageGenerationItems = new Set<string>();
private readonly emittedImageViewItems = new Set<string>();
private readonly seenReasoningDeltaItemIds = new Set<string>();
private readonly reasoningSummaryFilters = new Map<string, ReasoningSummaryFilter>();
private readonly terminalCommandIds = new Set<string>();
private readonly terminalCommandOutputIds = new Set<string>();
private proposedPlanMarkdown = "";
Expand Down Expand Up @@ -175,9 +177,9 @@ export class CodexEventHandler {
case "thread/compacted":
return this.createContextCompactedEvent();
case "item/reasoning/summaryTextDelta":
return this.createReasoningDeltaEvent(notification.params);
return this.createReasoningSummaryDeltaEvent(notification.params);
case "item/reasoning/textDelta":
return this.createReasoningDeltaEvent(notification.params);
return this.createRawReasoningDeltaEvent(notification.params);
case "item/reasoning/summaryPartAdded":
return this.createReasoningSectionBreakEvent(notification.params);
case "model/rerouted":
Expand Down Expand Up @@ -404,16 +406,35 @@ export class CodexEventHandler {
&& left.tokenBudget === right.tokenBudget;
}

private createReasoningDeltaEvent(
event: ReasoningSummaryTextDeltaNotification | ReasoningTextDeltaNotification
): UpdateSessionEvent {
private createReasoningSummaryDeltaEvent(event: ReasoningSummaryTextDeltaNotification): UpdateSessionEvent | null {
this.seenReasoningDeltaItemIds.add(event.itemId);
let filter = this.reasoningSummaryFilters.get(event.itemId);
if (!filter) {
filter = new ReasoningSummaryFilter();
this.reasoningSummaryFilters.set(event.itemId, filter);
}
const text = filter.push(event.delta);
return text.length > 0 ? this.createAgentThoughtEvent(text, event.itemId) : null;
}

private createRawReasoningDeltaEvent(event: ReasoningTextDeltaNotification): UpdateSessionEvent {
this.seenReasoningDeltaItemIds.add(event.itemId);
return this.createAgentThoughtEvent(event.delta, event.itemId);
}

private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent {
this.seenReasoningDeltaItemIds.add(event.itemId);
return this.createAgentThoughtEvent("\n\n", event.itemId);
const trailingText = this.finishReasoningSummaryFilter(event.itemId);
return this.createAgentThoughtEvent(`${trailingText}\n\n`, event.itemId);
}

private finishReasoningSummaryFilter(itemId: string): string {
const filter = this.reasoningSummaryFilters.get(itemId);
if (!filter) {
return "";
}
this.reasoningSummaryFilters.delete(itemId);
return filter.finish();
}

private createAgentThoughtEvent(text: string, messageId: string): UpdateSessionEvent {
Expand Down Expand Up @@ -494,7 +515,10 @@ export class CodexEventHandler {
return createImageGenerationUpdate(event.item, { terminalStatus: true });
case "reasoning":
if (this.seenReasoningDeltaItemIds.delete(event.item.id)) {
return null;
const trailingText = this.finishReasoningSummaryFilter(event.item.id);
return trailingText.length > 0
? this.createAgentThoughtEvent(trailingText, event.item.id)
: null;
}
return this.createCompletedReasoningEvent(event.item);
case "webSearch":
Expand Down Expand Up @@ -525,8 +549,7 @@ export class CodexEventHandler {
}

private createCompletedReasoningEvent(item: ThreadItem & { type: "reasoning" }): UpdateSessionEvent | null {
const parts = item.summary.length > 0 ? item.summary : item.content;
const text = parts.filter(part => part.length > 0).join("\n\n");
const text = sanitizeReasoningParts(item.summary, item.content).join("\n\n");
if (text.length === 0) {
return null;
}
Expand Down
90 changes: 90 additions & 0 deletions src/ReasoningText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const COMPLETE_EMPTY_COMMENT = /^<!--\s*(?:\*\/\s*)?-->\s*$/u;
const PARTIAL_COMMENT_BODY = /^\s*(?:\*\/?\s*)?$/u;
const PARTIAL_COMMENT_CLOSE = /^\s*(?:\*\/\s*)?-{1,2}$/u;

const isHorizontalWhitespace = (char: string): boolean => char === " " || char === "\t";

const canBecomeEmptyComment = (text: string): boolean => {
if (text === "<" || text === "<!" || text === "<!-") {
return true;
}
if (!text.startsWith("<!--")) {
return false;
}

const tail = text.slice(4);
return COMPLETE_EMPTY_COMMENT.test(text)
|| PARTIAL_COMMENT_BODY.test(tail)
|| PARTIAL_COMMENT_CLOSE.test(tail);
};

/**
* Removes Codex's trailing, line-isolated empty reasoning comments.
*
* The marker can be split across JSON-RPC deltas, so a per-delta regex would miss it. Text that
* stops matching the narrow marker grammar is released immediately and unchanged.
*/
export class ReasoningSummaryFilter {
private pending = "";
private lineHasContent = false;

push(delta: string): string {
let output = "";

const emit = (text: string) => {
output += text;
for (const char of text) {
if (char === "\n" || char === "\r") {
this.lineHasContent = false;
} else if (!isHorizontalWhitespace(char)) {
this.lineHasContent = true;
}
}
};

for (const char of delta) {
if (this.pending) {
this.pending += char;
if (canBecomeEmptyComment(this.pending)) {
continue;
}
emit(this.pending);
this.pending = "";
continue;
}

if (char === "<" && !this.lineHasContent) {
this.pending = char;
} else {
emit(char);
}
}

return output;
}

finish(): string {
const output = COMPLETE_EMPTY_COMMENT.test(this.pending) ? "" : this.pending;
this.pending = "";
this.lineHasContent = false;
return output;
}
}

export function stripEmptyReasoningComments(text: string): string {
const filter = new ReasoningSummaryFilter();
return filter.push(text) + filter.finish();
}

/**
* Selects a reasoning item's displayable parts: summary when present, else raw content. Only the
* summary carries the model's empty-comment markers, so stripping is applied to summary parts only,
* never to raw content. Empty parts are dropped.
*/
export function sanitizeReasoningParts(summary: string[], content: string[]): string[] {
const useSummary = summary.length > 0;
const parts = useSummary ? summary : content;
return parts
.map((part) => useSummary ? stripEmptyReasoningComments(part) : part)
.filter((part) => part.length > 0);
}
22 changes: 12 additions & 10 deletions src/ResponseItemHistoryFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { CommandAction, Thread, ThreadItem } from "./app-server/v2";
import { createCommandActionEvent } from "./CodexToolCallMapper";
import { createTerminalOutputMeta, type TerminalOutputMode } from "./TerminalOutputMode";
import { createAgentMessageChunk, createCodexMessagePhaseMeta } from "./ContentChunks";
import { sanitizeReasoningParts, stripEmptyReasoningComments } from "./ReasoningText";

type JsonRecord = Record<string, unknown>;
type AcpToolCallEvent = Extract<UpdateSessionEvent, { sessionUpdate: "tool_call" }>;
Expand Down Expand Up @@ -282,9 +283,14 @@ function createAgentReasoningEventUpdates(payload: JsonRecord): UpdateSessionEve
return [];
}

const sanitizedText = stripEmptyReasoningComments(text);
if (sanitizedText.length === 0) {
return [];
}

return [{
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text },
content: { type: "text", text: sanitizedText },
}];
}

Expand Down Expand Up @@ -330,15 +336,11 @@ function contentBlocksFromResponseContent(content: unknown): ContentBlock[] {
}

function createReasoningUpdates(item: JsonRecord): UpdateSessionEvent[] {
const parts = textParts(item["summary"]);
if (parts.length === 0) {
parts.push(...textParts(item["content"]));
}

return parts.map((text) => ({
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text },
}));
return sanitizeReasoningParts(textParts(item["summary"]), textParts(item["content"]))
.map((text) => ({
sessionUpdate: "agent_thought_chunk" as const,
content: { type: "text" as const, text },
}));
}

function textParts(value: unknown): string[] {
Expand Down
53 changes: 53 additions & 0 deletions src/__tests__/CodexACPAgent/reasoning-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,57 @@ describe("CodexEventHandler - reasoning events", () => {
"data/reasoning-completed-parts.json"
);
});

it("filters a trailing empty comment split across reasoning summary deltas", async () => {
const notifications: ServerNotification[] = [
{
method: "item/reasoning/summaryTextDelta",
params: {
threadId: sessionId,
turnId: "turn-1",
itemId: "reasoning-comment",
summaryIndex: 0,
delta: "**Checking files**\n\n<!",
},
},
{
method: "item/reasoning/summaryTextDelta",
params: {
threadId: sessionId,
turnId: "turn-1",
itemId: "reasoning-comment",
summaryIndex: 0,
delta: "-- -->",
},
},
{
method: "item/completed",
params: {
threadId: sessionId,
turnId: "turn-1",
completedAtMs: 0,
item: {
type: "reasoning",
id: "reasoning-comment",
summary: ["**Checking files**\n\n<!-- -->"],
content: [],
},
},
},
];

await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications);

expect(mockFixture.getAcpConnectionEvents([])).toEqual([{
method: "sessionUpdate",
args: [{
sessionId,
update: {
sessionUpdate: "agent_thought_chunk",
messageId: "reasoning-comment",
content: {type: "text", text: "**Checking files**\n\n"},
},
}],
}]);
});
});
32 changes: 32 additions & 0 deletions src/__tests__/ReasoningText.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { ReasoningSummaryFilter, stripEmptyReasoningComments } from "../ReasoningText";

describe("reasoning summary text", () => {
it("strips trailing empty comments, including the observed malformed variant", () => {
expect(stripEmptyReasoningComments("**Checking files**\n\n<!-- -->")).toBe(
"**Checking files**\n\n"
);
expect(stripEmptyReasoningComments("**Checking files**\n\n<!-- */--> \n")).toBe(
"**Checking files**\n\n"
);
});

it("handles a marker split at every delta boundary", () => {
const text = "**Checking files**\n\n<!-- -->";
for (let split = 0; split <= text.length; split += 1) {
const filter = new ReasoningSummaryFilter();
const output = filter.push(text.slice(0, split))
+ filter.push(text.slice(split))
+ filter.finish();
expect(output).toBe("**Checking files**\n\n");
}
});

it("preserves comments that may be meaningful", () => {
expect(stripEmptyReasoningComments("Text <!-- -->")).toBe("Text <!-- -->");
expect(stripEmptyReasoningComments("<!-- keep this -->")).toBe("<!-- keep this -->");
expect(stripEmptyReasoningComments("<!-- -->\nMore text")).toBe("<!-- -->\nMore text");
expect(stripEmptyReasoningComments("```html\n<!-- -->\n```"))
.toBe("```html\n<!-- -->\n```");
});
});