Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -2962,6 +2962,57 @@ describe("CodexAppServerAgent", () => {
});
});

it("expands a persisted plan only when it is the last replayed item", async () => {
const stub = makeStubRpc({
"thread/start": { thread: { id: "t1" } },
"thread/resume": {
thread: {
id: "t1",
turns: [
{
items: [
{ type: "plan", id: "p1", text: "# Earlier plan" },
{ type: "agentMessage", id: "a1", text: "More work" },
{ type: "plan", id: "p2", text: "# Latest plan" },
],
},
],
},
},
});
const { client, sessionUpdates } = makeFakeClient();
const agent = new CodexAppServerAgent(client, {
processOptions: { binaryPath: "/x/codex" },
model: "gpt-5.5",
rpcFactory: stub.factory,
});
await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest);

await agent.loadSession({
sessionId: "t1",
cwd: "/r",
mcpServers: [],
} as unknown as Parameters<typeof agent.loadSession>[0]);

const plans = sessionUpdates
.map(
(entry) =>
entry as {
update?: {
kind?: string;
rawInput?: Record<string, unknown>;
};
},
)
.filter((entry) => entry.update?.kind === "switch_mode");
expect(plans).toHaveLength(2);
expect(plans[0]?.update?.rawInput).not.toHaveProperty("initiallyExpanded");
expect(plans[1]?.update?.rawInput).toMatchObject({
historical: true,
initiallyExpanded: true,
});
});

it("restores subagent relationships from resumed thread history", async () => {
const stub = makeStubRpc({
initialize: {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,13 +448,29 @@ export class CodexAppServerAgent extends BaseAcpAgent {
/** Replay a resumed thread's persisted turns (from the thread/resume response) as session updates. */
private replayHistory(thread: AppServerThread | undefined): void {
if (!this.sessionId || !thread?.turns?.length) return;
const updates: SessionNotification[] = [];
for (const turn of thread.turns) {
for (const item of turn.items ?? []) {
for (const update of mapHistoryItem(this.sessionId, item)) {
void this.client.sessionUpdate(update).catch(() => undefined);
}
updates.push(...mapHistoryItem(this.sessionId, item));
}
}

const lastUpdate = updates.at(-1)?.update;
if (
lastUpdate?.sessionUpdate === "tool_call" &&
lastUpdate.kind === "switch_mode" &&
lastUpdate.rawInput &&
typeof lastUpdate.rawInput === "object"
) {
lastUpdate.rawInput = {
...lastUpdate.rawInput,
initiallyExpanded: true,
};
}

for (const update of updates) {
void this.client.sessionUpdate(update).catch(() => undefined);
}
}

async listSessions(
Expand Down
39 changes: 39 additions & 0 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,45 @@ describe("AgentServer HTTP Mode", () => {
expect(testServer.eventStreamSender.stop).not.toHaveBeenCalled();
});

it("settles a plan prompt before closing ACP during cleanup", async () => {
const testServer = stubSessionCleanup(createServer()) as ReturnType<
typeof stubSessionCleanup
> & {
activeOwnedTurnCount: number;
pendingPermissions: Map<
string,
{
resolve: (response: {
outcome: { outcome: "selected"; optionId: string };
}) => void;
}
>;
runOwnedTurn<T>(operation: () => Promise<T>): Promise<T>;
};
const session = testServer.session as {
acpConnection: { cleanup: ReturnType<typeof vi.fn> };
};
let settlePrompt!: () => void;
const prompt = testServer.runOwnedTurn(
() =>
new Promise<void>((resolve) => {
settlePrompt = resolve;
}),
);
testServer.pendingPermissions.set("plan-approval", {
resolve: () => queueMicrotask(settlePrompt),
});
session.acpConnection.cleanup.mockImplementation(async () => {
expect(testServer.activeOwnedTurnCount).toBe(0);
});

await testServer.cleanupSession();
await prompt;

expect(session.acpConnection.cleanup).toHaveBeenCalledOnce();
expect(testServer.pendingPermissions).toHaveLength(0);
});

it("stops event ingest for terminal session cleanup without fake task completion", async () => {
const testServer = stubSessionCleanup(createServer());

Expand Down
35 changes: 35 additions & 0 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ export class AgentServer {
private pendingCompactContinuationMessageIds = new Set<string>();
private inFlightMessageDeliveries = new Map<string, Promise<unknown>>();
private activeOwnedTurnCount = 0;
private ownedTurnsSettled: Promise<void> | null = null;
private resolveOwnedTurnsSettled: (() => void) | null = null;
private pendingPermissions = new Map<
string,
{
Expand Down Expand Up @@ -1918,11 +1920,38 @@ export class AgentServer {
}

private async runOwnedTurn<T>(operation: () => Promise<T>): Promise<T> {
if (this.activeOwnedTurnCount === 0) {
this.ownedTurnsSettled = new Promise<void>((resolve) => {
this.resolveOwnedTurnsSettled = resolve;
});
}
this.activeOwnedTurnCount += 1;
try {
return await operation();
} finally {
this.activeOwnedTurnCount -= 1;
if (this.activeOwnedTurnCount === 0) {
this.resolveOwnedTurnsSettled?.();
this.resolveOwnedTurnsSettled = null;
this.ownedTurnsSettled = null;
}
}
}

private async waitForOwnedTurnsToSettle(): Promise<void> {
if (this.activeOwnedTurnCount === 0 || !this.ownedTurnsSettled) return;

let timeout: ReturnType<typeof setTimeout> | undefined;
const settled = await Promise.race([
this.ownedTurnsSettled.then(() => true),
new Promise<false>((resolve) => {
timeout = setTimeout(() => resolve(false), 1_000);
}),
]).finally(() => clearTimeout(timeout));
if (!settled) {
this.logger.warn("Timed out waiting for active turns during shutdown", {
activeOwnedTurnCount: this.activeOwnedTurnCount,
});
}
}

Expand Down Expand Up @@ -4496,6 +4525,12 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions}
}
this.pendingPermissions.clear();

// A plan handoff keeps the original prompt RPC open while it waits for the
// approval above. Give that prompt a chance to return before closing ACP;
// otherwise the SDK rejects it with "ACP connection closed" and the
// intentional inactivity shutdown is persisted as a user-visible error.
await this.waitForOwnedTurnsToSettle();

try {
await this.session.acpConnection.cleanup();
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,25 @@ describe("PlanApprovalView", () => {
).toBeInTheDocument();
});

it("opens a historical plan when replay marks it as the latest item", () => {
renderView({
toolCall: makeToolCall({
status: "completed",
rawInput: {
plan: PLAN_MARKER,
historical: true,
initiallyExpanded: true,
},
}),
});

expect(screen.getByText(PLAN_MARKER)).toBeInTheDocument();
expect(screen.getByRole("button", { name: /hide plan/i })).toHaveAttribute(
"aria-expanded",
"true",
);
});

it("uses updated content instead of stale raw input while streaming", () => {
renderView({
toolCall: makeToolCall({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,18 @@ export function PlanApprovalView({
turnCancelled,
turnComplete,
);
const [isPlanExpanded, setIsPlanExpanded] = useState(false);
const rawInput = toolCall.rawInput as
| { historical?: boolean; initiallyExpanded?: boolean; plan?: string }
| undefined;
const isHistoricalPlan = rawInput?.historical === true;
const [isPlanExpanded, setIsPlanExpanded] = useState(
isHistoricalPlan && rawInput?.initiallyExpanded === true,
);
const taskId = useSessionTaskId();
const modelOption = useModelConfigOptionForTask(taskId ?? undefined);
const hasModelSelector =
modelOption?.type === "select" &&
flattenSelectOptions(modelOption.options).length > 0;
const rawInput = toolCall.rawInput as
| { historical?: boolean; plan?: string }
| undefined;
const isHistoricalPlan = rawInput?.historical === true;

const planText = useMemo(() => {
if (content?.length) {
const textContent = content.find((c) => c.type === "content");
Expand Down
Loading