mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
* fix(app): stop completed turns from appearing stuck Turn activity was inferred from a user-message row flag, so a stale duplicate row could keep the working footer active after the turn ended. Track submission lifecycle separately and route every send path through the shared submission flow. * fix(app): guard pending message identity * test(app): measure submission layout independent of scroll * fix(app): keep submitted messages consistent through reconnects Track each in-flight send until its own RPC establishes acceptance, and keep canonical timeline placement authoritative. Restore legacy cached rewind IDs during cache deserialization. * fix(app): close canonical submission races * test(app): cover canonical submission races in browser * fix(app): keep replacement submissions authoritative Keep current pending rows out of legacy cache migration and leave ambiguous terminal lifecycle events to the daemon snapshot. This prevents fabricated rewind identities and stale completion events from marking replacement turns idle. * fix(app): keep submitted messages stable across sync Give submission transport and canonical timeline ingestion separate authority. Preserve every unresolved local send across replacement, reconcile provider identity once, and bridge RPC acceptance to authoritative running state without deriving lifecycle from timeline rows. * fix(app): settle submissions in either acknowledgement order Complete submission transactions when RPC and provider acknowledgement arrive in either order. Cache only transaction-owned local rows as transient data, preserve canonical ID-less prompts, and invalidate ambiguous legacy display caches instead of inventing provider identity. * fix(app): keep agent visible during history handoff Running and terminal updates could clear create continuity before the initial authoritative timeline arrived, leaving a streaming agent behind the loading screen. End the handoff only when authoritative history is applied. * fix(app): settle attachment-only submissions Canonical providers can acknowledge image-only prompts with empty text. Reconcile those events by client identity without rendering a blank canonical row. * fix(agent): settle out-of-band message submissions Accepted commands that do not allocate a foreground turn previously had no canonical user acknowledgement. Record the command before its handler runs so submission state and reconnect history converge through the normal timeline producer. * fix(app): settle out-of-band submissions compatibly * fix(app): preserve canonical prompt order * test(app): keep workspace status check timing-independent The workspace-status scenario asserted footer settlement before initial agent creation had necessarily completed. The dedicated draft-handoff coverage owns that lifecycle contract.
421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
import type { Page, WebSocketRoute } from "@playwright/test";
|
|
import { daemonWsRoutePattern } from "./daemon-port";
|
|
|
|
export interface DirectoryBootstrapCounts {
|
|
agents: number;
|
|
workspaces: number;
|
|
}
|
|
|
|
export interface DirectoryRequestStartCounts {
|
|
subscribed: DirectoryBootstrapCounts;
|
|
unsubscribed: DirectoryBootstrapCounts;
|
|
total: DirectoryBootstrapCounts;
|
|
}
|
|
|
|
interface ClientRequest {
|
|
type?: unknown;
|
|
subscribe?: unknown;
|
|
page?: { cursor?: unknown };
|
|
payload?: unknown;
|
|
}
|
|
|
|
function readSessionMessage(message: string | Buffer): ClientRequest | null {
|
|
if (typeof message !== "string") return null;
|
|
try {
|
|
const envelope = JSON.parse(message) as {
|
|
type?: unknown;
|
|
message?: ClientRequest;
|
|
};
|
|
return envelope.message ?? envelope;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readClientRequest(message: string | Buffer): ClientRequest | null {
|
|
if (typeof message !== "string") return null;
|
|
try {
|
|
const envelope = JSON.parse(message) as {
|
|
type?: unknown;
|
|
message?: ClientRequest;
|
|
};
|
|
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function directoryForRequest(request: ClientRequest): keyof DirectoryBootstrapCounts | null {
|
|
if (request.page?.cursor) return null;
|
|
if (request.type === "fetch_agents_request") return "agents";
|
|
if (request.type === "fetch_workspaces_request") return "workspaces";
|
|
return null;
|
|
}
|
|
|
|
function stripAssistantMessageId(
|
|
message: string | Buffer,
|
|
enabled: boolean,
|
|
messageType: unknown,
|
|
): string | Buffer {
|
|
if (!enabled || messageType !== "agent_stream" || typeof message !== "string") return message;
|
|
const envelope = JSON.parse(message) as {
|
|
message?: { payload?: { event?: { type?: unknown; item?: Record<string, unknown> } } };
|
|
payload?: { event?: { type?: unknown; item?: Record<string, unknown> } };
|
|
};
|
|
const event = (envelope.message?.payload ?? envelope.payload)?.event;
|
|
if (event?.type !== "timeline" || event.item?.type !== "assistant_message") return message;
|
|
delete event.item.messageId;
|
|
return JSON.stringify(envelope);
|
|
}
|
|
|
|
function stripMessageSubmissionDisposition(
|
|
message: string | Buffer,
|
|
enabled: boolean,
|
|
messageType: unknown,
|
|
): string | Buffer {
|
|
if (!enabled || messageType !== "send_agent_message_response" || typeof message !== "string") {
|
|
return message;
|
|
}
|
|
const envelope = JSON.parse(message) as {
|
|
message?: { payload?: Record<string, unknown> };
|
|
payload?: Record<string, unknown>;
|
|
};
|
|
const payload = envelope.message?.payload ?? envelope.payload;
|
|
if (!payload) return message;
|
|
delete payload.outOfBand;
|
|
return JSON.stringify(envelope);
|
|
}
|
|
|
|
function forceTimelineReset(message: string | Buffer, enabled: boolean): string | Buffer {
|
|
if (!enabled || typeof message !== "string") return message;
|
|
const envelope = JSON.parse(message) as {
|
|
message?: { payload?: Record<string, unknown> };
|
|
payload?: Record<string, unknown>;
|
|
};
|
|
const payload = envelope.message?.payload ?? envelope.payload;
|
|
if (!payload) return message;
|
|
payload.epoch = `playwright-reset-${Date.now()}`;
|
|
payload.reset = true;
|
|
return JSON.stringify(envelope);
|
|
}
|
|
|
|
function readAgentStreamEventType(message: ClientRequest | null): string | null {
|
|
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
|
|
return null;
|
|
}
|
|
const event = (message.payload as { event?: { type?: unknown } }).event;
|
|
return typeof event?.type === "string" ? event.type : null;
|
|
}
|
|
|
|
function readAgentStreamItemType(message: ClientRequest | null): string | null {
|
|
if (message?.type !== "agent_stream" || !message.payload || typeof message.payload !== "object") {
|
|
return null;
|
|
}
|
|
const event = (message.payload as { event?: { type?: unknown; item?: { type?: unknown } } })
|
|
.event;
|
|
return event?.type === "timeline" && typeof event.item?.type === "string"
|
|
? event.item.type
|
|
: null;
|
|
}
|
|
|
|
function shouldSuppressServerMessage(input: {
|
|
message: ClientRequest | null;
|
|
messageTypes: ReadonlySet<string>;
|
|
agentStreamEventTypes: ReadonlySet<string>;
|
|
suppressAgentStream: boolean;
|
|
}): boolean {
|
|
const messageType = typeof input.message?.type === "string" ? input.message.type : null;
|
|
if (messageType && input.messageTypes.has(messageType)) return true;
|
|
if (input.suppressAgentStream && messageType === "agent_stream") return true;
|
|
const eventType = readAgentStreamEventType(input.message);
|
|
return Boolean(eventType && input.agentStreamEventTypes.has(eventType));
|
|
}
|
|
|
|
export async function installDaemonWebSocketGate(page: Page) {
|
|
let acceptingConnections = true;
|
|
let reconnectWithFreshClient = false;
|
|
let suppressAgentStream = false;
|
|
let forceTimelineEpochReset = false;
|
|
let stripAssistantMessageIds = false;
|
|
let stripSubmissionDisposition = false;
|
|
let heldClientRequestType: string | null = null;
|
|
let heldClientRequest: { server: WebSocketRoute; message: string | Buffer } | null = null;
|
|
let resolveHeldClientRequest: (() => void) | null = null;
|
|
let heldServerMessageType: string | null = null;
|
|
let heldServerMessage: { browser: WebSocketRoute; message: string | Buffer } | null = null;
|
|
let resolveHeldServerMessage: (() => void) | null = null;
|
|
const suppressedServerMessageTypes = new Set<string>();
|
|
const suppressedAgentStreamEventTypes = new Set<string>();
|
|
const activeSockets = new Set<WebSocketRoute>();
|
|
let latestServer: WebSocketRoute | null = null;
|
|
const directoryStarts: DirectoryRequestStartCounts = {
|
|
subscribed: { agents: 0, workspaces: 0 },
|
|
unsubscribed: { agents: 0, workspaces: 0 },
|
|
total: { agents: 0, workspaces: 0 },
|
|
};
|
|
const clientRequestCounts = new Map<string, number>();
|
|
const serverMessageCounts = new Map<string, number>();
|
|
const agentStreamItemCounts = new Map<string, number>();
|
|
const serverMessageWaiters = new Set<() => void>();
|
|
|
|
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
|
|
if (!acceptingConnections) {
|
|
void ws.close({ code: 1008, reason: "Blocked by reconnect test." });
|
|
return;
|
|
}
|
|
|
|
activeSockets.add(ws);
|
|
const server = ws.connectToServer();
|
|
latestServer = server;
|
|
|
|
ws.onMessage((message) => {
|
|
if (!acceptingConnections) return;
|
|
if (reconnectWithFreshClient && typeof message === "string") {
|
|
const hello = readClientRequest(message);
|
|
if (hello?.type === "hello") {
|
|
const parsed = JSON.parse(message) as { clientId?: string };
|
|
parsed.clientId = `${parsed.clientId ?? "playwright"}-fresh-${Date.now()}`;
|
|
reconnectWithFreshClient = false;
|
|
server.send(JSON.stringify(parsed));
|
|
return;
|
|
}
|
|
}
|
|
const request = readClientRequest(message);
|
|
if (typeof request?.type === "string") {
|
|
clientRequestCounts.set(request.type, (clientRequestCounts.get(request.type) ?? 0) + 1);
|
|
const directory = directoryForRequest(request);
|
|
if (directory) {
|
|
const subscription = request.subscribe === undefined ? "unsubscribed" : "subscribed";
|
|
directoryStarts[subscription][directory] += 1;
|
|
directoryStarts.total[directory] += 1;
|
|
}
|
|
}
|
|
if (request?.type === heldClientRequestType) {
|
|
heldClientRequest = { server, message };
|
|
resolveHeldClientRequest?.();
|
|
resolveHeldClientRequest = null;
|
|
return;
|
|
}
|
|
try {
|
|
server.send(message);
|
|
} catch {
|
|
activeSockets.delete(ws);
|
|
}
|
|
});
|
|
|
|
server.onMessage((message) => {
|
|
if (!acceptingConnections) return;
|
|
const serverMessage = readSessionMessage(message);
|
|
let outboundMessage = stripAssistantMessageId(
|
|
message,
|
|
stripAssistantMessageIds,
|
|
serverMessage?.type,
|
|
);
|
|
outboundMessage = stripMessageSubmissionDisposition(
|
|
outboundMessage,
|
|
stripSubmissionDisposition,
|
|
serverMessage?.type,
|
|
);
|
|
const shouldForceTimelineReset =
|
|
forceTimelineEpochReset && serverMessage?.type === "fetch_agent_timeline_response";
|
|
outboundMessage = forceTimelineReset(outboundMessage, shouldForceTimelineReset);
|
|
if (shouldForceTimelineReset) forceTimelineEpochReset = false;
|
|
if (typeof serverMessage?.type === "string") {
|
|
serverMessageCounts.set(
|
|
serverMessage.type,
|
|
(serverMessageCounts.get(serverMessage.type) ?? 0) + 1,
|
|
);
|
|
for (const resolve of serverMessageWaiters) resolve();
|
|
serverMessageWaiters.clear();
|
|
}
|
|
const agentStreamItemType = readAgentStreamItemType(serverMessage);
|
|
if (agentStreamItemType) {
|
|
agentStreamItemCounts.set(
|
|
agentStreamItemType,
|
|
(agentStreamItemCounts.get(agentStreamItemType) ?? 0) + 1,
|
|
);
|
|
for (const resolve of serverMessageWaiters) resolve();
|
|
serverMessageWaiters.clear();
|
|
}
|
|
if (serverMessage?.type === heldServerMessageType) {
|
|
heldServerMessage = { browser: ws, message: outboundMessage };
|
|
resolveHeldServerMessage?.();
|
|
resolveHeldServerMessage = null;
|
|
return;
|
|
}
|
|
if (
|
|
shouldSuppressServerMessage({
|
|
message: serverMessage,
|
|
messageTypes: suppressedServerMessageTypes,
|
|
agentStreamEventTypes: suppressedAgentStreamEventTypes,
|
|
suppressAgentStream,
|
|
})
|
|
)
|
|
return;
|
|
try {
|
|
ws.send(outboundMessage);
|
|
} catch {
|
|
activeSockets.delete(ws);
|
|
}
|
|
});
|
|
});
|
|
|
|
return {
|
|
async drop(): Promise<void> {
|
|
acceptingConnections = false;
|
|
const sockets = Array.from(activeSockets);
|
|
activeSockets.clear();
|
|
await Promise.all(
|
|
sockets.map((ws) =>
|
|
ws.close({ code: 1008, reason: "Dropped by reconnect test." }).catch(() => undefined),
|
|
),
|
|
);
|
|
},
|
|
restore(): void {
|
|
acceptingConnections = true;
|
|
},
|
|
restoreFresh(): void {
|
|
reconnectWithFreshClient = true;
|
|
acceptingConnections = true;
|
|
},
|
|
holdNextClientRequest(type: string): void {
|
|
heldClientRequestType = type;
|
|
heldClientRequest = null;
|
|
},
|
|
waitForHeldClientRequest(): Promise<void> {
|
|
if (heldClientRequest) return Promise.resolve();
|
|
return new Promise<void>((resolve) => {
|
|
resolveHeldClientRequest = resolve;
|
|
});
|
|
},
|
|
releaseHeldClientRequest(): void {
|
|
if (!heldClientRequest) throw new Error("No held client request to release");
|
|
heldClientRequest.server.send(heldClientRequest.message);
|
|
heldClientRequest = null;
|
|
heldClientRequestType = null;
|
|
},
|
|
holdNextServerMessage(type: string): void {
|
|
heldServerMessageType = type;
|
|
heldServerMessage = null;
|
|
},
|
|
waitForHeldServerMessage(): Promise<void> {
|
|
if (heldServerMessage) return Promise.resolve();
|
|
return new Promise<void>((resolve) => {
|
|
resolveHeldServerMessage = resolve;
|
|
});
|
|
},
|
|
releaseHeldServerMessage(): void {
|
|
if (!heldServerMessage) throw new Error("No held server message to release");
|
|
heldServerMessage.browser.send(heldServerMessage.message);
|
|
heldServerMessage = null;
|
|
heldServerMessageType = null;
|
|
},
|
|
requestTimelineTail(agentId: string): void {
|
|
if (!latestServer) throw new Error("No daemon WebSocket is connected");
|
|
latestServer.send(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_agent_timeline_request",
|
|
agentId,
|
|
requestId: `playwright-timeline-${Date.now()}`,
|
|
direction: "tail",
|
|
limit: 0,
|
|
projection: "projected",
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
getHeldTimelineLastItemType(): string | null {
|
|
if (!heldServerMessage) throw new Error("No held server message to inspect");
|
|
const response = readSessionMessage(heldServerMessage.message);
|
|
const payload = response?.payload;
|
|
if (!payload || typeof payload !== "object") return null;
|
|
const entries = (payload as { entries?: unknown }).entries;
|
|
if (!Array.isArray(entries)) return null;
|
|
const last = entries.at(-1) as { item?: { type?: unknown } } | undefined;
|
|
return typeof last?.item?.type === "string" ? last.item.type : null;
|
|
},
|
|
truncateHeldTimelineAfterLast(itemType: string): void {
|
|
if (!heldServerMessage || typeof heldServerMessage.message !== "string") {
|
|
throw new Error("No held text server message to truncate");
|
|
}
|
|
const envelope = JSON.parse(heldServerMessage.message) as {
|
|
message?: { payload?: Record<string, unknown> };
|
|
payload?: Record<string, unknown>;
|
|
};
|
|
const payload = envelope.message?.payload ?? envelope.payload;
|
|
if (!payload) throw new Error("Held message has no payload");
|
|
const entries = payload.entries;
|
|
if (!Array.isArray(entries)) throw new Error("Held message is not a timeline response");
|
|
const index = entries.findLastIndex(
|
|
(entry) =>
|
|
typeof entry === "object" &&
|
|
entry !== null &&
|
|
(entry as { item?: { type?: unknown } }).item?.type === itemType,
|
|
);
|
|
if (index < 0) throw new Error(`Timeline response has no ${itemType} item`);
|
|
const retained = entries.slice(0, index + 1) as Array<{ seqEnd?: unknown }>;
|
|
const lastSeq = retained.at(-1)?.seqEnd;
|
|
if (typeof lastSeq !== "number") throw new Error("Timeline entry has no sequence end");
|
|
payload.entries = retained;
|
|
payload.endCursor = { epoch: payload.epoch, seq: lastSeq };
|
|
payload.hasNewer = false;
|
|
if (payload.window && typeof payload.window === "object") {
|
|
(payload.window as Record<string, unknown>).maxSeq = lastSeq;
|
|
(payload.window as Record<string, unknown>).nextSeq = lastSeq + 1;
|
|
}
|
|
heldServerMessage.message = JSON.stringify(envelope);
|
|
},
|
|
setServerMessageSuppressed(type: string, suppressed: boolean): void {
|
|
if (suppressed) {
|
|
suppressedServerMessageTypes.add(type);
|
|
} else {
|
|
suppressedServerMessageTypes.delete(type);
|
|
}
|
|
},
|
|
setAgentStreamEventSuppressed(type: string, suppressed: boolean): void {
|
|
if (suppressed) {
|
|
suppressedAgentStreamEventTypes.add(type);
|
|
} else {
|
|
suppressedAgentStreamEventTypes.delete(type);
|
|
}
|
|
},
|
|
setAssistantMessageIdsStripped(stripped: boolean): void {
|
|
stripAssistantMessageIds = stripped;
|
|
},
|
|
setMessageSubmissionDispositionStripped(stripped: boolean): void {
|
|
stripSubmissionDisposition = stripped;
|
|
},
|
|
setAgentStreamSuppressed(suppressed: boolean): void {
|
|
suppressAgentStream = suppressed;
|
|
},
|
|
forceNextTimelineEpochReset(): void {
|
|
forceTimelineEpochReset = true;
|
|
},
|
|
getDirectoryRequestStartCounts(): DirectoryRequestStartCounts {
|
|
return {
|
|
subscribed: { ...directoryStarts.subscribed },
|
|
unsubscribed: { ...directoryStarts.unsubscribed },
|
|
total: { ...directoryStarts.total },
|
|
};
|
|
},
|
|
getClientRequestCount(type: string): number {
|
|
return clientRequestCounts.get(type) ?? 0;
|
|
},
|
|
getAgentStreamItemCount(type: string): number {
|
|
return agentStreamItemCounts.get(type) ?? 0;
|
|
},
|
|
async waitForServerMessage(type: string, count = 1): Promise<void> {
|
|
while ((serverMessageCounts.get(type) ?? 0) < count) {
|
|
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
|
|
}
|
|
},
|
|
async waitForAgentStreamItem(type: string, count = 1): Promise<void> {
|
|
while ((agentStreamItemCounts.get(type) ?? 0) < count) {
|
|
await new Promise<void>((resolve) => serverMessageWaiters.add(resolve));
|
|
}
|
|
},
|
|
};
|
|
}
|