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:
Onur Solmaz
2026-02-26 11:00:09 +01:00
committed by GitHub
parent a9d9a968ed
commit a7d56e3554
151 changed files with 19005 additions and 324 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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