mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-14 12:25:05 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40147c6805 | ||
|
|
974299450c | ||
|
|
2fa0e4b169 | ||
|
|
d11239fd95 | ||
|
|
d30aa16beb |
@@ -61,6 +61,10 @@ The daemon journals the assigned agent ID before creation. Concurrent retries sh
|
||||
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
|
||||
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
|
||||
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
|
||||
A direct send (one without `sourceAgentId`) rejected before its prompt is handed to a run, for
|
||||
example because the agent could not be loaded or the requested mode could not be set, leaves no
|
||||
receipt, so retrying its message ID submits it. A failure during or after run startup is treated
|
||||
as an unfinished delivery.
|
||||
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
|
||||
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
|
||||
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface OlderTimelinePagesGate {
|
||||
getRepeatedEntryCount(): number;
|
||||
getOwnedEntryCountContaining(text: string): number;
|
||||
releasePage(pageNumber: number): void;
|
||||
waitForRequestCount(count: number): Promise<void>;
|
||||
}
|
||||
|
||||
export interface DaemonHydrationGate {
|
||||
@@ -371,15 +370,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
const ownedEntries = new Map<string, string>();
|
||||
const releasedPages = new Set<number>();
|
||||
const delayedForwards = new Map<number, Array<() => void>>();
|
||||
const requestWaiters = new Map<number, Array<() => void>>();
|
||||
|
||||
const resolveRequestWaiters = () => {
|
||||
for (const [count, resolvers] of requestWaiters) {
|
||||
if (requestCount < count) continue;
|
||||
requestWaiters.delete(count);
|
||||
for (const resolve of resolvers) resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const routePattern =
|
||||
daemonPort === undefined ? daemonWsRoutePattern() : wsRoutePatternForPort(String(daemonPort));
|
||||
@@ -393,7 +383,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
sessionMessage.direction === "before"
|
||||
) {
|
||||
requestCount += 1;
|
||||
resolveRequestWaiters();
|
||||
}
|
||||
server.send(message);
|
||||
});
|
||||
@@ -439,14 +428,6 @@ export async function holdAgentOlderTimelinePages(
|
||||
for (const forward of delayedForwards.get(pageNumber) ?? []) forward();
|
||||
delayedForwards.delete(pageNumber);
|
||||
},
|
||||
waitForRequestCount(count) {
|
||||
if (requestCount >= count) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const resolvers = requestWaiters.get(count) ?? [];
|
||||
resolvers.push(resolve);
|
||||
requestWaiters.set(count, resolvers);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -427,7 +427,11 @@ export async function holdOlderHistoryPages(
|
||||
const loader = page.getByTestId("load-older-history-spinner");
|
||||
return {
|
||||
async expectRequestedPages(count) {
|
||||
await gate.waitForRequestCount(count);
|
||||
await expect
|
||||
.poll(() => gate.getRequestCount(), {
|
||||
message: `older history requests ${count} page(s)`,
|
||||
})
|
||||
.toBeGreaterThanOrEqual(count);
|
||||
expect(gate.getRequestCount()).toBe(count);
|
||||
},
|
||||
async expectSettledWithRequestedPages(count) {
|
||||
|
||||
@@ -49,8 +49,21 @@ afterEach(() => {
|
||||
host.remove();
|
||||
});
|
||||
|
||||
function mountTimeline(historyMounted: StreamItem[]) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
type OlderHistoryInput = Pick<
|
||||
StreamRenderInput,
|
||||
"onNearHistoryStart" | "hasOlderHistory" | "olderHistoryProgressKey"
|
||||
>;
|
||||
|
||||
interface MountTimelineOptions {
|
||||
olderHistory?: OlderHistoryInput;
|
||||
isMobileBreakpoint?: boolean;
|
||||
}
|
||||
|
||||
function mountTimeline(
|
||||
historyMounted: StreamItem[],
|
||||
{ olderHistory, isMobileBreakpoint = true }: MountTimelineOptions = {},
|
||||
) {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const input: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
@@ -76,6 +89,7 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
contentMaxWidth: DEFAULT_CONTENT_MAX_WIDTH,
|
||||
...olderHistory,
|
||||
};
|
||||
const render = (segments: Partial<StreamRenderInput["segments"]>) =>
|
||||
root.render(strategy.render({ ...input, segments: { ...input.segments, ...segments } }));
|
||||
@@ -100,8 +114,20 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
scrollTo: (top: number) => {
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
// A wheel listener runs before the browser applies the scroll the wheel causes.
|
||||
wheelUpTo: (top: number) => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -1000 }));
|
||||
scrollContainer.scrollTop = top;
|
||||
},
|
||||
distanceFromEnd: () =>
|
||||
scrollContainer.scrollHeight - scrollContainer.clientHeight - scrollContainer.scrollTop,
|
||||
scrollTop: () => scrollContainer.scrollTop,
|
||||
scrollUp: (distance: number) =>
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -distance }));
|
||||
scrollContainer.scrollTop -= distance;
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
}),
|
||||
// Resize observers and the hold's settle run on animation frames.
|
||||
settle: () =>
|
||||
act(async () => {
|
||||
@@ -113,6 +139,53 @@ function mountTimeline(historyMounted: StreamItem[]) {
|
||||
}
|
||||
|
||||
describe("web disclosure hold", () => {
|
||||
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
|
||||
"keeps the reading position when mounted history becomes virtualized (compact: $isMobileBreakpoint)",
|
||||
async (options) => {
|
||||
const history = Array.from({ length: 80 }, (_, index) => row(index));
|
||||
const timeline = mountTimeline(history, options);
|
||||
await timeline.settle();
|
||||
timeline.scrollUp(600);
|
||||
await timeline.settle();
|
||||
const offset = timeline.scrollTop();
|
||||
const readingTop = timeline.rowTop("message-64");
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
});
|
||||
await timeline.settle();
|
||||
|
||||
expect(timeline.scrollTop()).toBe(offset);
|
||||
expect(timeline.rowTop("message-64")).toBe(readingTop);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ isMobileBreakpoint: true }, { isMobileBreakpoint: false }])(
|
||||
"keeps following output when mounted history becomes virtualized at the end (compact: $isMobileBreakpoint)",
|
||||
async (options) => {
|
||||
const history = Array.from({ length: 80 }, (_, index) => row(index));
|
||||
const timeline = mountTimeline(history, options);
|
||||
await timeline.settle();
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
});
|
||||
await timeline.settle();
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
|
||||
timeline.render({
|
||||
historyVirtualized: history.slice(0, 60),
|
||||
historyMounted: history.slice(60),
|
||||
liveHead: [row(80)],
|
||||
});
|
||||
await timeline.settle();
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps the trigger's top when an anchored toggle at the bottom opens rows below it, and stops following", async () => {
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history);
|
||||
@@ -208,3 +281,32 @@ describe("web disclosure hold", () => {
|
||||
expect(timeline.distanceFromEnd()).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("web history start", () => {
|
||||
it("loads older history when an upward wheel lands at the start after the reader stopped following", async () => {
|
||||
let olderHistoryLoads = 0;
|
||||
const history = [1, 2, 3, 4, 5, 6].map((index) => row(index));
|
||||
const timeline = mountTimeline(history, {
|
||||
olderHistory: {
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch:1",
|
||||
onNearHistoryStart: async () => {
|
||||
olderHistoryLoads += 1;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
await timeline.settle();
|
||||
// Opening a row at the bottom stops following output without any scroll.
|
||||
timeline.hold("message-5", () =>
|
||||
timeline.render({ historyMounted: [...history.slice(0, 5), row(7, 200), history[5]!] }),
|
||||
);
|
||||
await timeline.settle();
|
||||
expect(olderHistoryLoads).toBe(0);
|
||||
|
||||
timeline.wheelUpTo(0);
|
||||
await timeline.settle();
|
||||
|
||||
expect(olderHistoryLoads).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -457,6 +457,8 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: segments.historyVirtualized.length,
|
||||
enabled: shouldUseVirtualizer,
|
||||
// History can become virtualized after the reader has already scrolled the mounted rows.
|
||||
initialOffset: () => scrollContainerRef.current?.scrollTop ?? 0,
|
||||
initialRect: {
|
||||
width: scrollContainerRef.current?.clientWidth ?? 0,
|
||||
height: scrollContainerRef.current?.clientHeight ?? 0,
|
||||
@@ -605,6 +607,15 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
|
||||
}, []);
|
||||
|
||||
const hasUpwardInputEvidence = useCallback(() => {
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
return (
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current
|
||||
);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
return;
|
||||
@@ -791,16 +802,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
const hasUpwardInputEvidence =
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current;
|
||||
const userScrolledUp = scrolledUp && hasUpwardInputEvidence();
|
||||
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||
} else if (followOutputRef.current && userScrolledUp) {
|
||||
stopFollowingOutputFromUserIntent();
|
||||
} else if (userScrolledUp) {
|
||||
// Wheel listeners can run before or after the browser applies the scroll, so the
|
||||
// upward scroll itself re-arms loading when it reaches the start.
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
@@ -811,8 +822,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasUpwardInputEvidence,
|
||||
isJumpSettling,
|
||||
readingAnchor,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ProviderInputSchema, type ProviderInput } from "./provider.js";
|
||||
|
||||
type ReviewMode = "uncommitted" | "base" | "all";
|
||||
type Delivery = "auto" | "steer";
|
||||
|
||||
const contextLine = {
|
||||
oldLineNumber: null,
|
||||
newLineNumber: 12,
|
||||
type: "add",
|
||||
content: "const value = 1;",
|
||||
} as const;
|
||||
|
||||
function reviewPrompt(mode: ReviewMode, delivery: Delivery): ProviderInput {
|
||||
return {
|
||||
type: "session.prompt",
|
||||
sessionId: "session-1",
|
||||
prompt: {
|
||||
clientMessageId: "message-1",
|
||||
delivery,
|
||||
input: {
|
||||
type: "message",
|
||||
content: [
|
||||
{
|
||||
type: "review",
|
||||
mimeType: "application/paseo-review",
|
||||
cwd: "/workspace",
|
||||
mode,
|
||||
baseRef: "main",
|
||||
comments: [
|
||||
{
|
||||
filePath: "src/index.ts",
|
||||
side: "new",
|
||||
lineNumber: 12,
|
||||
body: "Please review this change.",
|
||||
context: {
|
||||
hunkHeader: "@@ -10,2 +10,3 @@",
|
||||
targetLine: contextLine,
|
||||
lines: [{ ...contextLine, type: "context", oldLineNumber: 11 }, contextLine],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Provider review attachments", () => {
|
||||
it.each(["uncommitted", "base", "all"] as const)(
|
||||
"preserves mode %s on an ordinary prompt",
|
||||
(mode) => {
|
||||
const input = reviewPrompt(mode, "auto");
|
||||
expect(ProviderInputSchema.parse(input)).toEqual(input);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["uncommitted", "base", "all"] as const)(
|
||||
"preserves mode %s on a steering prompt",
|
||||
(mode) => {
|
||||
const input = reviewPrompt(mode, "steer");
|
||||
expect(ProviderInputSchema.parse(input)).toEqual(input);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -213,7 +213,7 @@ interface ProviderReviewAttachment {
|
||||
type: "review";
|
||||
mimeType: "application/paseo-review";
|
||||
cwd: string;
|
||||
mode: "uncommitted" | "base";
|
||||
mode: "uncommitted" | "base" | "all";
|
||||
baseRef?: string | null;
|
||||
comments: Array<{
|
||||
filePath: string;
|
||||
@@ -881,7 +881,7 @@ const providerContentSchema: z.ZodType<ProviderContent> = z.union([
|
||||
type: z.literal("review"),
|
||||
mimeType: z.literal("application/paseo-review"),
|
||||
cwd: z.string(),
|
||||
mode: z.enum(["uncommitted", "base"]),
|
||||
mode: z.enum(["uncommitted", "base", "all"]),
|
||||
baseRef: z.string().nullable().optional(),
|
||||
comments: z.array(
|
||||
z
|
||||
|
||||
@@ -210,6 +210,8 @@ export async function unarchiveAgentState(
|
||||
}
|
||||
|
||||
export interface SendPromptToAgentParams {
|
||||
/** Awaited after the agent is loaded and its mode is set, before the prompt is handed to a run. */
|
||||
beforeDispatch?: () => Promise<void>;
|
||||
onDispatch?: StartAgentRunOptions["onDispatch"];
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
@@ -357,6 +359,7 @@ export async function sendPreparedPromptToAgent(
|
||||
await params.agentManager.setAgentMode(params.agentId, params.sessionMode);
|
||||
}
|
||||
|
||||
await params.beforeDispatch?.();
|
||||
return await startAgentRun(params.agentManager, params.agentId, params.prompt, params.logger, {
|
||||
replaceRunning: true,
|
||||
onDispatch: params.onDispatch,
|
||||
|
||||
@@ -182,6 +182,46 @@ test("ordinary Hub create and message retries do not duplicate agents or prompts
|
||||
).toMatchObject({ payload: { accepted: false } });
|
||||
});
|
||||
|
||||
test("Hub retries a message whose send failed before reaching the provider", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const agentId = await hub.createUnrelatedLocalAgent();
|
||||
const message = {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: "mode-change-failed",
|
||||
text: "retry me",
|
||||
sessionMode: "full-access",
|
||||
};
|
||||
hub.failNextProviderModeChange();
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "first-attempt" })).toMatchObject({
|
||||
payload: { accepted: false, error: expect.stringContaining("mode change failure") },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(0);
|
||||
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
|
||||
payload: { accepted: true, error: null },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "retry me")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Hub never resubmits a message whose send failed after reaching the provider", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const agentId = await hub.createUnrelatedLocalAgent();
|
||||
const message = {
|
||||
type: "send_agent_message_request",
|
||||
agentId,
|
||||
messageId: "turn-start-failed",
|
||||
text: "do not repeat me",
|
||||
};
|
||||
hub.failProviderPromptStart("do not repeat me");
|
||||
await hub.requestOrdinary({ ...message, requestId: "first-attempt" });
|
||||
|
||||
expect(await hub.requestOrdinary({ ...message, requestId: "retry" })).toMatchObject({
|
||||
payload: { accepted: false, error: "agent_request_outcome_unknown" },
|
||||
});
|
||||
expect(hub.providerPromptTexts().filter((text) => text === "do not repeat me")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("ordinary Hub requests survive daemon restart and restore an archived workspace", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const create = {
|
||||
|
||||
@@ -458,6 +458,7 @@ export class HubRelationshipHarness {
|
||||
private readonly claimedCliSockets = new Set<WebSocket>();
|
||||
private readonly promptsToFail = new Set<string>();
|
||||
private failNextSessionClose = false;
|
||||
private failNextModeChange = false;
|
||||
private observedEnrollments = 0;
|
||||
private observedSockets = 0;
|
||||
private readonly codex: ControlledAgentClient;
|
||||
@@ -474,6 +475,11 @@ export class HubRelationshipHarness {
|
||||
this.failNextSessionClose = false;
|
||||
throw new Error("Requested provider session close failure");
|
||||
},
|
||||
beforeSetMode: async () => {
|
||||
if (!this.failNextModeChange) return;
|
||||
this.failNextModeChange = false;
|
||||
throw new Error("Requested provider mode change failure");
|
||||
},
|
||||
onStartTurn: (prompt) => {
|
||||
const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt);
|
||||
if (this.promptsToFail.delete(promptText)) {
|
||||
@@ -674,6 +680,10 @@ export class HubRelationshipHarness {
|
||||
this.promptsToFail.add(prompt);
|
||||
}
|
||||
|
||||
failNextProviderModeChange(): void {
|
||||
this.failNextModeChange = true;
|
||||
}
|
||||
|
||||
failNextProviderSessionClose(): void {
|
||||
this.failNextSessionClose = true;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ test("message retries survive reconstruction without submitting twice", async ()
|
||||
agentId: "agent",
|
||||
messageId: "arrival",
|
||||
request: { text: "hello" },
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
await beforeDispatch();
|
||||
deliveries++;
|
||||
},
|
||||
};
|
||||
@@ -41,7 +42,8 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
|
||||
agentId: "agent",
|
||||
messageId: "arrival",
|
||||
request: {},
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
await beforeDispatch();
|
||||
deliveries++;
|
||||
throw new Error("connection lost");
|
||||
},
|
||||
@@ -53,7 +55,7 @@ test("ambiguous provider delivery is never blindly replayed after restart", asyn
|
||||
expect(deliveries).toBe(1);
|
||||
});
|
||||
|
||||
test("failed local message preparation does not leave an ambiguous receipt", async () => {
|
||||
test("a send that fails before beforeDispatch can be retried with the same message", async () => {
|
||||
const { requests, directory } = await fixture();
|
||||
let available = false;
|
||||
let sends = 0;
|
||||
@@ -61,14 +63,13 @@ test("failed local message preparation does not leave an ambiguous receipt", asy
|
||||
agentId: "agent",
|
||||
messageId: "message",
|
||||
request: {},
|
||||
prepare: async () => {
|
||||
if (!available) throw new Error("load failed");
|
||||
},
|
||||
send: async () => {
|
||||
send: async (beforeDispatch: () => Promise<void>) => {
|
||||
if (!available) throw new Error("mode change failed");
|
||||
await beforeDispatch();
|
||||
sends++;
|
||||
},
|
||||
};
|
||||
await expect(requests.send(input)).rejects.toThrow("load failed");
|
||||
await expect(requests.send(input)).rejects.toThrow("mode change failed");
|
||||
available = true;
|
||||
await new MessageReceipts(directory).send(input);
|
||||
available = false;
|
||||
|
||||
@@ -13,8 +13,11 @@ interface SendMessageInput {
|
||||
agentId: string;
|
||||
messageId: string;
|
||||
request: unknown;
|
||||
send: () => Promise<void>;
|
||||
prepare?: () => Promise<void>;
|
||||
/**
|
||||
* Delivers the message. It awaits `beforeDispatch` immediately before the provider can receive
|
||||
* the message; a failure before that call proves nothing was submitted and leaves no receipt.
|
||||
*/
|
||||
send: (beforeDispatch: () => Promise<void>) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Owns message delivery receipts; creation is owned by CreationService. */
|
||||
@@ -48,10 +51,12 @@ export class MessageReceipts {
|
||||
// A provider may have accepted the message before its receipt was committed.
|
||||
throw new Error("agent_request_outcome_unknown");
|
||||
}
|
||||
await input.prepare?.();
|
||||
const receipt = { fingerprint, agentId: input.agentId };
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "pending" });
|
||||
await input.send();
|
||||
let pending: Promise<void> | null = null;
|
||||
await input.send(() => {
|
||||
pending ??= writeJsonFileAtomic(file, { ...receipt, state: "pending" });
|
||||
return pending;
|
||||
});
|
||||
await writeJsonFileAtomic(file, { ...receipt, state: "completed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8918,8 +8918,9 @@ export class Session {
|
||||
queued = acceptAdmission;
|
||||
});
|
||||
let deliveryStatus: "queued" | undefined;
|
||||
const send = async () => {
|
||||
const send = async (beforeDispatch: () => Promise<void>) => {
|
||||
if (msg.sourceAgentId) {
|
||||
await beforeDispatch();
|
||||
const receipt = this.agentMessages.enqueue({
|
||||
recipientId: agentId,
|
||||
messageId: msg.messageId,
|
||||
@@ -8950,6 +8951,7 @@ export class Session {
|
||||
activeTurnBehavior: msg.activeTurnBehavior ?? "interrupt",
|
||||
sessionMode: msg.sessionMode,
|
||||
clearPendingPermissions: true,
|
||||
beforeDispatch,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
if (result.disposition === "turn_started") {
|
||||
@@ -8972,12 +8974,12 @@ export class Session {
|
||||
activeTurnBehavior:
|
||||
msg.activeTurnBehavior ?? (msg.sourceAgentId ? "steer" : "interrupt"),
|
||||
},
|
||||
prepare: async () => {
|
||||
send: async (beforeDispatch) => {
|
||||
if (!msg.sourceAgentId) await this.prepareAgentMessage(agentId, msg.text);
|
||||
await send(beforeDispatch);
|
||||
},
|
||||
send,
|
||||
})
|
||||
: send();
|
||||
: send(async () => {});
|
||||
void sent.catch((error) => {
|
||||
if (deliveryStatus === "queued") {
|
||||
this.handleAgentRunError(agentId, error, "Failed to dispatch queued agent message");
|
||||
|
||||
@@ -84,6 +84,7 @@ interface FakeAgentSessionOptions {
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
steerActiveTurn?: AgentSession["steerActiveTurn"];
|
||||
onInterrupt?: () => void;
|
||||
beforeSetMode?: (modeId: string) => Promise<void>;
|
||||
historyEvents?: readonly AgentStreamEvent[];
|
||||
}
|
||||
|
||||
@@ -95,6 +96,7 @@ export interface TestAgentClientOptions {
|
||||
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
|
||||
steerActiveTurn?: AgentSession["steerActiveTurn"];
|
||||
onInterrupt?: () => void;
|
||||
beforeSetMode?: (modeId: string) => Promise<void>;
|
||||
supportsMcpServers?: boolean;
|
||||
historyEvents?: readonly AgentStreamEvent[];
|
||||
}
|
||||
@@ -369,6 +371,7 @@ class FakeAgentSession implements AgentSession {
|
||||
|
||||
readonly steerActiveTurn: AgentSession["steerActiveTurn"];
|
||||
private readonly onInterrupt: (() => void) | undefined;
|
||||
private readonly beforeSetMode: ((modeId: string) => Promise<void>) | undefined;
|
||||
private readonly closeSession: (() => Promise<void>) | undefined;
|
||||
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
|
||||
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
|
||||
@@ -385,6 +388,7 @@ class FakeAgentSession implements AgentSession {
|
||||
this.memoryMarker = options.memoryMarker ?? null;
|
||||
this.steerActiveTurn = options.steerActiveTurn;
|
||||
this.onInterrupt = options.onInterrupt;
|
||||
this.beforeSetMode = options.beforeSetMode;
|
||||
this.closeSession = options.closeSession;
|
||||
this.onStartTurn = options.onStartTurn;
|
||||
this.beforeTurnComplete = options.beforeTurnComplete;
|
||||
@@ -891,6 +895,7 @@ class FakeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
await this.beforeSetMode?.(modeId);
|
||||
this.config.modeId = modeId;
|
||||
}
|
||||
|
||||
@@ -1264,6 +1269,7 @@ class FakeAgentClient implements AgentClient {
|
||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||
steerActiveTurn: this.options.steerActiveTurn,
|
||||
onInterrupt: this.options.onInterrupt,
|
||||
beforeSetMode: this.options.beforeSetMode,
|
||||
historyEvents: this.options.historyEvents,
|
||||
});
|
||||
}
|
||||
@@ -1294,6 +1300,7 @@ class FakeAgentClient implements AgentClient {
|
||||
beforeTurnComplete: this.options.beforeTurnComplete,
|
||||
steerActiveTurn: this.options.steerActiveTurn,
|
||||
onInterrupt: this.options.onInterrupt,
|
||||
beforeSetMode: this.options.beforeSetMode,
|
||||
historyEvents: this.options.historyEvents,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -306,7 +306,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
|
||||
export function createMessageReceiptsStub(): SessionOptions["messageReceipts"] {
|
||||
return {
|
||||
send: (input) => input.send(),
|
||||
send: (input) => input.send(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user