mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
chore(lint): enable no-misused-promises
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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" &&
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -287,7 +287,9 @@ function startPollingLoop(params: ZaloPollingLoopParams) {
|
||||
}
|
||||
|
||||
if (!isStopped() && !abortSignal.aborted) {
|
||||
setImmediate(poll);
|
||||
setImmediate(() => {
|
||||
void poll();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
})();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
})();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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?.();
|
||||
};
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user