fix(codex): release textless completed app-server items

This commit is contained in:
Mariano Belinky
2026-05-12 09:06:41 +02:00
parent 60718f4f53
commit 2e9abc2b98
2 changed files with 180 additions and 2 deletions

View File

@@ -1709,6 +1709,168 @@ describe("runCodexAppServerAttempt", () => {
);
});
it("releases the session when a real completed agent message omits text", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
return turnStartResult("turn-1", "inProgress");
}
return {};
});
__testing.setCodexAppServerClientFactoryForTests(
async () =>
({
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
}) as never,
);
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 200;
const run = runCodexAppServerAttempt(params, {
turnAssistantCompletionIdleTimeoutMs: 5,
});
await vi.waitFor(
() =>
expect(request).toHaveBeenCalledWith("turn/start", expect.anything(), expect.anything()),
{ interval: 1 },
);
await notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-final-1",
delta: "Done.",
},
});
await notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "agentMessage",
id: "msg-final-1",
},
},
});
await expect(run).resolves.toMatchObject({
aborted: false,
timedOut: false,
promptError: null,
assistantTexts: ["Done."],
});
await vi.waitFor(
() =>
expect(request).toHaveBeenCalledWith(
"turn/interrupt",
{
threadId: "thread-1",
turnId: "turn-1",
},
{ timeoutMs: 5_000 },
),
{ interval: 1 },
);
});
it("keeps the completed assistant release armed across bookkeeping notifications", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
return turnStartResult("turn-1", "inProgress");
}
return {};
});
__testing.setCodexAppServerClientFactoryForTests(
async () =>
({
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
}) as never,
);
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 200;
const run = runCodexAppServerAttempt(params, {
turnAssistantCompletionIdleTimeoutMs: 5,
});
await vi.waitFor(
() =>
expect(request).toHaveBeenCalledWith("turn/start", expect.anything(), expect.anything()),
{ interval: 1 },
);
await notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-final-1",
delta: "Done.",
},
});
await notify({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "agentMessage",
id: "msg-final-1",
},
},
});
await notify({
method: "turn/plan/updated",
params: {
threadId: "thread-1",
turnId: "turn-1",
plan: [],
},
});
await expect(run).resolves.toMatchObject({
aborted: false,
timedOut: false,
promptError: null,
assistantTexts: ["Done."],
});
await vi.waitFor(
() =>
expect(request).toHaveBeenCalledWith(
"turn/interrupt",
{
threadId: "thread-1",
turnId: "turn-1",
},
{ timeoutMs: 5_000 },
),
{ interval: 1 },
);
});
it("does not release the session after only a raw assistant response item", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string) => {

View File

@@ -1258,7 +1258,10 @@ export async function runCodexAppServerAttempt(
armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification));
} else if (unblockedAssistantCompletionRelease) {
armTurnAssistantCompletionIdleWatch(describeNotificationActivity(notification));
} else if (isCurrentTurnNotification) {
} else if (
isCurrentTurnNotification &&
shouldDisarmAssistantCompletionIdleWatch(notification)
) {
disarmTurnAssistantCompletionIdleWatch();
}
if (
@@ -2635,7 +2638,20 @@ function isCompletedAssistantNotification(notification: CodexServerNotification)
return false;
}
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
return Boolean(item && readString(item, "type") === "agentMessage" && readString(item, "text"));
return Boolean(item && readString(item, "type") === "agentMessage");
}
function shouldDisarmAssistantCompletionIdleWatch(notification: CodexServerNotification): boolean {
if (!isJsonObject(notification.params)) {
return false;
}
if (notification.method === "item/started") {
return true;
}
if (notification.method === "item/agentMessage/delta") {
return true;
}
return false;
}
function readNotificationItemId(notification: CodexServerNotification): string | undefined {