Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andluke 4f96142112 test(app): read expanded turns across the virtual window
Verify every command remains accessible while scrolling and after older history loads. Requiring all expanded rows in the DOM at once contradicts bounded desktop mounting.

Co-authored-by: luke <luopeihuan@gmail.com>
2026-11-03 10:04:10 +00:00
luke a5fa06b80b fix(app): virtualize chat turns that exceed the mounted window
- Desktop web aligns the mounted tail to the current turn, so one long tool run stayed fully mounted and stalled the chat open.
- Cap that rewind at 60 rows for the web virtualizer. A larger minimum still wins, and history paging plus the replica cache still keep a whole turn.
2026-11-03 16:16:32 +08:00
paseo-bot[bot]andHomicideZero b507fc1986 fix(server): return 404 for missing well-known metadata (#8622)
* fix(server): return 404 for unserved well-known paths

The daemon web UI middleware fell back to the SPA's index.html for every
unknown GET, including /.well-known/* URIs. MCP clients that run OAuth
discovery (RFC 9728 protected resource metadata, RFC 8414 authorization
server metadata) received a 200 text/html body, treated it as malformed
metadata and retried instead of concluding the server has no OAuth.

Well-known paths now serve a real file from the dist directory if one
exists and otherwise return a JSON 404. Other paths keep the SPA fallback.

* fix(server): verify metadata routing through the daemon

Retain the contributed well-known routing fix with named resolver options and a full daemon regression that fails when metadata requests receive app HTML.

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>

---------

Co-authored-by: HomicideZero <219062565+HomicideZero@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 07:53:13 +00:00
12 changed files with 218 additions and 551 deletions
+36 -3
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import {
holdOlderHistoryPages,
userScrollsTimelineToHistoryStart,
waitForTimelineGeometryToSettle,
} from "../support/helpers/timeline-pagination";
import { getActiveTabTestId } from "../support/helpers/launcher";
import type { Page, Locator } from "@playwright/test";
@@ -407,6 +408,38 @@ async function seedWholeTurnHistory() {
return agent;
}
/** Read every command across the scroll window, without requiring offscreen rows to mount. */
async function expectExpandedCommandsReadable(page: Page, count: number) {
const timeline = page.getByTestId("agent-chat-scroll");
const commands = new Set<number>();
await timeline.hover();
for (let step = 0; step < count && commands.size < count; step += 1) {
const labels = await timeline
.getByRole("button", { name: /echo stress-\d+/ })
.allTextContents();
for (const label of labels) {
const command = /echo stress-(\d+)/.exec(label);
if (command) commands.add(Number(command[1]));
}
if (commands.size < count) {
await page.mouse.wheel(0, 300);
await waitForTimelineGeometryToSettle(page);
}
}
expect([...commands].sort((a, b) => a - b)).toEqual(
Array.from({ length: count }, (_, index) => index),
);
}
async function returnToExpandedSummary(page: Page, summary: Locator) {
await page.getByTestId("agent-chat-scroll").hover();
for (let step = 0; step < 120 && !(await summary.isVisible()); step += 1) {
await page.mouse.wheel(0, -300);
await waitForTimelineGeometryToSettle(page);
}
await summary.scrollIntoViewIfNeeded();
}
test("loads complete long turns and preserves expansion while revealing older history", async ({
page,
}) => {
@@ -423,7 +456,7 @@ test("loads complete long turns and preserves expansion while revealing older hi
await expect(latest).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await expectExpandedCommandsReadable(page, 120);
await userScrollsTimelineToHistoryStart(page);
await history.expectRequestedPages(1);
history.releasePage(1);
@@ -432,9 +465,9 @@ test("loads complete long turns and preserves expansion while revealing older hi
.getByTestId("turn-summary")
.getByRole("button", { name: "Ran 60 commands", exact: true }),
).toBeVisible();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(120);
await expectExpandedCommandsReadable(page, 120);
history.expectNoRepeatedEntries();
await latest.scrollIntoViewIfNeeded();
await returnToExpandedSummary(page, latest);
await latest.click();
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
} finally {
@@ -26,14 +26,22 @@ export function getMountedRecentStreamItems(): number {
export function findMountedWindowStart(input: {
items: StreamItem[];
minMountedCount: number;
// Caps how far turn alignment may rewind. History paging and the replica
// cache omit it and still keep a whole turn.
maxMountedCount?: number;
}): number {
const { items, minMountedCount } = input;
if (items.length <= minMountedCount) {
return 0;
}
const mountedLimit =
input.maxMountedCount === undefined
? items.length
: Math.max(minMountedCount, input.maxMountedCount);
const earliestStart = Math.max(0, items.length - mountedLimit);
let startIndex = Math.max(items.length - minMountedCount, 0);
while (startIndex > 0 && continuesTurn(items[startIndex - 1], items[startIndex])) {
while (startIndex > earliestStart && continuesTurn(items[startIndex - 1], items[startIndex])) {
startIndex -= 1;
}
return startIndex;
@@ -3,6 +3,7 @@ import type { StreamItem } from "@/types/stream";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { findMountedWindowStart } from "./history-window";
import { buildAgentStreamRenderModel } from "./model";
import { MAX_MOUNTED_WEB_WINDOW_ITEMS } from "./web-virtualization";
function createTimestamp(seed: number): Date {
return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`);
@@ -125,6 +126,30 @@ describe("buildAgentStreamRenderModel", () => {
expect(model.history).not.toContain(head[0]);
});
it("virtualizes a desktop web turn that runs past the mounted cap", () => {
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 80;
const tail = Array.from({ length }, (_, index) => ({
...userMessage(`row-${index}`, index % 60),
turnId: "long-run",
}));
const model = buildAgentStreamRenderModel({
isTurnActive: false,
activeTurnStartedAt: null,
tail,
head: [],
platform: "web",
isMobileBreakpoint: false,
});
expect(model.segments.historyMounted).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(model.segments.historyVirtualized).toHaveLength(80);
expect(model.segments.historyMounted[0]?.id).toBe(
`row-${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`,
);
expect(model.segments.historyVirtualized[0]?.id).toBe("row-0");
});
it("keeps the full committed tail mounted on mobile web", () => {
const tail = [userMessage("u1", 1), assistantMessage("a1", 2)];
const head = [assistantMessage("live-a", 3)];
+5 -1
View File
@@ -2,7 +2,10 @@ import type { ReactNode } from "react";
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
import { getWebPartialVirtualizationThreshold } from "./web-virtualization";
import {
getWebPartialVirtualizationThreshold,
MAX_MOUNTED_WEB_WINDOW_ITEMS,
} from "./web-virtualization";
import { orderHeadForStreamRenderStrategy, orderTailForStreamRenderStrategy } from "./strategy";
import { resolveStreamRenderStrategy } from "./strategy-resolver";
@@ -134,6 +137,7 @@ function splitOrderedTail(params: {
const mountedWindowStart = findMountedWindowStart({
items: orderedTail,
minMountedCount: getMountedRecentStreamItems(),
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
});
const split = {
history: orderedTail,
@@ -10,6 +10,7 @@ import {
DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD,
estimateStreamItemHeight,
findMountedWindowStart,
MAX_MOUNTED_WEB_WINDOW_ITEMS,
getWebMountedRecentStreamItems,
getWebPartialVirtualizationThreshold,
splitWebVirtualizedHistory,
@@ -108,6 +109,37 @@ describe("findMountedWindowStart", () => {
}),
).toBe(39);
});
it("keeps a whole turn when the caller does not cap the rewind", () => {
const items = Array.from({ length: MAX_MOUNTED_WEB_WINDOW_ITEMS + 40 }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
expect(findMountedWindowStart({ items, minMountedCount: 20 })).toBe(0);
});
it("stops a long turn at the cap and still honors a larger minimum", () => {
const items = Array.from({ length: 200 }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
expect(
findMountedWindowStart({
items,
minMountedCount: 20,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
}),
).toBe(200 - MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(
findMountedWindowStart({
items,
minMountedCount: 80,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
}),
).toBe(120);
});
});
describe("splitWebVirtualizedHistory", () => {
@@ -130,6 +162,39 @@ describe("splitWebVirtualizedHistory", () => {
expect(window.mountedEntries[0]?.item.id).toBe("u5");
expect(window.mountedEntries).toHaveLength(50);
});
it("virtualizes the older rows of a turn that is longer than the mounted cap", () => {
const length = MAX_MOUNTED_WEB_WINDOW_ITEMS + 25;
const items = Array.from({ length }, (_, index) => ({
...toolCall(`t${index}`, index % 60),
turnId: "long",
}));
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
});
expect(window.mountedEntries).toHaveLength(MAX_MOUNTED_WEB_WINDOW_ITEMS);
expect(window.virtualizedEntries).toHaveLength(25);
expect(window.mountedEntries[0]?.item.id).toBe(`t${length - MAX_MOUNTED_WEB_WINDOW_ITEMS}`);
expect(window.virtualizedEntries[0]?.item.id).toBe("t0");
});
it("keeps a turn that fits under the cap mounted together", () => {
const items = Array.from({ length: 40 }, (_, index) => ({
...toolCall(`t${index}`, index),
turnId: "short",
}));
const window = splitWebVirtualizedHistory({
entries: indexEntries(items),
minMountedCount: DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS,
});
expect(window.virtualizedEntries).toHaveLength(0);
expect(window.mountedEntries).toHaveLength(40);
});
});
describe("estimateStreamItemHeight", () => {
@@ -8,6 +8,9 @@ import {
export const DEFAULT_WEB_PARTIAL_VIRTUALIZATION_THRESHOLD = 100;
export const DEFAULT_WEB_MOUNTED_RECENT_STREAM_ITEMS = DEFAULT_MOUNTED_RECENT_STREAM_ITEMS;
// Collapsed tool rows are about 40px, so this stays past a desktop viewport.
// Opening a long run mounts that tail and virtualizes the older rows.
export const MAX_MOUNTED_WEB_WINDOW_ITEMS = 60;
const COLLAPSED_TOOL_SEQUENCE_ROW_HEIGHT_ESTIMATE = 40;
type BottomAnchorE2ETestGlobals = typeof globalThis & {
@@ -86,6 +89,7 @@ export function splitWebVirtualizedHistory(input: {
const startIndex = findMountedWindowStart({
items: input.entries.map((entry) => entry.item),
minMountedCount: input.minMountedCount,
maxMountedCount: MAX_MOUNTED_WEB_WINDOW_ITEMS,
});
return {
virtualizedEntries: input.entries.slice(0, startIndex),
@@ -32,405 +32,6 @@ import {
toAgentUsage,
} from "./codex-app-server-agent.js";
describe("Codex prompt hook rejection", () => {
test.each([
{ status: "blocked", entryKind: "feedback" },
{ status: "stopped", entryKind: "stop" },
])(
"shows a $status queued follow-up without failing accepted work in the same turn",
async ({ status, entryKind }) => {
const appServer = createFakeCodexAppServer({
"turn/steer": () => ({ turn: { id: "native-A" } }),
});
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await session.steerActiveTurn!("Allowed follow-up", { expectedTurnId: paseoTurnId });
await session.steerActiveTurn!("Rejected follow-up", { expectedTurnId: paseoTurnId });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "completed",
entries: [],
});
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status,
entries: [{ kind: entryKind, text: "Follow-up rejected" }],
});
appServer.says({ threadId: "thread-1", text: "Accepted work completed" });
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({ type: "turn_completed" });
expect(events).toContainEqual(
expect.objectContaining({
type: "timeline",
item: expect.objectContaining({
type: "assistant_message",
text: "Accepted work completed",
}),
}),
);
expect(events).toContainEqual({
type: "timeline",
provider: "codex",
item: { type: "notification", level: "error", message: "Follow-up rejected" },
turnId: paseoTurnId,
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each(["before", "after"])(
"keeps an accepted input successful when rejected input arrives %s it, even without a reply",
async (order) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
function rejectInput(): void {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected follow-up" }],
});
}
if (order === "before") rejectInput();
appServer.acceptsUserMessage({
threadId: "thread-1",
turnId: "native-A",
itemId: "accepted-input",
text: "Accepted prompt",
});
if (order === "after") rejectInput();
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({ type: "turn_completed" });
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ status: "interrupted", expected: { type: "turn_canceled", reason: "interrupted" } },
{ status: "failed", expected: { type: "turn_failed", error: "Native failure" } },
] as const)(
"preserves native $status outcomes after a rejected prompt",
async ({ status, expected }) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected prompt" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn({ status, error: { message: "Native failure" } });
await expect(completion).resolves.toMatchObject(expected);
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ threadId: "thread-1", turnId: "older-turn" },
{ threadId: "child-thread", turnId: "native-A" },
])("does not count foreign input as accepted work: $threadId $turnId", async (input) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
appServer.acceptsUserMessage({ ...input, itemId: "foreign-input", text: "Unrelated input" });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected prompt" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({
type: "turn_failed",
error: "Rejected prompt",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each(["compaction", "hook context"])(
"does not count pre-prompt %s as accepted task work",
async (housekeeping) => {
const appServer = createFakeCodexAppServer();
const { session } = await startPublicSteeringSession(appServer);
try {
if (housekeeping === "compaction") {
appServer.startsCompaction({ threadId: "thread-1", itemId: "pre-prompt-compaction" });
appServer.completesCompaction({ threadId: "thread-1", itemId: "pre-prompt-compaction" });
} else {
appServer.addsHookContext({
threadId: "thread-1",
turnId: "native-A",
itemId: "hook-context",
text: "Housekeeping context",
});
}
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Rejected after compaction" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await expect(completion).resolves.toMatchObject({
type: "turn_failed",
error: "Rejected after compaction",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test.each([
{ status: "blocked", entryKind: "feedback" },
{ status: "stopped", entryKind: "stop" },
])(
"fails a fully $status manager run and permits a corrected submission",
async ({ status, entryKind }) => {
const appServer = createFakeCodexAppServer();
const provider = createProviderWithFakeAppServer(appServer);
const client: AgentClient = {
provider: "codex",
capabilities: provider.capabilities,
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
createSession: (...args) => provider.createSession(...args),
resumeSession: (...args) => provider.resumeSession(...args),
};
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
const agent = await manager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "thread-1" },
createConfig({ cwd: process.cwd() }),
);
try {
const run = manager.runAgent(agent.id, "Disallowed prompt", {
clientMessageId: "blocked-prompt",
});
const failure = expect(run).rejects.toThrow("Remove the disallowed text and retry.");
await appServer.waitForTurnStart();
appServer.startsTurn({ threadId: "thread-1", turnId: "native-A" });
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status,
entries: [{ kind: entryKind, text: "Remove the disallowed text and retry." }],
});
appServer.completeTurn();
await failure;
expect(manager.getTimeline(agent.id)).toEqual([
{
type: "user_message",
text: "Disallowed prompt",
messageId: "blocked-prompt",
clientMessageId: "blocked-prompt",
},
{
type: "notification",
level: "error",
message: "Remove the disallowed text and retry.",
},
{
type: "assistant_message",
text: "[System Error] Remove the disallowed text and retry.",
},
]);
expect(manager.hasInFlightRun(agent.id)).toBe(false);
expect(manager.getAgent(agent.id)?.lastError).toBe("Remove the disallowed text and retry.");
const retry = manager.runAgent(agent.id, "Corrected prompt", {
clientMessageId: "corrected-prompt",
});
await appServer.waitForTurnStart(2);
appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" });
appServer.says({ threadId: "thread-1", text: "Accepted" });
appServer.completeTurn();
await expect(retry).resolves.toMatchObject({ finalText: "Accepted", canceled: false });
expect(manager.hasInFlightRun(agent.id)).toBe(false);
appServer.assertNoErrors();
} finally {
await manager.closeAgent(agent.id);
}
},
);
test("shows blocking feedback and accepts the next turn", async () => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [{ kind: "feedback", text: "Remove the disallowed text and retry." }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "notification",
level: "error",
message: "Remove the disallowed text and retry.",
},
turnId: paseoTurnId,
},
{
type: "turn_failed",
provider: "codex",
error: "Remove the disallowed text and retry.",
turnId: paseoTurnId,
},
]);
events.length = 0;
const replacement = await session.startTurn("Allowed replacement prompt");
appServer.startsTurn({ threadId: "thread-1", turnId: "native-B" });
appServer.says({ threadId: "thread-1", text: "Accepted" });
const replacementCompletion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await replacementCompletion;
expect(events.filter((event) => event.type.startsWith("turn_"))).toEqual([
{ type: "turn_started", provider: "codex", turnId: replacement.turnId },
{ type: "turn_completed", provider: "codex", usage: undefined, turnId: replacement.turnId },
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test.each([
{ eventName: "preToolUse", status: "blocked", turnId: "native-A", threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "completed",
turnId: "native-A",
threadId: "thread-1",
},
{ eventName: "userPromptSubmit", status: "failed", turnId: "native-A", threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "blocked",
turnId: "older-turn",
threadId: "thread-1",
},
{ eventName: "userPromptSubmit", status: "blocked", turnId: null, threadId: "thread-1" },
{
eventName: "userPromptSubmit",
status: "blocked",
turnId: "native-A",
threadId: "child-thread",
},
])(
"does not fail the foreground turn for an unrelated hook: $eventName $status $turnId $threadId",
async ({ eventName, status, turnId, threadId }) => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId,
turnId,
eventName,
status,
entries: [{ kind: "feedback", text: "Unrelated feedback" }],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{ type: "turn_completed", provider: "codex", turnId: paseoTurnId, usage: undefined },
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
},
);
test("provides an actionable failure when the blocking hook has no feedback", async () => {
const appServer = createFakeCodexAppServer();
const { session, paseoTurnId } = await startPublicSteeringSession(appServer);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
appServer.completesHook({
threadId: "thread-1",
turnId: "native-A",
eventName: "userPromptSubmit",
status: "blocked",
entries: [],
});
const completion = waitForNextTurnCompletion(session);
appServer.completeTurn();
await completion;
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
item: {
type: "notification",
level: "error",
message: "Prompt rejected by a UserPromptSubmit hook.",
},
turnId: paseoTurnId,
},
{
type: "turn_failed",
provider: "codex",
error: "Prompt rejected by a UserPromptSubmit hook.",
turnId: paseoTurnId,
},
]);
appServer.assertNoErrors();
} finally {
await session.close();
}
});
});
describe("mapCodexPlanUpdateToTodo", () => {
test("preserves checklist progress without creating a plan card", () => {
expect(
@@ -490,7 +91,6 @@ import {
fakeCodexThreadHistory,
type FakeCodexAppServer,
waitForNextEvent,
waitForNextTurnCompletion,
waitForNextPermission,
waitForNextTimelineItem,
waitForProviderSubagent,
@@ -2574,14 +2574,6 @@ const CodexEventThreadRolledBackNotificationSchema = z
type ParsedCodexNotification =
| { kind: "thread_started"; threadId: string }
| { kind: "turn_started"; turnId: string; threadId: string | null }
| {
kind: "hook_completed";
threadId: string;
turnId: string | null;
eventName: string;
status: string;
entries: Array<{ kind: string; text: string }>;
}
| {
kind: "turn_completed";
status: string;
@@ -2702,36 +2694,6 @@ function isCodexDeltaNotification(
}
const CodexNotificationSchema = z.union([
z
.object({
method: z.literal("hook/completed"),
params: z.object({
threadId: z.string(),
turnId: z.string().nullish(),
run: z.object({
eventName: z.string(),
status: z.string(),
entries: z.array(z.object({ kind: z.string(), text: z.string() })),
}),
}),
})
.transform(
({ params }): ParsedCodexNotification => ({
kind: "hook_completed",
threadId: params.threadId,
turnId: params.turnId ?? null,
eventName: params.run.eventName,
status: params.run.status,
entries: params.run.entries,
}),
),
z.object({ method: z.literal("hook/completed"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({ method: z.literal("thread/started"), params: ThreadStartedNotificationSchema })
.transform(
@@ -3521,7 +3483,6 @@ export class CodexAppServerAgentSession implements AgentSession {
// in this app-server, so no other app-server can resume it and it is not reported.
private savedThreadId: string | null = null;
private currentTurnId: string | null = null;
private turnPromptOutcome: "unobserved" | "accepted" | { rejection: string } = "unobserved";
private pendingForegroundTurnIdentification: {
foregroundTurnId: string;
promise: Promise<string | null>;
@@ -5817,9 +5778,6 @@ export class CodexAppServerAgentSession implements AgentSession {
case "turn_completed":
this.handleTurnCompletedNotification(parsed);
return;
case "hook_completed":
this.handleHookCompletedNotification(parsed);
return;
case "plan_updated":
this.handlePlanUpdatedNotification(parsed);
return;
@@ -5847,11 +5805,9 @@ export class CodexAppServerAgentSession implements AgentSession {
this.handlePatchApplyCompletedNotification(parsed);
return;
case "item_completed":
this.recordAcceptedTurnWork(parsed);
await this.handleItemCompletedNotification(parsed);
return;
case "item_started":
this.recordAcceptedTurnWork(parsed);
await this.handleItemStartedNotification(parsed);
return;
case "invalid_payload":
@@ -6437,34 +6393,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: current });
}
private handleHookCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "hook_completed" }>,
): void {
const rejectsCurrentPrompt =
parsed.eventName === "userPromptSubmit" &&
(parsed.status === "blocked" || parsed.status === "stopped") &&
this.currentTurnId !== null &&
parsed.turnId === this.currentTurnId;
if (!rejectsCurrentPrompt) return;
const feedback = parsed.entries
.filter(
(entry) => entry.kind === "feedback" || entry.kind === "stop" || entry.kind === "error",
)
.map((entry) => entry.text.trim())
.filter((text) => text.length > 0)
.join("\n\n");
const message = feedback || "Prompt rejected by a UserPromptSubmit hook.";
if (this.turnPromptOutcome !== "accepted") {
this.turnPromptOutcome = { rejection: message };
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "notification", level: "error", message },
});
}
private handleTurnCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "turn_completed" }>,
): void {
@@ -6495,12 +6423,6 @@ export class CodexAppServerAgentSession implements AgentSession {
} else if (parsed.status === "interrupted") {
this.dismissInterruptedAsyncQuestions();
this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" });
} else if (typeof this.turnPromptOutcome === "object") {
this.emitEvent({
type: "turn_failed",
provider: CODEX_PROVIDER,
error: this.turnPromptOutcome.rejection,
});
} else {
if (this.planModeEnabled && this.latestPlanResult?.text) {
this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text);
@@ -6520,24 +6442,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.resetTurnTrackingState();
}
private recordAcceptedTurnWork(
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
): void {
// Pre-prompt housekeeping can precede a rejected input. Only admitted input or
// task work keeps a rejected follow-up from failing the native turn.
const itemType = normalizeCodexThreadItemType(parsed.item.type);
if (itemType === CODEX_CONTEXT_COMPACTION_TYPE || itemType === "hookPrompt") return;
if (
this.currentTurnId !== null &&
parsed.threadId === this.currentThreadId &&
(parsed.turnId === null || parsed.turnId === this.currentTurnId)
) {
this.turnPromptOutcome = "accepted";
}
}
private resetTurnTrackingState(): void {
this.turnPromptOutcome = "unobserved";
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
@@ -56,13 +56,6 @@ interface FakeLegacyPatch {
path: string;
diff: string;
}
interface FakeHookCompletion {
threadId: string;
turnId: string | null;
eventName: string;
status: string;
entries: Array<{ kind: string; text: string }>;
}
type CodexAppServerChildProcess = ChildProcessWithoutNullStreams & {
stdin: PassThrough;
stdout: PassThrough;
@@ -74,21 +67,13 @@ export interface FakeCodexAppServer {
readonly recordedRollbacks: JsonObject[];
requests(): readonly JsonObject[];
assertNoErrors(): void;
waitForTurnStart(occurrence?: number): Promise<JsonObject>;
waitForTurnStart(): Promise<JsonObject>;
waitForRequest(method: string): Promise<JsonObject>;
disconnect(): void;
nextResponse(): Promise<string>;
startsTurn(params: { threadId: string; turnId?: string }): void;
startsCompaction(params: { threadId: string; itemId: string }): void;
updatesPlan(params: { threadId: string; steps: string[] }): void;
acceptsUserMessage(params: {
threadId: string;
turnId: string;
itemId: string;
text: string;
}): void;
addsHookContext(params: { threadId: string; turnId: string; itemId: string; text: string }): void;
completesHook(params: FakeHookCompletion): void;
completeTurn(params?: {
threadId?: string;
status?: "completed" | "failed" | "interrupted";
@@ -383,11 +368,9 @@ export function createFakeCodexAppServer(
throw errors[0];
}
},
async waitForTurnStart(occurrence = 1) {
async waitForTurnStart() {
const message = await waitForMessage(
(candidate) =>
messages.filter((request) => request.method === "turn/start")[occurrence - 1] ===
candidate,
(candidate) => candidate.method === "turn/start",
"turn start request",
);
return toJsonObject(message.params);
@@ -435,27 +418,6 @@ export function createFakeCodexAppServer(
})}\n`,
);
},
acceptsUserMessage({ threadId, turnId, itemId, text }) {
const params = {
threadId,
turnId,
item: { type: "userMessage", id: itemId, content: [{ type: "text", text }] },
};
writeNotification("item/started", params);
writeNotification("item/completed", params);
},
addsHookContext({ threadId, turnId, itemId, text }) {
const params = {
threadId,
turnId,
item: { type: "hookPrompt", id: itemId, fragments: [{ text, hookRunId: "hook-run" }] },
};
writeNotification("item/started", params);
writeNotification("item/completed", params);
},
completesHook({ threadId, turnId, ...run }) {
writeNotification("hook/completed", { threadId, turnId, run });
},
completeTurn(params = {}) {
child.stdout.write(
`${JSON.stringify({
@@ -727,17 +689,16 @@ type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent
export function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession,
type: TType | readonly TType[],
type: TType,
accepts?: (event: StreamEventOfType<TType>) => boolean,
): Promise<StreamEventOfType<TType>> {
const types: readonly StreamEventType[] = typeof type === "string" ? [type] : type;
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
unsubscribe();
reject(new Error(`Timed out waiting for ${types.join(" or ")}`));
reject(new Error(`Timed out waiting for ${type}`));
}, 1000);
const unsubscribe = session.subscribe((event) => {
if (!types.includes(event.type)) {
if (event.type !== type) {
return;
}
const typedEvent = event as StreamEventOfType<TType>;
@@ -751,10 +712,6 @@ export function waitForNextEvent<TType extends StreamEventType>(
});
}
export function waitForNextTurnCompletion(session: AgentSession) {
return waitForNextEvent(session, ["turn_completed", "turn_failed", "turn_canceled"]);
}
type TimelineEvent = StreamEventOfType<"timeline">;
type ProviderSubagentEvent = StreamEventOfType<"provider_subagent">;
@@ -84,6 +84,23 @@ describe("daemon web UI bootstrap", () => {
}
});
test("returns 404 for missing metadata while serving web UI deep links", async () => {
const distDir = await createWebUiDist();
daemonHandle = await createTestPaseoDaemon({
mcpEnabled: true,
webUi: { enabled: true, distDir },
});
const origin = `http://127.0.0.1:${daemonHandle.port}`;
const metadata = await fetch(`${origin}/.well-known/oauth-protected-resource/mcp/agents`);
expect(metadata.status).toBe(404);
expect(metadata.headers.get("content-type")).toBe("application/json; charset=utf-8");
expect(await metadata.json()).toEqual({ error: "Not found" });
const deepLink = await fetch(`${origin}/h/example/agent/123`);
expect(deepLink.status).toBe(200);
expect(await deepLink.text()).toContain("<body>app</body>");
});
test("advertises ComputerName as the hostname over HTTP and WebSocket", async () => {
daemonHandle = await createTestPaseoDaemon({ mcpEnabled: false });
const response = await fetch(`http://127.0.0.1:${daemonHandle.port}/api/status`);
+27
View File
@@ -187,6 +187,33 @@ describe("daemon web UI route module", () => {
expect(res.body).toContain("app");
});
test.each([
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp/agents",
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
])("returns a JSON 404 instead of the SPA for missing %s", async (requestPath) => {
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", requestPath);
expect(res.status).toBe(404);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(JSON.parse(res.body)).toEqual({ error: "Not found" });
});
test("serves well-known files that exist in the dist directory", async () => {
await mkdir(path.join(distDir, ".well-known"), { recursive: true });
await writeFile(path.join(distDir, ".well-known", "security.json"), '{"ok":true}');
const app = createApp({ enabled: true, distDir, publicDir });
const res = await request(app, "GET", "/.well-known/security.json");
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toBe("application/json; charset=utf-8");
expect(res.body).toBe('{"ok":true}');
});
test("serves static assets", async () => {
const app = createApp({ enabled: true, distDir, publicDir });
+24 -2
View File
@@ -15,6 +15,13 @@ function isExcludedPath(requestPath: string): boolean {
return EXCLUDED_PATHS.has(requestPath);
}
// RFC 8615 well-known URIs are machine-read metadata. Clients such as MCP OAuth
// discovery (RFC 9728, RFC 8414) need a 404 for documents the daemon does not
// serve; the SPA's index.html with a 200 reads as malformed metadata.
function isWellKnownPath(requestPath: string): boolean {
return requestPath === "/.well-known" || requestPath.startsWith("/.well-known/");
}
const CONTENT_TYPES: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
@@ -71,7 +78,14 @@ interface ResolvedTarget {
isIndexHtml: boolean;
}
function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget | null {
interface ResolveTargetFileOptions {
distDir: string;
requestPath: string;
spaFallback: boolean;
}
function resolveTargetFile(options: ResolveTargetFileOptions): ResolvedTarget | null {
const { distDir, requestPath, spaFallback } = options;
const safePath = path.normalize(requestPath).replace(/^(\.\.[/\\])+/, "");
let filePath = path.join(distDir, safePath);
@@ -82,6 +96,9 @@ function resolveTargetFile(distDir: string, requestPath: string): ResolvedTarget
const finalStat = safeStat(filePath);
if (!finalStat?.isFile()) {
if (!spaFallback) {
return null;
}
filePath = path.join(distDir, "index.html");
const fallbackStat = safeStat(filePath);
if (!fallbackStat?.isFile()) {
@@ -190,8 +207,13 @@ interface ServeWebUiFileOptions {
function serveWebUiFile(options: ServeWebUiFileOptions): void {
const { distDir, requestPath, label, req, res } = options;
const target = resolveTargetFile(distDir, requestPath);
const wellKnown = isWellKnownPath(requestPath);
const target = resolveTargetFile({ distDir, requestPath, spaFallback: !wellKnown });
if (!target) {
if (wellKnown) {
res.status(404).json({ error: "Not found" });
return;
}
res.status(404).end();
return;
}