test(gateway): capture codex bind outbound replies

This commit is contained in:
Vincent Koc
2026-05-06 01:28:56 -07:00
parent 53423a2a7f
commit 5e218b402f

View File

@@ -4,6 +4,7 @@ import os from "node:os";
import path from "node:path";
import { describe, it } from "vitest";
import { isLiveTestEnabled } from "../agents/live-test-helpers.js";
import type { ChannelOutboundContext } from "../channels/plugins/types.public.js";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isTruthyEnvValue } from "../infra/env.js";
@@ -31,7 +32,14 @@ const CODEX_BIND_TIMEOUT_MS = 10 * 60_000;
const CODEX_BIND_REQUEST_TIMEOUT_MS = 180_000;
const DEFAULT_CODEX_BIND_MODEL = "gpt-5.4";
function createSlackCurrentConversationBindingRegistry() {
type CapturedOutboundReply = {
accountId?: string;
text: string;
threadId?: string | number;
to: string;
};
function createSlackCurrentConversationBindingRegistry(outboundReplies: CapturedOutboundReply[]) {
return createTestRegistry([
{
pluginId: "slack",
@@ -54,6 +62,18 @@ function createSlackCurrentConversationBindingRegistry() {
conversationBindings: {
supportsCurrentConversationBinding: true,
},
outbound: {
deliveryMode: "direct",
sendText: async ({ accountId, text, threadId, to }: ChannelOutboundContext) => {
outboundReplies.push({
...(accountId ? { accountId } : {}),
text,
...(threadId != null ? { threadId } : {}),
to,
});
return { channel: "slack", messageId: `slack-${outboundReplies.length}` };
},
},
bindings: {
compileConfiguredBinding: () => null,
matchInboundConversation: () => null,
@@ -104,6 +124,36 @@ function formatAssistantTextPreview(texts: string[], maxChars = 800): string {
return combined.length <= maxChars ? combined : combined.slice(-maxChars);
}
async function waitForOutboundText(params: {
replies: CapturedOutboundReply[];
contains: string;
minReplyCount?: number;
timeoutMs?: number;
}): Promise<{ outboundTexts: string[]; matchedText: string }> {
const timeoutMs = params.timeoutMs ?? 60_000;
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const outboundTexts = params.replies
.map((reply) => reply.text)
.filter((value) => value.trim().length > 0);
const minReplyCount = params.minReplyCount ?? 1;
const matchedText = outboundTexts
.slice(Math.max(0, minReplyCount - 1))
.find((text) => text.includes(params.contains));
if (outboundTexts.length >= minReplyCount && matchedText) {
return { outboundTexts, matchedText };
}
await sleep(500);
}
throw new Error(
`timed out waiting for outbound text containing ${params.contains}: ${formatAssistantTextPreview(
params.replies.map((reply) => reply.text),
)}`,
);
}
function restoreEnvVar(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
@@ -327,6 +377,7 @@ describeLive("gateway live (native Codex conversation binding)", () => {
const conversationId = `user:${slackUserId}`;
const bindModel =
process.env.OPENCLAW_LIVE_CODEX_BIND_MODEL?.trim() || DEFAULT_CODEX_BIND_MODEL;
const outboundReplies: CapturedOutboundReply[] = [];
await fs.mkdir(workspace, { recursive: true });
await fs.writeFile(
@@ -374,7 +425,7 @@ describeLive("gateway live (native Codex conversation binding)", () => {
requestTimeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
clientDisplayName: "vitest-codex-bind-live",
});
const channelRegistry = createSlackCurrentConversationBindingRegistry();
const channelRegistry = createSlackCurrentConversationBindingRegistry(outboundReplies);
pinActivePluginChannelRegistry(channelRegistry);
try {
@@ -394,9 +445,8 @@ describeLive("gateway live (native Codex conversation binding)", () => {
originatingTo: conversationId,
originatingAccountId: accountId,
});
const bindHistory = await waitForAssistantText({
client,
sessionKey,
const bindReply = await waitForOutboundText({
replies: outboundReplies,
contains: "Bound this conversation to Codex thread",
timeoutMs: CODEX_BIND_REQUEST_TIMEOUT_MS,
});
@@ -405,7 +455,7 @@ describeLive("gateway live (native Codex conversation binding)", () => {
accountId,
conversationId,
});
let commandAssistantCount = bindHistory.assistantTexts.length;
let commandReplyCount = bindReply.outboundTexts.length;
const sendCodexCommand = async (message: string, contains: string, timeoutMs = 60_000) => {
await sendChatAndWait({
@@ -417,14 +467,13 @@ describeLive("gateway live (native Codex conversation binding)", () => {
originatingTo: conversationId,
originatingAccountId: accountId,
});
const result = await waitForAssistantText({
client,
sessionKey,
const result = await waitForOutboundText({
replies: outboundReplies,
contains,
minAssistantCount: commandAssistantCount + 1,
minReplyCount: commandReplyCount + 1,
timeoutMs,
});
commandAssistantCount = result.assistantTexts.length;
commandReplyCount = result.outboundTexts.length;
return result;
};
@@ -442,9 +491,9 @@ describeLive("gateway live (native Codex conversation binding)", () => {
await sendCodexCommand("/codex stop", "No active Codex run to stop.");
const bindingStatus = await sendCodexCommand("/codex binding", "- Fast: on");
if (!bindingStatus.matchedAssistantText.includes("- Permissions: default")) {
if (!bindingStatus.matchedText.includes("- Permissions: default")) {
throw new Error(
`binding status did not include default permissions: ${bindingStatus.matchedAssistantText}`,
`binding status did not include default permissions: ${bindingStatus.matchedText}`,
);
}