chore(lint): enable no-misused-promises

This commit is contained in:
Peter Steinberger
2026-05-31 20:41:57 +01:00
parent 6f58a71582
commit 2df95c0b10
74 changed files with 2206 additions and 2028 deletions
+1
View File
@@ -78,6 +78,7 @@
"typescript/no-extraneous-class": "error",
"typescript/no-import-type-side-effects": "error",
"typescript/no-meaningless-void-operator": "error",
"typescript/no-misused-promises": "error",
"typescript/no-inferrable-types": "error",
"typescript/no-non-null-asserted-nullish-coalescing": "error",
"typescript/no-unnecessary-qualifier": "error",
+3 -1
View File
@@ -1793,7 +1793,9 @@ function watchTerminalMemorySearchResult(params: {
if (stopped) {
return;
}
timeoutId = setTimeout(tick, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
timeoutId = setTimeout(() => {
void tick();
}, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
timeoutId.unref?.();
};
const tick = async () => {
@@ -54,9 +54,14 @@ const { testing, runCodexAppServerSideQuestion } = await import("./side-question
type ServerRequest = Required<Pick<RpcRequest, "id" | "method">> & {
params?: RpcRequest["params"];
};
type ClientRequest = (
method: string,
requestParams?: unknown,
options?: unknown,
) => Promise<unknown>;
type FakeClient = {
request: ReturnType<typeof vi.fn>;
request: ReturnType<typeof vi.fn<ClientRequest>>;
addNotificationHandler: ReturnType<typeof vi.fn>;
addRequestHandler: ReturnType<typeof vi.fn>;
notifications: Array<(notification: CodexServerNotification) => void>;
@@ -71,7 +76,7 @@ function createFakeClient(): FakeClient {
const client: FakeClient = {
notifications,
requests,
request: vi.fn(),
request: vi.fn<ClientRequest>(),
addNotificationHandler: vi.fn((handler: (notification: CodexServerNotification) => void) => {
notifications.push(handler);
return () => {
@@ -625,19 +630,21 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
approvalResponse = await client.handleRequest({
id: 42,
method: "item/commandExecution/requestApproval",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "cmd-side",
command: "/bin/bash -lc 'node -v'",
cwd: "/tmp/workspace",
},
});
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
setTimeout(() => {
void (async () => {
approvalResponse = await client.handleRequest({
id: 42,
method: "item/commandExecution/requestApproval",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "cmd-side",
command: "/bin/bash -lc 'node -v'",
cwd: "/tmp/workspace",
},
});
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
@@ -913,20 +920,22 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
toolResponse = await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
setTimeout(() => {
void (async () => {
toolResponse = await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
@@ -966,20 +975,22 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
setTimeout(() => {
void (async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
@@ -1045,20 +1056,22 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
setTimeout(() => {
void (async () => {
await client.handleRequest({
id: 42,
method: "item/tool/call",
params: {
threadId: "side-thread",
turnId: "turn-1",
callId: "tool-1",
tool: "wiki_status",
arguments: { topic: "AGENTS.md" },
},
});
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
})();
}, 0);
return turnStartResult("turn-1");
}
@@ -1098,35 +1111,37 @@ describe("runCodexAppServerSideQuestion", () => {
return {};
}
if (method === "turn/start") {
setTimeout(async () => {
unrelatedUserInputResponse = await client.handleRequest({
id: 42,
method: "item/tool/requestUserInput",
params: {
threadId: "parent-thread",
turnId: "parent-turn",
itemId: "input-parent",
questions: [],
},
});
userInputResponse = await client.handleRequest({
id: 43,
method: "item/tool/requestUserInput",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "input-1",
questions: [
{
id: "choice",
header: "Choice",
question: "Pick one",
options: [{ label: "A", description: "" }],
},
],
},
});
client.emit(turnCompleted("side-thread", "turn-1", "No input needed."));
setTimeout(() => {
void (async () => {
unrelatedUserInputResponse = await client.handleRequest({
id: 42,
method: "item/tool/requestUserInput",
params: {
threadId: "parent-thread",
turnId: "parent-turn",
itemId: "input-parent",
questions: [],
},
});
userInputResponse = await client.handleRequest({
id: 43,
method: "item/tool/requestUserInput",
params: {
threadId: "side-thread",
turnId: "turn-1",
itemId: "input-1",
questions: [
{
id: "choice",
header: "Choice",
question: "Pick one",
options: [{ label: "A", description: "" }],
},
],
},
});
client.emit(turnCompleted("side-thread", "turn-1", "No input needed."));
})();
}, 0);
return turnStartResult("turn-1");
}
+7 -6
View File
@@ -53,16 +53,17 @@ type SessionEventShape = {
timestamp: string;
type: string;
};
type SendAndWaitFn = (options?: unknown) => Promise<SessionEventShape | undefined>;
type FakeSession = {
abort: ReturnType<typeof vi.fn>;
abort: ReturnType<typeof vi.fn<() => Promise<void>>>;
cfg: Record<string, unknown>;
disconnect: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn<() => Promise<void>>>;
emit: (eventType: string, data: Record<string, unknown>) => void;
id: string;
off: ReturnType<typeof vi.fn>;
on: ReturnType<typeof vi.fn>;
sendAndWait: ReturnType<typeof vi.fn>;
sendAndWait: ReturnType<typeof vi.fn<SendAndWaitFn>>;
sessionId: string;
};
@@ -129,9 +130,9 @@ function makeAssistantMessageEvent(
function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSession {
const listeners = new Map<string, Array<(event: SessionEventShape) => void>>();
return {
abort: vi.fn(async () => undefined),
abort: vi.fn<() => Promise<void>>(async () => undefined),
cfg,
disconnect: vi.fn(async () => undefined),
disconnect: vi.fn<() => Promise<void>>(async () => undefined),
emit: (eventType: string, data: Record<string, unknown>) => {
const event = makeEvent(eventType, data);
for (const listener of listeners.get(eventType) ?? []) {
@@ -151,7 +152,7 @@ function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSessio
handlers.push(handler);
listeners.set(eventType, handlers);
}),
sendAndWait: vi.fn(async () => makeAssistantMessageEvent()),
sendAndWait: vi.fn<SendAndWaitFn>(async () => makeAssistantMessageEvent()),
sessionId: id,
};
}
+42 -42
View File
@@ -179,52 +179,52 @@ class DiscordOpusEncodeStream extends Transform {
return this.#encoder;
}
override async _transform(
chunk: Buffer,
_encoding: BufferEncoding,
done: TransformCallback,
): Promise<void> {
try {
const encoder = await this.#getEncoder();
this.#buffer =
this.#buffer.length > 0 ? Buffer.concat([this.#buffer, chunk]) : Buffer.from(chunk);
while (this.#buffer.length >= DISCORD_OPUS_FRAME_BYTES) {
const frame = this.#buffer.subarray(0, DISCORD_OPUS_FRAME_BYTES);
this.#buffer = this.#buffer.subarray(DISCORD_OPUS_FRAME_BYTES);
this.push(
Buffer.from(
encoder.encode(frame, {
frameSize: DISCORD_OPUS_FRAME_SIZE,
}),
),
);
override _transform(chunk: Buffer, _encoding: BufferEncoding, done: TransformCallback): void {
void (async () => {
try {
const encoder = await this.#getEncoder();
this.#buffer =
this.#buffer.length > 0 ? Buffer.concat([this.#buffer, chunk]) : Buffer.from(chunk);
while (this.#buffer.length >= DISCORD_OPUS_FRAME_BYTES) {
const frame = this.#buffer.subarray(0, DISCORD_OPUS_FRAME_BYTES);
this.#buffer = this.#buffer.subarray(DISCORD_OPUS_FRAME_BYTES);
this.push(
Buffer.from(
encoder.encode(frame, {
frameSize: DISCORD_OPUS_FRAME_SIZE,
}),
),
);
}
done();
} catch (err) {
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
}
done();
} catch (err) {
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
}
})();
}
override async _final(done: TransformCallback): Promise<void> {
try {
if (this.#buffer.length > 0) {
const encoder = await this.#getEncoder();
const frame = Buffer.alloc(DISCORD_OPUS_FRAME_BYTES);
this.#buffer.copy(frame);
this.#buffer = Buffer.alloc(0);
this.push(
Buffer.from(
encoder.encode(frame, {
frameSize: DISCORD_OPUS_FRAME_SIZE,
}),
),
);
override _final(done: TransformCallback): void {
void (async () => {
try {
if (this.#buffer.length > 0) {
const encoder = await this.#getEncoder();
const frame = Buffer.alloc(DISCORD_OPUS_FRAME_BYTES);
this.#buffer.copy(frame);
this.#buffer = Buffer.alloc(0);
this.push(
Buffer.from(
encoder.encode(frame, {
frameSize: DISCORD_OPUS_FRAME_SIZE,
}),
),
);
}
this.#freeEncoder();
done();
} catch (err) {
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
}
this.#freeEncoder();
done();
} catch (err) {
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
}
})();
}
override _destroy(err: Error | null, done: (error?: Error | null) => void): void {
+30 -28
View File
@@ -790,34 +790,36 @@ export class DiscordVoiceManager {
this.scheduleCaptureFinalize(entry, userId, "speaker end");
};
const disconnectedHandler: (() => Promise<void>) | undefined = async () => {
try {
logVoiceVerbose(
`disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`,
);
await Promise.race([
voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Signalling,
reconnectGraceMs,
),
voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Connecting,
reconnectGraceMs,
),
]);
logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`);
} catch (err) {
logger.warn(
`discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
);
clearSessionIfCurrent();
stopEntry(entry, {
destroyConnection: true,
reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
});
}
const disconnectedHandler: (() => void) | undefined = () => {
void (async () => {
try {
logVoiceVerbose(
`disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`,
);
await Promise.race([
voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Signalling,
reconnectGraceMs,
),
voiceSdk.entersState(
connection,
voiceSdk.VoiceConnectionStatus.Connecting,
reconnectGraceMs,
),
]);
logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`);
} catch (err) {
logger.warn(
`discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
);
clearSessionIfCurrent();
stopEntry(entry, {
destroyConnection: true,
reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
});
}
})();
};
const destroyedHandler: (() => void) | undefined = () => {
clearSessionIfCurrent();
@@ -8,6 +8,7 @@ import { WEBHOOK_IN_FLIGHT_DEFAULTS } from "openclaw/plugin-sdk/webhook-request-
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
type LineNodeWebhookHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>;
type LineHandleWebhook = (...args: unknown[]) => Promise<void>;
const {
createLineBotMock,
@@ -17,7 +18,7 @@ const {
} = vi.hoisted(() => ({
createLineBotMock: vi.fn(() => ({
account: { accountId: "default" },
handleWebhook: vi.fn(),
handleWebhook: vi.fn<LineHandleWebhook>(),
})),
createLineNodeWebhookHandlerMock: vi.fn<() => LineNodeWebhookHandler>(() =>
vi.fn<LineNodeWebhookHandler>(async () => {}),
@@ -163,7 +164,7 @@ describe("monitorLineProvider lifecycle", () => {
createLineBotMock.mockReset();
createLineBotMock.mockImplementation(() => ({
account: { accountId: "default" },
handleWebhook: vi.fn(),
handleWebhook: vi.fn<LineHandleWebhook>(),
}));
innerLineWebhookHandlerMock = vi.fn<LineNodeWebhookHandler>(async () => {});
createLineNodeWebhookHandlerMock
@@ -362,11 +363,11 @@ describe("monitorLineProvider lifecycle", () => {
let releaseWebhook: (() => void) | undefined;
const bot = createLineBotMock.mock.results[0]?.value as {
handleWebhook: ReturnType<typeof vi.fn>;
handleWebhook: ReturnType<typeof vi.fn<LineHandleWebhook>>;
};
bot.handleWebhook.mockImplementation(
async () =>
await new Promise<void>((resolve) => {
() =>
new Promise<void>((resolve) => {
releaseWebhook = resolve;
}),
);
@@ -60,25 +60,27 @@ export function registerMatrixAutoJoin(params: {
};
// Handle invites directly so both "always" and "allowlist" modes share the same path.
client.on("room.invite", async (roomId: string, _inviteEvent: unknown) => {
if (autoJoin === "allowlist") {
const allowedAliasRoomIds = await resolveAllowedAliasRoomIds();
const allowed =
autoJoinAllowlist.has("*") ||
allowedRoomIds.has(roomId) ||
allowedAliasRoomIds.some((resolvedRoomId) => resolvedRoomId === roomId);
client.on("room.invite", (roomId: string, _inviteEvent: unknown) => {
void (async () => {
if (autoJoin === "allowlist") {
const allowedAliasRoomIds = await resolveAllowedAliasRoomIds();
const allowed =
autoJoinAllowlist.has("*") ||
allowedRoomIds.has(roomId) ||
allowedAliasRoomIds.some((resolvedRoomId) => resolvedRoomId === roomId);
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
if (!allowed) {
logVerbose(`matrix: invite ignored (not in allowlist) room=${roomId}`);
return;
}
}
}
try {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);
} catch (err) {
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
}
try {
await client.joinRoom(roomId);
logVerbose(`matrix: joined room ${roomId}`);
} catch (err) {
runtime.error?.(`matrix: failed to join room ${roomId}: ${String(err)}`);
}
})();
});
}
@@ -271,9 +271,8 @@ export function registerMatrixMonitorEvents(params: {
);
});
client.on(
"room.failed_decryption",
async (roomId: string, event: MatrixRawEvent, error: Error) => {
client.on("room.failed_decryption", (roomId: string, event: MatrixRawEvent, error: Error) => {
void (async () => {
const failureState = postHealthySyncDecryptFailureTracker.recordFailure(roomId, event, error);
const selfUserId = await resolveMatrixSelfUserId(client, logVerboseMessage);
const sender = typeof event.sender === "string" ? event.sender : null;
@@ -320,8 +319,8 @@ export function registerMatrixMonitorEvents(params: {
logVerboseMessage(
`matrix: failed decrypt room=${roomId} id=${event.event_id ?? "unknown"} freshAfterHealthySync=${String(failureState.freshAfterHealthySync)} error=${error.message}`,
);
},
);
})();
});
client.on("verification.summary", (summary) => {
void runMonitorTask("verification summary handler", async () => {
@@ -370,7 +370,7 @@ export class MatrixCryptoBootstrapper<TRawEvent extends MatrixRawEvent> {
// Remote-user verifications are only auto-accepted. The human-operated
// client must explicitly choose "Verify by emoji" so we do not race a
// second SAS start from the bot side and end up with mismatched keys.
crypto.on(CryptoEvent.VerificationRequestReceived, async (request) => {
crypto.on(CryptoEvent.VerificationRequestReceived, (request) => {
const verificationRequest = request as MatrixVerificationRequestLike;
try {
this.deps.verificationManager.trackVerificationRequest(verificationRequest);
@@ -43,33 +43,35 @@ async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknow
async function startEmbeddingServer(): Promise<TestServer> {
const requests: CapturedRequest[] = [];
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
try {
const body = await readJsonBody(req);
requests.push({
method: req.method,
url: req.url,
headers: req.headers,
body,
});
const input = body.input;
const texts = Array.isArray(input) ? input : [input];
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
object: "list",
data: texts.map((text, index) => ({
object: "embedding",
embedding: [String(text).length, index + 0.5, 3],
index,
})),
model: body.model,
}),
);
} catch (error) {
res.writeHead(500, { "content-type": "application/json" });
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
void (async () => {
try {
const body = await readJsonBody(req);
requests.push({
method: req.method,
url: req.url,
headers: req.headers,
body,
});
const input = body.input;
const texts = Array.isArray(input) ? input : [input];
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify({
object: "list",
data: texts.map((text, index) => ({
object: "embedding",
embedding: [String(text).length, index + 0.5, 3],
index,
})),
model: body.model,
}),
);
} catch (error) {
res.writeHead(500, { "content-type": "application/json" });
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
})();
});
await new Promise<void>((resolve, reject) => {
+8 -15
View File
@@ -58,25 +58,18 @@ beforeAll(async () => {
privateKey = priv;
publicPem = await exportSPKI(publicKey);
// Patch `JwksClient.prototype.getSigningKey` so every JWKS lookup the SDK
// Patch `JwksClient.prototype.getSigningKeys` so every JWKS lookup the SDK
// performs returns our in-memory test key instead of fetching from
// `login.botframework.com` / `login.microsoftonline.com`. We patch the
// prototype here (rather than mocking the `jwks-rsa` module) because
// `jwks-rsa`'s constructor captures the prototype method reference into a
// cache wrapper at construction time — patching the prototype before any
// `JwksClient` is constructed in the tests is sufficient and avoids the
// CJS `__importDefault` shaping headaches of mocking the package itself.
vi.spyOn(JwksClient.prototype, "getSigningKey").mockImplementation((async (
kid?: string | null,
) => {
const key: SigningKey = {
kid: kid ?? TEST_KID,
// `login.botframework.com` / `login.microsoftonline.com` while preserving
// the package's callback/promise getSigningKey wrapper behavior.
vi.spyOn(JwksClient.prototype, "getSigningKeys").mockResolvedValue([
{
kid: TEST_KID,
alg: "RS256",
getPublicKey: () => publicPem,
rsaPublicKey: publicPem,
};
return key;
}) as JwksClient["getSigningKey"]);
} as SigningKey,
]);
});
// Logger that surfaces SDK validation failures so we can see *why* a token
+3 -1
View File
@@ -283,7 +283,9 @@ export const msteamsSetupWizard: ChannelSetupWizard = {
{
isRemote: true,
openUrl: openDelegatedOAuthUrl,
log: (msg) => params.prompter.note(msg),
log: (msg) => {
void params.prompter.note(msg);
},
note: (msg, title) => params.prompter.note(msg, title),
prompt: (msg) => params.prompter.text({ message: msg }),
progress,
+79 -77
View File
@@ -257,101 +257,103 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
pruneIntervalMs: authRateLimitWindowMs,
});
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.url === HEALTH_PATH) {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
return;
}
if (req.url !== path || req.method !== "POST") {
res.writeHead(404);
res.end();
return;
}
const clientIp = req.socket.remoteAddress ?? "unknown";
if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) {
res.writeHead(429);
res.end("Too Many Requests");
return;
}
try {
const headers = validateWebhookHeaders({
req,
res,
isBackendAllowed,
});
if (!headers) {
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
void (async () => {
if (req.url === HEALTH_PATH) {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("ok");
return;
}
const body = await readBody(req, maxBodyBytes);
const hasValidSignature = verifyWebhookSignature({
headers,
body,
secret,
res,
clientIp,
authRateLimiter: webhookAuthRateLimiter,
});
if (!hasValidSignature) {
if (req.url !== path || req.method !== "POST") {
res.writeHead(404);
res.end();
return;
}
const decoded = decodeWebhookCreateMessage({
body,
res,
});
if (decoded.kind === "invalid") {
return;
}
if (decoded.kind === "ignore") {
writeJsonResponse(res, 200);
const clientIp = req.socket.remoteAddress ?? "unknown";
if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) {
res.writeHead(429);
res.end("Too Many Requests");
return;
}
const message = decoded.message;
if (processMessage) {
writeJsonResponse(res, 200);
try {
await processMessage(message);
} catch (err) {
onError?.(err instanceof Error ? err : new Error(formatError(err)));
try {
const headers = validateWebhookHeaders({
req,
res,
isBackendAllowed,
});
if (!headers) {
return;
}
return;
}
if (shouldProcessMessage) {
const shouldProcess = await shouldProcessMessage(message);
if (!shouldProcess) {
const body = await readBody(req, maxBodyBytes);
const hasValidSignature = verifyWebhookSignature({
headers,
body,
secret,
res,
clientIp,
authRateLimiter: webhookAuthRateLimiter,
});
if (!hasValidSignature) {
return;
}
const decoded = decodeWebhookCreateMessage({
body,
res,
});
if (decoded.kind === "invalid") {
return;
}
if (decoded.kind === "ignore") {
writeJsonResponse(res, 200);
return;
}
}
writeJsonResponse(res, 200);
const message = decoded.message;
if (processMessage) {
writeJsonResponse(res, 200);
try {
await processMessage(message);
} catch (err) {
onError?.(err instanceof Error ? err : new Error(formatError(err)));
}
return;
}
try {
await onMessage(message);
if (shouldProcessMessage) {
const shouldProcess = await shouldProcessMessage(message);
if (!shouldProcess) {
writeJsonResponse(res, 200);
return;
}
}
writeJsonResponse(res, 200);
try {
await onMessage(message);
} catch (err) {
onError?.(err instanceof Error ? err : new Error(formatError(err)));
}
} catch (err) {
onError?.(err instanceof Error ? err : new Error(formatError(err)));
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
return;
}
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
return;
}
const error = err instanceof Error ? err : new Error(formatError(err));
onError?.(error);
writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
}
} catch (err) {
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
return;
}
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
return;
}
const error = err instanceof Error ? err : new Error(formatError(err));
onError?.(error);
writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
}
})();
});
const start = (): Promise<void> => {
+6 -3
View File
@@ -619,7 +619,9 @@ export async function startNostrBus(options: NostrBusOptions): Promise<NostrBusH
>[1];
const relayAbort = new AbortController();
const sub = pool.subscribeMany(relays, dmFilter, {
onevent: handleEvent,
onevent: (event) => {
void handleEvent(event);
},
oneose: () => {
// EOSE handler - called when all stored events have been received
for (const relay of relays) {
@@ -766,10 +768,11 @@ async function sendEncryptedDm(
const startTime = Date.now();
try {
const [publishPromise] = pool.publish([relay], reply);
if (!publishPromise) {
const publishPromises = pool.publish([relay], reply);
if (publishPromises.length === 0) {
throw new Error(`Failed to create publish promise for relay ${relay}`);
}
const publishPromise = publishPromises[0];
await publishPromise;
const latency = Date.now() - startTime;
@@ -321,9 +321,9 @@ export async function loginOpenAICodexOAuth(params: {
localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser...",
manualPromptMessage: manualInputPromptMessage,
});
const onAuth: typeof baseOnAuth = async (event) => {
const onAuth = (event: Parameters<typeof baseOnAuth>[0]) => {
browserAuthStarted = true;
await baseOnAuth(event);
void baseOnAuth(event);
};
const creds = await loginOpenAICodex({
+7 -5
View File
@@ -192,11 +192,13 @@ export async function handleQaBusRequest(params: {
}
export function createQaBusServer(state: QaBusState): Server {
return createServer(async (req, res) => {
const handled = await handleQaBusRequest({ req, res, state });
if (!handled) {
writeError(res, 404, "not found");
}
return createServer((req, res) => {
void (async () => {
const handled = await handleQaBusRequest({ req, res, state });
if (!handled) {
writeError(res, 404, "not found");
}
})();
});
}
+290 -286
View File
@@ -312,314 +312,318 @@ export async function startQaLabServer(
return result;
}
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
const server = createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (await handleQaBusRequest({ req, res, state })) {
return;
}
try {
if (controlUiProxyTarget && isControlUiProxyPath(url.pathname)) {
await proxyHttpRequest({
req,
res,
target: controlUiProxyTarget,
pathname: url.pathname,
search: url.search,
authorizationToken: controlUiProxyToken,
});
if (await handleQaBusRequest({ req, res, state })) {
return;
}
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
void ensureRunnerModelCatalog();
const resolvedControlUiUrl = controlUiProxyTarget
? `${publicBaseUrl}/control-ui/`
: controlUiUrl;
const safeControlUiUrl = sanitizeControlUiPublicUrl(resolvedControlUiUrl);
writeJson(res, 200, {
baseUrl: publicBaseUrl,
latestReport,
controlUiUrl: safeControlUiUrl,
controlUiEmbeddedUrl: safeControlUiUrl,
kickoffTask: scenarioCatalog.kickoffTask,
scenarios: scenarioCatalog.scenarios,
defaults: bootstrapDefaults,
runner: runnerSnapshot,
runnerCatalog: {
status: runnerModelCatalogStatus,
real: runnerModelOptions,
},
});
return;
}
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/readyz")) {
writeJson(res, 200, { ok: true, status: "live" });
return;
}
if (req.method === "GET" && url.pathname === "/api/state") {
writeJson(res, 200, state.getSnapshot());
return;
}
if (req.method === "GET" && url.pathname === "/api/report") {
writeJson(res, 200, { report: latestReport });
return;
}
if (req.method === "GET" && url.pathname === "/api/ui-version") {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify({ version: resolveUiAssetVersion(params?.uiDistDir) }));
return;
}
if (req.method === "GET" && url.pathname === "/api/outcomes") {
writeJson(res, 200, { run: latestScenarioRun });
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/sessions") {
writeJson(res, 200, {
sessions: captureStore.listSessions(50),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/startup-status") {
const proxyUrl = captureSettings.proxyUrl || "http://127.0.0.1:7799";
const gatewayUrl = controlUiUrl || "http://127.0.0.1:18789/";
const [proxy, gatewayLocal] = await Promise.all([
probeTcpReachability(proxyUrl),
probeTcpReachability(gatewayUrl),
]);
writeJson(res, 200, {
status: {
proxy: {
...proxy,
label: "Proxy",
},
gateway: {
...gatewayLocal,
label: "Gateway",
},
qaLab: {
label: "QA Lab",
url: publicBaseUrl,
ok: true,
},
},
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/events") {
const sessionId = url.searchParams.get("sessionId")?.trim();
writeJson(res, 200, {
events: sessionId
? captureStore.getSessionEvents(sessionId, 200).map(mapCaptureEventForQa)
: [],
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/coverage") {
const sessionId = url.searchParams.get("sessionId")?.trim();
if (!sessionId) {
writeError(res, 400, "Missing sessionId");
try {
if (controlUiProxyTarget && isControlUiProxyPath(url.pathname)) {
await proxyHttpRequest({
req,
res,
target: controlUiProxyTarget,
pathname: url.pathname,
search: url.search,
authorizationToken: controlUiProxyToken,
});
return;
}
writeJson(res, 200, {
coverage: captureStore.summarizeSessionCoverage(sessionId),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/query") {
const preset = url.searchParams.get("preset")?.trim();
const sessionId = url.searchParams.get("sessionId")?.trim() || undefined;
if (!preset) {
writeError(res, 400, "Missing preset");
return;
}
if (!isCaptureQueryPreset(preset)) {
writeError(res, 400, "Unknown preset");
return;
}
writeJson(res, 200, {
rows: captureStore.queryPreset(preset, sessionId),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/blob") {
const blobId = url.searchParams.get("id")?.trim();
if (!blobId) {
writeError(res, 400, "Missing blob id");
return;
}
const content = captureStore.readBlob(blobId);
if (content == null) {
writeError(res, 404, "Blob not found");
return;
}
writeJson(res, 200, { id: blobId, content });
return;
}
if (req.method === "POST" && url.pathname === "/api/capture/delete-sessions") {
const body = (await readQaJsonBody(req)) as { sessionIds?: unknown };
const sessionIds = Array.isArray(body.sessionIds)
? body.sessionIds.filter((value): value is string => typeof value === "string")
: [];
writeJson(res, 200, {
result: captureStore.deleteSessions(sessionIds),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/capture/purge") {
writeJson(res, 200, {
result: captureStore.purgeAll(),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/reset") {
if (activeSuiteRun) {
writeError(res, 409, "QA suite run already in progress");
return;
}
state.reset();
latestReport = null;
latestScenarioRun = null;
runnerSnapshot = {
...runnerSnapshot,
status: "idle",
artifacts: null,
error: null,
startedAt: undefined,
finishedAt: undefined,
};
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/inbound/message") {
const body = await readQaJsonBody(req);
writeJson(res, 200, {
message: state.addInboundMessage(body as Parameters<QaBusState["addInboundMessage"]>[0]),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/kickoff") {
writeJson(res, 200, {
message: injectKickoffMessage({
state,
defaults: bootstrapDefaults,
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
void ensureRunnerModelCatalog();
const resolvedControlUiUrl = controlUiProxyTarget
? `${publicBaseUrl}/control-ui/`
: controlUiUrl;
const safeControlUiUrl = sanitizeControlUiPublicUrl(resolvedControlUiUrl);
writeJson(res, 200, {
baseUrl: publicBaseUrl,
latestReport,
controlUiUrl: safeControlUiUrl,
controlUiEmbeddedUrl: safeControlUiUrl,
kickoffTask: scenarioCatalog.kickoffTask,
}),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/scenario/self-check") {
if (activeSuiteRun) {
writeError(res, 409, "QA suite run already in progress");
scenarios: scenarioCatalog.scenarios,
defaults: bootstrapDefaults,
runner: runnerSnapshot,
runnerCatalog: {
status: runnerModelCatalogStatus,
real: runnerModelOptions,
},
});
return;
}
const result = await runSelfCheck();
writeJson(res, 200, serializeSelfCheck(result));
return;
}
if (req.method === "POST" && url.pathname === "/api/scenario/suite") {
if (activeSuiteRun) {
writeError(res, 409, "QA suite run already in progress");
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/readyz")) {
writeJson(res, 200, { ok: true, status: "live" });
return;
}
const selection = normalizeQaRunSelection(
await readQaJsonBody(req),
scenarioCatalog.scenarios,
);
state.reset();
latestReport = null;
latestScenarioRun = null;
const startedAt = new Date().toISOString();
runnerSnapshot = {
status: "running",
selection,
startedAt,
finishedAt: undefined,
artifacts: null,
error: null,
};
activeSuiteRun = (async () => {
try {
const { runQaSuite } = await import("./suite.js");
const result = await runQaSuite({
lab: labHandle ?? undefined,
startLab: startQaLabServer,
outputDir: createQaRunOutputDir(repoRoot),
providerMode: selection.providerMode,
primaryModel: selection.primaryModel,
alternateModel: selection.alternateModel,
scenarioIds: selection.scenarioIds,
});
runnerSnapshot = {
status: "completed",
selection,
startedAt,
finishedAt: new Date().toISOString(),
artifacts: {
outputDir: result.outputDir,
reportPath: result.reportPath,
summaryPath: result.summaryPath,
watchUrl: result.watchUrl,
if (req.method === "GET" && url.pathname === "/api/state") {
writeJson(res, 200, state.getSnapshot());
return;
}
if (req.method === "GET" && url.pathname === "/api/report") {
writeJson(res, 200, { report: latestReport });
return;
}
if (req.method === "GET" && url.pathname === "/api/ui-version") {
res.writeHead(200, {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store",
});
res.end(JSON.stringify({ version: resolveUiAssetVersion(params?.uiDistDir) }));
return;
}
if (req.method === "GET" && url.pathname === "/api/outcomes") {
writeJson(res, 200, { run: latestScenarioRun });
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/sessions") {
writeJson(res, 200, {
sessions: captureStore.listSessions(50),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/startup-status") {
const proxyUrl = captureSettings.proxyUrl || "http://127.0.0.1:7799";
const gatewayUrl = controlUiUrl || "http://127.0.0.1:18789/";
const [proxy, gatewayLocal] = await Promise.all([
probeTcpReachability(proxyUrl),
probeTcpReachability(gatewayUrl),
]);
writeJson(res, 200, {
status: {
proxy: {
...proxy,
label: "Proxy",
},
error: null,
};
} catch (error) {
runnerSnapshot = {
status: "failed",
selection,
startedAt,
finishedAt: new Date().toISOString(),
artifacts: null,
error: formatErrorMessage(error),
};
} finally {
activeSuiteRun = null;
gateway: {
...gatewayLocal,
label: "Gateway",
},
qaLab: {
label: "QA Lab",
url: publicBaseUrl,
ok: true,
},
},
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/events") {
const sessionId = url.searchParams.get("sessionId")?.trim();
writeJson(res, 200, {
events: sessionId
? captureStore.getSessionEvents(sessionId, 200).map(mapCaptureEventForQa)
: [],
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/coverage") {
const sessionId = url.searchParams.get("sessionId")?.trim();
if (!sessionId) {
writeError(res, 400, "Missing sessionId");
return;
}
})();
writeJson(res, 202, {
ok: true,
runner: runnerSnapshot,
});
return;
}
writeJson(res, 200, {
coverage: captureStore.summarizeSessionCoverage(sessionId),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/query") {
const preset = url.searchParams.get("preset")?.trim();
const sessionId = url.searchParams.get("sessionId")?.trim() || undefined;
if (!preset) {
writeError(res, 400, "Missing preset");
return;
}
if (!isCaptureQueryPreset(preset)) {
writeError(res, 400, "Unknown preset");
return;
}
writeJson(res, 200, {
rows: captureStore.queryPreset(preset, sessionId),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/capture/blob") {
const blobId = url.searchParams.get("id")?.trim();
if (!blobId) {
writeError(res, 400, "Missing blob id");
return;
}
const content = captureStore.readBlob(blobId);
if (content == null) {
writeError(res, 404, "Blob not found");
return;
}
writeJson(res, 200, { id: blobId, content });
return;
}
if (req.method === "POST" && url.pathname === "/api/capture/delete-sessions") {
const body = (await readQaJsonBody(req)) as { sessionIds?: unknown };
const sessionIds = Array.isArray(body.sessionIds)
? body.sessionIds.filter((value): value is string => typeof value === "string")
: [];
writeJson(res, 200, {
result: captureStore.deleteSessions(sessionIds),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/capture/purge") {
writeJson(res, 200, {
result: captureStore.purgeAll(),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/reset") {
if (activeSuiteRun) {
writeError(res, 409, "QA suite run already in progress");
return;
}
state.reset();
latestReport = null;
latestScenarioRun = null;
runnerSnapshot = {
...runnerSnapshot,
status: "idle",
artifacts: null,
error: null,
startedAt: undefined,
finishedAt: undefined,
};
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "POST" && url.pathname === "/api/inbound/message") {
const body = await readQaJsonBody(req);
writeJson(res, 200, {
message: state.addInboundMessage(
body as Parameters<QaBusState["addInboundMessage"]>[0],
),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/kickoff") {
writeJson(res, 200, {
message: injectKickoffMessage({
state,
defaults: bootstrapDefaults,
kickoffTask: scenarioCatalog.kickoffTask,
}),
});
return;
}
if (req.method === "POST" && url.pathname === "/api/scenario/self-check") {
if (activeSuiteRun) {
writeError(res, 409, "QA suite run already in progress");
return;
}
const result = await runSelfCheck();
writeJson(res, 200, serializeSelfCheck(result));
return;
}
if (req.method === "POST" && url.pathname === "/api/scenario/suite") {
if (activeSuiteRun) {
writeError(res, 409, "QA suite run already in progress");
return;
}
const selection = normalizeQaRunSelection(
await readQaJsonBody(req),
scenarioCatalog.scenarios,
);
state.reset();
latestReport = null;
latestScenarioRun = null;
const startedAt = new Date().toISOString();
runnerSnapshot = {
status: "running",
selection,
startedAt,
finishedAt: undefined,
artifacts: null,
error: null,
};
activeSuiteRun = (async () => {
try {
const { runQaSuite } = await import("./suite.js");
const result = await runQaSuite({
lab: labHandle ?? undefined,
startLab: startQaLabServer,
outputDir: createQaRunOutputDir(repoRoot),
providerMode: selection.providerMode,
primaryModel: selection.primaryModel,
alternateModel: selection.alternateModel,
scenarioIds: selection.scenarioIds,
});
runnerSnapshot = {
status: "completed",
selection,
startedAt,
finishedAt: new Date().toISOString(),
artifacts: {
outputDir: result.outputDir,
reportPath: result.reportPath,
summaryPath: result.summaryPath,
watchUrl: result.watchUrl,
},
error: null,
};
} catch (error) {
runnerSnapshot = {
status: "failed",
selection,
startedAt,
finishedAt: new Date().toISOString(),
artifacts: null,
error: formatErrorMessage(error),
};
} finally {
activeSuiteRun = null;
}
})();
writeJson(res, 202, {
ok: true,
runner: runnerSnapshot,
});
return;
}
if (req.method !== "GET" && req.method !== "HEAD") {
writeError(res, 404, "not found");
return;
}
if (req.method !== "GET" && req.method !== "HEAD") {
writeError(res, 404, "not found");
return;
}
const asset = tryResolveUiAsset(url.pathname, params?.uiDistDir, repoRoot);
if (!asset) {
const html = missingUiHtml();
const asset = tryResolveUiAsset(url.pathname, params?.uiDistDir, repoRoot);
if (!asset) {
const html = missingUiHtml();
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(html),
});
if (req.method === "HEAD") {
res.end();
return;
}
res.end(html);
return;
}
const body = fs.readFileSync(asset);
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-length": Buffer.byteLength(html),
"content-type": detectContentType(asset),
"content-length": body.byteLength,
});
if (req.method === "HEAD") {
res.end();
return;
}
res.end(html);
return;
res.end(body);
} catch (error) {
writeQaLabServerError(res, error);
}
const body = fs.readFileSync(asset);
res.writeHead(200, {
"content-type": detectContentType(asset),
"content-length": body.byteLength,
});
if (req.method === "HEAD") {
res.end();
return;
}
res.end(body);
} catch (error) {
writeQaLabServerError(res, error);
}
})();
});
await new Promise<void>((resolve, reject) => {
@@ -3046,163 +3046,165 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
let lastRequest: MockOpenAiRequestSnapshot | null = null;
const requests: MockOpenAiRequestSnapshot[] = [];
const imageGenerationRequests: Array<Record<string, unknown>> = [];
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/readyz")) {
writeJson(res, 200, { ok: true, status: "live" });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, {
data: [
{ id: "gpt-5.5", object: "model" },
{ id: "gpt-5.5-alt", object: "model" },
{ id: "gpt-image-1", object: "model" },
{ id: "text-embedding-3-small", object: "model" },
{ id: "claude-opus-4-8", object: "model" },
{ id: "claude-sonnet-4-6", object: "model" },
],
});
return;
}
if (req.method === "GET" && url.pathname === "/debug/last-request") {
writeJson(res, 200, lastRequest ?? { ok: false, error: "no request recorded" });
return;
}
if (req.method === "GET" && url.pathname === "/debug/requests") {
writeJson(res, 200, requests);
return;
}
if (req.method === "GET" && url.pathname === "/debug/image-generations") {
writeJson(res, 200, imageGenerationRequests);
return;
}
if (req.method === "POST" && url.pathname === "/v1/images/generations") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
imageGenerationRequests.push(body);
if (imageGenerationRequests.length > 20) {
imageGenerationRequests.splice(0, imageGenerationRequests.length - 20);
}
writeJson(res, 200, {
data: [
{
b64_json: TINY_PNG_BASE64,
revised_prompt: "A QA lighthouse with protocol droid silhouette.",
},
],
});
return;
}
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const inputs = extractEmbeddingInputTexts(body.input);
writeJson(res, 200, {
object: "list",
data: inputs.map((text, index) => ({
object: "embedding",
index,
embedding: buildDeterministicEmbedding(text),
})),
model:
typeof body.model === "string" && body.model.trim()
? body.model
: "text-embedding-3-small",
usage: {
prompt_tokens: inputs.reduce((sum, text) => sum + countApproxTokens(text), 0),
total_tokens: inputs.reduce((sum, text) => sum + countApproxTokens(text), 0),
},
});
return;
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const input = Array.isArray(body.input) ? (body.input as ResponsesInputItem[]) : [];
const events = await buildResponsesPayload(body, scenarioState);
const resolvedModel = typeof body.model === "string" ? body.model : "";
lastRequest = {
raw,
body,
prompt: extractLastUserText(input),
allInputText: extractAllRequestTexts(input, body),
instructions: extractInstructionsText(body) || undefined,
toolOutput: extractToolOutput(input),
model: resolvedModel,
providerVariant: resolveProviderVariant(resolvedModel),
imageInputCount: countImageInputs(input),
plannedToolName: extractPlannedToolName(events),
plannedToolArgs: extractPlannedToolArgs(events),
};
requests.push(lastRequest);
if (requests.length > MOCK_OPENAI_DEBUG_REQUEST_LIMIT) {
requests.splice(0, requests.length - MOCK_OPENAI_DEBUG_REQUEST_LIMIT);
}
if (body.stream === false) {
const completion = events.at(-1);
if (!completion || completion.type !== "response.completed") {
writeJson(res, 500, { error: "mock completion failed" });
return;
}
writeJson(res, 200, completion.response);
const server = createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/readyz")) {
writeJson(res, 200, { ok: true, status: "live" });
return;
}
writeSse(res, events);
return;
}
if (req.method === "POST" && url.pathname === "/v1/messages") {
const raw = await readBody(req);
let body: AnthropicMessagesRequest = {};
try {
body = raw ? (JSON.parse(raw) as AnthropicMessagesRequest) : {};
} catch {
writeJson(res, 400, {
type: "error",
error: {
type: "invalid_request_error",
message: "Malformed JSON body for Anthropic Messages request.",
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, {
data: [
{ id: "gpt-5.5", object: "model" },
{ id: "gpt-5.5-alt", object: "model" },
{ id: "gpt-image-1", object: "model" },
{ id: "text-embedding-3-small", object: "model" },
{ id: "claude-opus-4-8", object: "model" },
{ id: "claude-sonnet-4-6", object: "model" },
],
});
return;
}
if (req.method === "GET" && url.pathname === "/debug/last-request") {
writeJson(res, 200, lastRequest ?? { ok: false, error: "no request recorded" });
return;
}
if (req.method === "GET" && url.pathname === "/debug/requests") {
writeJson(res, 200, requests);
return;
}
if (req.method === "GET" && url.pathname === "/debug/image-generations") {
writeJson(res, 200, imageGenerationRequests);
return;
}
if (req.method === "POST" && url.pathname === "/v1/images/generations") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
imageGenerationRequests.push(body);
if (imageGenerationRequests.length > 20) {
imageGenerationRequests.splice(0, imageGenerationRequests.length - 20);
}
writeJson(res, 200, {
data: [
{
b64_json: TINY_PNG_BASE64,
revised_prompt: "A QA lighthouse with protocol droid silhouette.",
},
],
});
return;
}
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const inputs = extractEmbeddingInputTexts(body.input);
writeJson(res, 200, {
object: "list",
data: inputs.map((text, index) => ({
object: "embedding",
index,
embedding: buildDeterministicEmbedding(text),
})),
model:
typeof body.model === "string" && body.model.trim()
? body.model
: "text-embedding-3-small",
usage: {
prompt_tokens: inputs.reduce((sum, text) => sum + countApproxTokens(text), 0),
total_tokens: inputs.reduce((sum, text) => sum + countApproxTokens(text), 0),
},
});
return;
}
const {
events,
input,
responseBody,
streamEvents,
model: normalizedModel,
} = await buildMessagesPayload(body, scenarioState);
// Record the adapted request snapshot so /debug/requests gives the QA
// suite the same plannedToolName / allInputText / toolOutput signals
// on the Anthropic route that the OpenAI route already exposes. This
// is what lets a single parity run diff assertions across both lanes.
// Reuse the normalized model so an empty-string body.model no longer
// leaks through to `lastRequest.model`.
lastRequest = {
raw,
body: body as Record<string, unknown>,
prompt: extractLastUserText(input),
allInputText: extractAllInputTexts(input),
toolOutput: extractToolOutput(input),
model: normalizedModel,
providerVariant: resolveProviderVariant(normalizedModel),
imageInputCount: countImageInputs(input),
plannedToolName: extractPlannedToolName(events),
plannedToolArgs: extractPlannedToolArgs(events),
};
requests.push(lastRequest);
if (requests.length > MOCK_OPENAI_DEBUG_REQUEST_LIMIT) {
requests.splice(0, requests.length - MOCK_OPENAI_DEBUG_REQUEST_LIMIT);
}
if (body.stream === true) {
writeAnthropicSse(res, streamEvents);
if (req.method === "POST" && url.pathname === "/v1/responses") {
const raw = await readBody(req);
const body = raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
const input = Array.isArray(body.input) ? (body.input as ResponsesInputItem[]) : [];
const events = await buildResponsesPayload(body, scenarioState);
const resolvedModel = typeof body.model === "string" ? body.model : "";
lastRequest = {
raw,
body,
prompt: extractLastUserText(input),
allInputText: extractAllRequestTexts(input, body),
instructions: extractInstructionsText(body) || undefined,
toolOutput: extractToolOutput(input),
model: resolvedModel,
providerVariant: resolveProviderVariant(resolvedModel),
imageInputCount: countImageInputs(input),
plannedToolName: extractPlannedToolName(events),
plannedToolArgs: extractPlannedToolArgs(events),
};
requests.push(lastRequest);
if (requests.length > MOCK_OPENAI_DEBUG_REQUEST_LIMIT) {
requests.splice(0, requests.length - MOCK_OPENAI_DEBUG_REQUEST_LIMIT);
}
if (body.stream === false) {
const completion = events.at(-1);
if (!completion || completion.type !== "response.completed") {
writeJson(res, 500, { error: "mock completion failed" });
return;
}
writeJson(res, 200, completion.response);
return;
}
writeSse(res, events);
return;
}
writeJson(res, 200, responseBody);
return;
}
writeJson(res, 404, { error: "not found" });
if (req.method === "POST" && url.pathname === "/v1/messages") {
const raw = await readBody(req);
let body: AnthropicMessagesRequest = {};
try {
body = raw ? (JSON.parse(raw) as AnthropicMessagesRequest) : {};
} catch {
writeJson(res, 400, {
type: "error",
error: {
type: "invalid_request_error",
message: "Malformed JSON body for Anthropic Messages request.",
},
});
return;
}
const {
events,
input,
responseBody,
streamEvents,
model: normalizedModel,
} = await buildMessagesPayload(body, scenarioState);
// Record the adapted request snapshot so /debug/requests gives the QA
// suite the same plannedToolName / allInputText / toolOutput signals
// on the Anthropic route that the OpenAI route already exposes. This
// is what lets a single parity run diff assertions across both lanes.
// Reuse the normalized model so an empty-string body.model no longer
// leaks through to `lastRequest.model`.
lastRequest = {
raw,
body: body as Record<string, unknown>,
prompt: extractLastUserText(input),
allInputText: extractAllInputTexts(input),
toolOutput: extractToolOutput(input),
model: normalizedModel,
providerVariant: resolveProviderVariant(normalizedModel),
imageInputCount: countImageInputs(input),
plannedToolName: extractPlannedToolName(events),
plannedToolArgs: extractPlannedToolArgs(events),
};
requests.push(lastRequest);
if (requests.length > MOCK_OPENAI_DEBUG_REQUEST_LIMIT) {
requests.splice(0, requests.length - MOCK_OPENAI_DEBUG_REQUEST_LIMIT);
}
if (body.stream === true) {
writeAnthropicSse(res, streamEvents);
return;
}
writeJson(res, 200, responseBody);
return;
}
writeJson(res, 404, { error: "not found" });
})();
});
await new Promise<void>((resolve, reject) => {
+26 -24
View File
@@ -958,28 +958,29 @@ export async function createQaLabApp(root: HTMLDivElement) {
});
root
.querySelector<HTMLButtonElement>("#capture-delete-selected-sessions")
?.addEventListener("click", async () => {
if (state.selectedCaptureSessionIds.length === 0) {
return;
}
const confirmed = window.confirm(
`Delete ${state.selectedCaptureSessionIds.length} selected capture session${
state.selectedCaptureSessionIds.length === 1 ? "" : "s"
}?`,
);
if (!confirmed) {
return;
}
await postJson("/api/capture/delete-sessions", {
sessionIds: state.selectedCaptureSessionIds,
});
state.selectedCaptureSessionIds = [];
state.selectedCaptureEventKey = null;
await refresh();
?.addEventListener("click", () => {
void (async () => {
if (state.selectedCaptureSessionIds.length === 0) {
return;
}
const confirmed = window.confirm(
`Delete ${state.selectedCaptureSessionIds.length} selected capture session${
state.selectedCaptureSessionIds.length === 1 ? "" : "s"
}?`,
);
if (!confirmed) {
return;
}
await postJson("/api/capture/delete-sessions", {
sessionIds: state.selectedCaptureSessionIds,
});
state.selectedCaptureSessionIds = [];
state.selectedCaptureEventKey = null;
await refresh();
})();
});
root
.querySelector<HTMLButtonElement>("#capture-purge-all")
?.addEventListener("click", async () => {
root.querySelector<HTMLButtonElement>("#capture-purge-all")?.addEventListener("click", () => {
void (async () => {
const confirmed = window.confirm("Purge all captured sessions, events, and blobs?");
if (!confirmed) {
return;
@@ -988,7 +989,8 @@ export async function createQaLabApp(root: HTMLDivElement) {
state.selectedCaptureSessionIds = [];
state.selectedCaptureEventKey = null;
await refresh();
});
})();
});
root.querySelector<HTMLSelectElement>("#capture-preset")?.addEventListener("change", (e) => {
state.captureQueryPreset = (e.currentTarget as HTMLSelectElement)
.value as UiState["captureQueryPreset"];
@@ -1327,12 +1329,12 @@ export async function createQaLabApp(root: HTMLDivElement) {
});
});
root.querySelectorAll<HTMLButtonElement>("[data-copy-text]").forEach((node) => {
node.addEventListener("click", async () => {
node.addEventListener("click", () => {
const text = node.dataset.copyText ?? "";
if (!text) {
return;
}
await navigator.clipboard.writeText(text).catch(() => undefined);
void navigator.clipboard.writeText(text).catch(() => undefined);
});
});
root.querySelectorAll<HTMLElement>("[data-capture-sparkline-window]").forEach((node) => {
@@ -11,19 +11,21 @@ async function startTargetServer(params?: { responseBody?: string }) {
method: string;
url: string;
}> = [];
const server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
requests.push({
...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
body: Buffer.concat(chunks).toString("utf8"),
method: req.method ?? "GET",
url: req.url ?? "/",
});
res.writeHead(200, { "content-type": "application/json" });
res.end(params?.responseBody ?? JSON.stringify({ forwarded: true }));
const server = createServer((req, res) => {
void (async () => {
const chunks: Buffer[] = [];
for await (const chunk of req) {
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
}
requests.push({
...(req.headers.authorization ? { authorization: req.headers.authorization } : {}),
body: Buffer.concat(chunks).toString("utf8"),
method: req.method ?? "GET",
url: req.url ?? "/",
});
res.writeHead(200, { "content-type": "application/json" });
res.end(params?.responseBody ?? JSON.stringify({ forwarded: true }));
})();
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
@@ -299,65 +299,67 @@ export async function startMatrixQaFaultProxy(params: {
const maxRequestBytes = params.maxRequestBytes ?? DEFAULT_FAULT_PROXY_REQUEST_MAX_BYTES;
const maxResponseBytes = params.maxResponseBytes ?? DEFAULT_FAULT_PROXY_RESPONSE_MAX_BYTES;
const hits: MatrixQaFaultProxyHit[] = [];
const server = createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", targetBaseUrl);
const path = requestUrl.pathname;
const bearerToken = extractBearerToken(req.headers);
const request: MatrixQaFaultProxyRequest = {
...(bearerToken ? { bearerToken } : {}),
headers: req.headers,
method: req.method ?? "GET",
path,
search: requestUrl.search,
};
const body = await readRequestBody(req, maxRequestBytes);
const rule = params.rules.find((candidate) => candidate.match(request));
if (rule) {
hits.push({
method: request.method,
path: request.path,
ruleId: rule.id,
const server = createServer((req, res) => {
void (async () => {
try {
const requestUrl = new URL(req.url ?? "/", targetBaseUrl);
const path = requestUrl.pathname;
const bearerToken = extractBearerToken(req.headers);
const request: MatrixQaFaultProxyRequest = {
...(bearerToken ? { bearerToken } : {}),
headers: req.headers,
method: req.method ?? "GET",
path,
search: requestUrl.search,
};
const body = await readRequestBody(req, maxRequestBytes);
const rule = params.rules.find((candidate) => candidate.match(request));
if (rule) {
hits.push({
method: request.method,
path: request.path,
ruleId: rule.id,
});
if (rule.response) {
writeJsonResponse(res, rule.response(request));
return;
}
}
const forwarded = await forwardMatrixQaFaultProxyRequest({
body,
maxResponseBytes,
req,
targetUrl: requestUrl,
});
if (rule.response) {
writeJsonResponse(res, rule.response(request));
const response =
rule?.mutateResponse !== undefined
? await rule.mutateResponse({
request,
response: forwarded,
})
: forwarded;
writeForwardedResponse(res, response);
} catch (error) {
if (error instanceof MatrixQaFaultProxyHttpError) {
writeJsonResponse(res, {
body: {
errcode: error.code,
error: error.message,
},
...(error.status === 413 ? { headers: { connection: "close" } } : {}),
status: error.status,
});
return;
}
}
const forwarded = await forwardMatrixQaFaultProxyRequest({
body,
maxResponseBytes,
req,
targetUrl: requestUrl,
});
const response =
rule?.mutateResponse !== undefined
? await rule.mutateResponse({
request,
response: forwarded,
})
: forwarded;
writeForwardedResponse(res, response);
} catch (error) {
if (error instanceof MatrixQaFaultProxyHttpError) {
writeJsonResponse(res, {
body: {
errcode: error.code,
error: error.message,
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
error: error instanceof Error ? error.message : String(error),
},
...(error.status === 413 ? { headers: { connection: "close" } } : {}),
status: error.status,
status: 502,
});
return;
}
writeJsonResponse(res, {
body: {
errcode: "MATRIX_QA_FAULT_PROXY_ERROR",
error: error instanceof Error ? error.message : String(error),
},
status: 502,
});
}
})();
});
await new Promise<void>((resolve, reject) => {
@@ -29,10 +29,10 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
// ============ Test doubles ============
/** Build a minimal ApiClient stub whose `request` is fully mockable. */
function mockApiClient(): ApiClient & { request: ReturnType<typeof vi.fn> } {
function mockApiClient(): ApiClient & { request: ReturnType<typeof vi.fn<ApiClient["request"]>> } {
return {
request: vi.fn(),
} as unknown as ApiClient & { request: ReturnType<typeof vi.fn> };
request: vi.fn<ApiClient["request"]>(),
} as unknown as ApiClient & { request: ReturnType<typeof vi.fn<ApiClient["request"]>> };
}
/** Minimal TokenManager stub returning a static token. */
@@ -200,22 +200,23 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => {
// plus one complete. Because concurrency=2 the order of part_finish is
// not strictly deterministic, so match on path + payload key.
client.request.mockImplementation(
async (_token: string, _method: string, pathLocal: string, body: Record<string, unknown>) => {
async (_token: string, _method: string, pathLocal: string, body: unknown) => {
const uploadBody = body as Record<string, unknown>;
if (pathLocal.endsWith("/upload_prepare")) {
expect(body.file_type).toBe(MediaFileType.FILE);
expect(typeof body.md5).toBe("string");
expect(typeof body.sha1).toBe("string");
expect(typeof body.md5_10m).toBe("string");
expect(body.file_size).toBe(FIXTURE_BUFFER.length);
expect(uploadBody.file_type).toBe(MediaFileType.FILE);
expect(typeof uploadBody.md5).toBe("string");
expect(typeof uploadBody.sha1).toBe("string");
expect(typeof uploadBody.md5_10m).toBe("string");
expect(uploadBody.file_size).toBe(FIXTURE_BUFFER.length);
return prepareResp;
}
if (pathLocal.endsWith("/upload_part_finish")) {
expect(body.upload_id).toBe("uid-1");
expect(typeof body.part_index).toBe("number");
expect(uploadBody.upload_id).toBe("uid-1");
expect(typeof uploadBody.part_index).toBe("number");
return {};
}
if (pathLocal.endsWith("/files")) {
expect(body.upload_id).toBe("uid-1");
expect(uploadBody.upload_id).toBe("uid-1");
return completeResp;
}
throw new Error(`unexpected path ${pathLocal}`);
@@ -323,9 +324,7 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => {
expect(result.file_info).toBe("fi");
// Verify prepare received the md5 of the on-disk bytes.
const prepareCall = client.request.mock.calls.find((c) =>
String(c[2]).endsWith("/upload_prepare"),
)!;
const prepareCall = client.request.mock.calls.find((c) => c[2].endsWith("/upload_prepare"))!;
const prepareBody = prepareCall[3] as { md5: string; file_name: string };
expect(prepareBody.md5).toBe(crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex"));
expect(prepareBody.file_name).toBe("fixture.bin");
@@ -368,9 +367,7 @@ describe("media-chunked: ChunkedMediaApi.uploadChunked", () => {
creds: { appId: "a", clientSecret: "s" },
});
const prepareCall = client.request.mock.calls.find((c) =>
String(c[2]).endsWith("/upload_prepare"),
)!;
const prepareCall = client.request.mock.calls.find((c) => c[2].endsWith("/upload_prepare"))!;
const prepareBody = prepareCall[3] as { md5: string };
expect(prepareBody.md5).toBe(crypto.createHash("md5").update(FIXTURE_BUFFER).digest("hex"));
} finally {
@@ -209,7 +209,7 @@ export class GatewayConnection {
});
// ---- WebSocket: message ----
ws.on("message", async (data) => {
ws.on("message", (data) => {
try {
const rawData = decodeGatewayMessageData(data);
const payload = JSON.parse(rawData) as WSPayload;
@@ -196,12 +196,10 @@ export async function dispatchOutbound(
clearTimeout(toolOnlyTimeoutId);
toolRenewalCount++;
}
toolOnlyTimeoutId = setTimeout(async () => {
toolOnlyTimeoutId = setTimeout(() => {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
try {
await sendToolFallback();
} catch {}
void sendToolFallback().catch(() => {});
}
}, TOOL_ONLY_TIMEOUT);
return true;
@@ -1,5 +1,7 @@
import { vi } from "vitest";
type AsyncMock = ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
const mocks = vi.hoisted(() => ({
dispatchMock: vi.fn(),
readAllowFromStoreMock: vi.fn(),
@@ -7,7 +9,7 @@ const mocks = vi.hoisted(() => ({
resolveAgentRouteMock: vi.fn(),
finalizeInboundContextMock: vi.fn(),
resolveConversationLabelMock: vi.fn(),
recordSessionMetaFromInboundMock: vi.fn(),
recordSessionMetaFromInboundMock: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
resolveStorePathMock: vi.fn(),
}));
@@ -32,7 +34,7 @@ type SlashHarnessMocks = {
resolveAgentRouteMock: ReturnType<typeof vi.fn>;
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
resolveConversationLabelMock: ReturnType<typeof vi.fn>;
recordSessionMetaFromInboundMock: ReturnType<typeof vi.fn>;
recordSessionMetaFromInboundMock: AsyncMock;
resolveStorePathMock: ReturnType<typeof vi.fn>;
};
+20 -10
View File
@@ -59,11 +59,11 @@ const { sendMessageSlack, clearSlackDmChannelCache, clearSlackSendQueuesForTest
const SLACK_TEST_CFG = { channels: { slack: { botToken: "xoxb-test" } } };
type UploadTestClient = WebClient & {
conversations: { open: ReturnType<typeof vi.fn> };
chat: { postMessage: ReturnType<typeof vi.fn> };
conversations: { open: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>> };
chat: { postMessage: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>> };
files: {
getUploadURLExternal: ReturnType<typeof vi.fn>;
completeUploadExternal: ReturnType<typeof vi.fn>;
getUploadURLExternal: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
completeUploadExternal: ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
};
};
@@ -135,18 +135,24 @@ function expectCompletedUpload(params: {
function createUploadTestClient(): UploadTestClient {
return {
conversations: {
open: vi.fn(async () => ({ channel: { id: "D99RESOLVED" } })),
open: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
channel: { id: "D99RESOLVED" },
})),
},
chat: {
postMessage: vi.fn(async () => ({ ts: "171234.567" })),
postMessage: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
ts: "171234.567",
})),
},
files: {
getUploadURLExternal: vi.fn(async () => ({
getUploadURLExternal: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
ok: true,
upload_url: "https://uploads.slack.test/upload",
file_id: "F001",
})),
completeUploadExternal: vi.fn(async () => ({ ok: true })),
completeUploadExternal: vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
ok: true,
})),
},
} as unknown as UploadTestClient;
}
@@ -235,8 +241,12 @@ describe("sendMessageSlack file upload with user IDs", () => {
it("serializes concurrent sends to the same Slack target", async () => {
const client = createUploadTestClient();
let resolveFirst: (() => void) | undefined;
client.chat.postMessage.mockImplementation(async (payload: { text?: string }) => {
if (payload.text === "first") {
client.chat.postMessage.mockImplementation(async (payload: unknown) => {
const text =
typeof payload === "object" && payload !== null && "text" in payload
? payload.text
: undefined;
if (text === "first") {
await new Promise<void>((resolve) => {
resolveFirst = resolve;
});
@@ -961,8 +961,8 @@ export const registerTelegramHandlers = ({
const scheduleTextFragmentFlush = (entry: TextFragmentEntry) => {
clearTimeout(entry.timer);
entry.timer = setTimeout(async () => {
await runTextFragmentFlush(entry);
entry.timer = setTimeout(() => {
void runTextFragmentFlush(entry);
}, TELEGRAM_TEXT_FRAGMENT_MAX_GAP_MS);
};
@@ -1797,9 +1797,9 @@ export const registerTelegramHandlers = ({
existing.dispatchDedupeKeys,
dispatchDedupeKeys,
);
existing.timer = setTimeout(async () => {
existing.timer = setTimeout(() => {
mediaGroupBuffer.delete(mediaGroupKey);
await queueBufferedProcessing(mediaGroupProcessingByKey, mediaGroupKey, async () => {
void queueBufferedProcessing(mediaGroupProcessingByKey, mediaGroupKey, async () => {
await processMediaGroup(existing);
});
}, mediaGroupTimeoutMs);
@@ -1818,9 +1818,9 @@ export const registerTelegramHandlers = ({
topicConfig,
dispatchDedupeKeys,
...promptContextBoundaryOptions(promptContextMinTimestampMs),
timer: setTimeout(async () => {
timer: setTimeout(() => {
mediaGroupBuffer.delete(mediaGroupKey);
await queueBufferedProcessing(mediaGroupProcessingByKey, mediaGroupKey, async () => {
void queueBufferedProcessing(mediaGroupProcessingByKey, mediaGroupKey, async () => {
await processMediaGroup(entry);
});
}, mediaGroupTimeoutMs),
@@ -8,7 +8,7 @@ import { beforeEach, vi } from "vitest";
import type { TelegramBotDeps } from "./bot-deps.js";
type AnyMock = ReturnType<typeof vi.fn>;
type AnyAsyncMock = ReturnType<typeof vi.fn>;
type AnyAsyncMock = ReturnType<typeof vi.fn<(...args: unknown[]) => Promise<unknown>>>;
type GetRuntimeConfigFn =
typeof import("openclaw/plugin-sdk/runtime-config-snapshot").getRuntimeConfig;
type LoadSessionStoreFn =
@@ -103,7 +103,7 @@ export function setSessionStoreEntriesForTest(entries: SessionStore) {
const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted(
(): {
readChannelAllowFromStore: MockFn<TelegramBotDeps["readChannelAllowFromStore"]>;
upsertChannelPairingRequest: AnyAsyncMock;
upsertChannelPairingRequest: MockFn<TelegramBotDeps["upsertChannelPairingRequest"]>;
} => ({
readChannelAllowFromStore: vi.fn(async () => [] as string[]),
upsertChannelPairingRequest: vi.fn(async () => ({
@@ -113,20 +113,26 @@ const { readChannelAllowFromStore, upsertChannelPairingRequest } = vi.hoisted(
}),
);
export function getReadChannelAllowFromStoreMock(): AnyAsyncMock {
export function getReadChannelAllowFromStoreMock(): MockFn<
TelegramBotDeps["readChannelAllowFromStore"]
> {
return readChannelAllowFromStore;
}
export function getUpsertChannelPairingRequestMock(): AnyAsyncMock {
export function getUpsertChannelPairingRequestMock(): MockFn<
TelegramBotDeps["upsertChannelPairingRequest"]
> {
return upsertChannelPairingRequest;
}
const skillCommandListHoisted = vi.hoisted(() => ({
listSkillCommandsForAgents: vi.fn(() => []),
}));
const modelProviderDataHoisted = vi.hoisted(() => ({
buildModelsProviderData: vi.fn(),
}));
const modelProviderDataHoisted = vi.hoisted(
(): { buildModelsProviderData: MockFn<TelegramBotDeps["buildModelsProviderData"]> } => ({
buildModelsProviderData: vi.fn(),
}),
);
const replySpyHoisted = vi.hoisted(() => ({
replySpy: vi.fn(async (_ctx: MsgContext, opts?: GetReplyOptions) => {
await opts?.onReplyStart?.();
@@ -163,7 +169,7 @@ async function dispatchHarnessReplies(
await params.dispatcherOptions.deliver?.(finalPayload, { kind: "final" });
finalCount += 1;
} catch (err) {
params.dispatcherOptions.onError?.(err, { kind: "final" });
void params.dispatcherOptions.onError?.(err, { kind: "final" });
}
}
return {
@@ -47,6 +47,9 @@ const {
throttlerSpy,
useSpy,
} = harness;
type BuildModelsProviderDataMock = ReturnType<
typeof vi.fn<NonNullable<typeof telegramBotDepsForTest.buildModelsProviderData>>
>;
const { resolveTelegramFetch } = await import("./fetch.js");
const {
createTelegramBotCore: createTelegramBotBase,
@@ -630,7 +633,7 @@ describe("createTelegramBot", () => {
clearTimeout(
setTimeoutSpy.mock.results[debounceCallIndex]?.value as ReturnType<typeof setTimeout>,
);
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => Promise<void>) | undefined;
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => void) | undefined;
};
try {
@@ -650,7 +653,7 @@ describe("createTelegramBot", () => {
});
const flushFirst = extractLatestDebounceFlush();
const firstFlush = flushFirst?.();
flushFirst?.();
await vi.waitFor(
() => {
@@ -674,7 +677,7 @@ describe("createTelegramBot", () => {
});
const flushSecond = extractLatestDebounceFlush();
const secondFlush = flushSecond?.();
flushSecond?.();
await Promise.resolve();
expect(startedBodies).toHaveLength(1);
@@ -684,7 +687,6 @@ describe("createTelegramBot", () => {
throw new Error("Expected first Telegram run release callback to be initialized");
}
releaseFirstRun();
await Promise.all([firstFlush, secondFlush]);
await vi.waitFor(
() => {
@@ -743,9 +745,7 @@ describe("createTelegramBot", () => {
clearTimeout(
setTimeoutSpy.mock.results[debounceCallIndex]?.value as ReturnType<typeof setTimeout>,
);
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as
| (() => Promise<void>)
| undefined;
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => void) | undefined;
};
try {
@@ -791,7 +791,8 @@ describe("createTelegramBot", () => {
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("stop");
await flushFirst?.();
flushFirst?.();
await Promise.resolve();
expect(startedBodies).toHaveLength(1);
expect(sendMessageSpy.mock.calls.map((call) => String(call[1])).join("\n")).not.toContain(
"reply:first",
@@ -814,8 +815,13 @@ describe("createTelegramBot", () => {
});
const flushReplay = extractLatestDebounceFlush();
await flushReplay?.();
expect(startedBodies).toHaveLength(2);
flushReplay?.();
await vi.waitFor(
() => {
expect(startedBodies).toHaveLength(2);
},
{ interval: 1, timeout: 500 },
);
expect(startedBodies[1]).toContain("first");
} finally {
setTimeoutSpy.mockRestore();
@@ -858,7 +864,7 @@ describe("createTelegramBot", () => {
clearTimeout(
setTimeoutSpy.mock.results[debounceCallIndex]?.value as ReturnType<typeof setTimeout>,
);
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => Promise<void>) | undefined;
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => void) | undefined;
};
try {
@@ -905,7 +911,8 @@ describe("createTelegramBot", () => {
expect(startedBodies).toHaveLength(1);
expect(startedBodies[0]).toContain("stop");
await flushForward?.();
flushForward?.();
await Promise.resolve();
expect(startedBodies).toHaveLength(1);
expect(sendMessageSpy.mock.calls.map((call) => String(call[1])).join("\n")).not.toContain(
"reply:forwarded first",
@@ -956,7 +963,7 @@ describe("createTelegramBot", () => {
clearTimeout(
setTimeoutSpy.mock.results[debounceCallIndex]?.value as ReturnType<typeof setTimeout>,
);
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => Promise<void>) | undefined;
return setTimeoutSpy.mock.calls[debounceCallIndex]?.[0] as (() => void) | undefined;
};
try {
@@ -999,7 +1006,7 @@ describe("createTelegramBot", () => {
finalHandler: messageHandler,
});
await flushFirst?.();
flushFirst?.();
await vi.waitFor(() => {
expect(startedBodies.some((body) => body.includes("first"))).toBe(true);
});
@@ -1213,7 +1220,7 @@ describe("createTelegramBot", () => {
});
it("reloads callback model routing bindings without recreating the bot", async () => {
const buildModelsProviderDataMock =
telegramBotDepsForTest.buildModelsProviderData as unknown as ReturnType<typeof vi.fn>;
telegramBotDepsForTest.buildModelsProviderData as unknown as BuildModelsProviderDataMock;
let boundAgentId = "agent-a";
loadConfig.mockImplementation(() => ({
agents: {
@@ -4166,7 +4173,7 @@ describe("createTelegramBot", () => {
});
const buildModelsProviderDataMock =
telegramBotDepsForTest.buildModelsProviderData as unknown as ReturnType<typeof vi.fn>;
telegramBotDepsForTest.buildModelsProviderData as unknown as BuildModelsProviderDataMock;
buildModelsProviderDataMock.mockClear();
editMessageTextSpy.mockClear();
@@ -4674,12 +4681,13 @@ describe("createTelegramBot", () => {
}
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
expect(String(editMessageTextSpy.mock.calls.at(-1)?.[2] ?? "")).toContain(
const finalEditMessageText = editMessageTextSpy.mock.calls.at(-1)?.[2];
expect(typeof finalEditMessageText === "string" ? finalEditMessageText : "").toContain(
"Session-only model selection. Runtime unchanged.",
);
expect(
editMessageTextSpy.mock.calls.some((call) =>
String(call[2] ?? "").includes("Failed to change model"),
(typeof call[2] === "string" ? call[2] : "").includes("Failed to change model"),
),
).toBe(false);
});
+91 -81
View File
@@ -1092,7 +1092,9 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await api.subscribe({
app: "channels",
path: "/v2",
event: handleChannelsFirehose,
event: (event) => {
void handleChannelsFirehose(event);
},
err: (error) => {
runtime.error?.(`[tlon] Channels firehose error: ${String(error)}`);
},
@@ -1106,7 +1108,9 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await api.subscribe({
app: "chat",
path: "/v3",
event: handleChatFirehose,
event: (event) => {
void handleChatFirehose(event);
},
err: (error) => {
runtime.error?.(`[tlon] Chat firehose error: ${String(error)}`);
},
@@ -1196,81 +1200,36 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
await api.subscribe({
app: "groups",
path: "/groups/ui",
event: async (event: unknown) => {
try {
const eventRecord = asRecord(event);
// Handle group/channel join events
// Event structure: { group: { flag: "~host/group-name", ... }, channels: { ... } }
if (eventRecord) {
// Check for new channels being added to groups
const channels = asRecord(eventRecord.channels);
if (channels) {
for (const [channelNest, _channelData] of Object.entries(channels)) {
// Only monitor chat channels
if (!channelNest.startsWith("chat/")) {
continue;
}
// If this is a new channel we're not watching yet, add it
if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
runtime.log?.(
`[tlon] Auto-detected new channel (invite accepted): ${channelNest}`,
);
// Persist to settings store so it survives restarts
if (effectiveAutoAcceptGroupInvites) {
try {
const currentChannels = currentSettings.groupChannels || [];
if (!currentChannels.includes(channelNest)) {
const updatedChannels = [...currentChannels, channelNest];
// Poke settings store to persist
await api.poke({
app: "settings",
mark: "settings-event",
json: {
"put-entry": {
"bucket-key": "tlon",
"entry-key": "groupChannels",
value: updatedChannels,
desk: "moltbot",
},
},
});
runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
}
} catch (err) {
runtime.error?.(
`[tlon] Failed to persist channel to settings: ${String(err)}`,
);
}
}
}
}
}
// Also check for the "join" event structure
const join = asRecord(eventRecord.join);
if (join) {
const joinChannels = Array.isArray(join.channels) ? join.channels : [];
if (joinChannels.length > 0) {
for (const channelNest of joinChannels) {
if (typeof channelNest !== "string") {
continue;
}
event: (event: unknown) => {
void (async () => {
try {
const eventRecord = asRecord(event);
// Handle group/channel join events
// Event structure: { group: { flag: "~host/group-name", ... }, channels: { ... } }
if (eventRecord) {
// Check for new channels being added to groups
const channels = asRecord(eventRecord.channels);
if (channels) {
for (const [channelNest, _channelData] of Object.entries(channels)) {
// Only monitor chat channels
if (!channelNest.startsWith("chat/")) {
continue;
}
// If this is a new channel we're not watching yet, add it
if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
runtime.log?.(`[tlon] Auto-detected joined channel: ${channelNest}`);
runtime.log?.(
`[tlon] Auto-detected new channel (invite accepted): ${channelNest}`,
);
// Persist to settings store
// Persist to settings store so it survives restarts
if (effectiveAutoAcceptGroupInvites) {
try {
const currentChannels = currentSettings.groupChannels || [];
if (!currentChannels.includes(channelNest)) {
const updatedChannels = [...currentChannels, channelNest];
// Poke settings store to persist
await api.poke({
app: "settings",
mark: "settings-event",
@@ -1294,11 +1253,60 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
}
}
}
// Also check for the "join" event structure
const join = asRecord(eventRecord.join);
if (join) {
const joinChannels = Array.isArray(join.channels) ? join.channels : [];
if (joinChannels.length > 0) {
for (const channelNest of joinChannels) {
if (typeof channelNest !== "string") {
continue;
}
if (!channelNest.startsWith("chat/")) {
continue;
}
if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
runtime.log?.(`[tlon] Auto-detected joined channel: ${channelNest}`);
// Persist to settings store
if (effectiveAutoAcceptGroupInvites) {
try {
const currentChannels = currentSettings.groupChannels || [];
if (!currentChannels.includes(channelNest)) {
const updatedChannels = [...currentChannels, channelNest];
await api.poke({
app: "settings",
mark: "settings-event",
json: {
"put-entry": {
"bucket-key": "tlon",
"entry-key": "groupChannels",
value: updatedChannels,
desk: "moltbot",
},
},
});
runtime.log?.(`[tlon] Persisted ${channelNest} to settings store`);
}
} catch (err) {
runtime.error?.(
`[tlon] Failed to persist channel to settings: ${String(err)}`,
);
}
}
}
}
}
}
}
} catch (error: unknown) {
runtime.error?.(
`[tlon] Error handling groups-ui event: ${formatErrorMessage(error)}`,
);
}
} catch (error: unknown) {
runtime.error?.(`[tlon] Error handling groups-ui event: ${formatErrorMessage(error)}`);
}
})();
},
err: (error) => {
runtime.error?.(`[tlon] Groups-ui subscription error: ${String(error)}`);
@@ -1469,22 +1477,24 @@ export async function monitorTlonProvider(opts: MonitorTlonOpts = {}): Promise<v
// Periodically refresh channel discovery
const pollInterval = setInterval(
async () => {
if (!opts.abortSignal?.aborted) {
try {
if (effectiveAutoDiscoverChannels) {
const discoveredChannels = await fetchAllChannels(api, runtime);
for (const channelNest of discoveredChannels) {
if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
runtime.log?.(`[tlon] Now watching new channel: ${channelNest}`);
() => {
void (async () => {
if (!opts.abortSignal?.aborted) {
try {
if (effectiveAutoDiscoverChannels) {
const discoveredChannels = await fetchAllChannels(api, runtime);
for (const channelNest of discoveredChannels) {
if (!watchedChannels.has(channelNest)) {
watchedChannels.add(channelNest);
runtime.log?.(`[tlon] Now watching new channel: ${channelNest}`);
}
}
}
} catch (error: unknown) {
runtime.error?.(`[tlon] Channel refresh error: ${formatErrorMessage(error)}`);
}
} catch (error: unknown) {
runtime.error?.(`[tlon] Channel refresh error: ${formatErrorMessage(error)}`);
}
}
})();
},
2 * 60 * 1000,
);
@@ -383,12 +383,14 @@ export async function speakInitialMessage(
const delaySec = ctx.config.outbound.notifyHangupDelaySec;
const delayMs = resolveVoiceCallSecondsTimerDelayMs(delaySec, 0);
console.log(`[voice-call] Notify mode: auto-hangup in ${delaySec}s for call ${call.callId}`);
setTimeout(async () => {
const currentCall = ctx.activeCalls.get(call.callId);
if (currentCall && !TerminalStates.has(currentCall.state)) {
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
await endCall(ctx, call.callId);
}
setTimeout(() => {
void (async () => {
const currentCall = ctx.activeCalls.get(call.callId);
if (currentCall && !TerminalStates.has(currentCall.state)) {
console.log(`[voice-call] Notify mode: hanging up call ${call.callId}`);
await endCall(ctx, call.callId);
}
})();
}, delayMs);
} else if (
mode === "conversation" &&
+13 -11
View File
@@ -43,17 +43,19 @@ export function startMaxDurationTimer(params: {
`[voice-call] Starting max duration timer (${Math.ceil(maxDurationMs / 1000)}s) for call ${params.callId}`,
);
const timer = setTimeout(async () => {
params.ctx.maxDurationTimers.delete(params.callId);
const call = params.ctx.activeCalls.get(params.callId);
if (call && !TerminalStates.has(call.state)) {
console.log(
`[voice-call] Max duration reached (${Math.ceil(maxDurationMs / 1000)}s), ending call ${params.callId}`,
);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);
await params.onTimeout(params.callId);
}
const timer = setTimeout(() => {
void (async () => {
params.ctx.maxDurationTimers.delete(params.callId);
const call = params.ctx.activeCalls.get(params.callId);
if (call && !TerminalStates.has(call.state)) {
console.log(
`[voice-call] Max duration reached (${Math.ceil(maxDurationMs / 1000)}s), ending call ${params.callId}`,
);
call.endReason = "timeout";
persistCallRecord(params.ctx.storePath, call);
await params.onTimeout(params.callId);
}
})();
}, maxDurationMs);
params.ctx.maxDurationTimers.set(params.callId, timer);
+4 -2
View File
@@ -176,7 +176,9 @@ export class MediaStreamHandler {
// Reject oversized frames before app-level parsing runs on unauthenticated sockets.
maxPayload: MAX_INBOUND_MESSAGE_BYTES,
});
this.wss.on("connection", (ws, req) => this.handleConnection(ws, req));
this.wss.on("connection", (ws, req) => {
void this.handleConnection(ws, req);
});
}
const currentConnections = this.getCurrentConnectionCount();
@@ -230,7 +232,7 @@ export class MediaStreamHandler {
return;
}
ws.on("message", async (data: RawData) => {
ws.on("message", (data: RawData) => {
try {
const message = parseTwilioMediaMessage(data);
+27 -25
View File
@@ -188,34 +188,36 @@ export async function createWaSocket(
});
sock.ev.on("creds.update", () => enqueueSaveCreds(authDir, saveCreds, sessionLogger));
sock.ev.on("connection.update", async (update: Partial<import("baileys").ConnectionState>) => {
try {
const { connection, lastDisconnect, qr } = update;
if (qr) {
opts.onQr?.(qr);
if (printQr) {
console.log("Open the WhatsApp app, go to Linked Devices, then scan this QR:");
void printTerminalQr(qr).catch((err) => {
sessionLogger.warn({ error: String(err) }, "failed rendering WhatsApp QR");
});
sock.ev.on("connection.update", (update: Partial<import("baileys").ConnectionState>) => {
void (async () => {
try {
const { connection, lastDisconnect, qr } = update;
if (qr) {
opts.onQr?.(qr);
if (printQr) {
console.log("Open the WhatsApp app, go to Linked Devices, then scan this QR:");
void printTerminalQr(qr).catch((err) => {
sessionLogger.warn({ error: String(err) }, "failed rendering WhatsApp QR");
});
}
}
}
if (connection === "close") {
const status = getStatusCode(lastDisconnect?.error);
if (status === LOGGED_OUT_STATUS) {
console.error(
danger(
`WhatsApp session logged out. Run: ${formatCliCommand("openclaw channels login")}`,
),
);
if (connection === "close") {
const status = getStatusCode(lastDisconnect?.error);
if (status === LOGGED_OUT_STATUS) {
console.error(
danger(
`WhatsApp session logged out. Run: ${formatCliCommand("openclaw channels login")}`,
),
);
}
}
if (connection === "open" && verbose) {
console.log(success("WhatsApp Web connected."));
}
} catch (err) {
sessionLogger.error({ error: String(err) }, "connection.update handler error");
}
if (connection === "open" && verbose) {
console.log(success("WhatsApp Web connected."));
}
} catch (err) {
sessionLogger.error({ error: String(err) }, "connection.update handler error");
}
})();
});
// Handle WebSocket-level errors to prevent unhandled exceptions from crashing the process
@@ -51,7 +51,9 @@ describe("Zalo pairing lifecycle", () => {
try {
await withServer(
(req, res) => monitor.route.handler(req, res),
(req, res) => {
void monitor.route.handler(req, res);
},
async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
@@ -108,7 +110,9 @@ describe("Zalo pairing lifecycle", () => {
try {
await withServer(
(req, res) => monitor.route.handler(req, res),
(req, res) => {
void monitor.route.handler(req, res);
},
async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
@@ -83,7 +83,9 @@ describe("Zalo reply-once lifecycle", () => {
try {
await withServer(
(req, res) => monitor.route.handler(req, res),
(req, res) => {
void monitor.route.handler(req, res);
},
async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
@@ -145,7 +147,9 @@ describe("Zalo reply-once lifecycle", () => {
try {
await withServer(
(req, res) => monitor.route.handler(req, res),
(req, res) => {
void monitor.route.handler(req, res);
},
async (baseUrl) => {
const { first, replay } = await postWebhookReplay({
baseUrl,
+3 -1
View File
@@ -287,7 +287,9 @@ function startPollingLoop(params: ZaloPollingLoopParams) {
}
if (!isStopped() && !abortSignal.aborted) {
setImmediate(poll);
setImmediate(() => {
void poll();
});
}
};
+10 -8
View File
@@ -34,14 +34,16 @@ const DEFAULT_ACCOUNT: ResolvedZaloAccount = {
};
function createWebhookRequestHandler(processUpdate?: ZaloWebhookProcessUpdate): RequestListener {
return async (req, res) => {
const handled = processUpdate
? await handleZaloWebhookRequestInternal(req, res, processUpdate)
: await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
return (req, res) => {
void (async () => {
const handled = processUpdate
? await handleZaloWebhookRequestInternal(req, res, processUpdate)
: await handleZaloWebhookRequest(req, res);
if (!handled) {
res.statusCode = 404;
res.end("not found");
}
})();
};
}
+51 -49
View File
@@ -311,59 +311,61 @@ function extractProxyCapture(rawBody: string, req: http.IncomingMessage): ProxyC
async function startAnthropicProxy(params: { port: number; upstreamBaseUrl: string }) {
let lastCapture: ProxyCapture | undefined;
const sockets = new Set<import("node:net").Socket>();
const server = http.createServer(async (req, res) => {
try {
const method = req.method ?? "GET";
const requestBody = await readRequestBody(req);
const rawBody = requestBody.toString("utf8");
lastCapture = extractProxyCapture(rawBody, req);
const server = http.createServer((req, res) => {
void (async () => {
try {
const method = req.method ?? "GET";
const requestBody = await readRequestBody(req);
const rawBody = requestBody.toString("utf8");
lastCapture = extractProxyCapture(rawBody, req);
const upstreamUrl = resolveAnthropicUpstreamUrl(req.url, params.upstreamBaseUrl);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) {
continue;
const upstreamUrl = resolveAnthropicUpstreamUrl(req.url, params.upstreamBaseUrl);
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
if (value === undefined) {
continue;
}
const lower = key.toLowerCase();
if (lower === "host" || lower === "content-length") {
continue;
}
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
}
const lower = key.toLowerCase();
if (lower === "host" || lower === "content-length") {
continue;
const upstreamRes = await fetch(upstreamUrl, {
method,
headers,
body:
method === "GET" || method === "HEAD" || requestBody.byteLength === 0
? undefined
: requestBody,
duplex: "half",
});
const responseHeaders: Record<string, string> = {};
for (const [key, value] of upstreamRes.headers.entries()) {
const lower = key.toLowerCase();
if (
lower === "content-length" ||
lower === "content-encoding" ||
lower === "transfer-encoding" ||
lower === "connection" ||
lower === "keep-alive"
) {
continue;
}
responseHeaders[key] = value;
}
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
res.writeHead(upstreamRes.status, responseHeaders);
if (upstreamRes.body) {
for await (const chunk of upstreamRes.body) {
res.write(Buffer.from(chunk));
}
}
res.end();
} catch (error) {
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
res.end(redactForDevToolLog(`proxy error: ${String(error)}`));
}
const upstreamRes = await fetch(upstreamUrl, {
method,
headers,
body:
method === "GET" || method === "HEAD" || requestBody.byteLength === 0
? undefined
: requestBody,
duplex: "half",
});
const responseHeaders: Record<string, string> = {};
for (const [key, value] of upstreamRes.headers.entries()) {
const lower = key.toLowerCase();
if (
lower === "content-length" ||
lower === "content-encoding" ||
lower === "transfer-encoding" ||
lower === "connection" ||
lower === "keep-alive"
) {
continue;
}
responseHeaders[key] = value;
}
res.writeHead(upstreamRes.status, responseHeaders);
if (upstreamRes.body) {
for await (const chunk of upstreamRes.body) {
res.write(Buffer.from(chunk));
}
}
res.end();
} catch (error) {
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
res.end(redactForDevToolLog(`proxy error: ${String(error)}`));
}
})();
});
server.on("connection", (socket) => {
sockets.add(socket);
@@ -81,48 +81,50 @@ function responseEvents(text) {
];
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/health") {
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, {
object: "list",
data: [{ id: "gpt-5", object: "model", owned_by: "openclaw-e2e" }],
const server = http.createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/health") {
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, {
object: "list",
data: [{ id: "gpt-5", object: "model", owned_by: "openclaw-e2e" }],
});
return;
}
const bodyText = await readBody(req);
let body = {};
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
fs.appendFileSync(
requestLog,
`${JSON.stringify({ method: req.method, path: url.pathname, body })}\n`,
);
if (req.method === "POST" && url.pathname === "/v1/responses") {
if (bodyContainsForceReject(body)) {
writeOpenAiReject(res);
return;
}
if (body?.reasoning?.effort === "minimal" && hasWebSearchTool(body.tools)) {
writeOpenAiReject(res);
return;
}
writeSse(res, responseEvents(successMarker));
return;
}
writeJson(res, 404, {
error: { message: `unhandled mock route: ${req.method} ${url.pathname}` },
});
return;
}
const bodyText = await readBody(req);
let body = {};
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
fs.appendFileSync(
requestLog,
`${JSON.stringify({ method: req.method, path: url.pathname, body })}\n`,
);
if (req.method === "POST" && url.pathname === "/v1/responses") {
if (bodyContainsForceReject(body)) {
writeOpenAiReject(res);
return;
}
if (body?.reasoning?.effort === "minimal" && hasWebSearchTool(body.tools)) {
writeOpenAiReject(res);
return;
}
writeSse(res, responseEvents(successMarker));
return;
}
writeJson(res, 404, {
error: { message: `unhandled mock route: ${req.method} ${url.pathname}` },
});
})();
});
server.listen(port, "127.0.0.1", () => {
@@ -171,78 +171,80 @@ function broadcast(event) {
}
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (!checkAuth(req, res)) {
return;
}
if (req.method === "GET" && url.pathname === "/health") {
json(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/api/me") {
json(res, 200, { user: botUser });
return;
}
if (req.method === "GET" && url.pathname === "/api/workspaces") {
json(res, 200, { workspaces: [workspace] });
return;
}
if (req.method === "GET" && url.pathname === `/api/workspaces/${workspace.id}/channels`) {
json(res, 200, { channels: [channel] });
return;
}
if (req.method === "GET" && url.pathname === `/api/channels/${channel.id}/messages`) {
const afterSeq = Number(url.searchParams.get("after_seq") ?? 0);
json(res, 200, {
messages: messages.filter((message) => (message.channel_seq ?? 0) > afterSeq),
});
return;
}
if (req.method === "POST" && url.pathname === `/api/channels/${channel.id}/messages`) {
const body = await readBody(req);
const message = createMessage({ body: String(body.body ?? ""), author: botUser });
outboundMessages.push(message);
persist();
json(res, 200, { message });
return;
}
const threadReplyMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread\/replies$/u);
if (req.method === "POST" && threadReplyMatch) {
const body = await readBody(req);
const message = createMessage({
body: String(body.body ?? ""),
author: botUser,
parentMessageId: decodeURIComponent(threadReplyMatch[1]),
});
json(res, 200, { message });
return;
}
const threadMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread$/u);
if (req.method === "GET" && threadMatch) {
const rootId = decodeURIComponent(threadMatch[1]);
json(res, 200, {
root: messages.find((message) => message.id === rootId) ?? null,
replies: threadReplies.filter((message) => message.thread_root_id === rootId),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/realtime/events") {
json(res, 200, { events: [] });
return;
}
if (req.method === "POST" && url.pathname === "/fixture/inbound") {
const body = await readBody(req);
const message = createMessage({ body: String(body.body ?? ""), author: humanUser });
broadcast(eventFor(message));
json(res, 200, { message });
return;
}
if (req.method === "GET" && url.pathname === "/fixture/state") {
json(res, 200, { messages, threadReplies, outboundMessages, socketCount: sockets.size });
return;
}
json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` });
const server = http.createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (!checkAuth(req, res)) {
return;
}
if (req.method === "GET" && url.pathname === "/health") {
json(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/api/me") {
json(res, 200, { user: botUser });
return;
}
if (req.method === "GET" && url.pathname === "/api/workspaces") {
json(res, 200, { workspaces: [workspace] });
return;
}
if (req.method === "GET" && url.pathname === `/api/workspaces/${workspace.id}/channels`) {
json(res, 200, { channels: [channel] });
return;
}
if (req.method === "GET" && url.pathname === `/api/channels/${channel.id}/messages`) {
const afterSeq = Number(url.searchParams.get("after_seq") ?? 0);
json(res, 200, {
messages: messages.filter((message) => (message.channel_seq ?? 0) > afterSeq),
});
return;
}
if (req.method === "POST" && url.pathname === `/api/channels/${channel.id}/messages`) {
const body = await readBody(req);
const message = createMessage({ body: String(body.body ?? ""), author: botUser });
outboundMessages.push(message);
persist();
json(res, 200, { message });
return;
}
const threadReplyMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread\/replies$/u);
if (req.method === "POST" && threadReplyMatch) {
const body = await readBody(req);
const message = createMessage({
body: String(body.body ?? ""),
author: botUser,
parentMessageId: decodeURIComponent(threadReplyMatch[1]),
});
json(res, 200, { message });
return;
}
const threadMatch = url.pathname.match(/^\/api\/messages\/([^/]+)\/thread$/u);
if (req.method === "GET" && threadMatch) {
const rootId = decodeURIComponent(threadMatch[1]);
json(res, 200, {
root: messages.find((message) => message.id === rootId) ?? null,
replies: threadReplies.filter((message) => message.thread_root_id === rootId),
});
return;
}
if (req.method === "GET" && url.pathname === "/api/realtime/events") {
json(res, 200, { events: [] });
return;
}
if (req.method === "POST" && url.pathname === "/fixture/inbound") {
const body = await readBody(req);
const message = createMessage({ body: String(body.body ?? ""), author: humanUser });
broadcast(eventFor(message));
json(res, 200, { message });
return;
}
if (req.method === "GET" && url.pathname === "/fixture/state") {
json(res, 200, { messages, threadReplies, outboundMessages, socketCount: sockets.size });
return;
}
json(res, 404, { error: `unhandled ${req.method} ${url.pathname}` });
})();
});
server.on("upgrade", (req, socket) => {
+79 -77
View File
@@ -115,90 +115,92 @@ function resolveResponseText(bodyText) {
return matches.at(-1)?.[0] ?? successMarker;
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/health") {
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, {
object: "list",
data: [{ id: "gpt-5.5", object: "model", owned_by: "openclaw-e2e" }],
});
return;
}
const bodyText = await readBody(req);
if (requestLog) {
fs.appendFileSync(
requestLog,
`${JSON.stringify({ method: req.method, path: url.pathname, body: bodyText })}\n`,
);
}
let body = {};
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const responseText = resolveResponseText(bodyText);
if (body.stream === false) {
const server = http.createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && url.pathname === "/health") {
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, {
id: "resp_e2e",
object: "response",
status: "completed",
output: [
{
type: "message",
id: "msg_e2e_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: responseText, annotations: [] }],
},
],
usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18 },
object: "list",
data: [{ id: "gpt-5.5", object: "model", owned_by: "openclaw-e2e" }],
});
return;
}
writeSse(res, responseEvents(responseText));
return;
}
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
const responseText = resolveResponseText(bodyText);
writeChatCompletion(res, body.stream !== false, responseText);
return;
}
const bodyText = await readBody(req);
if (requestLog) {
fs.appendFileSync(
requestLog,
`${JSON.stringify({ method: req.method, path: url.pathname, body: bodyText })}\n`,
);
}
let body = {};
try {
body = bodyText ? JSON.parse(bodyText) : {};
} catch {
body = {};
}
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
const input = Array.isArray(body.input) ? body.input : [body.input ?? ""];
writeJson(res, 200, {
object: "list",
data: input.map((_, index) => ({
object: "embedding",
index,
embedding: [1, index / 100, 0, 0],
})),
model: body.model ?? "text-embedding-3-small",
usage: { prompt_tokens: input.length, total_tokens: input.length },
if (req.method === "POST" && url.pathname === "/v1/responses") {
const responseText = resolveResponseText(bodyText);
if (body.stream === false) {
writeJson(res, 200, {
id: "resp_e2e",
object: "response",
status: "completed",
output: [
{
type: "message",
id: "msg_e2e_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: responseText, annotations: [] }],
},
],
usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18 },
});
return;
}
writeSse(res, responseEvents(responseText));
return;
}
if (req.method === "POST" && url.pathname === "/v1/chat/completions") {
const responseText = resolveResponseText(bodyText);
writeChatCompletion(res, body.stream !== false, responseText);
return;
}
if (req.method === "POST" && url.pathname === "/v1/embeddings") {
const input = Array.isArray(body.input) ? body.input : [body.input ?? ""];
writeJson(res, 200, {
object: "list",
data: input.map((_, index) => ({
object: "embedding",
index,
embedding: [1, index / 100, 0, 0],
})),
model: body.model ?? "text-embedding-3-small",
usage: { prompt_tokens: input.length, total_tokens: input.length },
});
return;
}
if (
req.method === "POST" &&
(url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
) {
writeImageGeneration(res);
return;
}
writeJson(res, 404, {
error: { message: `unhandled mock route: ${req.method} ${url.pathname}` },
});
return;
}
if (
req.method === "POST" &&
(url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
) {
writeImageGeneration(res);
return;
}
writeJson(res, 404, {
error: { message: `unhandled mock route: ${req.method} ${url.pathname}` },
});
})();
});
server.listen(port, "127.0.0.1", () => {
+64 -62
View File
@@ -67,71 +67,73 @@ async function startMockServer(records: RequestRecord[]): Promise<{
baseUrl: string;
close: () => Promise<void>;
}> {
const server = http.createServer(async (req, res) => {
try {
const body = await readBody(req);
records.push({
method: req.method,
url: req.url,
authorization: req.headers.authorization,
accept: req.headers.accept,
contentType: req.headers["content-type"],
body,
});
if (req.method === "POST" && req.url === "/v1/images/generations") {
assert(
req.headers.authorization === `Bearer ${DIRECT_TOKEN}`,
`direct image route used wrong auth: ${req.headers.authorization}`,
);
const parsed = JSON.parse(body) as { model?: string; prompt?: string; size?: string };
assert(parsed.model === "gpt-image-2", `direct route model mismatch: ${body}`);
assert(
parsed.prompt === "docker direct image auth",
`direct route prompt mismatch: ${body}`,
);
assert(parsed.size === "1024x1024", `direct route size mismatch: ${body}`);
writeJson(res, 200, {
data: [
{
b64_json: DIRECT_IMAGE_BYTES.toString("base64"),
revised_prompt: "docker direct revised prompt",
},
],
const server = http.createServer((req, res) => {
void (async () => {
try {
const body = await readBody(req);
records.push({
method: req.method,
url: req.url,
authorization: req.headers.authorization,
accept: req.headers.accept,
contentType: req.headers["content-type"],
body,
});
return;
}
if (req.method === "POST" && req.url === "/backend-api/codex/responses") {
assert(
req.headers.authorization === `Bearer ${CODEX_TOKEN}`,
`codex image route used wrong auth: ${req.headers.authorization}`,
);
const parsed = JSON.parse(body) as {
tools?: Array<{ type?: string; model?: string; size?: string }>;
input?: Array<{ content?: Array<{ type?: string; text?: string }> }>;
};
assert(
parsed.tools?.[0]?.type === "image_generation" &&
parsed.tools[0].model === "gpt-image-2" &&
parsed.tools[0].size === "1024x1024",
`codex image tool mismatch: ${body}`,
);
assert(
parsed.input?.[0]?.content?.some(
(entry) =>
entry.type === "input_text" && entry.text === "docker codex oauth image auth",
),
`codex prompt missing: ${body}`,
);
writeCodexSse(res);
return;
}
if (req.method === "POST" && req.url === "/v1/images/generations") {
assert(
req.headers.authorization === `Bearer ${DIRECT_TOKEN}`,
`direct image route used wrong auth: ${req.headers.authorization}`,
);
const parsed = JSON.parse(body) as { model?: string; prompt?: string; size?: string };
assert(parsed.model === "gpt-image-2", `direct route model mismatch: ${body}`);
assert(
parsed.prompt === "docker direct image auth",
`direct route prompt mismatch: ${body}`,
);
assert(parsed.size === "1024x1024", `direct route size mismatch: ${body}`);
writeJson(res, 200, {
data: [
{
b64_json: DIRECT_IMAGE_BYTES.toString("base64"),
revised_prompt: "docker direct revised prompt",
},
],
});
return;
}
writeJson(res, 404, { error: `unexpected ${req.method} ${req.url}` });
} catch (error) {
writeJson(res, 500, { error: String(error instanceof Error ? error.message : error) });
}
if (req.method === "POST" && req.url === "/backend-api/codex/responses") {
assert(
req.headers.authorization === `Bearer ${CODEX_TOKEN}`,
`codex image route used wrong auth: ${req.headers.authorization}`,
);
const parsed = JSON.parse(body) as {
tools?: Array<{ type?: string; model?: string; size?: string }>;
input?: Array<{ content?: Array<{ type?: string; text?: string }> }>;
};
assert(
parsed.tools?.[0]?.type === "image_generation" &&
parsed.tools[0].model === "gpt-image-2" &&
parsed.tools[0].size === "1024x1024",
`codex image tool mismatch: ${body}`,
);
assert(
parsed.input?.[0]?.content?.some(
(entry) =>
entry.type === "input_text" && entry.text === "docker codex oauth image auth",
),
`codex prompt missing: ${body}`,
);
writeCodexSse(res);
return;
}
writeJson(res, 404, { error: `unexpected ${req.method} ${req.url}` });
} catch (error) {
writeJson(res, 500, { error: String(error instanceof Error ? error.message : error) });
}
})();
});
await new Promise<void>((resolve) => {
+14 -12
View File
@@ -205,18 +205,20 @@ export async function runStreaming(
}, options.timeoutMs);
child.on("error", reject);
child.on("close", async (code, signal) => {
if (timer) {
clearTimeout(timer);
}
if (options.logPath) {
await writeFile(options.logPath, log, "utf8");
}
if (timedOut) {
resolve(124);
} else {
resolve(code ?? (signal ? 128 : 1));
}
child.on("close", (code, signal) => {
void (async () => {
if (timer) {
clearTimeout(timer);
}
if (options.logPath) {
await writeFile(options.logPath, log, "utf8");
}
if (timedOut) {
resolve(124);
} else {
resolve(code ?? (signal ? 128 : 1));
}
})();
});
});
}
+5 -3
View File
@@ -654,9 +654,11 @@ class NpmUpdateSmoke {
onOutput(text);
});
child.on("error", reject);
child.on("close", async (code) => {
await writeFile(logPath, log, "utf8");
resolve(code ?? 1);
child.on("close", (code) => {
void (async () => {
await writeFile(logPath, log, "utf8");
resolve(code ?? 1);
})();
});
});
}
+59 -57
View File
@@ -693,69 +693,71 @@ function startLocalOtlpReceiver(disallowedBodyNeedlesLocal: string[] = []) {
const capturedMetrics: CapturedMetric[] = [];
const capturedLogRecords: CapturedLogRecord[] = [];
const capturedBodyText: Partial<Record<OtlpSignal, string[]>> = {};
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
if (req.method !== "POST" || !req.url) {
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
return;
}
const requestPath = req.url;
const signal = OTLP_SIGNAL_PATHS.get(requestPath);
if (!signal) {
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
return;
}
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
void (async () => {
if (req.method !== "POST" || !req.url) {
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
return;
}
const requestPath = req.url;
const signal = OTLP_SIGNAL_PATHS.get(requestPath);
if (!signal) {
res.writeHead(404, { "content-type": "text/plain" });
res.end("not found");
return;
}
const contentEncoding = headerValue(req.headers["content-encoding"]);
let body: Buffer;
try {
const compressedBody = await readRequestBody(req);
body = decodeRequestBody(compressedBody, contentEncoding);
} catch (error) {
const statusCode =
typeof (error as { statusCode?: unknown }).statusCode === "number"
? (error as { statusCode: number }).statusCode
: 400;
const contentEncoding = headerValue(req.headers["content-encoding"]);
let body: Buffer;
try {
const compressedBody = await readRequestBody(req);
body = decodeRequestBody(compressedBody, contentEncoding);
} catch (error) {
const statusCode =
typeof (error as { statusCode?: unknown }).statusCode === "number"
? (error as { statusCode: number }).statusCode
: 400;
capturedRequests.push({
path: requestPath,
signal,
bytes: 0,
contentEncoding,
status: statusCode,
spanCount: 0,
metricCount: 0,
logCount: 0,
});
res.writeHead(statusCode, { "content-type": "text/plain" });
res.end(error instanceof Error ? error.message : String(error));
return;
}
const spans = signal === "traces" ? decodeTraceRequest(body) : [];
const metrics = signal === "metrics" ? decodeMetricRequest(body) : [];
const logRecords = signal === "logs" ? decodeLogRequest(body) : [];
if (spans.length > 0) {
capturedSpans.push(...spans);
}
if (metrics.length > 0) {
capturedMetrics.push(...metrics);
}
if (logRecords.length > 0) {
capturedLogRecords.push(...logRecords);
}
appendCapturedBodyText(capturedBodyText, signal, body, undefined, disallowedBodyNeedlesLocal);
capturedRequests.push({
path: requestPath,
signal,
bytes: 0,
bytes: body.length,
contentEncoding,
status: statusCode,
spanCount: 0,
metricCount: 0,
logCount: 0,
status: 200,
spanCount: spans.length,
metricCount: metrics.length,
logCount: logRecords.length,
});
res.writeHead(statusCode, { "content-type": "text/plain" });
res.end(error instanceof Error ? error.message : String(error));
return;
}
const spans = signal === "traces" ? decodeTraceRequest(body) : [];
const metrics = signal === "metrics" ? decodeMetricRequest(body) : [];
const logRecords = signal === "logs" ? decodeLogRequest(body) : [];
if (spans.length > 0) {
capturedSpans.push(...spans);
}
if (metrics.length > 0) {
capturedMetrics.push(...metrics);
}
if (logRecords.length > 0) {
capturedLogRecords.push(...logRecords);
}
appendCapturedBodyText(capturedBodyText, signal, body, undefined, disallowedBodyNeedlesLocal);
capturedRequests.push({
path: requestPath,
signal,
bytes: body.length,
contentEncoding,
status: 200,
spanCount: spans.length,
metricCount: metrics.length,
logCount: logRecords.length,
});
res.writeHead(200, { "content-type": "application/x-protobuf" });
res.end();
res.writeHead(200, { "content-type": "application/x-protobuf" });
res.end();
})();
});
return {
+24 -22
View File
@@ -202,29 +202,31 @@ export async function runAcpClientInteractive(opts: AcpClientOptions = {}): Prom
console.log('Type a prompt, or "exit" to quit.\n');
const prompt = () => {
rl.question("> ", async (input) => {
const text = input.trim();
if (!text) {
rl.question("> ", (input) => {
void (async () => {
const text = input.trim();
if (!text) {
prompt();
return;
}
if (text === "exit" || text === "quit") {
agent.kill();
rl.close();
process.exit(0);
}
try {
const response = await client.prompt({
sessionId,
prompt: [{ type: "text", text }],
});
console.log(`\n[${response.stopReason}]\n`);
} catch (err) {
console.error(`\n[error] ${String(err)}\n`);
}
prompt();
return;
}
if (text === "exit" || text === "quit") {
agent.kill();
rl.close();
process.exit(0);
}
try {
const response = await client.prompt({
sessionId,
prompt: [{ type: "text", text }],
});
console.log(`\n[${response.stopReason}]\n`);
} catch (err) {
console.error(`\n[error] ${String(err)}\n`);
}
prompt();
})();
});
};
+18 -18
View File
@@ -136,17 +136,17 @@ export function expectNoMockCallFields(
export function createRuntime(): {
runtime: AcpRuntime;
ensureSession: ReturnType<typeof vi.fn>;
runTurn: ReturnType<typeof vi.fn>;
prepareFreshSession: ReturnType<typeof vi.fn>;
cancel: ReturnType<typeof vi.fn>;
close: ReturnType<typeof vi.fn>;
getCapabilities: ReturnType<typeof vi.fn>;
getStatus: ReturnType<typeof vi.fn>;
setMode: ReturnType<typeof vi.fn>;
setConfigOption: ReturnType<typeof vi.fn>;
ensureSession: ReturnType<typeof vi.fn<AcpRuntime["ensureSession"]>>;
runTurn: ReturnType<typeof vi.fn<AcpRuntime["runTurn"]>>;
prepareFreshSession: ReturnType<typeof vi.fn<NonNullable<AcpRuntime["prepareFreshSession"]>>>;
cancel: ReturnType<typeof vi.fn<AcpRuntime["cancel"]>>;
close: ReturnType<typeof vi.fn<AcpRuntime["close"]>>;
getCapabilities: ReturnType<typeof vi.fn<NonNullable<AcpRuntime["getCapabilities"]>>>;
getStatus: ReturnType<typeof vi.fn<NonNullable<AcpRuntime["getStatus"]>>>;
setMode: ReturnType<typeof vi.fn<NonNullable<AcpRuntime["setMode"]>>>;
setConfigOption: ReturnType<typeof vi.fn<NonNullable<AcpRuntime["setConfigOption"]>>>;
} {
const ensureSession = vi.fn(
const ensureSession = vi.fn<AcpRuntime["ensureSession"]>(
async (input: {
sessionKey: string;
agent: string;
@@ -161,23 +161,23 @@ export function createRuntime(): {
runtimeSessionName: `${input.sessionKey}:${input.mode}:runtime`,
}),
);
const runTurn = vi.fn(async function* () {
const runTurn = vi.fn<AcpRuntime["runTurn"]>(async function* () {
yield { type: "done" as const };
});
const prepareFreshSession = vi.fn(async () => {});
const cancel = vi.fn(async () => {});
const close = vi.fn(async () => {});
const getCapabilities = vi.fn(
const prepareFreshSession = vi.fn<NonNullable<AcpRuntime["prepareFreshSession"]>>(async () => {});
const cancel = vi.fn<AcpRuntime["cancel"]>(async () => {});
const close = vi.fn<AcpRuntime["close"]>(async () => {});
const getCapabilities = vi.fn<NonNullable<AcpRuntime["getCapabilities"]>>(
async (): Promise<AcpRuntimeCapabilities> => ({
controls: ["session/set_mode", "session/set_config_option", "session/status"],
}),
);
const getStatus = vi.fn(async () => ({
const getStatus = vi.fn<NonNullable<AcpRuntime["getStatus"]>>(async () => ({
summary: "status=alive",
details: { status: "alive" },
}));
const setMode = vi.fn(async () => {});
const setConfigOption = vi.fn(async () => {});
const setMode = vi.fn<NonNullable<AcpRuntime["setMode"]>>(async () => {});
const setConfigOption = vi.fn<NonNullable<AcpRuntime["setConfigOption"]>>(async () => {});
return {
runtime: {
ensureSession,
@@ -31,12 +31,14 @@ async function startLocalStreamableHttpMcpServer(): Promise<{
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await mcpServer.connect(transport);
const httpServer = http.createServer(async (req, res) => {
if (!req.url?.startsWith("/mcp")) {
res.writeHead(404).end();
return;
}
await transport.handleRequest(req, res);
const httpServer = http.createServer((req, res) => {
void (async () => {
if (!req.url?.startsWith("/mcp")) {
res.writeHead(404).end();
return;
}
await transport.handleRequest(req, res);
})();
});
await new Promise<void>((resolve) => {
@@ -1110,7 +1110,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
it("forwards internal compaction hook messages to the caller", async () => {
const onHookMessages = vi.fn();
triggerInternalHook.mockImplementation(async (event: unknown) => {
triggerInternalHook.mockImplementation((event: unknown) => {
const hookEvent = event as { action?: string; messages?: string[] };
hookEvent.messages?.push(`${hookEvent.action} notice`);
});
@@ -642,6 +642,24 @@ function scheduleDeferredTurnMaintenance(
});
return undefined;
}
const cleanupDeferredTurnMaintenance = async () => {
schedulerAbort.dispose();
const current = activeDeferredTurnMaintenanceRuns.get(sessionKey);
if (current !== state) {
return;
}
const shutdownTriggered = schedulerAbort.abortSignal?.aborted === true;
const rerunParams =
current.rerunRequested && !shutdownTriggered ? current.latestParams : undefined;
const discardedRerunParams =
current.rerunRequested && shutdownTriggered ? current.latestParams : undefined;
activeDeferredTurnMaintenanceRuns.delete(sessionKey);
if (rerunParams) {
await scheduleDeferredTurnMaintenance(rerunParams);
} else if (discardedRerunParams?.disposeContextEngineAfterMaintenance) {
await disposeDeferredMaintenanceContextEngine(discardedRerunParams.contextEngine);
}
};
const trackedPromise = runPromise
.catch((err) => {
params.onScheduleFailure?.(err);
@@ -651,23 +669,9 @@ function scheduleDeferredTurnMaintenance(
error: err,
});
})
.finally(async () => {
schedulerAbort.dispose();
const current = activeDeferredTurnMaintenanceRuns.get(sessionKey);
if (current !== state) {
return;
}
const shutdownTriggered = schedulerAbort.abortSignal?.aborted === true;
const rerunParams =
current.rerunRequested && !shutdownTriggered ? current.latestParams : undefined;
const discardedRerunParams =
current.rerunRequested && shutdownTriggered ? current.latestParams : undefined;
activeDeferredTurnMaintenanceRuns.delete(sessionKey);
if (rerunParams) {
await scheduleDeferredTurnMaintenance(rerunParams);
} else if (discardedRerunParams?.disposeContextEngineAfterMaintenance) {
await disposeDeferredMaintenanceContextEngine(discardedRerunParams.contextEngine);
}
.then(cleanupDeferredTurnMaintenance, async (err) => {
await cleanupDeferredTurnMaintenance();
throw err;
});
const state: DeferredTurnMaintenanceRunState = {
promise: trackedPromise,
@@ -409,10 +409,10 @@ describe("installToolResultContextGuard", () => {
});
type MockedEngine = ContextEngine & {
afterTurn: ReturnType<typeof vi.fn>;
assemble: ReturnType<typeof vi.fn>;
ingest: ReturnType<typeof vi.fn>;
ingestBatch?: ReturnType<typeof vi.fn>;
afterTurn: ReturnType<typeof vi.fn<NonNullable<ContextEngine["afterTurn"]>>>;
assemble: ReturnType<typeof vi.fn<ContextEngine["assemble"]>>;
ingest: ReturnType<typeof vi.fn<ContextEngine["ingest"]>>;
ingestBatch?: ReturnType<typeof vi.fn<NonNullable<ContextEngine["ingestBatch"]>>>;
};
function makeMockEngine(
@@ -429,13 +429,15 @@ function makeMockEngine(
omitIngestBatch?: boolean;
} = {},
): MockedEngine {
const defaultAfterTurn = vi.fn(async () => {});
const defaultAssemble = vi.fn(async (params: Parameters<ContextEngine["assemble"]>[0]) => ({
messages: params.messages,
estimatedTokens: 0,
}));
const defaultIngest = vi.fn(async () => ({ ingested: true }));
const defaultIngestBatch = vi.fn(
const defaultAfterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const defaultAssemble = vi.fn<ContextEngine["assemble"]>(
async (params: Parameters<ContextEngine["assemble"]>[0]) => ({
messages: params.messages,
estimatedTokens: 0,
}),
);
const defaultIngest = vi.fn<ContextEngine["ingest"]>(async () => ({ ingested: true }));
const defaultIngestBatch = vi.fn<NonNullable<ContextEngine["ingestBatch"]>>(
async (params: Parameters<NonNullable<ContextEngine["ingestBatch"]>>[0]) => ({
ingestedCount: params.messages.length,
}),
@@ -443,14 +445,18 @@ function makeMockEngine(
const afterTurn = overrides.omitAfterTurn
? undefined
: overrides.afterTurn
? vi.fn(overrides.afterTurn)
? vi.fn<NonNullable<ContextEngine["afterTurn"]>>(overrides.afterTurn)
: defaultAfterTurn;
const assemble = overrides.assemble ? vi.fn(overrides.assemble) : defaultAssemble;
const ingest = overrides.ingest ? vi.fn(overrides.ingest) : defaultIngest;
const assemble = overrides.assemble
? vi.fn<ContextEngine["assemble"]>(overrides.assemble)
: defaultAssemble;
const ingest = overrides.ingest
? vi.fn<ContextEngine["ingest"]>(overrides.ingest)
: defaultIngest;
const ingestBatch = overrides.omitIngestBatch
? undefined
: overrides.ingestBatch
? vi.fn(overrides.ingestBatch)
? vi.fn<NonNullable<ContextEngine["ingestBatch"]>>(overrides.ingestBatch)
: defaultIngestBatch;
const engine = {
info: {
@@ -21,13 +21,13 @@ type ToolExecutionEndEvent = Extract<AgentEvent, { type: "tool_execution_end" }>
function createTestContext(): {
ctx: ToolHandlerContext;
warn: ReturnType<typeof vi.fn>;
onBlockReplyFlush: ReturnType<typeof vi.fn>;
onBlockReplyFlush: ReturnType<typeof vi.fn<() => Promise<void>>>;
onAgentEvent: ReturnType<typeof vi.fn>;
onExecutionPhase: ReturnType<typeof vi.fn>;
trace: ReturnType<typeof vi.fn>;
isEnabled: ReturnType<typeof vi.fn>;
} {
const onBlockReplyFlush = vi.fn();
const onBlockReplyFlush = vi.fn<() => Promise<void>>();
const onAgentEvent = vi.fn();
const onExecutionPhase = vi.fn();
const warn = vi.fn();
+2 -2
View File
@@ -17,7 +17,7 @@ let augmentCatalogMock: ReturnType<typeof vi.fn>;
let ensureOpenClawModelsJsonMock: ReturnType<typeof vi.fn>;
let currentPluginMetadataSnapshotMock: ReturnType<typeof vi.fn<(...args: unknown[]) => unknown>>;
let loadPluginMetadataSnapshotMock: ReturnType<typeof vi.fn<(...args: unknown[]) => unknown>>;
let readFileMock: ReturnType<typeof vi.fn>;
let readFileMock: ReturnType<typeof vi.fn<(pathname: string) => Promise<string>>>;
vi.mock("./model-suppression.runtime.js", () => ({
shouldSuppressBuiltInModel: (params: { provider?: string; id?: string }) =>
@@ -230,7 +230,7 @@ function requireMockCallParam(
describe("loadModelCatalog", () => {
beforeAll(async () => {
vi.resetModules();
readFileMock = vi.fn();
readFileMock = vi.fn<(pathname: string) => Promise<string>>();
vi.doMock("node:fs/promises", async (importOriginal) => ({
...(await importOriginal<typeof import("node:fs/promises")>()),
readFile: readFileMock,
+70 -68
View File
@@ -339,78 +339,80 @@ export function createGrepToolDefinition(
cleanup();
settle(() => reject(new Error(`Failed to run ripgrep: ${error.message}`)));
});
child.on("close", async (code) => {
cleanup();
if (aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
if (!killedDueToLimit && code !== 0 && code !== 1) {
const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
settle(() => reject(new Error(errorMsg)));
return;
}
if (matchCount === 0) {
child.on("close", (code) => {
void (async () => {
cleanup();
if (aborted) {
settle(() => reject(new Error("Operation aborted")));
return;
}
if (!killedDueToLimit && code !== 0 && code !== 1) {
const errorMsg = stderr.trim() || `ripgrep exited with code ${code}`;
settle(() => reject(new Error(errorMsg)));
return;
}
if (matchCount === 0) {
settle(() =>
resolve({
content: [{ type: "text", text: "No matches found" }],
details: undefined,
}),
);
return;
}
// Format matches after streaming finishes so custom readFile() backends can be async.
for (const match of matches) {
if (contextValue === 0 && match.lineText !== undefined) {
const relativePath = formatPath(match.filePath);
const sanitized = match.lineText
.replace(/\r\n/g, "\n")
.replace(/\r/g, "")
.replace(/\n$/, "");
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
if (wasTruncated) {
linesTruncated = true;
}
outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`);
} else {
const block = await formatBlock(match.filePath, match.lineNumber);
outputLines.push(...block);
}
}
const rawOutput = outputLines.join("\n");
// Apply byte truncation. There is no line limit here because the match limit already capped rows.
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let output = truncation.content;
const details: GrepToolDetails = {};
// Build actionable notices for truncation and match limits.
const notices: string[] = [];
if (matchLimitReached) {
notices.push(
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.matchLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (linesTruncated) {
notices.push(
`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
);
details.linesTruncated = true;
}
if (notices.length > 0) {
output += `\n\n[${notices.join(". ")}]`;
}
settle(() =>
resolve({
content: [{ type: "text", text: "No matches found" }],
details: undefined,
content: [{ type: "text", text: output }],
details: Object.keys(details).length > 0 ? details : undefined,
}),
);
return;
}
// Format matches after streaming finishes so custom readFile() backends can be async.
for (const match of matches) {
if (contextValue === 0 && match.lineText !== undefined) {
const relativePath = formatPath(match.filePath);
const sanitized = match.lineText
.replace(/\r\n/g, "\n")
.replace(/\r/g, "")
.replace(/\n$/, "");
const { text: truncatedText, wasTruncated } = truncateLine(sanitized);
if (wasTruncated) {
linesTruncated = true;
}
outputLines.push(`${relativePath}:${match.lineNumber}: ${truncatedText}`);
} else {
const block = await formatBlock(match.filePath, match.lineNumber);
outputLines.push(...block);
}
}
const rawOutput = outputLines.join("\n");
// Apply byte truncation. There is no line limit here because the match limit already capped rows.
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
let output = truncation.content;
const details: GrepToolDetails = {};
// Build actionable notices for truncation and match limits.
const notices: string[] = [];
if (matchLimitReached) {
notices.push(
`${effectiveLimit} matches limit reached. Use limit=${effectiveLimit * 2} for more, or refine pattern`,
);
details.matchLimitReached = effectiveLimit;
}
if (truncation.truncated) {
notices.push(`${formatSize(DEFAULT_MAX_BYTES)} limit reached`);
details.truncation = truncation;
}
if (linesTruncated) {
notices.push(
`Some lines truncated to ${GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines`,
);
details.linesTruncated = true;
}
if (notices.length > 0) {
output += `\n\n[${notices.join(". ")}]`;
}
settle(() =>
resolve({
content: [{ type: "text", text: output }],
details: Object.keys(details).length > 0 ? details : undefined,
}),
);
})();
});
} catch (err) {
settle(() => reject(err as Error));
+2 -2
View File
@@ -199,8 +199,8 @@ export function createInboundDebouncer<T>(params: InboundDebounceCreateParams<T>
if (buffer.timeout) {
clearTimeout(buffer.timeout);
}
buffer.timeout = setTimeout(async () => {
await flushBuffer(key, buffer);
buffer.timeout = setTimeout(() => {
void flushBuffer(key, buffer);
}, buffer.debounceMs);
buffer.timeout.unref?.();
};
+10 -7
View File
@@ -19,7 +19,10 @@ import type { TypingController } from "./typing.js";
export type { ReplyDispatchKind, ReplyDispatcher } from "./reply-dispatcher.types.js";
type ReplyDispatchErrorHandler = (err: unknown, info: { kind: ReplyDispatchKind }) => void;
type ReplyDispatchErrorHandler = (
err: unknown,
info: { kind: ReplyDispatchKind },
) => Promise<void> | void;
type ReplyDispatchSkipHandler = (
payload: ReplyPayload,
@@ -69,7 +72,7 @@ export type ReplyDispatcherOptions = {
* Called at normalization time, after model selection is complete. */
responsePrefixContextProvider?: () => ResponsePrefixContext;
onHeartbeatStrip?: () => void;
onIdle?: () => void;
onIdle?: () => Promise<void> | void;
onError?: ReplyDispatchErrorHandler;
// AIDEV-NOTE: onSkip lets channels detect silent/empty drops (e.g. Telegram empty-response fallback).
onSkip?: ReplyDispatchSkipHandler;
@@ -81,7 +84,7 @@ export type ReplyDispatcherOptions = {
export type ReplyDispatcherWithTypingOptions = Omit<ReplyDispatcherOptions, "onIdle"> & {
typingCallbacks?: TypingCallbacks;
onReplyStart?: () => Promise<void> | void;
onIdle?: () => void;
onIdle?: () => Promise<void> | void;
onSettled?: () => unknown;
onFreshSettledDelivery?: () => unknown;
/** Called when the typing controller is cleaned up (e.g., on NO_REPLY). */
@@ -206,7 +209,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
})
.catch((err) => {
failedCounts[kind] += 1;
options.onError?.(err, { kind });
void options.onError?.(err, { kind });
})
.finally(() => {
pending -= 1;
@@ -220,7 +223,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
if (pending === 0) {
// Unregister from global tracking when idle.
unregister();
options.onIdle?.();
void options.onIdle?.();
}
});
return true;
@@ -240,7 +243,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
pending -= 1;
if (pending === 0) {
unregister();
options.onIdle?.();
void options.onIdle?.();
}
}
});
@@ -311,7 +314,7 @@ export function createReplyDispatcherWithTyping(
...dispatcherOptions,
onIdle: () => {
typingController?.markDispatchIdle();
resolvedOnIdle?.();
return resolvedOnIdle?.();
},
});
+40 -38
View File
@@ -1739,49 +1739,51 @@ describe("update-cli", () => {
once: EventEmitter["once"];
};
const env = (options as { env?: NodeJS.ProcessEnv }).env;
queueMicrotask(async () => {
const resultPath = env?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH;
if (resultPath) {
await fs.writeFile(
resultPath,
JSON.stringify({
status: "warning",
changed: false,
warnings: [
{
pluginId: "demo",
reason: "Failed to update demo: registry timeout",
message:
'Plugin "demo" could not be processed after the core update: Failed to update demo: registry timeout Run openclaw doctor --fix to attempt automatic repair. Run openclaw plugins inspect demo --runtime --json for details.',
guidance: [
"Run openclaw doctor --fix to attempt automatic repair.",
"Run openclaw plugins inspect demo --runtime --json for details.",
],
},
],
sync: {
queueMicrotask(() => {
void (async () => {
const resultPath = env?.OPENCLAW_UPDATE_POST_CORE_RESULT_PATH;
if (resultPath) {
await fs.writeFile(
resultPath,
JSON.stringify({
status: "warning",
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: {
changed: false,
outcomes: [
warnings: [
{
pluginId: "demo",
status: "error",
message: "Failed to update demo: registry timeout",
reason: "Failed to update demo: registry timeout",
message:
'Plugin "demo" could not be processed after the core update: Failed to update demo: registry timeout Run openclaw doctor --fix to attempt automatic repair. Run openclaw plugins inspect demo --runtime --json for details.',
guidance: [
"Run openclaw doctor --fix to attempt automatic repair.",
"Run openclaw plugins inspect demo --runtime --json for details.",
],
},
],
},
integrityDrifts: [],
}),
"utf-8",
);
}
child.emit("exit", 0, null);
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: {
changed: false,
outcomes: [
{
pluginId: "demo",
status: "error",
message: "Failed to update demo: registry timeout",
},
],
},
integrityDrifts: [],
}),
"utf-8",
);
}
child.emit("exit", 0, null);
})();
});
return child;
});
+25 -23
View File
@@ -907,30 +907,32 @@ async function executeGatewayRequestWithScopes<T>(params: {
deviceIdentity,
minProtocol: opts.minProtocol ?? MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION,
onHelloOk: async (hello) => {
try {
ensureGatewaySupportsRequiredMethods({
requiredMethods: opts.requiredMethods,
methods: hello.features?.methods,
attemptedMethod: opts.method,
});
const activeClient = client;
if (!activeClient) {
throw new Error("gateway client not initialized");
onHelloOk: (hello) => {
void (async () => {
try {
ensureGatewaySupportsRequiredMethods({
requiredMethods: opts.requiredMethods,
methods: hello.features?.methods,
attemptedMethod: opts.method,
});
const activeClient = client;
if (!activeClient) {
throw new Error("gateway client not initialized");
}
primaryRequestStarted = true;
const result = await activeClient.request<T>(opts.method, opts.params, {
expectFinal: opts.expectFinal,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
onAccepted: opts.onAccepted,
});
ignoreClose = true;
stop(undefined, result);
} catch (err) {
ignoreClose = true;
stop(err as Error);
}
primaryRequestStarted = true;
const result = await activeClient.request<T>(opts.method, opts.params, {
expectFinal: opts.expectFinal,
timeoutMs: opts.timeoutMs,
signal: opts.signal,
onAccepted: opts.onAccepted,
});
ignoreClose = true;
stop(undefined, result);
} catch (err) {
ignoreClose = true;
stop(err as Error);
}
})();
},
onClose: (code, reason) => {
if (settled || ignoreClose) {
+25 -21
View File
@@ -181,17 +181,19 @@ async function requestManagedImage(params: {
);
const auth = { mode: "test" } as never;
const server = http.createServer(async (req, res) => {
const handled = await handleManagedOutgoingImageHttpRequest(req, res, {
auth,
trustedProxies: ["127.0.0.1/32"],
allowRealIpFallback: false,
stateDir: params.stateDir,
});
if (!handled) {
res.statusCode = 404;
res.end("unhandled");
}
const server = http.createServer((req, res) => {
void (async () => {
const handled = await handleManagedOutgoingImageHttpRequest(req, res, {
auth,
trustedProxies: ["127.0.0.1/32"],
allowRealIpFallback: false,
stateDir: params.stateDir,
});
if (!handled) {
res.statusCode = 404;
res.end("unhandled");
}
})();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
@@ -207,16 +209,18 @@ async function requestManagedImage(params: {
method: params.method ?? "GET",
headers: params.headers,
},
async (res) => {
const chunks: Buffer[] = [];
for await (const chunk of res) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks),
});
(res) => {
void (async () => {
const chunks: Buffer[] = [];
for await (const chunk of res) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
resolve({
statusCode: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks),
});
})();
},
);
req.on("error", reject);
+69 -67
View File
@@ -386,84 +386,86 @@ export async function probeGateway(opts: {
});
}
},
onHelloOk: async (hello) => {
connectLatencyMs = Date.now() - startedAt;
authMetadataPresent = typeof hello?.auth === "object" && hello.auth !== null;
server = {
version: typeof hello?.server?.version === "string" ? hello.server.version : null,
connId: typeof hello?.server?.connId === "string" ? hello.server.connId : null,
};
auth = resolveProbeAuthSummary({
role: typeof hello?.auth?.role === "string" ? hello.auth.role : null,
scopes: Array.isArray(hello?.auth?.scopes)
? hello.auth.scopes.filter((scope): scope is string => typeof scope === "string")
: [],
authMetadataPresent,
});
if (detailLevel === "none") {
settleProbe({
ok: true,
error: null,
verifiedRead: false,
health: null,
status: null,
presence: null,
configSnapshot: null,
onHelloOk: (hello) => {
void (async () => {
connectLatencyMs = Date.now() - startedAt;
authMetadataPresent = typeof hello?.auth === "object" && hello.auth !== null;
server = {
version: typeof hello?.server?.version === "string" ? hello.server.version : null,
connId: typeof hello?.server?.connId === "string" ? hello.server.connId : null,
};
auth = resolveProbeAuthSummary({
role: typeof hello?.auth?.role === "string" ? hello.auth.role : null,
scopes: Array.isArray(hello?.auth?.scopes)
? hello.auth.scopes.filter((scope): scope is string => typeof scope === "string")
: [],
authMetadataPresent,
});
return;
}
// Once the gateway has accepted the session, a slow follow-up RPC should no longer
// downgrade the probe to "unreachable". Give detail fetching its own budget.
armProbeTimer(() => {
settleProbe({
ok: false,
error: "timeout",
health: null,
status: null,
presence: null,
configSnapshot: null,
});
});
try {
if (detailLevel === "presence") {
const presence = await client.request("system-presence");
if (detailLevel === "none") {
settleProbe({
ok: true,
error: null,
verifiedRead: true,
verifiedRead: false,
health: null,
status: null,
presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null,
presence: null,
configSnapshot: null,
});
return;
}
const [health, status, presence, configSnapshot] = await Promise.all([
client.request("health"),
client.request("status"),
client.request("system-presence"),
client.request("config.get", {}),
]);
settleProbe({
ok: true,
error: null,
verifiedRead: true,
health,
status,
presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null,
configSnapshot,
// Once the gateway has accepted the session, a slow follow-up RPC should no longer
// downgrade the probe to "unreachable". Give detail fetching its own budget.
armProbeTimer(() => {
settleProbe({
ok: false,
error: "timeout",
health: null,
status: null,
presence: null,
configSnapshot: null,
});
});
} catch (err) {
const error = formatErrorMessage(err);
settleProbe({
ok: false,
error,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
}
try {
if (detailLevel === "presence") {
const presence = await client.request("system-presence");
settleProbe({
ok: true,
error: null,
verifiedRead: true,
health: null,
status: null,
presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null,
configSnapshot: null,
});
return;
}
const [health, status, presence, configSnapshot] = await Promise.all([
client.request("health"),
client.request("status"),
client.request("system-presence"),
client.request("config.get", {}),
]);
settleProbe({
ok: true,
error: null,
verifiedRead: true,
health,
status,
presence: Array.isArray(presence) ? (presence as SystemPresence[]) : null,
configSnapshot,
});
} catch (err) {
const error = formatErrorMessage(err);
settleProbe({
ok: false,
error,
health: null,
status: null,
presence: null,
configSnapshot: null,
});
}
})();
},
});
+1 -1
View File
@@ -582,7 +582,7 @@ export function createChannelManager(opts: ChannelManagerOptions): ChannelManage
setRuntime(channelId, id, { accountId: id, lastError: message });
log.error?.(`[${id}] channel exited: ${message}`);
})
.finally(async () => {
.then(async () => {
await cleanupTaskScopedApprovalRuntime("channel cleanup failed");
setRuntime(channelId, id, {
accountId: id,
+52 -50
View File
@@ -205,37 +205,52 @@ function schedule(coalesceMs: number, kind: WakeTimerKind = "normal") {
}
timerDueAt = dueAt;
timerKind = kind;
timer = setTimeout(async () => {
timer = null;
timerDueAt = null;
timerKind = null;
scheduled = false;
const active = handler;
if (!active) {
return;
}
if (running) {
scheduled = true;
schedule(delay, kind);
return;
}
timer = setTimeout(() => {
void (async () => {
timer = null;
timerDueAt = null;
timerKind = null;
scheduled = false;
const active = handler;
if (!active) {
return;
}
if (running) {
scheduled = true;
schedule(delay, kind);
return;
}
const pendingBatch = Array.from(pendingWakes.values());
pendingWakes.clear();
running = true;
try {
for (const pendingWake of pendingBatch) {
const wakeOpts = {
source: pendingWake.source,
intent: pendingWake.intent,
reason: pendingWake.reason ?? undefined,
...(pendingWake.agentId ? { agentId: pendingWake.agentId } : {}),
...(pendingWake.sessionKey ? { sessionKey: pendingWake.sessionKey } : {}),
...(pendingWake.heartbeat ? { heartbeat: pendingWake.heartbeat } : {}),
};
const res = await active(wakeOpts);
if (res.status === "skipped" && isRetryableHeartbeatBusySkipReason(res.reason)) {
// The target runtime is busy; retry this wake target soon.
const pendingBatch = Array.from(pendingWakes.values());
pendingWakes.clear();
running = true;
try {
for (const pendingWake of pendingBatch) {
const wakeOpts = {
source: pendingWake.source,
intent: pendingWake.intent,
reason: pendingWake.reason ?? undefined,
...(pendingWake.agentId ? { agentId: pendingWake.agentId } : {}),
...(pendingWake.sessionKey ? { sessionKey: pendingWake.sessionKey } : {}),
...(pendingWake.heartbeat ? { heartbeat: pendingWake.heartbeat } : {}),
};
const res = await active(wakeOpts);
if (res.status === "skipped" && isRetryableHeartbeatBusySkipReason(res.reason)) {
// The target runtime is busy; retry this wake target soon.
queuePendingWakeReason({
source: pendingWake.source,
intent: pendingWake.intent,
reason: pendingWake.reason ?? "retry",
agentId: pendingWake.agentId,
sessionKey: pendingWake.sessionKey,
heartbeat: pendingWake.heartbeat,
});
schedule(DEFAULT_RETRY_MS, "retry");
}
}
} catch {
// Error is already logged by the heartbeat runner; schedule a retry.
for (const pendingWake of pendingBatch) {
queuePendingWakeReason({
source: pendingWake.source,
intent: pendingWake.intent,
@@ -244,28 +259,15 @@ function schedule(coalesceMs: number, kind: WakeTimerKind = "normal") {
sessionKey: pendingWake.sessionKey,
heartbeat: pendingWake.heartbeat,
});
schedule(DEFAULT_RETRY_MS, "retry");
}
schedule(DEFAULT_RETRY_MS, "retry");
} finally {
running = false;
if (pendingWakes.size > 0 || scheduled) {
schedule(delay, "normal");
}
}
} catch {
// Error is already logged by the heartbeat runner; schedule a retry.
for (const pendingWake of pendingBatch) {
queuePendingWakeReason({
source: pendingWake.source,
intent: pendingWake.intent,
reason: pendingWake.reason ?? "retry",
agentId: pendingWake.agentId,
sessionKey: pendingWake.sessionKey,
heartbeat: pendingWake.heartbeat,
});
}
schedule(DEFAULT_RETRY_MS, "retry");
} finally {
running = false;
if (pendingWakes.size > 0 || scheduled) {
schedule(delay, "normal");
}
}
})();
}, delay);
timer.unref?.();
}
@@ -57,37 +57,39 @@ async function startEmbeddingServer(params?: {
status?: number;
}): Promise<{ baseUrl: string; requests: CapturedRequest[] }> {
const requests: CapturedRequest[] = [];
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
try {
const body = await readJsonBody(req);
const captured: CapturedRequest = {
method: req.method,
url: req.url,
headers: req.headers,
body,
};
requests.push(captured);
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
void (async () => {
try {
const body = await readJsonBody(req);
const captured: CapturedRequest = {
method: req.method,
url: req.url,
headers: req.headers,
body,
};
requests.push(captured);
if (params?.token) {
expect(req.headers.authorization).toBe(`Bearer ${params.token}`);
} else {
expect(req.headers.authorization).toBeUndefined();
if (params?.token) {
expect(req.headers.authorization).toBe(`Bearer ${params.token}`);
} else {
expect(req.headers.authorization).toBeUndefined();
}
res.writeHead(params?.status ?? 200, { "content-type": "application/json" });
res.end(
JSON.stringify(
params?.respond?.(captured) ?? {
object: "list",
data: [{ object: "embedding", embedding: [0.1, 0.2, 0.3], index: 0 }],
model: body.model,
},
),
);
} catch (error) {
res.writeHead(500, { "content-type": "application/json" });
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
res.writeHead(params?.status ?? 200, { "content-type": "application/json" });
res.end(
JSON.stringify(
params?.respond?.(captured) ?? {
object: "list",
data: [{ object: "embedding", embedding: [0.1, 0.2, 0.3], index: 0 }],
model: body.model,
},
),
);
} catch (error) {
res.writeHead(500, { "content-type": "application/json" });
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
}
})();
});
await new Promise<void>((resolve, reject) => {
+102 -100
View File
@@ -121,110 +121,112 @@ export async function startDebugProxyServer(params: {
const recordProxyEvent = createProxyCaptureRecorder({ store, settings: params.settings });
const host = params.host?.trim() || "127.0.0.1";
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
const flowId = randomUUID();
let target: URL;
try {
target = normalizeTargetUrl(req);
} catch (error) {
const message = "Invalid proxy target URL";
recordProxyEvent({
protocol: "http",
direction: "local",
kind: "error",
flowId,
method: req.method,
host: req.headers.host,
path: req.url ?? "",
errorText: error instanceof Error ? error.message : String(error),
});
const responseBody = `${message}\n`;
res.writeHead(400, {
Connection: "close",
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": Buffer.byteLength(responseBody),
});
res.end(responseBody);
return;
}
const targetProtocol = target.protocol === "https:" ? "https" : "http";
const targetPath = `${target.pathname}${target.search}`;
const recordTargetEvent = (
event: Omit<ProxyCaptureEventInput, "protocol" | "flowId" | "method" | "host" | "path">,
) =>
recordProxyEvent({
protocol: targetProtocol,
flowId,
method: req.method,
host: target.host,
path: targetPath,
...event,
});
try {
assertDebugProxyDirectUpstreamAllowed();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
recordTargetEvent({
direction: "local",
kind: "error",
errorText: message,
});
const responseBody = `${message}\n`;
res.writeHead(403, {
Connection: "close",
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": Buffer.byteLength(responseBody),
});
res.end(responseBody);
return;
}
const body = await readBody(req);
recordTargetEvent({
direction: "outbound",
kind: "request",
headersJson: JSON.stringify(req.headers),
dataText: body.subarray(0, 8192).toString("utf8"),
});
const upstream = (target.protocol === "https:" ? httpsRequest : httpRequest)(
target,
{
method: req.method,
headers: req.headers,
},
(upstreamRes) => {
const chunks: Buffer[] = [];
upstreamRes.on("data", (chunk) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(buffer);
res.write(buffer);
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
void (async () => {
const flowId = randomUUID();
let target: URL;
try {
target = normalizeTargetUrl(req);
} catch (error) {
const message = "Invalid proxy target URL";
recordProxyEvent({
protocol: "http",
direction: "local",
kind: "error",
flowId,
method: req.method,
host: req.headers.host,
path: req.url ?? "",
errorText: error instanceof Error ? error.message : String(error),
});
upstreamRes.on("end", () => {
const responseBody = Buffer.concat(chunks);
recordTargetEvent({
direction: "inbound",
kind: "response",
status: upstreamRes.statusCode ?? undefined,
headersJson: JSON.stringify(upstreamRes.headers),
dataText: responseBody.subarray(0, 8192).toString("utf8"),
const responseBody = `${message}\n`;
res.writeHead(400, {
Connection: "close",
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": Buffer.byteLength(responseBody),
});
res.end(responseBody);
return;
}
const targetProtocol = target.protocol === "https:" ? "https" : "http";
const targetPath = `${target.pathname}${target.search}`;
const recordTargetEvent = (
event: Omit<ProxyCaptureEventInput, "protocol" | "flowId" | "method" | "host" | "path">,
) =>
recordProxyEvent({
protocol: targetProtocol,
flowId,
method: req.method,
host: target.host,
path: targetPath,
...event,
});
try {
assertDebugProxyDirectUpstreamAllowed();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
recordTargetEvent({
direction: "local",
kind: "error",
errorText: message,
});
const responseBody = `${message}\n`;
res.writeHead(403, {
Connection: "close",
"Content-Type": "text/plain; charset=utf-8",
"Content-Length": Buffer.byteLength(responseBody),
});
res.end(responseBody);
return;
}
const body = await readBody(req);
recordTargetEvent({
direction: "outbound",
kind: "request",
headersJson: JSON.stringify(req.headers),
dataText: body.subarray(0, 8192).toString("utf8"),
});
const upstream = (target.protocol === "https:" ? httpsRequest : httpRequest)(
target,
{
method: req.method,
headers: req.headers,
},
(upstreamRes) => {
const chunks: Buffer[] = [];
upstreamRes.on("data", (chunk) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(buffer);
res.write(buffer);
});
res.end();
upstreamRes.on("end", () => {
const responseBody = Buffer.concat(chunks);
recordTargetEvent({
direction: "inbound",
kind: "response",
status: upstreamRes.statusCode ?? undefined,
headersJson: JSON.stringify(upstreamRes.headers),
dataText: responseBody.subarray(0, 8192).toString("utf8"),
});
res.end();
});
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
},
);
upstream.on("error", (error) => {
recordTargetEvent({
direction: "local",
kind: "error",
errorText: error.message,
});
res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
},
);
upstream.on("error", (error) => {
recordTargetEvent({
direction: "local",
kind: "error",
errorText: error.message,
res.statusCode = 502;
res.end(error.message);
});
res.statusCode = 502;
res.end(error.message);
});
if (body.byteLength > 0) {
upstream.write(body);
}
upstream.end();
if (body.byteLength > 0) {
upstream.write(body);
}
upstream.end();
})();
});
server.on("connect", (req, clientSocket, head) => {
+19 -17
View File
@@ -97,23 +97,25 @@ function writeResponsesSse(res: ServerResponse, text: string) {
async function startMockModelServer(replyText: string): Promise<MockModelServer> {
const requests: Array<Record<string, unknown>> = [];
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/readyz")) {
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, { data: [{ id: "gpt-5.5", object: "model" }] });
return;
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const raw = await readRequestBody(req);
requests.push(raw ? (JSON.parse(raw) as Record<string, unknown>) : {});
writeResponsesSse(res, replyText);
return;
}
writeJson(res, 404, { error: "not found" });
const server = createServer((req, res) => {
void (async () => {
const url = new URL(req.url ?? "/", "http://127.0.0.1");
if (req.method === "GET" && (url.pathname === "/healthz" || url.pathname === "/readyz")) {
writeJson(res, 200, { ok: true });
return;
}
if (req.method === "GET" && url.pathname === "/v1/models") {
writeJson(res, 200, { data: [{ id: "gpt-5.5", object: "model" }] });
return;
}
if (req.method === "POST" && url.pathname === "/v1/responses") {
const raw = await readRequestBody(req);
requests.push(raw ? (JSON.parse(raw) as Record<string, unknown>) : {});
writeResponsesSse(res, replyText);
return;
}
writeJson(res, 404, { error: "not found" });
})();
});
await new Promise<void>((resolve, reject) => {
+3 -1
View File
@@ -266,7 +266,9 @@ export async function finalizeSetupWizard(
env: process.env,
port: settings.port,
runtime: daemonRuntime,
warn: (message, title) => prompter.note(message, title),
warn: (message, title) => {
void prompter.note(message, title);
},
config: nextConfig,
},
);
+4 -2
View File
@@ -9,10 +9,12 @@ import type { GatewayHelloOk } from "./gateway.ts";
const loadChatHistoryMock = vi.hoisted(() => vi.fn(async () => undefined));
const loadControlUiBootstrapConfigMock = vi.hoisted(() => vi.fn(async () => undefined));
type GatewayRequest = (method: string, payload?: unknown) => Promise<unknown>;
type GatewayClientMock = {
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
request: ReturnType<typeof vi.fn>;
request: ReturnType<typeof vi.fn<GatewayRequest>>;
options: { clientVersion?: string };
emitHello: (hello?: GatewayHelloOk) => void;
emitClose: (info: {
@@ -43,7 +45,7 @@ vi.mock("./gateway.ts", async (importOriginal) => {
class GatewayBrowserClient {
readonly start = vi.fn();
readonly stop = vi.fn();
readonly request = vi.fn(async (method: string) => {
readonly request = vi.fn<GatewayRequest>(async (method: string) => {
if (method === "update.status") {
return { sentinel: null };
}
+1 -1
View File
@@ -134,7 +134,7 @@ export function renderUsageTab(state: AppViewState) {
state.usageSessionLogs = null;
void loadUsage(state);
},
onRefresh: () => loadUsage(state),
onRefresh: () => void loadUsage(state),
onTimeZoneChange: (zone) => {
state.usageTimeZone = zone;
state.usageSelectedDays = [];
+93 -81
View File
@@ -203,6 +203,14 @@ let pendingUpdate: (() => void) | undefined;
const notifyLazyViewChanged = () => pendingUpdate?.();
function runUiTask<Args extends unknown[]>(
task: (...args: Args) => Promise<unknown>,
): (...args: Args) => void {
return (...args) => {
void task(...args);
};
}
function renderSettingsSectionNav(state: AppViewState) {
if (!isSettingsTab(state.tab)) {
return nothing;
@@ -1250,12 +1258,12 @@ export function renderApp(state: AppViewState) {
onRequestUpdate: requestHostUpdate,
onFormPatch: (path: Array<string | number>, value: unknown) =>
updateConfigFormValue(state, path, value),
onReload: () => loadConfig(state, { discardPendingChanges: true }),
onReload: () => void loadConfig(state, { discardPendingChanges: true }),
onReset: () => resetConfigPendingChanges(state),
onSave: () => saveConfig(state),
onApply: () => applyConfig(state),
onUpdate: () => runUpdate(state),
onOpenFile: () => openConfigFile(state),
onSave: () => void saveConfig(state),
onApply: () => void applyConfig(state),
onUpdate: () => void runUpdate(state),
onOpenFile: () => void openConfigFile(state),
version: state.hello?.server?.version ?? "",
theme: state.theme,
themeMode: state.themeMode,
@@ -1501,8 +1509,8 @@ export function renderApp(state: AppViewState) {
requestHostUpdate?.();
},
onResetConfig: () => resetConfigPendingChanges(state),
onSaveConfig: () => saveConfig(state),
onApplyConfig: () => applyConfig(state),
onSaveConfig: () => void saveConfig(state),
onApplyConfig: () => void applyConfig(state),
onAdvancedSettings: () => {
state.configSettingsMode = "advanced";
requestHostUpdate?.();
@@ -1562,20 +1570,20 @@ export function renderApp(state: AppViewState) {
configFormDirty: state.configFormDirty,
nostrProfileFormState: state.nostrProfileFormState,
nostrProfileAccountId: state.nostrProfileAccountId,
onRefresh: (probe) => loadChannels(state, probe),
onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
onWhatsAppWait: () => state.handleWhatsAppWait(),
onWhatsAppLogout: () => state.handleWhatsAppLogout(),
onRefresh: (probe) => void loadChannels(state, probe),
onWhatsAppStart: (force) => void state.handleWhatsAppStart(force),
onWhatsAppWait: () => void state.handleWhatsAppWait(),
onWhatsAppLogout: () => void state.handleWhatsAppLogout(),
onConfigPatch: (path, value) => updateConfigFormValue(state, path, value),
onConfigSave: () => state.handleChannelConfigSave(),
onConfigReload: () => state.handleChannelConfigReload(),
onConfigSave: () => void state.handleChannelConfigSave(),
onConfigReload: () => void state.handleChannelConfigReload(),
onNostrProfileEdit: (accountId, profile) =>
state.handleNostrProfileEdit(accountId, profile),
onNostrProfileCancel: () => state.handleNostrProfileCancel(),
onNostrProfileFieldChange: (field, value) =>
state.handleNostrProfileFieldChange(field, value),
onNostrProfileSave: () => state.handleNostrProfileSave(),
onNostrProfileImport: () => state.handleNostrProfileImport(),
onNostrProfileSave: () => void state.handleNostrProfileSave(),
onNostrProfileImport: () => void state.handleNostrProfileImport(),
onNostrProfileToggleAdvanced: () => state.handleNostrProfileToggleAdvanced(),
}),
);
@@ -1601,9 +1609,9 @@ export function renderApp(state: AppViewState) {
subscribed: state.webPushSubscribed,
loading: state.webPushLoading,
},
onWebPushSubscribe: () => state.handleWebPushSubscribe(),
onWebPushUnsubscribe: () => state.handleWebPushUnsubscribe(),
onWebPushTest: () => state.handleWebPushTest(),
onWebPushSubscribe: () => void state.handleWebPushSubscribe(),
onWebPushUnsubscribe: () => void state.handleWebPushUnsubscribe(),
onWebPushTest: () => void state.handleWebPushTest(),
});
case "appearance":
return renderConfigTab({
@@ -1647,8 +1655,8 @@ export function renderApp(state: AppViewState) {
configSaving: state.configSaving,
configApplying: state.configApplying,
connected: state.connected,
onSaveConfig: () => saveConfig(state),
onApplyConfig: () => applyConfig(state),
onSaveConfig: () => void saveConfig(state),
onApplyConfig: () => void applyConfig(state),
onServerEnabledChange: (name, enabled) => {
updateMcpServerEnabled(state, name, enabled);
requestHostUpdate?.();
@@ -2072,9 +2080,9 @@ export function renderApp(state: AppViewState) {
state.overviewShowGatewayPassword = !state.overviewShowGatewayPassword;
},
onConnect: () => state.connect(),
onRefresh: () => state.loadOverview({ refresh: true }),
onRefresh: () => void state.loadOverview({ refresh: true }),
onNavigate: (tab) => state.setTab(tab as import("./navigation.ts").Tab),
onRefreshLogs: () => state.loadOverview({ refresh: true }),
onRefreshLogs: () => void state.loadOverview({ refresh: true }),
})
: nothing}
${state.tab === "activity"
@@ -2133,7 +2141,7 @@ export function renderApp(state: AppViewState) {
entries: state.presenceEntries,
lastError: state.presenceError,
statusMessage: state.presenceStatus,
onRefresh: () => loadPresence(state),
onRefresh: () => void loadPresence(state),
}),
)
: nothing}
@@ -2232,8 +2240,8 @@ export function renderApp(state: AppViewState) {
state.sessionsPageSize = s;
state.sessionsPage = 0;
},
onRefresh: () => loadSessions(state),
onPatch: (key, patch) => patchSession(state, key, patch),
onRefresh: () => void loadSessions(state),
onPatch: (key, patch) => void patchSession(state, key, patch),
onToggleSelect: (key) => {
const next = new Set(state.sessionsSelectedKeys);
if (next.has(key)) {
@@ -2260,7 +2268,7 @@ export function renderApp(state: AppViewState) {
onDeselectAll: () => {
state.sessionsSelectedKeys = new Set();
},
onDeleteSelected: async () => {
onDeleteSelected: runUiTask(async () => {
const keys = [...state.sessionsSelectedKeys];
const deleted = await deleteSessionsAndRefresh(state, keys);
if (deleted.length > 0) {
@@ -2270,14 +2278,14 @@ export function renderApp(state: AppViewState) {
}
state.sessionsSelectedKeys = next;
}
},
}),
onNavigateToChat: (sessionKey) => {
switchChatSession(state, sessionKey);
state.setTab("chat" as import("./navigation.ts").Tab);
},
onAddToWorkboard:
workboardEnabled && operatorCanWrite
? async (session) => {
? runUiTask(async (session) => {
await captureSessionToWorkboard({
host: state,
client: state.client,
@@ -2285,11 +2293,11 @@ export function renderApp(state: AppViewState) {
requestUpdate: requestHostUpdate,
});
state.setTab("workboard" as import("./navigation.ts").Tab);
}
})
: undefined,
onToggleCheckpointDetails: (sessionKey) =>
toggleSessionCompactionCheckpoints(state, sessionKey),
onBranchFromCheckpoint: async (sessionKey, checkpointId) => {
void toggleSessionCompactionCheckpoints(state, sessionKey),
onBranchFromCheckpoint: runUiTask(async (sessionKey, checkpointId) => {
const nextKey = await branchSessionFromCheckpoint(
state,
sessionKey,
@@ -2299,9 +2307,9 @@ export function renderApp(state: AppViewState) {
switchChatSession(state, nextKey);
state.setTab("chat" as import("./navigation.ts").Tab);
}
},
}),
onRestoreCheckpoint: (sessionKey, checkpointId) =>
restoreSessionFromCheckpoint(state, sessionKey, checkpointId),
void restoreSessionFromCheckpoint(state, sessionKey, checkpointId),
});
})
: nothing}
@@ -2379,7 +2387,7 @@ export function renderApp(state: AppViewState) {
state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch });
state.cronFieldErrors = validateCronForm(state.cronForm);
},
onRefresh: () => state.loadCron(),
onRefresh: () => void state.loadCron(),
onAdd: () => {
void (async () => {
const saved = await addCronJob(state);
@@ -2406,21 +2414,22 @@ export function renderApp(state: AppViewState) {
state.cronFormCollapsed = collapsed;
requestHostUpdate?.();
},
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job, mode) => runCronJob(state, job, mode ?? "force"),
onRemove: (job) => removeCronJob(state, job),
onToggle: (job, enabled) => void toggleCronJob(state, job, enabled),
onRun: (job, mode) => void runCronJob(state, job, mode ?? "force"),
onRemove: (job) => void removeCronJob(state, job),
onQuickCreate: () => {
state.cronQuickCreateOpen = true;
state.cronQuickCreateStep = "what";
state.cronQuickCreateDraft = createDefaultDraft();
requestHostUpdate?.();
},
onLoadRuns: async (jobId) => {
onLoadRuns: runUiTask(async (jobId) => {
updateCronRunsFilter(state, { cronRunsScope: "job" });
await loadCronRuns(state, jobId);
},
onLoadMoreJobs: () => loadCronJobsPage(state, { append: true, tableFilters: true }),
onJobsFiltersChange: async (patch) => {
}),
onLoadMoreJobs: () =>
void loadCronJobsPage(state, { append: true, tableFilters: true }),
onJobsFiltersChange: runUiTask(async (patch) => {
updateCronJobsFilter(state, patch);
const shouldReload =
typeof patch.cronJobsQuery === "string" ||
@@ -2432,8 +2441,8 @@ export function renderApp(state: AppViewState) {
if (shouldReload) {
await loadCronJobsPage(state, { append: false, tableFilters: true });
}
},
onJobsFiltersReset: async () => {
}),
onJobsFiltersReset: runUiTask(async () => {
updateCronJobsFilter(state, {
cronJobsQuery: "",
cronJobsEnabledFilter: "all",
@@ -2443,16 +2452,16 @@ export function renderApp(state: AppViewState) {
cronJobsSortDir: "asc",
});
await loadCronJobsPage(state, { append: false, tableFilters: true });
},
onLoadMoreRuns: () => loadMoreCronRuns(state),
onRunsFiltersChange: async (patch) => {
}),
onLoadMoreRuns: () => void loadMoreCronRuns(state),
onRunsFiltersChange: runUiTask(async (patch) => {
updateCronRunsFilter(state, patch);
if (state.cronRunsScope === "all") {
await loadCronRuns(state, null);
return;
}
await loadCronRuns(state, state.cronRunsJobId);
},
}),
onNavigateToChat: (sessionKey) => {
switchChatSession(state, sessionKey);
state.setTab("chat" as import("./navigation.ts").Tab);
@@ -2519,7 +2528,7 @@ export function renderApp(state: AppViewState) {
runtimeSessionKey: state.sessionKey,
runtimeSessionMatchesSelectedAgent: toolsPanelUsesActiveSession,
modelCatalog: state.chatModelCatalog ?? [],
onRefresh: async () => {
onRefresh: runUiTask(async () => {
await loadAgents(state);
const agentIds = state.agentsList?.agents?.map((entry) => entry.id) ?? [];
if (agentIds.length > 0) {
@@ -2527,7 +2536,7 @@ export function renderApp(state: AppViewState) {
}
loadAgentPanelDataForSelectedAgent(resolveSelectedAgentId());
refreshAgentsPanelSupplementalData(state.agentsPanel);
},
}),
onSelectAgent: (agentId) => {
if (state.agentsSelectedId === agentId) {
return;
@@ -2577,7 +2586,7 @@ export function renderApp(state: AppViewState) {
}
refreshAgentsPanelSupplementalData(panel);
},
onLoadFiles: (agentId) => loadAgentFiles(state, agentId),
onLoadFiles: (agentId) => void loadAgentFiles(state, agentId),
onSelectFile: (name) => {
state.agentFileActive = name;
if (!resolvedAgentId) {
@@ -2636,10 +2645,10 @@ export function renderApp(state: AppViewState) {
removeConfigFormValue(state, [...basePathCandidate, "deny"]);
}
},
onConfigReload: () => loadConfig(state, { discardPendingChanges: true }),
onConfigSave: () => saveAgentsConfig(state),
onChannelsRefresh: () => loadChannels(state, false),
onCronRefresh: () => state.loadCron(),
onConfigReload: () => void loadConfig(state, { discardPendingChanges: true }),
onConfigSave: () => void saveAgentsConfig(state),
onChannelsRefresh: () => void loadChannels(state, false),
onCronRefresh: () => void state.loadCron(),
onCronRunNow: (jobId) => {
const job = state.cronJobs.find((entry) => entry.id === jobId);
if (!job) {
@@ -2805,12 +2814,12 @@ export function renderApp(state: AppViewState) {
clawhubInstallMessage: state.clawhubInstallMessage,
onFilterChange: (next) => (state.skillsFilter = next),
onStatusFilterChange: (next) => (state.skillsStatusFilter = next),
onRefresh: () => loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
onRefresh: () => void loadSkills(state, { clearMessages: true }),
onToggle: (key, enabled) => void updateSkillEnabled(state, key, enabled),
onEdit: (key, value) => updateSkillEdit(state, key, value),
onSaveKey: (key) => saveSkillApiKey(state, key),
onSaveKey: (key) => void saveSkillApiKey(state, key),
onInstall: (skillKey, name, installId) =>
installSkill(state, skillKey, name, installId),
void installSkill(state, skillKey, name, installId),
onDetailOpen: (key) => {
state.skillsDetailKey = key;
state.skillsDetailTab = "overview";
@@ -2827,11 +2836,13 @@ export function renderApp(state: AppViewState) {
if (clawhubSearchTimer) {
clearTimeout(clawhubSearchTimer);
}
clawhubSearchTimer = setTimeout(() => searchClawHub(state, query), 300);
clawhubSearchTimer = setTimeout(() => {
void searchClawHub(state, query);
}, 300);
},
onClawHubDetailOpen: (slug) => loadClawHubDetail(state, slug),
onClawHubDetailOpen: (slug) => void loadClawHubDetail(state, slug),
onClawHubDetailClose: () => closeClawHubDetail(state),
onClawHubInstall: (slug) => installFromClawHub(state, slug),
onClawHubInstall: (slug) => void installFromClawHub(state, slug),
}),
)
: nothing}
@@ -2858,20 +2869,21 @@ export function renderApp(state: AppViewState) {
execApprovalsSelectedAgent: state.execApprovalsSelectedAgent,
execApprovalsTarget: state.execApprovalsTarget,
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
onRefresh: () => loadNodes(state),
onDevicesRefresh: () => loadDevices(state),
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
onRefresh: () => void loadNodes(state),
onDevicesRefresh: () => void loadDevices(state),
onDeviceApprove: (requestId) => void approveDevicePairing(state, requestId),
onDeviceReject: (requestId) => void rejectDevicePairing(state, requestId),
onDeviceRotate: (deviceId, role, scopes) =>
rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) => revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => loadConfig(state, { discardPendingChanges: true }),
void rotateDeviceToken(state, { deviceId, role, scopes }),
onDeviceRevoke: (deviceId, role) =>
void revokeDeviceToken(state, { deviceId, role }),
onLoadConfig: () => void loadConfig(state, { discardPendingChanges: true }),
onLoadExecApprovals: () => {
const target =
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return loadExecApprovals(state, target);
void loadExecApprovals(state, target);
},
onBindDefault: (nodeId) => {
if (nodeId) {
@@ -2888,7 +2900,7 @@ export function renderApp(state: AppViewState) {
removeConfigFormValue(state, basePathLocal);
}
},
onSaveBindings: () => saveConfig(state),
onSaveBindings: () => void saveConfig(state),
onExecApprovalsTargetChange: (kind, nodeId) => {
state.execApprovalsTarget = kind;
state.execApprovalsTargetNodeId = nodeId;
@@ -2908,7 +2920,7 @@ export function renderApp(state: AppViewState) {
state.execApprovalsTarget === "node" && state.execApprovalsTargetNodeId
? { kind: "node" as const, nodeId: state.execApprovalsTargetNodeId }
: { kind: "gateway" as const };
return saveExecApprovals(state, target);
void saveExecApprovals(state, target);
},
}),
)
@@ -2964,7 +2976,7 @@ export function renderApp(state: AppViewState) {
onRefresh: () => {
state.chatSideResult = null;
state.resetToolStream();
return refreshChat(state, { awaitHistory: true, scheduleScroll: false });
void refreshChat(state, { awaitHistory: true, scheduleScroll: false });
},
onToggleFocusMode: () => {
if (state.onboarding) {
@@ -2982,8 +2994,8 @@ export function renderApp(state: AppViewState) {
onHistoryKeydown: (input) => state.handleChatInputHistoryKey(input),
attachments: state.chatAttachments,
onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
onCompact: () => state.handleSendChat("/compact", { restoreDraft: true }),
onSend: () => void state.handleSendChat(),
onCompact: () => void state.handleSendChat("/compact", { restoreDraft: true }),
onOpenSessionCheckpoints: () => {
state.sessionsExpandedCheckpointKey = state.sessionKey;
state.setTab("sessions" as import("./navigation.ts").Tab);
@@ -2992,7 +3004,7 @@ export function renderApp(state: AppViewState) {
...scopedAgentListParamsForSession(state, state.sessionKey),
});
},
onToggleRealtimeTalk: () => state.toggleRealtimeTalk(),
onToggleRealtimeTalk: () => void state.toggleRealtimeTalk(),
onToggleRealtimeTalkOptions: () => {
state.realtimeTalkOptionsOpen = !state.realtimeTalkOptionsOpen;
},
@@ -3006,7 +3018,7 @@ export function renderApp(state: AppViewState) {
state.chatSideResult = null;
},
onNewSession: () => void createChatSession(state),
onClearHistory: async () => {
onClearHistory: runUiTask(async () => {
if (!state.client || !state.connected) {
return;
}
@@ -3037,7 +3049,7 @@ export function renderApp(state: AppViewState) {
state.lastError = String(err);
state.chatError = state.lastError;
}
},
}),
agentsList: state.agentsList,
currentAgentId: resolvedAgentId ?? "main",
fullMessageAgentId: scopedAgentParamsForSession(state, state.sessionKey).agentId,
@@ -3095,8 +3107,8 @@ export function renderApp(state: AppViewState) {
callError: state.debugCallError,
onCallMethodChange: (next) => (state.debugCallMethod = next),
onCallParamsChange: (next) => (state.debugCallParams = next),
onRefresh: () => loadDebug(state),
onCall: () => callDebugMethod(state),
onRefresh: () => void loadDebug(state),
onCall: () => void callDebugMethod(state),
}),
),
)
@@ -3119,7 +3131,7 @@ export function renderApp(state: AppViewState) {
state.logsLevelFilters = { ...state.logsLevelFilters, [level]: enabled };
},
onToggleAutoFollow: (next) => (state.logsAutoFollow = next),
onRefresh: () => loadLogs(state, { reset: true }),
onRefresh: () => void loadLogs(state, { reset: true }),
onExport: (lines, label) => state.exportLogs(lines, label),
onScroll: (event) => state.handleLogsScroll(event),
}),
@@ -3185,7 +3197,7 @@ export function renderApp(state: AppViewState) {
await loadWikiMemoryPalace(state);
})();
},
onOpenConfig: () => openConfigFile(state),
onOpenConfig: () => void openConfigFile(state),
onOpenWikiPage: (lookup: string) => openWikiPage(lookup),
onBackfillDiary: () => {
syncDreamingSelectedAgent();
+5 -3
View File
@@ -2,8 +2,10 @@ import { describe, expect, it, vi } from "vitest";
import { loadAgents, loadToolsCatalog, loadToolsEffective, saveAgentsConfig } from "./agents.ts";
import type { AgentsConfigSaveState, AgentsState } from "./agents.ts";
function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn> } {
const request = vi.fn();
type TestRequest = (method: string, payload?: unknown) => Promise<unknown>;
function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn<TestRequest>> } {
const request = vi.fn<TestRequest>();
const state: AgentsState = {
client: {
request,
@@ -46,7 +48,7 @@ function createState(): { state: AgentsState; request: ReturnType<typeof vi.fn>
function createSaveState(): {
state: AgentsConfigSaveState;
request: ReturnType<typeof vi.fn>;
request: ReturnType<typeof vi.fn<TestRequest>>;
} {
const { state, request } = createState();
return {
+20 -8
View File
@@ -15,8 +15,10 @@ import {
type DreamingState,
} from "./dreaming.ts";
function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn> } {
const request = vi.fn();
type TestRequest = (method: string, payload?: unknown) => Promise<unknown>;
function createState(): { state: DreamingState; request: ReturnType<typeof vi.fn<TestRequest>> } {
const request = vi.fn<TestRequest>();
const state: DreamingState = {
client: {
request,
@@ -61,7 +63,9 @@ function createDeferred<T>() {
return { promise, resolve, reject };
}
function getConfigPatchRawPayload(request: ReturnType<typeof vi.fn>): Record<string, unknown> {
function getConfigPatchRawPayload(
request: ReturnType<typeof vi.fn<TestRequest>>,
): Record<string, unknown> {
const patchCall = request.mock.calls.find((entry) => entry[0] === "config.patch");
if (!patchCall) {
throw new Error("Expected config.patch request");
@@ -71,7 +75,7 @@ function getConfigPatchRawPayload(request: ReturnType<typeof vi.fn>): Record<str
}
function getRequestPayload(
request: ReturnType<typeof vi.fn>,
request: ReturnType<typeof vi.fn<TestRequest>>,
method: string,
): Record<string, unknown> {
const call = request.mock.calls.find((entry) => entry[0] === method);
@@ -246,8 +250,12 @@ describe("dreaming controller", () => {
const { state, request } = createState();
const agentA = createDeferred<unknown>();
const agentB = createDeferred<unknown>();
request.mockImplementation(async (_method: string, payload?: { agentId?: string }) => {
return payload?.agentId === "agent-b" ? agentB.promise : agentA.promise;
request.mockImplementation(async (_method: string, payload?: unknown) => {
const agentId =
typeof payload === "object" && payload !== null && "agentId" in payload
? payload.agentId
: undefined;
return agentId === "agent-b" ? agentB.promise : agentA.promise;
});
state.selectedAgentId = "agent-a";
@@ -967,8 +975,12 @@ describe("dreaming controller", () => {
const { state, request } = createState();
const agentA = createDeferred<unknown>();
const agentB = createDeferred<unknown>();
request.mockImplementation(async (_method: string, payload?: { agentId?: string }) => {
return payload?.agentId === "agent-b" ? agentB.promise : agentA.promise;
request.mockImplementation(async (_method: string, payload?: unknown) => {
const agentId =
typeof payload === "object" && payload !== null && "agentId" in payload
? payload.agentId
: undefined;
return agentId === "agent-b" ? agentB.promise : agentA.promise;
});
state.selectedAgentId = "agent-a";
+9 -4
View File
@@ -11,8 +11,10 @@ import {
type SkillsState,
} from "./skills.ts";
function createState(): { state: SkillsState; request: ReturnType<typeof vi.fn> } {
const request = vi.fn();
type TestRequest = (method: string, payload?: unknown) => Promise<unknown>;
function createState(): { state: SkillsState; request: ReturnType<typeof vi.fn<TestRequest>> } {
const request = vi.fn<TestRequest>();
const state: SkillsState = {
client: {
request,
@@ -53,7 +55,7 @@ function createState(): { state: SkillsState; request: ReturnType<typeof vi.fn>
return { state, request };
}
function createDeferredRequestQueue(request: ReturnType<typeof vi.fn>) {
function createDeferredRequestQueue(request: ReturnType<typeof vi.fn<TestRequest>>) {
const resolvers: Array<(value: unknown) => void> = [];
request.mockImplementation(
() =>
@@ -68,7 +70,10 @@ function createDeferredRequestQueue(request: ReturnType<typeof vi.fn>) {
};
}
function mockSkillMutationRequests(request: ReturnType<typeof vi.fn>, installMessage?: string) {
function mockSkillMutationRequests(
request: ReturnType<typeof vi.fn<TestRequest>>,
installMessage?: string,
) {
request.mockImplementation(async (method: string) => {
if (method === "skills.install" && installMessage) {
return { message: installMessage };