mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 17:04:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
798b546e73 | ||
|
|
6301490a07 | ||
|
|
16f688b726 | ||
|
|
b507fc1986 |
@@ -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">;
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ import {
|
||||
mapOmpRpcUiPermissionRequest,
|
||||
} from "./rpc-ui-permission-mapper.js";
|
||||
import { DEFAULT_OMP_THINKING_LEVEL, mapOmpModel } from "./map-omp-model.js";
|
||||
import {
|
||||
OmpMountNoticeLedger,
|
||||
OmpMountNoticeLedgers,
|
||||
parseOmpMountNotice,
|
||||
} from "./mount-notice.js";
|
||||
|
||||
const OMP_PROVIDER = "omp";
|
||||
const OMP_CORE_CAPABILITIES: AgentCapabilityFlags = {
|
||||
@@ -208,6 +213,10 @@ interface OmpAgentSessionOptions {
|
||||
noTurnScheduler?: OmpNoTurnScheduler;
|
||||
usagePollScheduler?: OmpUsagePollScheduler;
|
||||
providerIdleDeadlineMs?: number;
|
||||
/** Shared by every session of the client so a relaunched agent keeps its ledger. */
|
||||
mountNotices: OmpMountNoticeLedgers;
|
||||
/** The Paseo agent whose projection this session feeds; absent for direct client use. */
|
||||
agentId?: string;
|
||||
/**
|
||||
* When false (resumed sessions), replayed session events are dropped until
|
||||
* the first prompt or agent_start so history is not re-emitted as live
|
||||
@@ -620,6 +629,8 @@ class OmpHistorySession implements AgentSession {
|
||||
private readonly sessionFile: string,
|
||||
provider: AgentProvider,
|
||||
private readonly bridgedTools: ReadonlyMap<string, OmpBridgedToolIdentity>,
|
||||
private readonly mountNotices: OmpMountNoticeLedgers,
|
||||
private readonly agentId: string | undefined,
|
||||
) {
|
||||
this.provider = provider;
|
||||
}
|
||||
@@ -641,6 +652,8 @@ class OmpHistorySession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
// This history seeds a fresh projection that holds no live mount notices.
|
||||
if (this.agentId) this.mountNotices.seedFromHistory(this.agentId, this);
|
||||
yield* streamOmpHistory({
|
||||
sessionFile: this.sessionFile,
|
||||
provider: this.provider,
|
||||
@@ -718,6 +731,11 @@ export class OmpAgentSession implements AgentSession {
|
||||
private readonly subagentIndex = new OmpSubagentIndex();
|
||||
private readonly subagentCardTracker: OmpSubagentCardTracker;
|
||||
private lastTodoItem: Extract<AgentTimelineItem, { type: "todo" }> | null = null;
|
||||
private readonly mountNotices: OmpMountNoticeLedgers;
|
||||
private readonly agentId: string | undefined;
|
||||
private readonly sessionMountNotices = new OmpMountNoticeLedger();
|
||||
// The client-wide ledger outlives this session; retain identity without its runtime resources.
|
||||
private readonly mountNoticeDeliverer = {};
|
||||
private state: OmpSessionState;
|
||||
private currentModeId: string | null;
|
||||
private runtimeDead = false;
|
||||
@@ -753,6 +771,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.logger = options.logger;
|
||||
this.live = options.live ?? true;
|
||||
this.mountNotices = options.mountNotices;
|
||||
this.agentId = options.agentId;
|
||||
this.providerIdleScheduler = options.providerIdleScheduler ?? createOmpProviderIdleScheduler();
|
||||
this.providerIdleDeadlineMs = options.providerIdleDeadlineMs ?? OMP_PROVIDER_IDLE_DEADLINE_MS;
|
||||
this.noTurnScheduler = options.noTurnScheduler ?? createOmpNoTurnScheduler();
|
||||
@@ -999,6 +1019,10 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
|
||||
// This history seeds a fresh projection: it keeps the mount notices this session already
|
||||
// delivered into it, and none from earlier sessions.
|
||||
if (this.agentId) this.mountNotices.seedFromHistory(this.agentId, this.mountNoticeDeliverer);
|
||||
else this.sessionMountNotices.retainDeliveredBy(this.mountNoticeDeliverer);
|
||||
yield* streamOmpHistory({
|
||||
sessionFile: this.state.sessionFile,
|
||||
runtimeSession: this.runtimeSession,
|
||||
@@ -1014,6 +1038,23 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private mountNoticeLedger(): OmpMountNoticeLedger {
|
||||
return this.agentId ? this.mountNotices.forAgent(this.agentId) : this.sessionMountNotices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops a mount notice whose devices already have that state in the projected timeline.
|
||||
* A notice no subscriber can receive is not recorded, so it cannot hide a later one.
|
||||
*/
|
||||
private isRedundantMountNotice(event: OmpRuntimeEvent): boolean {
|
||||
const notice = parseOmpMountNotice(event);
|
||||
if (!notice || this.subscribers.size === 0) return false;
|
||||
const ledger = this.mountNoticeLedger();
|
||||
if (!ledger.announces(notice)) return true;
|
||||
ledger.recordDelivered(notice, this.mountNoticeDeliverer);
|
||||
return false;
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
await this.refreshState();
|
||||
return this.runtimeInfoFromState();
|
||||
@@ -1170,6 +1211,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
await this.runtimeSession.branch(target);
|
||||
await this.refreshState();
|
||||
this.activeToolCalls.clear();
|
||||
// A conversation rewind replaces the whole projection, rows this session delivered included.
|
||||
if (this.agentId) this.mountNotices.forget(this.agentId);
|
||||
else this.sessionMountNotices.clear();
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
@@ -1718,6 +1762,10 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (this.isRedundantMountNotice(event)) {
|
||||
this.logger.debug({ event }, "Dropped OMP mount notice already shown in this timeline");
|
||||
return true;
|
||||
}
|
||||
const mappedEvent = mapOmpRuntimeEventToTimelineItem(event);
|
||||
if (!mappedEvent.handled) {
|
||||
return false;
|
||||
@@ -2246,6 +2294,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
private readonly usagePollScheduler?: OmpUsagePollScheduler;
|
||||
private readonly providerIdleDeadlineMs?: number;
|
||||
private readonly runtime?: OmpRuntime;
|
||||
private readonly mountNotices = new OmpMountNoticeLedgers();
|
||||
|
||||
constructor(options: OmpAgentClientOptions) {
|
||||
const runtimeSettings = mergeOmpRuntimeSettings(
|
||||
@@ -2346,6 +2395,8 @@ export class OmpAgentClient implements AgentClient {
|
||||
noTurnScheduler: this.noTurnScheduler,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
providerIdleDeadlineMs: this.providerIdleDeadlineMs,
|
||||
mountNotices: this.mountNotices,
|
||||
agentId: launchContext?.agentId,
|
||||
});
|
||||
} catch (error) {
|
||||
await hostTools?.close();
|
||||
@@ -2375,6 +2426,8 @@ export class OmpAgentClient implements AgentClient {
|
||||
sessionFile,
|
||||
this.provider,
|
||||
persistenceMetadata.bridgedTools,
|
||||
this.mountNotices,
|
||||
launchContext?.agentId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2417,6 +2470,8 @@ export class OmpAgentClient implements AgentClient {
|
||||
noTurnScheduler: this.noTurnScheduler,
|
||||
usagePollScheduler: this.usagePollScheduler,
|
||||
providerIdleDeadlineMs: this.providerIdleDeadlineMs,
|
||||
mountNotices: this.mountNotices,
|
||||
agentId: launchContext?.agentId,
|
||||
live: false,
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setImmediate as waitForImmediate } from "node:timers/promises";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type {
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
AgentStreamEvent,
|
||||
AgentTimelineItem,
|
||||
} from "../../agent-sdk-types.js";
|
||||
import { AgentManager, type AgentManagerEvent } from "../../agent-manager.js";
|
||||
import { OmpAgentClient } from "./agent.js";
|
||||
import { parseOmpMountNotice } from "./mount-notice.js";
|
||||
import type { OmpRuntimeEvent } from "./rpc-types.js";
|
||||
import { FakeOmp } from "./test-utils/fake-omp.js";
|
||||
|
||||
const AGENT_ID = "8b0c7a52-6f4e-4c4b-9f55-6a5f0f2f6b11";
|
||||
const BROWSER_AND_LINEAR = "xd://: mounted browser, mcp__linear__search";
|
||||
|
||||
type NoticeLevel = "info" | "warning" | "error";
|
||||
|
||||
function notice(
|
||||
message: string,
|
||||
options: { level?: NoticeLevel; source?: string | null } = {},
|
||||
): OmpRuntimeEvent {
|
||||
const source = options.source === undefined ? "xdev" : options.source;
|
||||
return {
|
||||
type: "notice",
|
||||
level: options.level ?? "info",
|
||||
message,
|
||||
...(source === null ? {} : { source }),
|
||||
};
|
||||
}
|
||||
|
||||
function notifications(items: readonly AgentTimelineItem[]): string[] {
|
||||
return items.flatMap((item) => (item.type === "notification" ? [item.message] : []));
|
||||
}
|
||||
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
|
||||
});
|
||||
|
||||
async function writeSessionFile(): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), "paseo-omp-mount-notice-"));
|
||||
cleanups.push(() => rm(directory, { recursive: true, force: true }));
|
||||
const sessionFile = join(directory, "session.jsonl");
|
||||
const entries = [
|
||||
{ type: "session", id: "root", parentId: null },
|
||||
{ type: "message", id: "u1", parentId: "root", message: { role: "user", content: "hi" } },
|
||||
];
|
||||
await writeFile(sessionFile, entries.map((entry) => JSON.stringify(entry)).join("\n"), "utf8");
|
||||
return sessionFile;
|
||||
}
|
||||
|
||||
function persistenceHandle(sessionFile: string): AgentPersistenceHandle {
|
||||
return {
|
||||
provider: "omp",
|
||||
sessionId: "omp-session",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { cwd: process.cwd() },
|
||||
};
|
||||
}
|
||||
|
||||
async function drain(history: AsyncGenerator<AgentStreamEvent>): Promise<void> {
|
||||
for await (const _event of history) {
|
||||
// Consumed the way the manager seeds a projection.
|
||||
}
|
||||
}
|
||||
|
||||
async function closeQuietly(manager: AgentManager, agentId: string): Promise<void> {
|
||||
await manager.closeAgent(agentId).catch(() => undefined);
|
||||
}
|
||||
|
||||
/** One Paseo agent whose OMP process the daemon relaunches, as idle collection and reload do. */
|
||||
class MountNoticeHarness {
|
||||
readonly omp = new FakeOmp();
|
||||
readonly client = new OmpAgentClient({ logger: createTestLogger(), runtime: this.omp });
|
||||
private readonly events: AgentStreamEvent[] = [];
|
||||
private session: AgentSession | null = null;
|
||||
|
||||
constructor(private readonly sessionFile: string) {}
|
||||
|
||||
handle(): AgentPersistenceHandle {
|
||||
return persistenceHandle(this.sessionFile);
|
||||
}
|
||||
|
||||
async relaunch(): Promise<void> {
|
||||
await this.session?.close();
|
||||
const session = await this.client.resumeSession(this.handle(), undefined, {
|
||||
agentId: AGENT_ID,
|
||||
});
|
||||
this.session = session;
|
||||
session.subscribe((event) => this.events.push(event));
|
||||
cleanups.push(() => session.close());
|
||||
}
|
||||
|
||||
async announce(event: OmpRuntimeEvent | string): Promise<void> {
|
||||
this.omp.latestSession().emit(typeof event === "string" ? notice(event) : event);
|
||||
await waitForImmediate();
|
||||
}
|
||||
|
||||
/** The current session seeds a fresh projection from provider history. */
|
||||
async seedFromHistory(): Promise<void> {
|
||||
if (!this.session) throw new Error("No OMP session");
|
||||
await drain(this.session.streamHistory());
|
||||
}
|
||||
|
||||
async readArchivedHistory(agentId = AGENT_ID): Promise<void> {
|
||||
const history = await this.client.resumeSession(
|
||||
this.handle(),
|
||||
undefined,
|
||||
{ agentId },
|
||||
{
|
||||
purpose: "history",
|
||||
},
|
||||
);
|
||||
await drain(history.streamHistory());
|
||||
await history.close();
|
||||
}
|
||||
|
||||
notifications(): string[] {
|
||||
return notifications(
|
||||
this.events.flatMap((event) => (event.type === "timeline" ? [event.item] : [])),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function harness(): Promise<MountNoticeHarness> {
|
||||
const omp = new MountNoticeHarness(await writeSessionFile());
|
||||
await omp.relaunch();
|
||||
return omp;
|
||||
}
|
||||
|
||||
describe("parseOmpMountNotice", () => {
|
||||
test("reads mounted and unmounted devices", () => {
|
||||
expect(parseOmpMountNotice(notice("xd://: mounted a, b; unmounted c"))).toEqual({
|
||||
mounted: ["a", "b"],
|
||||
unmounted: ["c"],
|
||||
});
|
||||
});
|
||||
|
||||
test("accepts OMP builds that omit the notice source", () => {
|
||||
expect(parseOmpMountNotice(notice("xd://: unmounted a", { source: null }))).toEqual({
|
||||
mounted: [],
|
||||
unmounted: ["a"],
|
||||
});
|
||||
});
|
||||
|
||||
test("leaves other notices alone", () => {
|
||||
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { source: "btw" }))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { level: "warning" }))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice(BROWSER_AND_LINEAR, { level: "error" }))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice("xd://: rescanned devices"))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice("xd://: mounted a, "))).toBeNull();
|
||||
expect(parseOmpMountNotice(notice("Session saved"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OMP xd:// mount notices", () => {
|
||||
test("retaining announced mounts does not retain closed OMP sessions", () => {
|
||||
const fixture = fileURLToPath(new URL("./test-utils/mount-notice-memory.ts", import.meta.url));
|
||||
const output = execFileSync(process.execPath, ["--expose-gc", "--import", "tsx", fixture], {
|
||||
encoding: "utf8",
|
||||
timeout: 20_000,
|
||||
});
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
closedWithoutNoticeRetained: false,
|
||||
closedWithNoticeRetained: false,
|
||||
noticesAfterRelaunch: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test("a relaunched OMP process does not repeat a mount set the timeline already shows", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
await omp.relaunch();
|
||||
await omp.announce("xd://: mounted mcp__linear__search");
|
||||
await omp.announce("xd://: mounted browser");
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("a changed mount set is shown as OMP reported it", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.relaunch();
|
||||
const withGithub = "xd://: mounted browser, mcp__linear__search, mcp__github__search";
|
||||
await omp.announce(withGithub);
|
||||
await omp.announce("xd://: unmounted browser");
|
||||
await omp.relaunch();
|
||||
await omp.announce("xd://: unmounted browser");
|
||||
|
||||
expect(omp.notifications()).toEqual([
|
||||
BROWSER_AND_LINEAR,
|
||||
withGithub,
|
||||
"xd://: unmounted browser",
|
||||
]);
|
||||
});
|
||||
|
||||
test("a device mounted again after an unmount is shown", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
await omp.announce("xd://: unmounted browser");
|
||||
await omp.announce("xd://: mounted browser");
|
||||
|
||||
expect(omp.notifications()).toEqual([
|
||||
BROWSER_AND_LINEAR,
|
||||
"xd://: unmounted browser",
|
||||
"xd://: mounted browser",
|
||||
]);
|
||||
});
|
||||
|
||||
test("identical ordinary notices are all shown", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(notice("Session saved", { source: "session-persistence" }));
|
||||
await omp.relaunch();
|
||||
await omp.announce(notice("Session saved", { source: "session-persistence" }));
|
||||
await omp.announce(notice(BROWSER_AND_LINEAR, { source: "btw-history" }));
|
||||
await omp.announce(notice(BROWSER_AND_LINEAR, { source: "btw-history" }));
|
||||
|
||||
expect(omp.notifications()).toEqual([
|
||||
"Session saved",
|
||||
"Session saved",
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
]);
|
||||
});
|
||||
|
||||
test("identical xd:// warnings and errors are all shown", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
for (const level of ["warning", "error"] as const) {
|
||||
await omp.announce(notice(BROWSER_AND_LINEAR, { level }));
|
||||
await omp.relaunch();
|
||||
await omp.announce(notice(BROWSER_AND_LINEAR, { level }));
|
||||
}
|
||||
|
||||
expect(omp.notifications()).toEqual([
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
BROWSER_AND_LINEAR,
|
||||
]);
|
||||
});
|
||||
|
||||
test("a projection seeded from history drops mounts earlier sessions showed", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.relaunch();
|
||||
await omp.seedFromHistory();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR, BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("a projection seeded from history keeps mounts its own session already showed", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
await omp.seedFromHistory();
|
||||
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("reading the agent's archived history drops the mounts it no longer shows", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.readArchivedHistory();
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR, BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("reading another agent's history of the same session file keeps this agent's mounts", async () => {
|
||||
const omp = await harness();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
await omp.readArchivedHistory("2d1e4f7a-0c3b-4d8e-a9f6-5b7c8d9e0f12");
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("a notice nobody received does not hide the next one", async () => {
|
||||
const omp = new MountNoticeHarness(await writeSessionFile());
|
||||
const unobserved = await omp.client.resumeSession(omp.handle(), undefined, {
|
||||
agentId: AGENT_ID,
|
||||
});
|
||||
cleanups.push(() => unobserved.close());
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
await unobserved.close();
|
||||
|
||||
await omp.relaunch();
|
||||
await omp.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(omp.notifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("an approval mode change keeps the mount set the timeline already shows", async () => {
|
||||
const omp = new FakeOmp();
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime: omp });
|
||||
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
|
||||
cleanups.push(() => session.close());
|
||||
const items: AgentTimelineItem[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "timeline") items.push(event.item);
|
||||
});
|
||||
omp.latestSession().emit(notice(BROWSER_AND_LINEAR));
|
||||
await waitForImmediate();
|
||||
|
||||
await session.setMode("ask");
|
||||
omp.latestSession().emit(notice(BROWSER_AND_LINEAR));
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.recordedLaunches).toHaveLength(2);
|
||||
expect(notifications(items)).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
});
|
||||
|
||||
/** A daemon AgentManager driving one OMP agent through FakeOmp. */
|
||||
class DaemonTimelineHarness {
|
||||
readonly omp = new FakeOmp();
|
||||
readonly manager = new AgentManager({
|
||||
clients: {
|
||||
omp: new OmpAgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: this.omp,
|
||||
// FakeOmp supplies the runtime; the manager still checks command availability.
|
||||
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
|
||||
}),
|
||||
},
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
readonly live: Array<{
|
||||
epoch: string | undefined;
|
||||
seq: number | undefined;
|
||||
item: AgentTimelineItem;
|
||||
}> = [];
|
||||
|
||||
constructor(readonly agentId: string) {
|
||||
cleanups.push(() => closeQuietly(this.manager, agentId));
|
||||
this.manager.subscribe(
|
||||
(event: AgentManagerEvent) => {
|
||||
if (event.type === "agent_stream" && event.event.type === "timeline") {
|
||||
this.live.push({ epoch: event.epoch, seq: event.seq, item: event.event.item });
|
||||
}
|
||||
},
|
||||
{ agentId, replayState: false },
|
||||
);
|
||||
}
|
||||
|
||||
/** What the agent loader does when a chat opens an agent the daemon has not loaded. */
|
||||
async load(handle: AgentPersistenceHandle): Promise<void> {
|
||||
await this.manager.resumeAgentFromPersistence(handle, undefined, this.agentId);
|
||||
await this.manager.hydrateTimelineFromProvider(this.agentId);
|
||||
}
|
||||
|
||||
async announce(event: OmpRuntimeEvent | string): Promise<void> {
|
||||
this.omp.latestSession().emit(typeof event === "string" ? notice(event) : event);
|
||||
await waitForImmediate();
|
||||
}
|
||||
|
||||
async prompt(text: string): Promise<void> {
|
||||
await this.manager.appendTimelineItem(this.agentId, { type: "user_message", text });
|
||||
}
|
||||
|
||||
fetchAll() {
|
||||
return this.manager.fetchTimeline(this.agentId, { direction: "tail", limit: 0 });
|
||||
}
|
||||
|
||||
fetchedNotifications(): string[] {
|
||||
return notifications(this.fetchAll().rows.map((row) => row.item));
|
||||
}
|
||||
}
|
||||
|
||||
describe("OMP mount notices in the daemon timeline", () => {
|
||||
test("live delivery and fetched history agree after idle collection relaunches OMP", async () => {
|
||||
const daemon = new DaemonTimelineHarness(AGENT_ID);
|
||||
await daemon.load(persistenceHandle(await writeSessionFile()));
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
await daemon.prompt("first prompt");
|
||||
const handle = daemon.manager.getAgent(AGENT_ID)?.persistence;
|
||||
if (!handle) throw new Error("OMP agent has no persistence handle");
|
||||
const epoch = daemon.fetchAll().epoch;
|
||||
const historyRows = daemon.fetchAll().rows.length - 2;
|
||||
|
||||
await daemon.manager.closeAgent(AGENT_ID);
|
||||
await daemon.load(handle);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
await daemon.announce(notice("Session saved", { source: null }));
|
||||
await daemon.prompt("second prompt");
|
||||
await daemon.manager.flush();
|
||||
|
||||
const fetched = daemon.fetchAll();
|
||||
expect(daemon.omp.recordedLaunches.at(-1)?.session).toBe(handle.nativeHandle);
|
||||
expect(fetched.epoch).toBe(epoch);
|
||||
expect(fetched.rows.slice(historyRows).map((row) => row.item)).toEqual([
|
||||
{ type: "notification", level: "info", message: BROWSER_AND_LINEAR },
|
||||
expect.objectContaining({ type: "user_message", text: "first prompt" }),
|
||||
{ type: "notification", level: "info", message: "Session saved" },
|
||||
expect.objectContaining({ type: "user_message", text: "second prompt" }),
|
||||
]);
|
||||
const liveRows = daemon.live.filter((entry) => entry.epoch === epoch);
|
||||
expect(liveRows.map((entry) => entry.item)).toEqual(
|
||||
fetched.rows.slice(historyRows).map((row) => row.item),
|
||||
);
|
||||
expect(liveRows.map((entry) => entry.seq)).toEqual(
|
||||
fetched.rows.slice(historyRows).map((row) => row.seqEnd),
|
||||
);
|
||||
const seqs = fetched.rows.map((row) => row.seqEnd);
|
||||
expect(seqs).toEqual(seqs.map((_seq, index) => index + 1));
|
||||
expect(fetched.hasOlder).toBe(false);
|
||||
expect(fetched.hasNewer).toBe(false);
|
||||
|
||||
const afterFirstPrompt = daemon.manager.fetchTimeline(AGENT_ID, {
|
||||
direction: "after",
|
||||
cursor: { epoch, seq: historyRows + 2 },
|
||||
limit: 0,
|
||||
});
|
||||
expect(afterFirstPrompt.gap).toBe(false);
|
||||
expect(afterFirstPrompt.rows.map((row) => row.item)).toEqual(
|
||||
fetched.rows.slice(historyRows + 2).map((row) => row.item),
|
||||
);
|
||||
});
|
||||
|
||||
test("a mount notice that lands before history seeding still suppresses the next relaunch", async () => {
|
||||
const daemon = new DaemonTimelineHarness(AGENT_ID);
|
||||
const handle = persistenceHandle(await writeSessionFile());
|
||||
await daemon.manager.resumeAgentFromPersistence(handle, undefined, AGENT_ID);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
await daemon.manager.hydrateTimelineFromProvider(AGENT_ID);
|
||||
|
||||
await daemon.manager.closeAgent(AGENT_ID);
|
||||
await daemon.load(handle);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("a conversation rewind starts a projection that shows the mount set once", async () => {
|
||||
const daemon = new DaemonTimelineHarness(AGENT_ID);
|
||||
const handle = persistenceHandle(await writeSessionFile());
|
||||
await daemon.load(handle);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
const userMessage = daemon
|
||||
.fetchAll()
|
||||
.rows.map((row) => row.item)
|
||||
.find((item) => item.type === "user_message");
|
||||
if (userMessage?.type !== "user_message" || !userMessage.messageId) {
|
||||
throw new Error("History has no user message to rewind to");
|
||||
}
|
||||
|
||||
await daemon.manager.rewind(AGENT_ID, userMessage.messageId, "conversation");
|
||||
expect(daemon.fetchedNotifications()).toEqual([]);
|
||||
await daemon.manager.closeAgent(AGENT_ID);
|
||||
await daemon.load(handle);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
|
||||
test("reloading from disk starts a projection that shows the mount set once", async () => {
|
||||
const daemon = new DaemonTimelineHarness(AGENT_ID);
|
||||
await daemon.load(persistenceHandle(await writeSessionFile()));
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
const epoch = daemon.fetchAll().epoch;
|
||||
|
||||
await daemon.manager.reloadAgentSession(AGENT_ID, undefined, { rehydrateFromDisk: true });
|
||||
await daemon.manager.hydrateTimelineFromProvider(AGENT_ID);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
await daemon.manager.reloadAgentSession(AGENT_ID);
|
||||
await daemon.announce(BROWSER_AND_LINEAR);
|
||||
|
||||
expect(daemon.fetchAll().epoch).not.toBe(epoch);
|
||||
expect(daemon.fetchedNotifications()).toEqual([BROWSER_AND_LINEAR]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { OmpNoticeEventSchema } from "./rpc-types.js";
|
||||
|
||||
// OMP 18.x announces device mounts as an info notice `xd://: mounted a, b; unmounted c` with
|
||||
// source `xdev`. Each OMP process diffs against the tools it had at startup, so every relaunch of
|
||||
// the same session (idle collection, reload, mode change) announces again every MCP server that
|
||||
// connects after OMP's startup wait. A relaunch never reports a device that went missing while
|
||||
// no process ran, and has no RPC that lists mounted devices, so a device that vanishes and later
|
||||
// remounts in a new process looks identical to one that was present all along.
|
||||
const MOUNT_NOTICE_PREFIX = "xd://: ";
|
||||
const MOUNT_NOTICE_SOURCE = "xdev";
|
||||
const MOUNT_NOTICE_LEVEL = "info";
|
||||
const MOUNTED_PREFIX = "mounted ";
|
||||
const UNMOUNTED_PREFIX = "unmounted ";
|
||||
|
||||
export interface OmpMountNotice {
|
||||
mounted: readonly string[];
|
||||
unmounted: readonly string[];
|
||||
}
|
||||
|
||||
function parseNames(list: string): string[] | null {
|
||||
const names = list.split(",").map((name) => name.trim());
|
||||
return names.every((name) => name.length > 0) ? names : null;
|
||||
}
|
||||
|
||||
/** Reads an OMP `notice` event as an xd:// mount change, or null for any other notice. */
|
||||
export function parseOmpMountNotice(event: unknown): OmpMountNotice | null {
|
||||
const parsed = OmpNoticeEventSchema.safeParse(event);
|
||||
if (!parsed.success) return null;
|
||||
const { level, message, source } = parsed.data;
|
||||
if (level !== MOUNT_NOTICE_LEVEL) return null;
|
||||
if (source !== undefined && source !== MOUNT_NOTICE_SOURCE) return null;
|
||||
if (!message.startsWith(MOUNT_NOTICE_PREFIX)) return null;
|
||||
|
||||
const mounted: string[] = [];
|
||||
const unmounted: string[] = [];
|
||||
for (const segment of message.slice(MOUNT_NOTICE_PREFIX.length).split(";")) {
|
||||
const part = segment.trim();
|
||||
let target: string[];
|
||||
let names: string[] | null;
|
||||
if (part.startsWith(UNMOUNTED_PREFIX)) {
|
||||
target = unmounted;
|
||||
names = parseNames(part.slice(UNMOUNTED_PREFIX.length));
|
||||
} else if (part.startsWith(MOUNTED_PREFIX)) {
|
||||
target = mounted;
|
||||
names = parseNames(part.slice(MOUNTED_PREFIX.length));
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (!names) return null;
|
||||
target.push(...names);
|
||||
}
|
||||
return mounted.length > 0 || unmounted.length > 0 ? { mounted, unmounted } : null;
|
||||
}
|
||||
|
||||
type OmpMountState = "mounted" | "unmounted";
|
||||
|
||||
/** The session that delivered a timeline row. A ledger keeps only rows its projection holds. */
|
||||
export type OmpMountNoticeDeliverer = object;
|
||||
|
||||
interface OmpAnnouncedMount {
|
||||
state: OmpMountState;
|
||||
deliverer: OmpMountNoticeDeliverer;
|
||||
}
|
||||
|
||||
/** The last xd:// state one agent's projected timeline shows for each device. */
|
||||
export class OmpMountNoticeLedger {
|
||||
private readonly announced = new Map<string, OmpAnnouncedMount>();
|
||||
|
||||
/** Reports whether the notice changes any device state the projection shows. */
|
||||
announces(notice: OmpMountNotice): boolean {
|
||||
return (
|
||||
notice.mounted.some((name) => this.announced.get(name)?.state !== "mounted") ||
|
||||
notice.unmounted.some((name) => this.announced.get(name)?.state !== "unmounted")
|
||||
);
|
||||
}
|
||||
|
||||
/** Records a row the deliverer added to the projection; it shows every device it names. */
|
||||
recordDelivered(notice: OmpMountNotice, deliverer: OmpMountNoticeDeliverer): void {
|
||||
for (const name of notice.mounted) this.announced.set(name, { state: "mounted", deliverer });
|
||||
for (const name of notice.unmounted) {
|
||||
this.announced.set(name, { state: "unmounted", deliverer });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps only rows the deliverer added. A session streams provider history only to seed a fresh
|
||||
* projection, which holds that history plus the rows this session already delivered into it.
|
||||
*/
|
||||
retainDeliveredBy(deliverer: OmpMountNoticeDeliverer): void {
|
||||
for (const [name, mount] of this.announced) {
|
||||
if (mount.deliverer !== deliverer) this.announced.delete(name);
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.announced.clear();
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.announced.size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount ledgers keyed by Paseo agent id. The agent's retained projection outlives one OMP
|
||||
* process and one Paseo session, so its ledger must too.
|
||||
*/
|
||||
export class OmpMountNoticeLedgers {
|
||||
private readonly byAgentId = new Map<string, OmpMountNoticeLedger>();
|
||||
|
||||
forAgent(agentId: string): OmpMountNoticeLedger {
|
||||
let ledger = this.byAgentId.get(agentId);
|
||||
if (!ledger) {
|
||||
ledger = new OmpMountNoticeLedger();
|
||||
this.byAgentId.set(agentId, ledger);
|
||||
}
|
||||
return ledger;
|
||||
}
|
||||
|
||||
/** The agent's whole projection is about to be replaced from provider history. */
|
||||
forget(agentId: string): void {
|
||||
this.byAgentId.delete(agentId);
|
||||
}
|
||||
|
||||
/** The agent's projection is being seeded from provider history by this deliverer. */
|
||||
seedFromHistory(agentId: string, deliverer: OmpMountNoticeDeliverer): void {
|
||||
const ledger = this.byAgentId.get(agentId);
|
||||
if (!ledger) return;
|
||||
ledger.retainDeliveredBy(deliverer);
|
||||
if (ledger.size === 0) this.byAgentId.delete(agentId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { setImmediate as nextTask } from "node:timers/promises";
|
||||
import pino from "pino";
|
||||
import { OmpAgentClient } from "../agent.js";
|
||||
import { FakeOmp } from "./fake-omp.js";
|
||||
|
||||
if (!global.gc) throw new Error("Run with --expose-gc");
|
||||
const runtime = new FakeOmp();
|
||||
const client = new OmpAgentClient({ logger: pino({ level: "silent" }), runtime });
|
||||
const notices: string[] = [];
|
||||
const notice = {
|
||||
type: "notice",
|
||||
level: "info",
|
||||
source: "xdev",
|
||||
message: "xd://: mounted browser",
|
||||
} as const;
|
||||
const agentId = "a7969ccf-b200-429a-a36a-e61ac01e59ab";
|
||||
|
||||
async function closedSession(announce: boolean, id: string) {
|
||||
const session = await client.createSession(
|
||||
{ provider: "omp", cwd: process.cwd() },
|
||||
{ agentId: id },
|
||||
);
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "timeline" && event.item.type === "notification") {
|
||||
notices.push(event.item.message);
|
||||
}
|
||||
});
|
||||
if (announce) runtime.latestSession().emit(notice);
|
||||
await nextTask();
|
||||
const persistence = session.describePersistence();
|
||||
if (!persistence) throw new Error("Session has no persistence handle");
|
||||
await session.close();
|
||||
return { reference: new WeakRef(session), persistence };
|
||||
}
|
||||
|
||||
const withoutNotice = await closedSession(false, "cd39924e-3984-4d10-b2ec-8a4e4ba029f5");
|
||||
const withNotice = await closedSession(true, agentId);
|
||||
// Move to another job before collecting: WeakRef targets survive their creation job.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await nextTask();
|
||||
global.gc();
|
||||
}
|
||||
const retained = {
|
||||
closedWithoutNoticeRetained: withoutNotice.reference.deref() !== undefined,
|
||||
closedWithNoticeRetained: withNotice.reference.deref() !== undefined,
|
||||
};
|
||||
|
||||
const resumed = await client.resumeSession(withNotice.persistence, undefined, { agentId });
|
||||
try {
|
||||
resumed.subscribe((event) => {
|
||||
if (event.type === "timeline" && event.item.type === "notification") {
|
||||
notices.push(event.item.message);
|
||||
}
|
||||
});
|
||||
runtime.latestSession().emit(notice);
|
||||
await nextTask();
|
||||
console.log(JSON.stringify({ ...retained, noticesAfterRelaunch: notices.length }));
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -550,14 +550,17 @@ function createGitHubPrRemoteRepo() {
|
||||
|
||||
describe("runWorktreeSetupInBackground", () => {
|
||||
const cleanupPaths: string[] = [];
|
||||
let stopSetup: (() => Promise<void>) | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await stopSetup?.();
|
||||
stopSetup = undefined;
|
||||
for (const target of cleanupPaths.splice(0)) {
|
||||
rmSync(target, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("runs setup from an exact workspace subdirectory", async () => {
|
||||
function startSubdirectorySetup(command: string) {
|
||||
const tempDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "worktree-setup-test-")));
|
||||
cleanupPaths.push(tempDir);
|
||||
const repoDir = path.join(tempDir, "source");
|
||||
@@ -578,13 +581,14 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
path.join(workspaceCwd, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
setup: ["pwd > setup-cwd.txt"],
|
||||
setup: [command],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
|
||||
await runWorktreeSetupInBackground(
|
||||
const controller = new AbortController();
|
||||
const completion = runWorktreeSetupInBackground(
|
||||
{
|
||||
paseoHome,
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
@@ -603,10 +607,56 @@ describe("runWorktreeSetupInBackground", () => {
|
||||
worktreePath: worktree.worktreePath,
|
||||
workspaceCwd,
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
const stop = async () => {
|
||||
controller.abort();
|
||||
await completion;
|
||||
};
|
||||
stopSetup = stop;
|
||||
return { tempDir, workspaceCwd, worktreePath: worktree.worktreePath, completion, stop };
|
||||
}
|
||||
|
||||
expect(existsSync(path.join(workspaceCwd, "setup-cwd.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(worktree.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
test("runs setup from an exact workspace subdirectory", async () => {
|
||||
const setup = startSubdirectorySetup("pwd > setup-cwd.txt");
|
||||
await setup.completion;
|
||||
|
||||
expect(existsSync(path.join(setup.workspaceCwd, "setup-cwd.txt"))).toBe(true);
|
||||
expect(existsSync(path.join(setup.worktreePath, "setup-cwd.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
test("teardown stops unfinished setup before removing its workspace", async ({
|
||||
onTestFinished,
|
||||
}) => {
|
||||
const setup = startSubdirectorySetup(
|
||||
`node -e "require('fs').writeFileSync('setup-pid.txt', String(process.pid)); setInterval(()=>{},1000)"`,
|
||||
);
|
||||
let pid = 0;
|
||||
const probeChild = () => process.kill(pid, 0);
|
||||
onTestFinished(async () => {
|
||||
try {
|
||||
expect(pid).toBeGreaterThan(0);
|
||||
expect(existsSync(setup.tempDir)).toBe(false);
|
||||
expect(probeChild).toThrow(expect.objectContaining({ code: "ESRCH" }));
|
||||
} finally {
|
||||
// Keep a failing cleanup regression from leaking its deliberately live child.
|
||||
await setup.stop();
|
||||
}
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
try {
|
||||
pid = Number(readFileSync(path.join(setup.workspaceCwd, "setup-pid.txt"), "utf8"));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
pid = 0;
|
||||
}
|
||||
return pid;
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("emits running then completed snapshots for no-setup workspaces without auto-starting scripts", async () => {
|
||||
|
||||
Reference in New Issue
Block a user