Merge branch 'main' into codex/cortex-openclaw-integration

This commit is contained in:
Marc J Saint-jour 2026-03-13 13:32:24 -04:00 committed by GitHub
commit 0031d4e02b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 215 additions and 243 deletions

View File

@ -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(

View File

@ -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: {

View File

@ -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);

View 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;
}

View File

@ -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,
});

View File

@ -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: {