Merge branch 'main' into codex/cortex-openclaw-integration
This commit is contained in:
commit
0031d4e02b
@ -15,6 +15,36 @@ vi.mock("@tloncorp/api", () => ({
|
||||
}));
|
||||
|
||||
describe("uploadImageFromUrl", () => {
|
||||
async function loadUploadMocks() {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
return {
|
||||
mockFetch: vi.mocked(fetchWithSsrFGuard),
|
||||
mockUploadFile: vi.mocked(uploadFile),
|
||||
uploadImageFromUrl,
|
||||
};
|
||||
}
|
||||
|
||||
type UploadMocks = Awaited<ReturnType<typeof loadUploadMocks>>;
|
||||
|
||||
function mockSuccessfulFetch(params: {
|
||||
mockFetch: UploadMocks["mockFetch"];
|
||||
blob: Blob;
|
||||
finalUrl: string;
|
||||
contentType: string;
|
||||
}) {
|
||||
params.mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": params.contentType }),
|
||||
blob: () => Promise.resolve(params.blob),
|
||||
} as unknown as Response,
|
||||
finalUrl: params.finalUrl,
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
@ -24,28 +54,17 @@ describe("uploadImageFromUrl", () => {
|
||||
});
|
||||
|
||||
it("fetches image and calls uploadFile, returns uploaded URL", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a successful response with a blob
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
mockSuccessfulFetch({
|
||||
mockFetch,
|
||||
blob: mockBlob,
|
||||
finalUrl: "https://example.com/image.png",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// Mock uploadFile to return a successful upload
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://memex.tlon.network/uploaded.png");
|
||||
@ -59,10 +78,8 @@ describe("uploadImageFromUrl", () => {
|
||||
});
|
||||
|
||||
it("returns original URL if fetch fails", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
const { mockFetch, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a failed response
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: false,
|
||||
@ -72,35 +89,23 @@ describe("uploadImageFromUrl", () => {
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://example.com/image.png");
|
||||
});
|
||||
|
||||
it("returns original URL if upload fails", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
|
||||
// Mock fetchWithSsrFGuard to return a successful response
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
mockSuccessfulFetch({
|
||||
mockFetch,
|
||||
blob: mockBlob,
|
||||
finalUrl: "https://example.com/image.png",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
// Mock uploadFile to throw an error
|
||||
mockUploadFile.mockRejectedValue(new Error("Upload failed"));
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
const result = await uploadImageFromUrl("https://example.com/image.png");
|
||||
|
||||
expect(result).toBe("https://example.com/image.png");
|
||||
@ -127,26 +132,18 @@ describe("uploadImageFromUrl", () => {
|
||||
});
|
||||
|
||||
it("extracts filename from URL path", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/jpeg" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/jpeg" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
mockSuccessfulFetch({
|
||||
mockFetch,
|
||||
blob: mockBlob,
|
||||
finalUrl: "https://example.com/path/to/my-image.jpg",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
contentType: "image/jpeg",
|
||||
});
|
||||
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.jpg" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
await uploadImageFromUrl("https://example.com/path/to/my-image.jpg");
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(
|
||||
@ -157,26 +154,18 @@ describe("uploadImageFromUrl", () => {
|
||||
});
|
||||
|
||||
it("uses default filename when URL has no path", async () => {
|
||||
const { fetchWithSsrFGuard } = await import("openclaw/plugin-sdk/tlon");
|
||||
const mockFetch = vi.mocked(fetchWithSsrFGuard);
|
||||
|
||||
const { uploadFile } = await import("@tloncorp/api");
|
||||
const mockUploadFile = vi.mocked(uploadFile);
|
||||
const { mockFetch, mockUploadFile, uploadImageFromUrl } = await loadUploadMocks();
|
||||
|
||||
const mockBlob = new Blob(["fake-image"], { type: "image/png" });
|
||||
mockFetch.mockResolvedValue({
|
||||
response: {
|
||||
ok: true,
|
||||
headers: new Headers({ "content-type": "image/png" }),
|
||||
blob: () => Promise.resolve(mockBlob),
|
||||
} as unknown as Response,
|
||||
mockSuccessfulFetch({
|
||||
mockFetch,
|
||||
blob: mockBlob,
|
||||
finalUrl: "https://example.com/",
|
||||
release: vi.fn().mockResolvedValue(undefined),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
mockUploadFile.mockResolvedValue({ url: "https://memex.tlon.network/uploaded.png" });
|
||||
|
||||
const { uploadImageFromUrl } = await import("./upload.js");
|
||||
await uploadImageFromUrl("https://example.com/");
|
||||
|
||||
expect(mockUploadFile).toHaveBeenCalledWith(
|
||||
|
||||
@ -1,68 +1,24 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
createActiveRun,
|
||||
createChatAbortContext,
|
||||
invokeChatAbortHandler,
|
||||
} from "./chat.abort.test-helpers.js";
|
||||
import { chatHandlers } from "./chat.js";
|
||||
|
||||
function createActiveRun(sessionKey: string, owner?: { connId?: string; deviceId?: string }) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
controller: new AbortController(),
|
||||
sessionId: `${sessionKey}-session`,
|
||||
sessionKey,
|
||||
startedAtMs: now,
|
||||
expiresAtMs: now + 30_000,
|
||||
ownerConnId: owner?.connId,
|
||||
ownerDeviceId: owner?.deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
function createContext(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
chatAbortControllers: new Map(),
|
||||
chatRunBuffers: new Map(),
|
||||
chatDeltaSentAt: new Map(),
|
||||
chatAbortedRuns: new Map<string, number>(),
|
||||
removeChatRun: vi
|
||||
.fn()
|
||||
.mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })),
|
||||
agentRunSeq: new Map<string, number>(),
|
||||
broadcast: vi.fn(),
|
||||
nodeSendToSession: vi.fn(),
|
||||
logGateway: { warn: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeChatAbort(params: {
|
||||
context: ReturnType<typeof createContext>;
|
||||
request: { sessionKey: string; runId?: string };
|
||||
client?: {
|
||||
connId?: string;
|
||||
connect?: {
|
||||
device?: { id?: string };
|
||||
scopes?: string[];
|
||||
};
|
||||
} | null;
|
||||
}) {
|
||||
const respond = vi.fn();
|
||||
await chatHandlers["chat.abort"]({
|
||||
params: params.request,
|
||||
respond: respond as never,
|
||||
context: params.context as never,
|
||||
req: {} as never,
|
||||
client: (params.client ?? null) as never,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return respond;
|
||||
}
|
||||
|
||||
describe("chat.abort authorization", () => {
|
||||
it("rejects explicit run aborts from other clients", async () => {
|
||||
const context = createContext({
|
||||
const context = createChatAbortContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
|
||||
[
|
||||
"run-1",
|
||||
createActiveRun("main", { owner: { connId: "conn-owner", deviceId: "dev-owner" } }),
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const respond = await invokeChatAbort({
|
||||
const respond = await invokeChatAbortHandler({
|
||||
handler: chatHandlers["chat.abort"],
|
||||
context,
|
||||
request: { sessionKey: "main", runId: "run-1" },
|
||||
client: {
|
||||
@ -79,13 +35,14 @@ describe("chat.abort authorization", () => {
|
||||
});
|
||||
|
||||
it("allows the same paired device to abort after reconnecting", async () => {
|
||||
const context = createContext({
|
||||
const context = createChatAbortContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-1", createActiveRun("main", { connId: "conn-old", deviceId: "dev-1" })],
|
||||
["run-1", createActiveRun("main", { owner: { connId: "conn-old", deviceId: "dev-1" } })],
|
||||
]),
|
||||
});
|
||||
|
||||
const respond = await invokeChatAbort({
|
||||
const respond = await invokeChatAbortHandler({
|
||||
handler: chatHandlers["chat.abort"],
|
||||
context,
|
||||
request: { sessionKey: "main", runId: "run-1" },
|
||||
client: {
|
||||
@ -101,14 +58,15 @@ describe("chat.abort authorization", () => {
|
||||
});
|
||||
|
||||
it("only aborts session-scoped runs owned by the requester", async () => {
|
||||
const context = createContext({
|
||||
const context = createChatAbortContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-mine", createActiveRun("main", { deviceId: "dev-1" })],
|
||||
["run-other", createActiveRun("main", { deviceId: "dev-2" })],
|
||||
["run-mine", createActiveRun("main", { owner: { deviceId: "dev-1" } })],
|
||||
["run-other", createActiveRun("main", { owner: { deviceId: "dev-2" } })],
|
||||
]),
|
||||
});
|
||||
|
||||
const respond = await invokeChatAbort({
|
||||
const respond = await invokeChatAbortHandler({
|
||||
handler: chatHandlers["chat.abort"],
|
||||
context,
|
||||
request: { sessionKey: "main" },
|
||||
client: {
|
||||
@ -125,13 +83,17 @@ describe("chat.abort authorization", () => {
|
||||
});
|
||||
|
||||
it("allows operator.admin clients to bypass owner checks", async () => {
|
||||
const context = createContext({
|
||||
const context = createChatAbortContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-1", createActiveRun("main", { connId: "conn-owner", deviceId: "dev-owner" })],
|
||||
[
|
||||
"run-1",
|
||||
createActiveRun("main", { owner: { connId: "conn-owner", deviceId: "dev-owner" } }),
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const respond = await invokeChatAbort({
|
||||
const respond = await invokeChatAbortHandler({
|
||||
handler: chatHandlers["chat.abort"],
|
||||
context,
|
||||
request: { sessionKey: "main", runId: "run-1" },
|
||||
client: {
|
||||
|
||||
@ -3,6 +3,11 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createActiveRun,
|
||||
createChatAbortContext,
|
||||
invokeChatAbortHandler,
|
||||
} from "./chat.abort.test-helpers.js";
|
||||
|
||||
type TranscriptLine = {
|
||||
message?: Record<string, unknown>;
|
||||
@ -31,17 +36,6 @@ vi.mock("../session-utils.js", async (importOriginal) => {
|
||||
|
||||
const { chatHandlers } = await import("./chat.js");
|
||||
|
||||
function createActiveRun(sessionKey: string, sessionId: string) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
controller: new AbortController(),
|
||||
sessionId,
|
||||
sessionKey,
|
||||
startedAtMs: now,
|
||||
expiresAtMs: now + 30_000,
|
||||
};
|
||||
}
|
||||
|
||||
async function writeTranscriptHeader(transcriptPath: string, sessionId: string) {
|
||||
const header = {
|
||||
type: "session",
|
||||
@ -81,49 +75,6 @@ async function createTranscriptFixture(prefix: string) {
|
||||
return { transcriptPath, sessionId };
|
||||
}
|
||||
|
||||
function createChatAbortContext(overrides: Record<string, unknown> = {}): {
|
||||
chatAbortControllers: Map<string, ReturnType<typeof createActiveRun>>;
|
||||
chatRunBuffers: Map<string, string>;
|
||||
chatDeltaSentAt: Map<string, number>;
|
||||
chatAbortedRuns: Map<string, number>;
|
||||
removeChatRun: ReturnType<typeof vi.fn>;
|
||||
agentRunSeq: Map<string, number>;
|
||||
broadcast: ReturnType<typeof vi.fn>;
|
||||
nodeSendToSession: ReturnType<typeof vi.fn>;
|
||||
logGateway: { warn: ReturnType<typeof vi.fn> };
|
||||
dedupe?: { get: ReturnType<typeof vi.fn> };
|
||||
} {
|
||||
return {
|
||||
chatAbortControllers: new Map(),
|
||||
chatRunBuffers: new Map(),
|
||||
chatDeltaSentAt: new Map(),
|
||||
chatAbortedRuns: new Map<string, number>(),
|
||||
removeChatRun: vi
|
||||
.fn()
|
||||
.mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })),
|
||||
agentRunSeq: new Map<string, number>(),
|
||||
broadcast: vi.fn(),
|
||||
nodeSendToSession: vi.fn(),
|
||||
logGateway: { warn: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
async function invokeChatAbort(
|
||||
context: ReturnType<typeof createChatAbortContext>,
|
||||
params: { sessionKey: string; runId?: string },
|
||||
respond: ReturnType<typeof vi.fn>,
|
||||
) {
|
||||
await chatHandlers["chat.abort"]({
|
||||
params,
|
||||
respond: respond as never,
|
||||
context: context as never,
|
||||
req: {} as never,
|
||||
client: null,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@ -134,7 +85,7 @@ describe("chat abort transcript persistence", () => {
|
||||
const runId = "idem-abort-run-1";
|
||||
const respond = vi.fn();
|
||||
const context = createChatAbortContext({
|
||||
chatAbortControllers: new Map([[runId, createActiveRun("main", sessionId)]]),
|
||||
chatAbortControllers: new Map([[runId, createActiveRun("main", { sessionId })]]),
|
||||
chatRunBuffers: new Map([[runId, "Partial from run abort"]]),
|
||||
chatDeltaSentAt: new Map([[runId, Date.now()]]),
|
||||
removeChatRun: vi
|
||||
@ -149,17 +100,27 @@ describe("chat abort transcript persistence", () => {
|
||||
logGateway: { warn: vi.fn() },
|
||||
});
|
||||
|
||||
await invokeChatAbort(context, { sessionKey: "main", runId }, respond);
|
||||
await invokeChatAbortHandler({
|
||||
handler: chatHandlers["chat.abort"],
|
||||
context,
|
||||
request: { sessionKey: "main", runId },
|
||||
respond,
|
||||
});
|
||||
|
||||
const [ok1, payload1] = respond.mock.calls.at(-1) ?? [];
|
||||
expect(ok1).toBe(true);
|
||||
expect(payload1).toMatchObject({ aborted: true, runIds: [runId] });
|
||||
|
||||
context.chatAbortControllers.set(runId, createActiveRun("main", sessionId));
|
||||
context.chatAbortControllers.set(runId, createActiveRun("main", { sessionId }));
|
||||
context.chatRunBuffers.set(runId, "Partial from run abort");
|
||||
context.chatDeltaSentAt.set(runId, Date.now());
|
||||
|
||||
await invokeChatAbort(context, { sessionKey: "main", runId }, respond);
|
||||
await invokeChatAbortHandler({
|
||||
handler: chatHandlers["chat.abort"],
|
||||
context,
|
||||
request: { sessionKey: "main", runId },
|
||||
respond,
|
||||
});
|
||||
|
||||
const lines = await readTranscriptLines(transcriptPath);
|
||||
const persisted = lines
|
||||
@ -188,8 +149,8 @@ describe("chat abort transcript persistence", () => {
|
||||
const respond = vi.fn();
|
||||
const context = createChatAbortContext({
|
||||
chatAbortControllers: new Map([
|
||||
["run-a", createActiveRun("main", sessionId)],
|
||||
["run-b", createActiveRun("main", sessionId)],
|
||||
["run-a", createActiveRun("main", { sessionId })],
|
||||
["run-b", createActiveRun("main", { sessionId })],
|
||||
]),
|
||||
chatRunBuffers: new Map([
|
||||
["run-a", "Session abort partial"],
|
||||
@ -201,7 +162,12 @@ describe("chat abort transcript persistence", () => {
|
||||
]),
|
||||
});
|
||||
|
||||
await invokeChatAbort(context, { sessionKey: "main" }, respond);
|
||||
await invokeChatAbortHandler({
|
||||
handler: chatHandlers["chat.abort"],
|
||||
context,
|
||||
request: { sessionKey: "main" },
|
||||
respond,
|
||||
});
|
||||
|
||||
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
|
||||
expect(ok).toBe(true);
|
||||
@ -280,12 +246,17 @@ describe("chat abort transcript persistence", () => {
|
||||
const runId = "idem-abort-run-blank";
|
||||
const respond = vi.fn();
|
||||
const context = createChatAbortContext({
|
||||
chatAbortControllers: new Map([[runId, createActiveRun("main", sessionId)]]),
|
||||
chatAbortControllers: new Map([[runId, createActiveRun("main", { sessionId })]]),
|
||||
chatRunBuffers: new Map([[runId, " \n\t "]]),
|
||||
chatDeltaSentAt: new Map([[runId, Date.now()]]),
|
||||
});
|
||||
|
||||
await invokeChatAbort(context, { sessionKey: "main", runId }, respond);
|
||||
await invokeChatAbortHandler({
|
||||
handler: chatHandlers["chat.abort"],
|
||||
context,
|
||||
request: { sessionKey: "main", runId },
|
||||
respond,
|
||||
});
|
||||
|
||||
const [ok, payload] = respond.mock.calls.at(-1) ?? [];
|
||||
expect(ok).toBe(true);
|
||||
|
||||
69
src/gateway/server-methods/chat.abort.test-helpers.ts
Normal file
69
src/gateway/server-methods/chat.abort.test-helpers.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { vi } from "vitest";
|
||||
|
||||
export function createActiveRun(
|
||||
sessionKey: string,
|
||||
params: {
|
||||
sessionId?: string;
|
||||
owner?: { connId?: string; deviceId?: string };
|
||||
} = {},
|
||||
) {
|
||||
const now = Date.now();
|
||||
return {
|
||||
controller: new AbortController(),
|
||||
sessionId: params.sessionId ?? `${sessionKey}-session`,
|
||||
sessionKey,
|
||||
startedAtMs: now,
|
||||
expiresAtMs: now + 30_000,
|
||||
ownerConnId: params.owner?.connId,
|
||||
ownerDeviceId: params.owner?.deviceId,
|
||||
};
|
||||
}
|
||||
|
||||
export function createChatAbortContext(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
chatAbortControllers: new Map(),
|
||||
chatRunBuffers: new Map(),
|
||||
chatDeltaSentAt: new Map(),
|
||||
chatAbortedRuns: new Map<string, number>(),
|
||||
removeChatRun: vi
|
||||
.fn()
|
||||
.mockImplementation((run: string) => ({ sessionKey: "main", clientRunId: run })),
|
||||
agentRunSeq: new Map<string, number>(),
|
||||
broadcast: vi.fn(),
|
||||
nodeSendToSession: vi.fn(),
|
||||
logGateway: { warn: vi.fn() },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export async function invokeChatAbortHandler(params: {
|
||||
handler: (args: {
|
||||
params: { sessionKey: string; runId?: string };
|
||||
respond: never;
|
||||
context: never;
|
||||
req: never;
|
||||
client: never;
|
||||
isWebchatConnect: () => boolean;
|
||||
}) => Promise<void>;
|
||||
context: ReturnType<typeof createChatAbortContext>;
|
||||
request: { sessionKey: string; runId?: string };
|
||||
client?: {
|
||||
connId?: string;
|
||||
connect?: {
|
||||
device?: { id?: string };
|
||||
scopes?: string[];
|
||||
};
|
||||
} | null;
|
||||
respond?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const respond = params.respond ?? vi.fn();
|
||||
await params.handler({
|
||||
params: params.request,
|
||||
respond: respond as never,
|
||||
context: params.context as never,
|
||||
req: {} as never,
|
||||
client: (params.client ?? null) as never,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return respond;
|
||||
}
|
||||
@ -195,24 +195,28 @@ async function invokeNode(params: {
|
||||
return respond;
|
||||
}
|
||||
|
||||
function createNodeClient(nodeId: string) {
|
||||
return {
|
||||
connect: {
|
||||
role: "node" as const,
|
||||
client: {
|
||||
id: nodeId,
|
||||
mode: "node" as const,
|
||||
name: "ios-test",
|
||||
platform: "iOS 26.4.0",
|
||||
version: "test",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pullPending(nodeId: string) {
|
||||
const respond = vi.fn();
|
||||
await nodeHandlers["node.pending.pull"]({
|
||||
params: {},
|
||||
respond: respond as never,
|
||||
context: {} as never,
|
||||
client: {
|
||||
connect: {
|
||||
role: "node",
|
||||
client: {
|
||||
id: nodeId,
|
||||
mode: "node",
|
||||
name: "ios-test",
|
||||
platform: "iOS 26.4.0",
|
||||
version: "test",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
client: createNodeClient(nodeId) as never,
|
||||
req: { type: "req", id: "req-node-pending", method: "node.pending.pull" },
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
@ -225,18 +229,7 @@ async function ackPending(nodeId: string, ids: string[]) {
|
||||
params: { ids },
|
||||
respond: respond as never,
|
||||
context: {} as never,
|
||||
client: {
|
||||
connect: {
|
||||
role: "node",
|
||||
client: {
|
||||
id: nodeId,
|
||||
mode: "node",
|
||||
name: "ios-test",
|
||||
platform: "iOS 26.4.0",
|
||||
version: "test",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
client: createNodeClient(nodeId) as never,
|
||||
req: { type: "req", id: "req-node-pending-ack", method: "node.pending.ack" },
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
|
||||
@ -34,6 +34,18 @@ vi.mock("../../config/sessions.js", () => ({
|
||||
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
|
||||
|
||||
describe("executeSendAction", () => {
|
||||
function pluginActionResult(messageId: string) {
|
||||
return {
|
||||
ok: true,
|
||||
value: { messageId },
|
||||
continuePrompt: "",
|
||||
output: "",
|
||||
sessionId: "s1",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.dispatchChannelMessageAction.mockClear();
|
||||
mocks.sendMessage.mockClear();
|
||||
@ -75,15 +87,7 @@ describe("executeSendAction", () => {
|
||||
});
|
||||
|
||||
it("uses plugin poll action when available", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { messageId: "poll-plugin" },
|
||||
continuePrompt: "",
|
||||
output: "",
|
||||
sessionId: "s1",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
});
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("poll-plugin"));
|
||||
|
||||
const result = await executePollAction({
|
||||
ctx: {
|
||||
@ -103,15 +107,7 @@ describe("executeSendAction", () => {
|
||||
});
|
||||
|
||||
it("passes agent-scoped media local roots to plugin dispatch", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { messageId: "msg-plugin" },
|
||||
continuePrompt: "",
|
||||
output: "",
|
||||
sessionId: "s1",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
});
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin"));
|
||||
|
||||
await executeSendAction({
|
||||
ctx: {
|
||||
@ -134,15 +130,7 @@ describe("executeSendAction", () => {
|
||||
});
|
||||
|
||||
it("passes mirror idempotency keys through plugin-handled sends", async () => {
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValue({
|
||||
ok: true,
|
||||
value: { messageId: "msg-plugin" },
|
||||
continuePrompt: "",
|
||||
output: "",
|
||||
sessionId: "s1",
|
||||
model: "gpt-5.2",
|
||||
usage: {},
|
||||
});
|
||||
mocks.dispatchChannelMessageAction.mockResolvedValue(pluginActionResult("msg-plugin"));
|
||||
|
||||
await executeSendAction({
|
||||
ctx: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user