Skip to content

Large diffs are not rendered by default.

370 changes: 338 additions & 32 deletions packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Large diffs are not rendered by default.

26 changes: 15 additions & 11 deletions packages/agent/src/adapters/codex-app-server/mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,20 +42,14 @@ describe("mapAppServerNotification", () => {
});
});

it("streams a plan delta as an ACP agent_message_chunk", () => {
it("keeps plan deltas out of the agent transcript", () => {
const result = mapAppServerNotification(
"s-1",
APP_SERVER_NOTIFICATIONS.PLAN_DELTA,
{ itemId: "p1", delta: "## Plan\n" },
);

expect(result).toEqual({
sessionId: "s-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "## Plan\n" },
},
});
expect(result).toBeNull();
});

it("returns null when the delta is missing or empty", () => {
Expand Down Expand Up @@ -609,15 +603,25 @@ describe("mapHistoryItem", () => {
]);
});

it("replays a persisted plan item as an agent_message_chunk", () => {
it("replays a persisted plan item as a historical plan tool call", () => {
expect(
mapHistoryItem("s-1", { type: "plan", id: "p1", text: "# The plan" }),
).toEqual([
{
sessionId: "s-1",
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "# The plan" },
sessionUpdate: "tool_call",
toolCallId: "p1:implement",
title: "Plan",
kind: "switch_mode",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: "# The plan" },
},
],
rawInput: { plan: "# The plan", historical: true },
},
},
]);
Expand Down
47 changes: 24 additions & 23 deletions packages/agent/src/adapters/codex-app-server/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,9 @@ export function mapAppServerNotification(
},
};
}
// Plan-mode proposal streaming as agent prose (codex strips it from agentMessage deltas).
case APP_SERVER_NOTIFICATIONS.PLAN_DELTA: {
const delta = readStringField(params, "delta");
if (!delta) return null;
return {
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: delta },
},
};
}
// Plan deltas are buffered by the adapter for the structured approval UI.
case APP_SERVER_NOTIFICATIONS.PLAN_DELTA:
return null;
case APP_SERVER_NOTIFICATIONS.TOKEN_USAGE_UPDATED: {
// Context indicator: renderer reads `used`/`size`; detailed breakdown comes via `_posthog/usage_update`.
const usage = readTokenUsage(params);
Expand Down Expand Up @@ -276,19 +267,29 @@ export function mapHistoryItem(
: [];
case "reasoning":
return [];
// Replay the proposed plan as agent prose so a reattached host still shows it.
case "plan":
return item.text
? [
{
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
case "plan": {
if (!item.text) return [];
const toolCallId = `${item.id ?? "codex-plan"}:implement`;
return [
{
sessionId,
update: {
sessionUpdate: "tool_call",
toolCallId,
title: "Plan",
kind: "switch_mode",
status: "completed",
content: [
{
type: "content",
content: { type: "text", text: item.text },
},
},
]
: [];
],
rawInput: { plan: item.text, historical: true },
},
},
];
}
default: {
const tool = describeTool(item);
if (!tool || !item.id) return [];
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/adapters/codex-app-server/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export const APP_SERVER_NOTIFICATIONS = {
REASONING_TEXT_DELTA: "item/reasoning/textDelta",
// Default reasoning stream for gpt-5 models; raw textDelta is off by default, so without this the host sees no reasoning.
REASONING_SUMMARY_TEXT_DELTA: "item/reasoning/summaryTextDelta",
// Plan-mode <proposed_plan> stream. codex strips the plan from agentMessage deltas,
// so without this the host sees nothing while the plan is written.
// Plan-mode <proposed_plan> stream. The adapter buffers it for the structured
// plan approval UI because codex strips it from agentMessage deltas.
PLAN_DELTA: "item/plan/delta",
TURN_PLAN_UPDATED: "turn/plan/updated",
TURN_COMPLETED: "turn/completed",
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ export {
readAgentToolName,
readMcpToolDescriptor,
readMcpToolName,
readParentToolCallId,
} from "./tool-meta";
export { TypedEventEmitter } from "./typed-event-emitter";
export { isSafeExternalUrl } from "./url";
Expand Down
38 changes: 38 additions & 0 deletions packages/shared/src/tool-meta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
readAgentToolName,
readMcpToolDescriptor,
readMcpToolName,
readParentToolCallId,
} from "./tool-meta";

describe("parseMcpToolName", () => {
Expand Down Expand Up @@ -49,6 +50,43 @@ describe("readAgentToolName", () => {
});
});

describe("readParentToolCallId", () => {
it("prefers the posthog channel over the legacy claudeCode fallback", () => {
expect(
readParentToolCallId({
posthog: { toolName: "Bash", parentToolCallId: "parent-1" },
claudeCode: { parentToolCallId: "stale" },
}),
).toBe("parent-1");
});

it("falls back to claudeCode when posthog is absent", () => {
expect(
readParentToolCallId({
claudeCode: { parentToolCallId: "parent-2" },
}),
).toBe("parent-2");
});

it("ignores malformed canonical metadata and uses a valid legacy fallback", () => {
expect(
readParentToolCallId({
posthog: { toolName: "Bash", parentToolCallId: {} },
claudeCode: { parentToolCallId: "parent-3" },
}),
).toBe("parent-3");
});

it("returns undefined for empty or non-string parent ids", () => {
expect(
readParentToolCallId({ posthog: { parentToolCallId: "" } }),
).toBeUndefined();
expect(
readParentToolCallId({ claudeCode: { parentToolCallId: 123 } }),
).toBeUndefined();
});
});

describe("readMcpToolDescriptor / readMcpToolName", () => {
it("uses the structured mcp descriptor when present (no name parsing)", () => {
const meta = {
Expand Down
13 changes: 12 additions & 1 deletion packages/shared/src/tool-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface PosthogToolMeta {
toolName: string;
/** Set only for MCP tool calls — the originating server + tool. */
mcp?: { server: string; tool: string };
/** Parent subagent tool call for nested activity. */
parentToolCallId?: string;
}

/** `_meta` fragment for adapters to spread onto a tool_call update. */
Expand Down Expand Up @@ -45,7 +47,7 @@ export function parseMcpToolName(
interface ToolCallMeta {
posthog?: PosthogToolMeta;
/** Legacy Claude-adapter channel, read only as a fallback. */
claudeCode?: { toolName?: string };
claudeCode?: { toolName?: string; parentToolCallId?: string };
}

function asToolCallMeta(meta: unknown): ToolCallMeta | undefined {
Expand All @@ -58,6 +60,15 @@ export function readAgentToolName(meta: unknown): string | undefined {
return m?.posthog?.toolName ?? m?.claudeCode?.toolName;
}

/** Parent subagent tool call: neutral channel first, legacy fallback. */
export function readParentToolCallId(meta: unknown): string | undefined {
const m = asToolCallMeta(meta);
const canonical = m?.posthog?.parentToolCallId;
if (typeof canonical === "string" && canonical.length > 0) return canonical;
const legacy = m?.claudeCode?.parentToolCallId;
return typeof legacy === "string" && legacy.length > 0 ? legacy : undefined;
}

/**
* The MCP `{ server, tool }` descriptor for a tool call, or undefined for a
* non-MCP call. Prefers the structured channel, else parses the legacy
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
isJsonRpcNotification,
isJsonRpcRequest,
isJsonRpcResponse,
readParentToolCallId,
type UserShellExecuteParams,
} from "@posthog/shared";
import {
Expand Down Expand Up @@ -173,7 +174,17 @@ function isThoughtItem(
}

export function markThoughtCompletion(items: ConversationItem[]) {
markThoughtCompletionInItems(items, new Set());
}

function markThoughtCompletionInItems(
items: ConversationItem[],
visited: Set<ConversationItem[]>,
) {
if (visited.has(items)) return;
visited.add(items);
const seenContexts = new Set<TurnContext>();
const itemContexts = new Set<TurnContext>();

for (let i = items.length - 1; i >= 0; i--) {
const item = items[i];
Expand All @@ -185,6 +196,13 @@ export function markThoughtCompletion(items: ConversationItem[]) {

if (item.type === "session_update") {
seenContexts.add(item.turnContext);
itemContexts.add(item.turnContext);
}
}

for (const context of itemContexts) {
for (const children of context.childItems.values()) {
markThoughtCompletionInItems(children, visited);
}
}
}
Expand Down Expand Up @@ -747,10 +765,7 @@ function extractUserPrompt(params: unknown): {
}

function getParentToolCallId(update: SessionUpdate): string | undefined {
const meta = (update as Record<string, unknown>)?._meta as
| { claudeCode?: { parentToolCallId?: string } }
| undefined;
return meta?.claudeCode?.parentToolCallId;
return readParentToolCallId((update as Record<string, unknown>)._meta);
}

function pushChildItem(b: ItemBuilder, parentId: string, update: RenderItem) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,22 @@ const childToolCallMsg = (
_meta: { claudeCode: { parentToolCallId } },
});

const childThoughtChunk = (
ts: number,
text: string,
parentToolCallId: string,
) =>
updateMsg(ts, {
sessionUpdate: "agent_thought_chunk",
content: { type: "text", text },
_meta: {
posthog: {
toolName: "subagent_activity",
parentToolCallId,
},
},
});

// --- normalization (cycle-free, Map-resolved) -----------------------------

function normContext(ctx: TurnContext) {
Expand Down Expand Up @@ -475,4 +491,58 @@ describe("createIncrementalConversationBuilder", () => {
expect(row2.turnContext.childItems).not.toBe(row1.turnContext.childItems);
expect(row2.turnContext.childItems.get("agent1")?.length).toBe(1);
});

it("groups canonical PostHog child metadata under its subagent", () => {
const inc = createIncrementalConversationBuilder();
const messages = [
userPromptMsg(1, 1, "go"),
toolCallMsg(2, "agent1", {
_meta: { posthog: { toolName: "spawn_agent" } },
}),
updateMsg(3, {
sessionUpdate: "tool_call",
toolCallId: "child1",
kind: "read",
status: "pending",
title: "child1",
_meta: {
posthog: {
toolName: "subagent_activity",
parentToolCallId: "agent1",
},
},
}),
];

const result = inc.update(messages, true);
const row = result.items.find((item) => item.type === "session_update");
if (row?.type !== "session_update") {
throw new Error("expected agent session_update row");
}
expect(row.turnContext.childItems.get("agent1")?.length).toBe(1);
});

it("marks nested subagent thoughts complete when the turn finishes", () => {
const inc = createIncrementalConversationBuilder();
const messages = [
userPromptMsg(1, 1, "go"),
toolCallMsg(2, "agent1", {
_meta: { posthog: { toolName: "spawn_agent" } },
}),
childThoughtChunk(3, "investigating", "agent1"),
promptResponseMsg(4, 1),
];

const result = inc.update(messages, false);
const row = result.items.find((item) => item.type === "session_update");
if (row?.type !== "session_update") {
throw new Error("expected agent session_update row");
}
const thought = row.turnContext.childItems.get("agent1")?.[0];
expect(thought).toMatchObject({
type: "session_update",
thoughtComplete: true,
update: { sessionUpdate: "agent_thought_chunk" },
});
});
});
Loading
Loading