mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 00:44:49 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f96142112 | ||
|
|
a5fa06b80b | ||
|
|
b507fc1986 |
@@ -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)];
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user