mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-07 19:51:22 +00:00
feat: ACP thread-bound agents (#23580)
* docs: add ACP thread-bound agents plan doc * docs: expand ACP implementation specification * feat(acp): route ACP sessions through core dispatch and lifecycle cleanup * feat(acp): add /acp commands and Discord spawn gate * ACP: add acpx runtime plugin backend * fix(subagents): defer transient lifecycle errors before announce * Agents: harden ACP sessions_spawn and tighten spawn guidance * Agents: require explicit ACP target for runtime spawns * docs: expand ACP control-plane implementation plan * ACP: harden metadata seeding and spawn guidance * ACP: centralize runtime control-plane manager and fail-closed dispatch * ACP: harden runtime manager and unify spawn helpers * Commands: route ACP sessions through ACP runtime in agent command * ACP: require persisted metadata for runtime spawns * Sessions: preserve ACP metadata when updating entries * Plugins: harden ACP backend registry across loaders * ACPX: make availability probe compatible with adapters * E2E: add manual Discord ACP plain-language smoke script * ACPX: preserve streamed spacing across Discord delivery * Docs: add ACP Discord streaming strategy * ACP: harden Discord stream buffering for thread replies * ACP: reuse shared block reply pipeline for projector * ACP: unify streaming config and adopt coalesceIdleMs * Docs: add temporary ACP production hardening plan * Docs: trim temporary ACP hardening plan goals * Docs: gate ACP thread controls by backend capabilities * ACP: add capability-gated runtime controls and /acp operator commands * Docs: remove temporary ACP hardening plan * ACP: fix spawn target validation and close cache cleanup * ACP: harden runtime dispatch and recovery paths * ACP: split ACP command/runtime internals and centralize policy * ACP: harden runtime lifecycle, validation, and observability * ACP: surface runtime and backend session IDs in thread bindings * docs: add temp plan for binding-service migration * ACP: migrate thread binding flows to SessionBindingService * ACP: address review feedback and preserve prompt wording * ACPX plugin: pin runtime dependency and prefer bundled CLI * Discord: complete binding-service migration cleanup and restore ACP plan * Docs: add standalone ACP agents guide * ACP: route harness intents to thread-bound ACP sessions * ACP: fix spawn thread routing and queue-owner stall * ACP: harden startup reconciliation and command bypass handling * ACP: fix dispatch bypass type narrowing * ACP: align runtime metadata to agentSessionId * ACP: normalize session identifier handling and labels * ACP: mark thread banner session ids provisional until first reply * ACP: stabilize session identity mapping and startup reconciliation * ACP: add resolved session-id notices and cwd in thread intros * Discord: prefix thread meta notices consistently * Discord: unify ACP/thread meta notices with gear prefix * Discord: split thread persona naming from meta formatting * Extensions: bump acpx plugin dependency to 0.1.9 * Agents: gate ACP prompt guidance behind acp.enabled * Docs: remove temp experiment plan docs * Docs: scope streaming plan to holy grail refactor * Docs: refactor ACP agents guide for human-first flow * Docs/Skill: add ACP feature-flag guidance and direct acpx telephone-game flow * Docs/Skill: add OpenCode and Pi to ACP harness lists * Docs/Skill: align ACP harness list with current acpx registry * Dev/Test: move ACP plain-language smoke script and mark as keep * Docs/Skill: reorder ACP harness lists with Pi first * ACP: split control-plane manager into core/types/utils modules * Docs: refresh ACP thread-bound agents plan * ACP: extract dispatch lane and split manager domains * ACP: centralize binding context and remove reverse deps * Infra: unify system message formatting * ACP: centralize error boundaries and session id rendering * ACP: enforce init concurrency cap and strict meta clear * Tests: fix ACP dispatch binding mock typing * Tests: fix Discord thread-binding mock drift and ACP request id * ACP: gate slash bypass and persist cleared overrides * ACPX: await pre-abort cancel before runTurn return * Extension: pin acpx runtime dependency to 0.1.11 * Docs: add pinned acpx install strategy for ACP extension * Extensions/acpx: enforce strict local pinned startup * Extensions/acpx: tighten acp-router install guidance * ACPX: retry runtime test temp-dir cleanup * Extensions/acpx: require proactive ACPX repair for thread spawns * Extensions/acpx: require restart offer after acpx reinstall * extensions/acpx: remove workspace protocol devDependency * extensions/acpx: bump pinned acpx to 0.1.13 * extensions/acpx: sync lockfile after dependency bump * ACPX: make runtime spawn Windows-safe * fix: align doctor-config-flow repair tests with default-account migration (#23580) (thanks @osolmaz)
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
import { ChannelType } from "@buape/carbon";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
registerSessionBindingAdapter,
|
||||
} from "../../infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
preflightDiscordMessage,
|
||||
resolvePreflightMentionRequirement,
|
||||
@@ -7,25 +11,35 @@ import {
|
||||
} from "./message-handler.preflight.js";
|
||||
import {
|
||||
__testing as threadBindingTesting,
|
||||
createNoopThreadBindingManager,
|
||||
createThreadBindingManager,
|
||||
} from "./thread-bindings.js";
|
||||
|
||||
function createThreadBinding(
|
||||
overrides?: Partial<import("./thread-bindings.js").ThreadBindingRecord>,
|
||||
overrides?: Partial<
|
||||
import("../../infra/outbound/session-binding-service.js").SessionBindingRecord
|
||||
>,
|
||||
) {
|
||||
return {
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
targetKind: "subagent",
|
||||
bindingId: "default:thread-1",
|
||||
targetSessionKey: "agent:main:subagent:child-1",
|
||||
agentId: "main",
|
||||
boundBy: "test",
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "thread-1",
|
||||
parentConversationId: "parent-1",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 1,
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
metadata: {
|
||||
agentId: "main",
|
||||
boundBy: "test",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
},
|
||||
...overrides,
|
||||
} satisfies import("./thread-bindings.js").ThreadBindingRecord;
|
||||
} satisfies import("../../infra/outbound/session-binding-service.js").SessionBindingRecord;
|
||||
}
|
||||
|
||||
describe("resolvePreflightMentionRequirement", () => {
|
||||
@@ -58,6 +72,10 @@ describe("resolvePreflightMentionRequirement", () => {
|
||||
});
|
||||
|
||||
describe("preflightDiscordMessage", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
});
|
||||
|
||||
it("bypasses mention gating in bound threads for allowed bot senders", async () => {
|
||||
const threadBinding = createThreadBinding();
|
||||
const threadId = "thread-bot-focus";
|
||||
@@ -99,6 +117,13 @@ describe("preflightDiscordMessage", () => {
|
||||
},
|
||||
} as unknown as import("@buape/carbon").Message;
|
||||
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
listBySession: () => [],
|
||||
resolveByConversation: (ref) => (ref.conversationId === threadId ? threadBinding : null),
|
||||
});
|
||||
|
||||
const result = await preflightDiscordMessage({
|
||||
cfg: {
|
||||
session: {
|
||||
@@ -122,9 +147,7 @@ describe("preflightDiscordMessage", () => {
|
||||
groupDmEnabled: true,
|
||||
ackReactionScope: "direct",
|
||||
groupPolicy: "open",
|
||||
threadBindings: {
|
||||
getByThreadId: (id: string) => (id === threadId ? threadBinding : undefined),
|
||||
} as import("./thread-bindings.js").ThreadBindingManager,
|
||||
threadBindings: createNoopThreadBindingManager("default"),
|
||||
data: {
|
||||
channel_id: threadId,
|
||||
guild_id: "guild-1",
|
||||
@@ -146,6 +169,7 @@ describe("preflightDiscordMessage", () => {
|
||||
|
||||
describe("shouldIgnoreBoundThreadWebhookMessage", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
threadBindingTesting.resetThreadBindingsForTests();
|
||||
});
|
||||
|
||||
@@ -171,7 +195,11 @@ describe("shouldIgnoreBoundThreadWebhookMessage", () => {
|
||||
expect(
|
||||
shouldIgnoreBoundThreadWebhookMessage({
|
||||
webhookId: "wh-1",
|
||||
threadBinding: createThreadBinding({ webhookId: undefined }),
|
||||
threadBinding: createThreadBinding({
|
||||
metadata: {
|
||||
webhookId: undefined,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ import { loadConfig } from "../../config/config.js";
|
||||
import { isDangerousNameMatchingEnabled } from "../../config/dangerous-name-matching.js";
|
||||
import { logVerbose, shouldLogVerbose } from "../../globals.js";
|
||||
import { recordChannelActivity } from "../../infra/channel-activity.js";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type SessionBindingRecord,
|
||||
} from "../../infra/outbound/session-binding-service.js";
|
||||
import { enqueueSystemEvent } from "../../infra/system-events.js";
|
||||
import { logDebug } from "../../logger.js";
|
||||
import { getChildLogger } from "../../logging.js";
|
||||
@@ -57,10 +61,7 @@ import {
|
||||
} from "./message-utils.js";
|
||||
import { resolveDiscordSenderIdentity, resolveDiscordWebhookId } from "./sender-identity.js";
|
||||
import { resolveDiscordSystemEvent } from "./system-events.js";
|
||||
import {
|
||||
isRecentlyUnboundThreadWebhookMessage,
|
||||
type ThreadBindingRecord,
|
||||
} from "./thread-bindings.js";
|
||||
import { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.js";
|
||||
import { resolveDiscordThreadChannel, resolveDiscordThreadParentInfo } from "./threading.js";
|
||||
|
||||
export type {
|
||||
@@ -82,13 +83,16 @@ export function shouldIgnoreBoundThreadWebhookMessage(params: {
|
||||
accountId?: string;
|
||||
threadId?: string;
|
||||
webhookId?: string | null;
|
||||
threadBinding?: ThreadBindingRecord;
|
||||
threadBinding?: SessionBindingRecord;
|
||||
}): boolean {
|
||||
const webhookId = params.webhookId?.trim() || "";
|
||||
if (!webhookId) {
|
||||
return false;
|
||||
}
|
||||
const boundWebhookId = params.threadBinding?.webhookId?.trim() || "";
|
||||
const boundWebhookId =
|
||||
typeof params.threadBinding?.metadata?.webhookId === "string"
|
||||
? params.threadBinding.metadata.webhookId.trim()
|
||||
: "";
|
||||
if (!boundWebhookId) {
|
||||
const threadId = params.threadId?.trim() || "";
|
||||
if (!threadId) {
|
||||
@@ -296,9 +300,15 @@ export async function preflightDiscordMessage(
|
||||
// Pass parent peer for thread binding inheritance
|
||||
parentPeer: earlyThreadParentId ? { kind: "channel", id: earlyThreadParentId } : undefined,
|
||||
});
|
||||
const threadBinding = earlyThreadChannel
|
||||
? params.threadBindings.getByThreadId(messageChannelId)
|
||||
: undefined;
|
||||
let threadBinding: SessionBindingRecord | undefined;
|
||||
if (earlyThreadChannel) {
|
||||
threadBinding =
|
||||
getSessionBindingService().resolveByConversation({
|
||||
channel: "discord",
|
||||
accountId: params.accountId,
|
||||
conversationId: messageChannelId,
|
||||
}) ?? undefined;
|
||||
}
|
||||
if (
|
||||
shouldIgnoreBoundThreadWebhookMessage({
|
||||
accountId: params.accountId,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { ChannelType, Client, User } from "@buape/carbon";
|
||||
import type { HistoryEntry } from "../../auto-reply/reply/history.js";
|
||||
import type { ReplyToMode } from "../../config/config.js";
|
||||
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||
import type { resolveAgentRoute } from "../../routing/resolve-route.js";
|
||||
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
|
||||
import type { DiscordChannelInfo } from "./message-utils.js";
|
||||
import type { DiscordThreadBindingLookup } from "./reply-delivery.js";
|
||||
import type { DiscordSenderIdentity } from "./sender-identity.js";
|
||||
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js";
|
||||
|
||||
export type { DiscordSenderIdentity } from "./sender-identity.js";
|
||||
import type { DiscordThreadChannel } from "./threading.js";
|
||||
@@ -52,7 +53,7 @@ export type DiscordMessagePreflightContext = {
|
||||
wasMentioned: boolean;
|
||||
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
threadBinding?: ThreadBindingRecord;
|
||||
threadBinding?: SessionBindingRecord;
|
||||
boundSessionKey?: string;
|
||||
boundAgentId?: string;
|
||||
|
||||
@@ -83,7 +84,7 @@ export type DiscordMessagePreflightContext = {
|
||||
canDetectMention: boolean;
|
||||
|
||||
historyEntry?: HistoryEntry;
|
||||
threadBindings: ThreadBindingManager;
|
||||
threadBindings: DiscordThreadBindingLookup;
|
||||
discordRestFetch?: typeof fetch;
|
||||
};
|
||||
|
||||
@@ -106,7 +107,7 @@ export type DiscordMessagePreflightParams = {
|
||||
guildEntries?: Record<string, DiscordGuildEntryResolved>;
|
||||
ackReactionScope: DiscordMessagePreflightContext["ackReactionScope"];
|
||||
groupPolicy: DiscordMessagePreflightContext["groupPolicy"];
|
||||
threadBindings: ThreadBindingManager;
|
||||
threadBindings: DiscordThreadBindingLookup;
|
||||
discordRestFetch?: typeof fetch;
|
||||
data: DiscordMessageEvent;
|
||||
client: Client;
|
||||
|
||||
@@ -53,8 +53,12 @@ const dispatchInboundMessage = vi.fn(async (_params?: DispatchInboundParams) =>
|
||||
counts: { final: 0, tool: 0, block: 0 },
|
||||
}));
|
||||
const recordInboundSession = vi.fn(async () => {});
|
||||
const readSessionUpdatedAt = vi.fn(() => undefined);
|
||||
const resolveStorePath = vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json");
|
||||
const configSessionsMocks = vi.hoisted(() => ({
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-discord-process-test-sessions.json"),
|
||||
}));
|
||||
const readSessionUpdatedAt = configSessionsMocks.readSessionUpdatedAt;
|
||||
const resolveStorePath = configSessionsMocks.resolveStorePath;
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
reactMessageDiscord: sendMocks.reactMessageDiscord,
|
||||
@@ -105,8 +109,8 @@ vi.mock("../../channels/session.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../config/sessions.js", () => ({
|
||||
readSessionUpdatedAt,
|
||||
resolveStorePath,
|
||||
readSessionUpdatedAt: configSessionsMocks.readSessionUpdatedAt,
|
||||
resolveStorePath: configSessionsMocks.resolveStorePath,
|
||||
}));
|
||||
|
||||
const { processDiscordMessage } = await import("./message-handler.process.js");
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
createDiscordNativeCommandMock,
|
||||
createNoopThreadBindingManagerMock,
|
||||
createThreadBindingManagerMock,
|
||||
reconcileAcpThreadBindingsOnStartupMock,
|
||||
createdBindingManagers,
|
||||
listNativeCommandSpecsForConfigMock,
|
||||
listSkillCommandsForAgentsMock,
|
||||
@@ -17,6 +18,8 @@ const {
|
||||
resolveDiscordAllowlistConfigMock,
|
||||
resolveNativeCommandsEnabledMock,
|
||||
resolveNativeSkillsEnabledMock,
|
||||
resolveThreadBindingSessionTtlMsMock,
|
||||
resolveThreadBindingsEnabledMock,
|
||||
} = vi.hoisted(() => {
|
||||
const createdBindingManagers: Array<{ stop: ReturnType<typeof vi.fn> }> = [];
|
||||
return {
|
||||
@@ -33,6 +36,11 @@ const {
|
||||
createdBindingManagers.push(manager);
|
||||
return manager;
|
||||
}),
|
||||
reconcileAcpThreadBindingsOnStartupMock: vi.fn(() => ({
|
||||
checked: 0,
|
||||
removed: 0,
|
||||
staleSessionKeys: [],
|
||||
})),
|
||||
createdBindingManagers,
|
||||
listNativeCommandSpecsForConfigMock: vi.fn(() => [{ name: "cmd" }]),
|
||||
listSkillCommandsForAgentsMock: vi.fn(() => []),
|
||||
@@ -55,6 +63,8 @@ const {
|
||||
})),
|
||||
resolveNativeCommandsEnabledMock: vi.fn(() => true),
|
||||
resolveNativeSkillsEnabledMock: vi.fn(() => false),
|
||||
resolveThreadBindingSessionTtlMsMock: vi.fn(() => undefined),
|
||||
resolveThreadBindingsEnabledMock: vi.fn(() => true),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -224,6 +234,9 @@ vi.mock("./rest-fetch.js", () => ({
|
||||
vi.mock("./thread-bindings.js", () => ({
|
||||
createNoopThreadBindingManager: createNoopThreadBindingManagerMock,
|
||||
createThreadBindingManager: createThreadBindingManagerMock,
|
||||
reconcileAcpThreadBindingsOnStartup: reconcileAcpThreadBindingsOnStartupMock,
|
||||
resolveThreadBindingSessionTtlMs: resolveThreadBindingSessionTtlMsMock,
|
||||
resolveThreadBindingsEnabled: resolveThreadBindingsEnabledMock,
|
||||
}));
|
||||
|
||||
describe("monitorDiscordProvider", () => {
|
||||
@@ -252,6 +265,11 @@ describe("monitorDiscordProvider", () => {
|
||||
createDiscordNativeCommandMock.mockClear().mockReturnValue({ name: "mock-command" });
|
||||
createNoopThreadBindingManagerMock.mockClear();
|
||||
createThreadBindingManagerMock.mockClear();
|
||||
reconcileAcpThreadBindingsOnStartupMock.mockClear().mockReturnValue({
|
||||
checked: 0,
|
||||
removed: 0,
|
||||
staleSessionKeys: [],
|
||||
});
|
||||
createdBindingManagers.length = 0;
|
||||
listNativeCommandSpecsForConfigMock.mockClear().mockReturnValue([{ name: "cmd" }]);
|
||||
listSkillCommandsForAgentsMock.mockClear().mockReturnValue([]);
|
||||
@@ -265,6 +283,8 @@ describe("monitorDiscordProvider", () => {
|
||||
});
|
||||
resolveNativeCommandsEnabledMock.mockClear().mockReturnValue(true);
|
||||
resolveNativeSkillsEnabledMock.mockClear().mockReturnValue(false);
|
||||
resolveThreadBindingSessionTtlMsMock.mockClear().mockReturnValue(undefined);
|
||||
resolveThreadBindingsEnabledMock.mockClear().mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("stops thread bindings when startup fails before lifecycle begins", async () => {
|
||||
@@ -296,6 +316,7 @@ describe("monitorDiscordProvider", () => {
|
||||
expect(monitorLifecycleMock).toHaveBeenCalledTimes(1);
|
||||
expect(createdBindingManagers).toHaveLength(1);
|
||||
expect(createdBindingManagers[0]?.stop).toHaveBeenCalledTimes(1);
|
||||
expect(reconcileAcpThreadBindingsOnStartupMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("captures gateway errors emitted before lifecycle wait starts", async () => {
|
||||
|
||||
@@ -71,7 +71,13 @@ import { resolveDiscordPresenceUpdate } from "./presence.js";
|
||||
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
|
||||
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
|
||||
import { resolveDiscordRestFetch } from "./rest-fetch.js";
|
||||
import { createNoopThreadBindingManager, createThreadBindingManager } from "./thread-bindings.js";
|
||||
import {
|
||||
createNoopThreadBindingManager,
|
||||
createThreadBindingManager,
|
||||
resolveThreadBindingSessionTtlMs,
|
||||
resolveThreadBindingsEnabled,
|
||||
reconcileAcpThreadBindingsOnStartup,
|
||||
} from "./thread-bindings.js";
|
||||
import { formatThreadBindingTtlLabel } from "./thread-bindings.messages.js";
|
||||
|
||||
export type MonitorDiscordOpts = {
|
||||
@@ -104,47 +110,6 @@ function summarizeGuilds(entries?: Record<string, unknown>) {
|
||||
return `${sample.join(", ")}${suffix}`;
|
||||
}
|
||||
|
||||
const DEFAULT_THREAD_BINDING_TTL_HOURS = 24;
|
||||
|
||||
function normalizeThreadBindingTtlHours(raw: unknown): number | undefined {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return undefined;
|
||||
}
|
||||
if (raw < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveThreadBindingSessionTtlMs(params: {
|
||||
channelTtlHoursRaw: unknown;
|
||||
sessionTtlHoursRaw: unknown;
|
||||
}): number {
|
||||
const ttlHours =
|
||||
normalizeThreadBindingTtlHours(params.channelTtlHoursRaw) ??
|
||||
normalizeThreadBindingTtlHours(params.sessionTtlHoursRaw) ??
|
||||
DEFAULT_THREAD_BINDING_TTL_HOURS;
|
||||
return Math.floor(ttlHours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function normalizeThreadBindingsEnabled(raw: unknown): boolean | undefined {
|
||||
if (typeof raw !== "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
function resolveThreadBindingsEnabled(params: {
|
||||
channelEnabledRaw: unknown;
|
||||
sessionEnabledRaw: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
normalizeThreadBindingsEnabled(params.channelEnabledRaw) ??
|
||||
normalizeThreadBindingsEnabled(params.sessionEnabledRaw) ??
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function formatThreadBindingSessionTtlLabel(ttlMs: number): string {
|
||||
const label = formatThreadBindingTtlLabel(ttlMs);
|
||||
return label === "disabled" ? "off" : label;
|
||||
@@ -365,6 +330,18 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
||||
sessionTtlMs: threadBindingSessionTtlMs,
|
||||
})
|
||||
: createNoopThreadBindingManager(account.accountId);
|
||||
if (threadBindingsEnabled) {
|
||||
const reconciliation = reconcileAcpThreadBindingsOnStartup({
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
sendFarewell: false,
|
||||
});
|
||||
if (reconciliation.removed > 0) {
|
||||
logVerbose(
|
||||
`discord: removed ${reconciliation.removed}/${reconciliation.checked} stale ACP thread bindings on startup for account ${account.accountId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
let lifecycleStarted = false;
|
||||
let releaseEarlyGatewayErrorGuard = () => {};
|
||||
try {
|
||||
|
||||
@@ -165,6 +165,23 @@ describe("deliverDiscordReply", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves leading whitespace in delivered text chunks", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [{ text: " leading text" }],
|
||||
target: "channel:789",
|
||||
token: "token",
|
||||
runtime,
|
||||
textLimit: 2000,
|
||||
});
|
||||
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledTimes(1);
|
||||
expect(sendMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"channel:789",
|
||||
" leading text",
|
||||
expect.objectContaining({ token: "token" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends bound-session text replies through webhook delivery", async () => {
|
||||
const threadBindings = await createBoundThreadBindings({ label: "codex-refactor" });
|
||||
|
||||
|
||||
@@ -8,7 +8,19 @@ import { convertMarkdownTables } from "../../markdown/tables.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { chunkDiscordTextWithMode } from "../chunk.js";
|
||||
import { sendMessageDiscord, sendVoiceMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
|
||||
import type { ThreadBindingManager, ThreadBindingRecord } from "./thread-bindings.js";
|
||||
|
||||
export type DiscordThreadBindingLookupRecord = {
|
||||
accountId: string;
|
||||
threadId: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
webhookId?: string;
|
||||
webhookToken?: string;
|
||||
};
|
||||
|
||||
export type DiscordThreadBindingLookup = {
|
||||
listBySessionKey: (targetSessionKey: string) => DiscordThreadBindingLookupRecord[];
|
||||
};
|
||||
|
||||
function resolveTargetChannelId(target: string): string | undefined {
|
||||
if (!target.startsWith("channel:")) {
|
||||
@@ -19,10 +31,10 @@ function resolveTargetChannelId(target: string): string | undefined {
|
||||
}
|
||||
|
||||
function resolveBoundThreadBinding(params: {
|
||||
threadBindings?: ThreadBindingManager;
|
||||
threadBindings?: DiscordThreadBindingLookup;
|
||||
sessionKey?: string;
|
||||
target: string;
|
||||
}): ThreadBindingRecord | undefined {
|
||||
}): DiscordThreadBindingLookupRecord | undefined {
|
||||
const sessionKey = params.sessionKey?.trim();
|
||||
if (!params.threadBindings || !sessionKey) {
|
||||
return undefined;
|
||||
@@ -38,7 +50,7 @@ function resolveBoundThreadBinding(params: {
|
||||
return bindings.find((entry) => entry.threadId === targetChannelId);
|
||||
}
|
||||
|
||||
function resolveBindingPersona(binding: ThreadBindingRecord | undefined): {
|
||||
function resolveBindingPersona(binding: DiscordThreadBindingLookupRecord | undefined): {
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
} {
|
||||
@@ -67,14 +79,14 @@ async function sendDiscordChunkWithFallback(params: {
|
||||
accountId?: string;
|
||||
rest?: RequestClient;
|
||||
replyTo?: string;
|
||||
binding?: ThreadBindingRecord;
|
||||
binding?: DiscordThreadBindingLookupRecord;
|
||||
username?: string;
|
||||
avatarUrl?: string;
|
||||
}) {
|
||||
const text = params.text.trim();
|
||||
if (!text) {
|
||||
if (!params.text.trim()) {
|
||||
return;
|
||||
}
|
||||
const text = params.text;
|
||||
const binding = params.binding;
|
||||
if (binding?.webhookId && binding?.webhookToken) {
|
||||
try {
|
||||
@@ -134,7 +146,7 @@ export async function deliverDiscordReply(params: {
|
||||
tableMode?: MarkdownTableMode;
|
||||
chunkMode?: ChunkMode;
|
||||
sessionKey?: string;
|
||||
threadBindings?: ThreadBindingManager;
|
||||
threadBindings?: DiscordThreadBindingLookup;
|
||||
}) {
|
||||
const chunkLimit = Math.min(params.textLimit, 2000);
|
||||
const replyTo = params.replyToId?.trim() || undefined;
|
||||
|
||||
21
src/discord/monitor/thread-bindings.config.ts
Normal file
21
src/discord/monitor/thread-bindings.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {
|
||||
resolveThreadBindingSessionTtlMs,
|
||||
resolveThreadBindingsEnabled,
|
||||
} from "../../channels/thread-bindings-policy.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
|
||||
export { resolveThreadBindingSessionTtlMs, resolveThreadBindingsEnabled };
|
||||
|
||||
export function resolveDiscordThreadBindingSessionTtlMs(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}): number {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const root = params.cfg.channels?.discord?.threadBindings;
|
||||
const account = params.cfg.channels?.discord?.accounts?.[accountId]?.threadBindings;
|
||||
return resolveThreadBindingSessionTtlMs({
|
||||
channelTtlHoursRaw: account?.ttlHours ?? root?.ttlHours,
|
||||
sessionTtlHoursRaw: params.cfg.session?.threadBindings?.ttlHours,
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { logVerbose } from "../../globals.js";
|
||||
import { createDiscordRestClient } from "../client.js";
|
||||
import { sendMessageDiscord, sendWebhookMessageDiscord } from "../send.js";
|
||||
import { createThreadDiscord } from "../send.messages.js";
|
||||
import { summarizeBindingPersona } from "./thread-bindings.messages.js";
|
||||
import { resolveThreadBindingPersonaFromRecord } from "./thread-bindings.persona.js";
|
||||
import {
|
||||
BINDINGS_BY_THREAD_ID,
|
||||
REUSABLE_WEBHOOKS_BY_ACCOUNT_CHANNEL,
|
||||
@@ -138,7 +138,7 @@ export async function maybeSendBindingMessage(params: {
|
||||
webhookToken: record.webhookToken,
|
||||
accountId: record.accountId,
|
||||
threadId: record.threadId,
|
||||
username: summarizeBindingPersona(record),
|
||||
username: resolveThreadBindingPersonaFromRecord(record),
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readAcpSessionEntry } from "../../acp/runtime/session-meta.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { parseDiscordTarget } from "../targets.js";
|
||||
import { resolveChannelIdForBinding } from "./thread-bindings.discord-api.js";
|
||||
@@ -22,6 +24,12 @@ import {
|
||||
} from "./thread-bindings.state.js";
|
||||
import type { ThreadBindingRecord, ThreadBindingTargetKind } from "./thread-bindings.types.js";
|
||||
|
||||
export type AcpThreadBindingReconciliationResult = {
|
||||
checked: number;
|
||||
removed: number;
|
||||
staleSessionKeys: string[];
|
||||
};
|
||||
|
||||
function resolveBindingIdsForTargetSession(params: {
|
||||
targetSessionKey: string;
|
||||
accountId?: string;
|
||||
@@ -212,3 +220,62 @@ export function setThreadBindingTtlBySessionKey(params: {
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
export function reconcileAcpThreadBindingsOnStartup(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
sendFarewell?: boolean;
|
||||
}): AcpThreadBindingReconciliationResult {
|
||||
const manager = getThreadBindingManager(params.accountId);
|
||||
if (!manager) {
|
||||
return {
|
||||
checked: 0,
|
||||
removed: 0,
|
||||
staleSessionKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
const acpBindings = manager.listBindings().filter((binding) => binding.targetKind === "acp");
|
||||
const staleBindings = acpBindings.filter((binding) => {
|
||||
const sessionKey = binding.targetSessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return true;
|
||||
}
|
||||
const session = readAcpSessionEntry({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
// Session store read failures are transient; never auto-unbind on uncertain reads.
|
||||
if (session?.storeReadFailed) {
|
||||
return false;
|
||||
}
|
||||
return !session?.acp;
|
||||
});
|
||||
if (staleBindings.length === 0) {
|
||||
return {
|
||||
checked: acpBindings.length,
|
||||
removed: 0,
|
||||
staleSessionKeys: [],
|
||||
};
|
||||
}
|
||||
|
||||
const staleSessionKeys: string[] = [];
|
||||
let removed = 0;
|
||||
for (const binding of staleBindings) {
|
||||
staleSessionKeys.push(binding.targetSessionKey);
|
||||
const unbound = manager.unbindThread({
|
||||
threadId: binding.threadId,
|
||||
reason: "stale-session",
|
||||
sendFarewell: params.sendFarewell ?? false,
|
||||
});
|
||||
if (unbound) {
|
||||
removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
checked: acpBindings.length,
|
||||
removed,
|
||||
staleSessionKeys: [...new Set(staleSessionKeys)],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -424,6 +424,9 @@ export function createThreadBindingManager(
|
||||
registerSessionBindingAdapter({
|
||||
channel: "discord",
|
||||
accountId,
|
||||
capabilities: {
|
||||
placements: ["current", "child"],
|
||||
},
|
||||
bind: async (input) => {
|
||||
if (input.conversation.channel !== "discord") {
|
||||
return null;
|
||||
@@ -433,6 +436,7 @@ export function createThreadBindingManager(
|
||||
return null;
|
||||
}
|
||||
const conversationId = input.conversation.conversationId.trim();
|
||||
const placement = input.placement === "child" ? "child" : "current";
|
||||
const metadata = input.metadata ?? {};
|
||||
const label =
|
||||
typeof metadata.label === "string" ? metadata.label.trim() || undefined : undefined;
|
||||
@@ -446,10 +450,27 @@ export function createThreadBindingManager(
|
||||
typeof metadata.boundBy === "string" ? metadata.boundBy.trim() || undefined : undefined;
|
||||
const agentId =
|
||||
typeof metadata.agentId === "string" ? metadata.agentId.trim() || undefined : undefined;
|
||||
let threadId: string | undefined;
|
||||
let channelId = input.conversation.parentConversationId?.trim() || undefined;
|
||||
let createThread = false;
|
||||
|
||||
if (placement === "child") {
|
||||
createThread = true;
|
||||
if (!channelId && conversationId) {
|
||||
channelId =
|
||||
(await resolveChannelIdForBinding({
|
||||
accountId,
|
||||
token: resolveCurrentToken(),
|
||||
threadId: conversationId,
|
||||
})) ?? undefined;
|
||||
}
|
||||
} else {
|
||||
threadId = conversationId || undefined;
|
||||
}
|
||||
const bound = await manager.bindTarget({
|
||||
threadId: conversationId || undefined,
|
||||
channelId: input.conversation.parentConversationId?.trim() || undefined,
|
||||
createThread: !conversationId,
|
||||
threadId,
|
||||
channelId,
|
||||
createThread,
|
||||
threadName,
|
||||
targetKind: toThreadBindingTargetKind(input.targetKind),
|
||||
targetSessionKey,
|
||||
|
||||
@@ -1,72 +1,6 @@
|
||||
import { DEFAULT_FAREWELL_TEXT, type ThreadBindingRecord } from "./thread-bindings.types.js";
|
||||
|
||||
function normalizeThreadBindingMessageTtlMs(raw: unknown): number {
|
||||
if (typeof raw !== "number" || !Number.isFinite(raw)) {
|
||||
return 0;
|
||||
}
|
||||
const ttlMs = Math.floor(raw);
|
||||
if (ttlMs < 0) {
|
||||
return 0;
|
||||
}
|
||||
return ttlMs;
|
||||
}
|
||||
|
||||
export function formatThreadBindingTtlLabel(ttlMs: number): string {
|
||||
if (ttlMs <= 0) {
|
||||
return "disabled";
|
||||
}
|
||||
if (ttlMs < 60_000) {
|
||||
return "<1m";
|
||||
}
|
||||
const totalMinutes = Math.floor(ttlMs / 60_000);
|
||||
if (totalMinutes % 60 === 0) {
|
||||
return `${Math.floor(totalMinutes / 60)}h`;
|
||||
}
|
||||
return `${totalMinutes}m`;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingThreadName(params: {
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
}): string {
|
||||
const label = params.label?.trim();
|
||||
const base = label || params.agentId?.trim() || "agent";
|
||||
const raw = `🤖 ${base}`.replace(/\s+/g, " ").trim();
|
||||
return raw.slice(0, 100);
|
||||
}
|
||||
|
||||
export function resolveThreadBindingIntroText(params: {
|
||||
agentId?: string;
|
||||
label?: string;
|
||||
sessionTtlMs?: number;
|
||||
}): string {
|
||||
const label = params.label?.trim();
|
||||
const base = label || params.agentId?.trim() || "agent";
|
||||
const normalized = base.replace(/\s+/g, " ").trim().slice(0, 100) || "agent";
|
||||
const ttlMs = normalizeThreadBindingMessageTtlMs(params.sessionTtlMs);
|
||||
if (ttlMs > 0) {
|
||||
return `🤖 ${normalized} session active (auto-unfocus in ${formatThreadBindingTtlLabel(ttlMs)}). Messages here go directly to this session.`;
|
||||
}
|
||||
return `🤖 ${normalized} session active. Messages here go directly to this session.`;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingFarewellText(params: {
|
||||
reason?: string;
|
||||
farewellText?: string;
|
||||
sessionTtlMs: number;
|
||||
}): string {
|
||||
const custom = params.farewellText?.trim();
|
||||
if (custom) {
|
||||
return custom;
|
||||
}
|
||||
if (params.reason === "ttl-expired") {
|
||||
return `Session ended automatically after ${formatThreadBindingTtlLabel(params.sessionTtlMs)}. Messages here will no longer be routed.`;
|
||||
}
|
||||
return DEFAULT_FAREWELL_TEXT;
|
||||
}
|
||||
|
||||
export function summarizeBindingPersona(record: ThreadBindingRecord): string {
|
||||
const label = record.label?.trim();
|
||||
const base = label || record.agentId;
|
||||
return (`🤖 ${base}`.trim() || "🤖 agent").slice(0, 80);
|
||||
}
|
||||
export {
|
||||
formatThreadBindingTtlLabel,
|
||||
resolveThreadBindingFarewellText,
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "../../channels/thread-bindings-messages.js";
|
||||
|
||||
33
src/discord/monitor/thread-bindings.persona.test.ts
Normal file
33
src/discord/monitor/thread-bindings.persona.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveThreadBindingPersona,
|
||||
resolveThreadBindingPersonaFromRecord,
|
||||
} from "./thread-bindings.persona.js";
|
||||
import type { ThreadBindingRecord } from "./thread-bindings.types.js";
|
||||
|
||||
describe("thread binding persona", () => {
|
||||
it("prefers explicit label and prefixes with gear", () => {
|
||||
expect(resolveThreadBindingPersona({ label: "codex thread", agentId: "codex" })).toBe(
|
||||
"⚙️ codex thread",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to agent id when label is missing", () => {
|
||||
expect(resolveThreadBindingPersona({ agentId: "codex" })).toBe("⚙️ codex");
|
||||
});
|
||||
|
||||
it("builds persona from binding record", () => {
|
||||
const record = {
|
||||
accountId: "default",
|
||||
channelId: "parent-1",
|
||||
threadId: "thread-1",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:codex:acp:session-1",
|
||||
agentId: "codex",
|
||||
boundBy: "system",
|
||||
boundAt: Date.now(),
|
||||
label: "codex-thread",
|
||||
} satisfies ThreadBindingRecord;
|
||||
expect(resolveThreadBindingPersonaFromRecord(record)).toBe("⚙️ codex-thread");
|
||||
});
|
||||
});
|
||||
25
src/discord/monitor/thread-bindings.persona.ts
Normal file
25
src/discord/monitor/thread-bindings.persona.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { SYSTEM_MARK } from "../../infra/system-message.js";
|
||||
import type { ThreadBindingRecord } from "./thread-bindings.types.js";
|
||||
|
||||
const THREAD_BINDING_PERSONA_MAX_CHARS = 80;
|
||||
|
||||
function normalizePersonaLabel(value: string | undefined): string | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.replace(/\s+/g, " ").trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
export function resolveThreadBindingPersona(params: { label?: string; agentId?: string }): string {
|
||||
const base =
|
||||
normalizePersonaLabel(params.label) || normalizePersonaLabel(params.agentId) || "agent";
|
||||
return `${SYSTEM_MARK} ${base}`.slice(0, THREAD_BINDING_PERSONA_MAX_CHARS);
|
||||
}
|
||||
|
||||
export function resolveThreadBindingPersonaFromRecord(record: ThreadBindingRecord): string {
|
||||
return resolveThreadBindingPersona({
|
||||
label: record.label,
|
||||
agentId: record.agentId,
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,16 @@ export {
|
||||
resolveThreadBindingIntroText,
|
||||
resolveThreadBindingThreadName,
|
||||
} from "./thread-bindings.messages.js";
|
||||
export {
|
||||
resolveThreadBindingPersona,
|
||||
resolveThreadBindingPersonaFromRecord,
|
||||
} from "./thread-bindings.persona.js";
|
||||
|
||||
export {
|
||||
resolveDiscordThreadBindingSessionTtlMs,
|
||||
resolveThreadBindingSessionTtlMs,
|
||||
resolveThreadBindingsEnabled,
|
||||
} from "./thread-bindings.config.js";
|
||||
|
||||
export { isRecentlyUnboundThreadWebhookMessage } from "./thread-bindings.state.js";
|
||||
|
||||
@@ -16,10 +26,13 @@ export {
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
listThreadBindingsBySessionKey,
|
||||
listThreadBindingsForAccount,
|
||||
reconcileAcpThreadBindingsOnStartup,
|
||||
setThreadBindingTtlBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
} from "./thread-bindings.lifecycle.js";
|
||||
|
||||
export type { AcpThreadBindingReconciliationResult } from "./thread-bindings.lifecycle.js";
|
||||
|
||||
export {
|
||||
__testing,
|
||||
createNoopThreadBindingManager,
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const sendMessageDiscord = vi.fn(async (_to: string, _text: string, _opts?: unknown) => ({}));
|
||||
@@ -22,6 +23,7 @@ const hoisted = vi.hoisted(() => {
|
||||
},
|
||||
}));
|
||||
const createThreadDiscord = vi.fn(async (..._args: unknown[]) => ({ id: "thread-created" }));
|
||||
const readAcpSessionEntry = vi.fn();
|
||||
return {
|
||||
sendMessageDiscord,
|
||||
sendWebhookMessageDiscord,
|
||||
@@ -29,6 +31,7 @@ const hoisted = vi.hoisted(() => {
|
||||
restPost,
|
||||
createDiscordRestClient,
|
||||
createThreadDiscord,
|
||||
readAcpSessionEntry,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -45,10 +48,15 @@ vi.mock("../send.messages.js", () => ({
|
||||
createThreadDiscord: hoisted.createThreadDiscord,
|
||||
}));
|
||||
|
||||
vi.mock("../../acp/runtime/session-meta.js", () => ({
|
||||
readAcpSessionEntry: hoisted.readAcpSessionEntry,
|
||||
}));
|
||||
|
||||
const {
|
||||
__testing,
|
||||
autoBindSpawnedDiscordSubagent,
|
||||
createThreadBindingManager,
|
||||
reconcileAcpThreadBindingsOnStartup,
|
||||
resolveThreadBindingIntroText,
|
||||
setThreadBindingTtlBySessionKey,
|
||||
unbindThreadBindingsBySessionKey,
|
||||
@@ -63,6 +71,7 @@ describe("thread binding ttl", () => {
|
||||
hoisted.restPost.mockClear();
|
||||
hoisted.createDiscordRestClient.mockClear();
|
||||
hoisted.createThreadDiscord.mockClear();
|
||||
hoisted.readAcpSessionEntry.mockReset().mockReturnValue(null);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -97,6 +106,16 @@ describe("thread binding ttl", () => {
|
||||
expect(intro).toContain("auto-unfocus in 24h");
|
||||
});
|
||||
|
||||
it("includes cwd near the top of intro text", () => {
|
||||
const intro = resolveThreadBindingIntroText({
|
||||
agentId: "codex",
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
sessionCwd: "/home/bob/clawd",
|
||||
sessionDetails: ["session ids: pending (available after the first reply)"],
|
||||
});
|
||||
expect(intro).toContain("\ncwd: /home/bob/clawd\nsession ids: pending");
|
||||
});
|
||||
|
||||
it("auto-unfocuses expired bindings and sends a ttl-expired message", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -479,6 +498,119 @@ describe("thread binding ttl", () => {
|
||||
expect(b.getByThreadId("thread-1")?.targetSessionKey).toBe("agent:main:subagent:b");
|
||||
});
|
||||
|
||||
it("removes stale ACP bindings during startup reconciliation", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-acp-healthy",
|
||||
channelId: "parent-1",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:codex:acp:healthy",
|
||||
agentId: "codex",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-acp-stale",
|
||||
channelId: "parent-1",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:codex:acp:stale",
|
||||
agentId: "codex",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-subagent",
|
||||
channelId: "parent-1",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
|
||||
hoisted.readAcpSessionEntry.mockImplementation((paramsUnknown: unknown) => {
|
||||
const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
|
||||
if (sessionKey === "agent:codex:acp:healthy") {
|
||||
return {
|
||||
sessionKey,
|
||||
storeSessionKey: sessionKey,
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "runtime:healthy",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
sessionKey,
|
||||
storeSessionKey: sessionKey,
|
||||
acp: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const result = reconcileAcpThreadBindingsOnStartup({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result.checked).toBe(2);
|
||||
expect(result.removed).toBe(1);
|
||||
expect(result.staleSessionKeys).toContain("agent:codex:acp:stale");
|
||||
expect(manager.getByThreadId("thread-acp-healthy")).toBeDefined();
|
||||
expect(manager.getByThreadId("thread-acp-stale")).toBeUndefined();
|
||||
expect(manager.getByThreadId("thread-subagent")).toBeDefined();
|
||||
expect(hoisted.sendMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(hoisted.sendWebhookMessageDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps ACP bindings when session store reads fail during startup reconciliation", async () => {
|
||||
const manager = createThreadBindingManager({
|
||||
accountId: "default",
|
||||
persist: false,
|
||||
enableSweeper: false,
|
||||
sessionTtlMs: 24 * 60 * 60 * 1000,
|
||||
});
|
||||
|
||||
await manager.bindTarget({
|
||||
threadId: "thread-acp-uncertain",
|
||||
channelId: "parent-1",
|
||||
targetKind: "acp",
|
||||
targetSessionKey: "agent:codex:acp:uncertain",
|
||||
agentId: "codex",
|
||||
webhookId: "wh-1",
|
||||
webhookToken: "tok-1",
|
||||
});
|
||||
|
||||
hoisted.readAcpSessionEntry.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:uncertain",
|
||||
storeSessionKey: "agent:codex:acp:uncertain",
|
||||
cfg: {} as OpenClawConfig,
|
||||
storePath: "/tmp/mock-sessions.json",
|
||||
storeReadFailed: true,
|
||||
entry: undefined,
|
||||
acp: undefined,
|
||||
});
|
||||
|
||||
const result = reconcileAcpThreadBindingsOnStartup({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(result.checked).toBe(1);
|
||||
expect(result.removed).toBe(0);
|
||||
expect(result.staleSessionKeys).toEqual([]);
|
||||
expect(manager.getByThreadId("thread-acp-uncertain")).toBeDefined();
|
||||
});
|
||||
|
||||
it("persists unbinds even when no manager is active", () => {
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
|
||||
|
||||
Reference in New Issue
Block a user