mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-20 14:30:57 +00:00
ACP: harden startup and move configured routing behind plugin seams (#48197)
* ACPX: keep plugin-local runtime installs out of dist * Gateway: harden ACP startup and service PATH * ACP: reinitialize error-state configured bindings * ACP: classify pre-turn runtime failures as session init failures * Plugins: move configured ACP routing behind channel seams * Telegram tests: align startup probe assertions after rebase * Discord: harden ACP configured binding recovery * ACP: recover Discord bindings after stale runtime exits * ACPX: replace dead sessions during ensure * Discord: harden ACP binding recovery * Discord: fix review follow-ups * ACP bindings: load channel snapshots across workspaces * ACP bindings: cache snapshot channel plugin resolution * Experiments: add ACP pluginification holy grail plan * Experiments: rename ACP pluginification plan doc * Experiments: drop old ACP pluginification doc path * ACP: move configured bindings behind plugin services * Experiments: update bindings capability architecture plan * Bindings: isolate configured binding routing and targets * Discord tests: fix runtime env helper path * Tests: fix channel binding CI regressions * Tests: normalize ACP workspace assertion on Windows * Bindings: isolate configured binding registry * Bindings: finish configured binding cleanup * Bindings: finish generic cleanup * Bindings: align runtime approval callbacks * ACP: delete residual bindings barrel * Bindings: restore legacy compatibility * Revert "Bindings: restore legacy compatibility" This reverts commit ac2ed68fa2426ecc874d68278c71c71ad363fcfe. * Tests: drop ACP route legacy helper names * Discord/ACP: fix binding regressions --------- Co-authored-by: Onur <2453968+osolmaz@users.noreply.github.com>
This commit is contained in:
@@ -603,137 +603,164 @@ export class AcpSessionManager {
|
||||
}
|
||||
await this.evictIdleRuntimeHandles({ cfg: input.cfg });
|
||||
await this.withSessionActor(sessionKey, async () => {
|
||||
const resolution = this.resolveSession({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
|
||||
const {
|
||||
runtime,
|
||||
handle: ensuredHandle,
|
||||
meta: ensuredMeta,
|
||||
} = await this.ensureRuntimeHandle({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
let handle = ensuredHandle;
|
||||
const meta = ensuredMeta;
|
||||
await this.applyRuntimeControls({
|
||||
sessionKey,
|
||||
runtime,
|
||||
handle,
|
||||
meta,
|
||||
});
|
||||
const turnStartedAt = Date.now();
|
||||
const actorKey = normalizeActorKey(sessionKey);
|
||||
|
||||
await this.setSessionState({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
state: "running",
|
||||
clearLastError: true,
|
||||
});
|
||||
|
||||
const internalAbortController = new AbortController();
|
||||
const onCallerAbort = () => {
|
||||
internalAbortController.abort();
|
||||
};
|
||||
if (input.signal?.aborted) {
|
||||
internalAbortController.abort();
|
||||
} else if (input.signal) {
|
||||
input.signal.addEventListener("abort", onCallerAbort, { once: true });
|
||||
}
|
||||
|
||||
const activeTurn: ActiveTurnState = {
|
||||
runtime,
|
||||
handle,
|
||||
abortController: internalAbortController,
|
||||
};
|
||||
this.activeTurnBySession.set(actorKey, activeTurn);
|
||||
|
||||
let streamError: AcpRuntimeError | null = null;
|
||||
try {
|
||||
const combinedSignal =
|
||||
input.signal && typeof AbortSignal.any === "function"
|
||||
? AbortSignal.any([input.signal, internalAbortController.signal])
|
||||
: internalAbortController.signal;
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
requestId: input.requestId,
|
||||
signal: combinedSignal,
|
||||
})) {
|
||||
if (event.type === "error") {
|
||||
streamError = new AcpRuntimeError(
|
||||
normalizeAcpErrorCode(event.code),
|
||||
event.message?.trim() || "ACP turn failed before completion.",
|
||||
);
|
||||
}
|
||||
if (input.onEvent) {
|
||||
await input.onEvent(event);
|
||||
}
|
||||
}
|
||||
if (streamError) {
|
||||
throw streamError;
|
||||
}
|
||||
this.recordTurnCompletion({
|
||||
startedAt: turnStartedAt,
|
||||
});
|
||||
await this.setSessionState({
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const resolution = this.resolveSession({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
state: "idle",
|
||||
clearLastError: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const acpError = toAcpRuntimeError({
|
||||
error,
|
||||
fallbackCode: "ACP_TURN_FAILED",
|
||||
fallbackMessage: "ACP turn failed before completion.",
|
||||
});
|
||||
this.recordTurnCompletion({
|
||||
startedAt: turnStartedAt,
|
||||
errorCode: acpError.code,
|
||||
});
|
||||
await this.setSessionState({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
state: "error",
|
||||
lastError: acpError.message,
|
||||
});
|
||||
throw acpError;
|
||||
} finally {
|
||||
if (input.signal) {
|
||||
input.signal.removeEventListener("abort", onCallerAbort);
|
||||
}
|
||||
if (this.activeTurnBySession.get(actorKey) === activeTurn) {
|
||||
this.activeTurnBySession.delete(actorKey);
|
||||
}
|
||||
if (meta.mode !== "oneshot") {
|
||||
({ handle } = await this.reconcileRuntimeSessionIdentifiers({
|
||||
const resolvedMeta = requireReadySessionMeta(resolution);
|
||||
let runtime: AcpRuntime | undefined;
|
||||
let handle: AcpRuntimeHandle | undefined;
|
||||
let meta: SessionAcpMeta | undefined;
|
||||
let activeTurn: ActiveTurnState | undefined;
|
||||
let internalAbortController: AbortController | undefined;
|
||||
let onCallerAbort: (() => void) | undefined;
|
||||
let activeTurnStarted = false;
|
||||
let sawTurnOutput = false;
|
||||
let retryFreshHandle = false;
|
||||
try {
|
||||
const ensured = await this.ensureRuntimeHandle({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
meta: resolvedMeta,
|
||||
});
|
||||
runtime = ensured.runtime;
|
||||
handle = ensured.handle;
|
||||
meta = ensured.meta;
|
||||
await this.applyRuntimeControls({
|
||||
sessionKey,
|
||||
runtime,
|
||||
handle,
|
||||
meta,
|
||||
failOnStatusError: false,
|
||||
}));
|
||||
}
|
||||
if (meta.mode === "oneshot") {
|
||||
try {
|
||||
await runtime.close({
|
||||
handle,
|
||||
reason: "oneshot-complete",
|
||||
});
|
||||
} catch (error) {
|
||||
logVerbose(`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`);
|
||||
} finally {
|
||||
this.clearCachedRuntimeState(sessionKey);
|
||||
});
|
||||
|
||||
await this.setSessionState({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
state: "running",
|
||||
clearLastError: true,
|
||||
});
|
||||
|
||||
internalAbortController = new AbortController();
|
||||
onCallerAbort = () => {
|
||||
internalAbortController?.abort();
|
||||
};
|
||||
if (input.signal?.aborted) {
|
||||
internalAbortController.abort();
|
||||
} else if (input.signal) {
|
||||
input.signal.addEventListener("abort", onCallerAbort, { once: true });
|
||||
}
|
||||
|
||||
activeTurn = {
|
||||
runtime,
|
||||
handle,
|
||||
abortController: internalAbortController,
|
||||
};
|
||||
this.activeTurnBySession.set(actorKey, activeTurn);
|
||||
activeTurnStarted = true;
|
||||
|
||||
let streamError: AcpRuntimeError | null = null;
|
||||
const combinedSignal =
|
||||
input.signal && typeof AbortSignal.any === "function"
|
||||
? AbortSignal.any([input.signal, internalAbortController.signal])
|
||||
: internalAbortController.signal;
|
||||
for await (const event of runtime.runTurn({
|
||||
handle,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
mode: input.mode,
|
||||
requestId: input.requestId,
|
||||
signal: combinedSignal,
|
||||
})) {
|
||||
if (event.type === "error") {
|
||||
streamError = new AcpRuntimeError(
|
||||
normalizeAcpErrorCode(event.code),
|
||||
event.message?.trim() || "ACP turn failed before completion.",
|
||||
);
|
||||
} else if (event.type === "text_delta" || event.type === "tool_call") {
|
||||
sawTurnOutput = true;
|
||||
}
|
||||
if (input.onEvent) {
|
||||
await input.onEvent(event);
|
||||
}
|
||||
}
|
||||
if (streamError) {
|
||||
throw streamError;
|
||||
}
|
||||
this.recordTurnCompletion({
|
||||
startedAt: turnStartedAt,
|
||||
});
|
||||
await this.setSessionState({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
state: "idle",
|
||||
clearLastError: true,
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
const acpError = toAcpRuntimeError({
|
||||
error,
|
||||
fallbackCode: activeTurnStarted ? "ACP_TURN_FAILED" : "ACP_SESSION_INIT_FAILED",
|
||||
fallbackMessage: activeTurnStarted
|
||||
? "ACP turn failed before completion."
|
||||
: "Could not initialize ACP session runtime.",
|
||||
});
|
||||
retryFreshHandle = this.shouldRetryTurnWithFreshHandle({
|
||||
attempt,
|
||||
sessionKey,
|
||||
error: acpError,
|
||||
sawTurnOutput,
|
||||
});
|
||||
if (retryFreshHandle) {
|
||||
continue;
|
||||
}
|
||||
this.recordTurnCompletion({
|
||||
startedAt: turnStartedAt,
|
||||
errorCode: acpError.code,
|
||||
});
|
||||
await this.setSessionState({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
state: "error",
|
||||
lastError: acpError.message,
|
||||
});
|
||||
throw acpError;
|
||||
} finally {
|
||||
if (input.signal && onCallerAbort) {
|
||||
input.signal.removeEventListener("abort", onCallerAbort);
|
||||
}
|
||||
if (activeTurn && this.activeTurnBySession.get(actorKey) === activeTurn) {
|
||||
this.activeTurnBySession.delete(actorKey);
|
||||
}
|
||||
if (!retryFreshHandle && runtime && handle && meta && meta.mode !== "oneshot") {
|
||||
({ handle } = await this.reconcileRuntimeSessionIdentifiers({
|
||||
cfg: input.cfg,
|
||||
sessionKey,
|
||||
runtime,
|
||||
handle,
|
||||
meta,
|
||||
failOnStatusError: false,
|
||||
}));
|
||||
}
|
||||
if (!retryFreshHandle && runtime && handle && meta && meta.mode === "oneshot") {
|
||||
try {
|
||||
await runtime.close({
|
||||
handle,
|
||||
reason: "oneshot-complete",
|
||||
});
|
||||
} catch (error) {
|
||||
logVerbose(
|
||||
`acp-manager: ACP oneshot close failed for ${sessionKey}: ${String(error)}`,
|
||||
);
|
||||
} finally {
|
||||
this.clearCachedRuntimeState(sessionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (retryFreshHandle) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -864,7 +891,9 @@ export class AcpSessionManager {
|
||||
});
|
||||
if (
|
||||
input.allowBackendUnavailable &&
|
||||
(acpError.code === "ACP_BACKEND_MISSING" || acpError.code === "ACP_BACKEND_UNAVAILABLE")
|
||||
(acpError.code === "ACP_BACKEND_MISSING" ||
|
||||
acpError.code === "ACP_BACKEND_UNAVAILABLE" ||
|
||||
this.isRecoverableAcpxExitError(acpError.message))
|
||||
) {
|
||||
// Treat unavailable backends as terminal for this cached handle so it
|
||||
// cannot continue counting against maxConcurrentSessions.
|
||||
@@ -916,7 +945,17 @@ export class AcpSessionManager {
|
||||
const agentMatches = cached.agent === agent;
|
||||
const modeMatches = cached.mode === mode;
|
||||
const cwdMatches = (cached.cwd ?? "") === (cwd ?? "");
|
||||
if (backendMatches && agentMatches && modeMatches && cwdMatches) {
|
||||
if (
|
||||
backendMatches &&
|
||||
agentMatches &&
|
||||
modeMatches &&
|
||||
cwdMatches &&
|
||||
(await this.isCachedRuntimeHandleReusable({
|
||||
sessionKey: params.sessionKey,
|
||||
runtime: cached.runtime,
|
||||
handle: cached.handle,
|
||||
}))
|
||||
) {
|
||||
return {
|
||||
runtime: cached.runtime,
|
||||
handle: cached.handle,
|
||||
@@ -1020,6 +1059,49 @@ export class AcpSessionManager {
|
||||
};
|
||||
}
|
||||
|
||||
private async isCachedRuntimeHandleReusable(params: {
|
||||
sessionKey: string;
|
||||
runtime: AcpRuntime;
|
||||
handle: AcpRuntimeHandle;
|
||||
}): Promise<boolean> {
|
||||
if (!params.runtime.getStatus) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const status = await params.runtime.getStatus({
|
||||
handle: params.handle,
|
||||
});
|
||||
if (this.isRuntimeStatusUnavailable(status)) {
|
||||
this.clearCachedRuntimeState(params.sessionKey);
|
||||
logVerbose(
|
||||
`acp-manager: evicting cached runtime handle for ${params.sessionKey} after unhealthy status probe: ${status.summary ?? "status unavailable"}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.clearCachedRuntimeState(params.sessionKey);
|
||||
logVerbose(
|
||||
`acp-manager: evicting cached runtime handle for ${params.sessionKey} after status probe failed: ${String(error)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private isRuntimeStatusUnavailable(status: AcpRuntimeStatus | undefined): boolean {
|
||||
if (!status) {
|
||||
return false;
|
||||
}
|
||||
const detailsStatus =
|
||||
typeof status.details?.status === "string" ? status.details.status.trim().toLowerCase() : "";
|
||||
if (detailsStatus === "dead" || detailsStatus === "no-session") {
|
||||
return true;
|
||||
}
|
||||
const summaryMatch = status.summary?.match(/\bstatus=([^\s]+)/i);
|
||||
const summaryStatus = summaryMatch?.[1]?.trim().toLowerCase() ?? "";
|
||||
return summaryStatus === "dead" || summaryStatus === "no-session";
|
||||
}
|
||||
|
||||
private async persistRuntimeOptions(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
@@ -1103,6 +1185,29 @@ export class AcpSessionManager {
|
||||
this.errorCountsByCode.set(normalized, (this.errorCountsByCode.get(normalized) ?? 0) + 1);
|
||||
}
|
||||
|
||||
private shouldRetryTurnWithFreshHandle(params: {
|
||||
attempt: number;
|
||||
sessionKey: string;
|
||||
error: AcpRuntimeError;
|
||||
sawTurnOutput: boolean;
|
||||
}): boolean {
|
||||
if (params.attempt > 0 || params.sawTurnOutput) {
|
||||
return false;
|
||||
}
|
||||
if (!this.isRecoverableAcpxExitError(params.error.message)) {
|
||||
return false;
|
||||
}
|
||||
this.clearCachedRuntimeState(params.sessionKey);
|
||||
logVerbose(
|
||||
`acp-manager: retrying ${params.sessionKey} with a fresh runtime handle after early turn failure: ${params.error.message}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private isRecoverableAcpxExitError(message: string): boolean {
|
||||
return /^acpx exited with code \d+/i.test(message.trim());
|
||||
}
|
||||
|
||||
private async evictIdleRuntimeHandles(params: { cfg: OpenClawConfig }): Promise<void> {
|
||||
const idleTtlMs = resolveRuntimeIdleTtlMs(params.cfg);
|
||||
if (idleTtlMs <= 0 || this.runtimeCache.size() === 0) {
|
||||
|
||||
@@ -354,6 +354,52 @@ describe("AcpSessionManager", () => {
|
||||
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("re-ensures cached runtime handles when the backend reports the session is dead", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
runtimeState.getStatus
|
||||
.mockResolvedValueOnce({
|
||||
summary: "status=alive",
|
||||
details: { status: "alive" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
summary: "status=dead",
|
||||
details: { status: "dead" },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
summary: "status=alive",
|
||||
details: { status: "alive" },
|
||||
});
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
storeSessionKey: "agent:codex:acp:session-1",
|
||||
acp: readySessionMeta(),
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
await manager.runTurn({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
text: "first",
|
||||
mode: "prompt",
|
||||
requestId: "r1",
|
||||
});
|
||||
await manager.runTurn({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
text: "second",
|
||||
mode: "prompt",
|
||||
requestId: "r2",
|
||||
});
|
||||
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeState.getStatus).toHaveBeenCalledTimes(3);
|
||||
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("rehydrates runtime handles after a manager restart", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
@@ -531,6 +577,61 @@ describe("AcpSessionManager", () => {
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("drops cached runtime handles when close sees a stale acpx process-exit error", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
runtimeState.close.mockRejectedValueOnce(new Error("acpx exited with code 1"));
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
hoisted.readAcpSessionEntryMock.mockImplementation((paramsUnknown: unknown) => {
|
||||
const sessionKey = (paramsUnknown as { sessionKey?: string }).sessionKey ?? "";
|
||||
return {
|
||||
sessionKey,
|
||||
storeSessionKey: sessionKey,
|
||||
acp: {
|
||||
...readySessionMeta(),
|
||||
runtimeSessionName: `runtime:${sessionKey}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
const limitedCfg = {
|
||||
acp: {
|
||||
...baseCfg.acp,
|
||||
maxConcurrentSessions: 1,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
await manager.runTurn({
|
||||
cfg: limitedCfg,
|
||||
sessionKey: "agent:codex:acp:session-a",
|
||||
text: "first",
|
||||
mode: "prompt",
|
||||
requestId: "r1",
|
||||
});
|
||||
|
||||
const closeResult = await manager.closeSession({
|
||||
cfg: limitedCfg,
|
||||
sessionKey: "agent:codex:acp:session-a",
|
||||
reason: "manual-close",
|
||||
allowBackendUnavailable: true,
|
||||
});
|
||||
expect(closeResult.runtimeClosed).toBe(false);
|
||||
expect(closeResult.runtimeNotice).toBe("acpx exited with code 1");
|
||||
|
||||
await expect(
|
||||
manager.runTurn({
|
||||
cfg: limitedCfg,
|
||||
sessionKey: "agent:codex:acp:session-b",
|
||||
text: "second",
|
||||
mode: "prompt",
|
||||
requestId: "r2",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("evicts idle cached runtimes before enforcing max concurrent limits", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
@@ -807,6 +908,82 @@ describe("AcpSessionManager", () => {
|
||||
expect(states.at(-1)).toBe("error");
|
||||
});
|
||||
|
||||
it("marks the session as errored when runtime ensure fails before turn start", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
runtimeState.ensureSession.mockRejectedValue(new Error("acpx exited with code 1"));
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
storeSessionKey: "agent:codex:acp:session-1",
|
||||
acp: {
|
||||
...readySessionMeta(),
|
||||
state: "running",
|
||||
},
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
await expect(
|
||||
manager.runTurn({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
text: "do work",
|
||||
mode: "prompt",
|
||||
requestId: "run-1",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "ACP_SESSION_INIT_FAILED",
|
||||
message: "acpx exited with code 1",
|
||||
});
|
||||
|
||||
const states = extractStatesFromUpserts();
|
||||
expect(states).not.toContain("running");
|
||||
expect(states.at(-1)).toBe("error");
|
||||
});
|
||||
|
||||
it("retries once with a fresh runtime handle after an early acpx exit", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
id: "acpx",
|
||||
runtime: runtimeState.runtime,
|
||||
});
|
||||
hoisted.readAcpSessionEntryMock.mockReturnValue({
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
storeSessionKey: "agent:codex:acp:session-1",
|
||||
acp: readySessionMeta(),
|
||||
});
|
||||
runtimeState.runTurn
|
||||
.mockImplementationOnce(async function* () {
|
||||
yield {
|
||||
type: "error" as const,
|
||||
message: "acpx exited with code 1",
|
||||
};
|
||||
})
|
||||
.mockImplementationOnce(async function* () {
|
||||
yield { type: "done" as const };
|
||||
});
|
||||
|
||||
const manager = new AcpSessionManager();
|
||||
await expect(
|
||||
manager.runTurn({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:codex:acp:session-1",
|
||||
text: "do work",
|
||||
mode: "prompt",
|
||||
requestId: "run-1",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(runtimeState.ensureSession).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeState.runTurn).toHaveBeenCalledTimes(2);
|
||||
const states = extractStatesFromUpserts();
|
||||
expect(states).toContain("running");
|
||||
expect(states).toContain("idle");
|
||||
expect(states).not.toContain("error");
|
||||
});
|
||||
|
||||
it("persists runtime mode changes through setSessionRuntimeMode", async () => {
|
||||
const runtimeState = createRuntime();
|
||||
hoisted.requireAcpRuntimeBackendMock.mockReturnValue({
|
||||
|
||||
100
src/acp/persistent-bindings.lifecycle.test.ts
Normal file
100
src/acp/persistent-bindings.lifecycle.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { importFreshModule } from "../../test/helpers/import-fresh.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
|
||||
const managerMocks = vi.hoisted(() => ({
|
||||
closeSession: vi.fn(),
|
||||
initializeSession: vi.fn(),
|
||||
updateSessionRuntimeOptions: vi.fn(),
|
||||
}));
|
||||
|
||||
const sessionMetaMocks = vi.hoisted(() => ({
|
||||
readAcpSessionEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
const resolveMocks = vi.hoisted(() => ({
|
||||
resolveConfiguredAcpBindingSpecBySessionKey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./control-plane/manager.js", () => ({
|
||||
getAcpSessionManager: () => ({
|
||||
closeSession: managerMocks.closeSession,
|
||||
initializeSession: managerMocks.initializeSession,
|
||||
updateSessionRuntimeOptions: managerMocks.updateSessionRuntimeOptions,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime/session-meta.js", () => ({
|
||||
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
|
||||
}));
|
||||
|
||||
vi.mock("./persistent-bindings.resolve.js", () => ({
|
||||
resolveConfiguredAcpBindingSpecBySessionKey:
|
||||
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey,
|
||||
}));
|
||||
type BindingTargetsModule = typeof import("../channels/plugins/binding-targets.js");
|
||||
let bindingTargets: BindingTargetsModule;
|
||||
let bindingTargetsImportScope = 0;
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
agents: {
|
||||
list: [{ id: "codex" }, { id: "claude" }],
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
bindingTargetsImportScope += 1;
|
||||
bindingTargets = await importFreshModule<BindingTargetsModule>(
|
||||
import.meta.url,
|
||||
`../channels/plugins/binding-targets.js?scope=${bindingTargetsImportScope}`,
|
||||
);
|
||||
managerMocks.closeSession.mockReset().mockResolvedValue({
|
||||
runtimeClosed: true,
|
||||
metaCleared: false,
|
||||
});
|
||||
managerMocks.initializeSession.mockReset().mockResolvedValue(undefined);
|
||||
managerMocks.updateSessionRuntimeOptions.mockReset().mockResolvedValue(undefined);
|
||||
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
|
||||
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockReset().mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe("resetConfiguredBindingTargetInPlace", () => {
|
||||
it("does not resolve configured bindings when ACP metadata already exists", async () => {
|
||||
const sessionKey = "agent:claude:acp:binding:discord:default:9373ab192b2317f4";
|
||||
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
||||
acp: {
|
||||
agent: "claude",
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
runtimeOptions: { cwd: "/home/bob/clawd" },
|
||||
},
|
||||
});
|
||||
resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey.mockImplementation(() => {
|
||||
throw new Error("configured binding resolution should be skipped");
|
||||
});
|
||||
|
||||
const result = await bindingTargets.resetConfiguredBindingTargetInPlace({
|
||||
cfg: baseCfg,
|
||||
sessionKey,
|
||||
reason: "reset",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(resolveMocks.resolveConfiguredAcpBindingSpecBySessionKey).not.toHaveBeenCalled();
|
||||
expect(managerMocks.closeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
clearMeta: false,
|
||||
}),
|
||||
);
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
agent: "claude",
|
||||
backendId: "acpx",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeText,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} from "./persistent-bindings.types.js";
|
||||
import { readAcpSessionEntry } from "./runtime/session-meta.js";
|
||||
|
||||
@@ -96,7 +97,7 @@ export async function ensureConfiguredAcpBindingSession(params: {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logVerbose(
|
||||
`acp-persistent-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
|
||||
`acp-configured-binding: failed ensuring ${params.spec.channel}:${params.spec.accountId}:${params.spec.conversationId} -> ${sessionKey}: ${message}`,
|
||||
);
|
||||
return {
|
||||
ok: false,
|
||||
@@ -106,6 +107,26 @@ export async function ensureConfiguredAcpBindingSession(params: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureConfiguredAcpBindingReady(params: {
|
||||
cfg: OpenClawConfig;
|
||||
configuredBinding: ResolvedConfiguredAcpBinding | null;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (!params.configuredBinding) {
|
||||
return { ok: true };
|
||||
}
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
cfg: params.cfg,
|
||||
spec: params.configuredBinding.spec,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: ensured.error ?? "unknown error",
|
||||
};
|
||||
}
|
||||
|
||||
export async function resetAcpSessionInPlace(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
@@ -119,14 +140,17 @@ export async function resetAcpSessionInPlace(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const configuredBinding = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
const meta = readAcpSessionEntry({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
})?.acp;
|
||||
const configuredBinding =
|
||||
!meta || !normalizeText(meta.agent)
|
||||
? resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
})
|
||||
: null;
|
||||
if (!meta) {
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
@@ -189,7 +213,7 @@ export async function resetAcpSessionInPlace(params: {
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logVerbose(`acp-persistent-binding: failed reset for ${sessionKey}: ${message}`);
|
||||
logVerbose(`acp-configured-binding: failed reset for ${sessionKey}: ${message}`);
|
||||
return {
|
||||
ok: false,
|
||||
error: message,
|
||||
|
||||
@@ -1,275 +1,17 @@
|
||||
import { getChannelPlugin } from "../channels/plugins/index.js";
|
||||
import { listAcpBindings } from "../config/bindings.js";
|
||||
import {
|
||||
resolveConfiguredBindingRecord,
|
||||
resolveConfiguredBindingRecordBySessionKey,
|
||||
resolveConfiguredBindingRecordForConversation,
|
||||
} from "../channels/plugins/binding-registry.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { AgentAcpBinding } from "../config/types.js";
|
||||
import { pickFirstExistingAgentId } from "../routing/resolve-route.js";
|
||||
import type { ConversationRef } from "../infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
normalizeAccountId,
|
||||
parseAgentSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeBindingConfig,
|
||||
normalizeMode,
|
||||
normalizeText,
|
||||
toConfiguredAcpBindingRecord,
|
||||
type ConfiguredAcpBindingChannel,
|
||||
resolveConfiguredAcpBindingSpecFromRecord,
|
||||
toResolvedConfiguredAcpBinding,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} from "./persistent-bindings.types.js";
|
||||
|
||||
function normalizeBindingChannel(value: string | undefined): ConfiguredAcpBindingChannel | null {
|
||||
const normalized = (value ?? "").trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const plugin = getChannelPlugin(normalized);
|
||||
return plugin?.acpBindings ? plugin.id : null;
|
||||
}
|
||||
|
||||
function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
||||
const trimmed = (match ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return 1;
|
||||
}
|
||||
return normalizeAccountId(trimmed) === actual ? 2 : 0;
|
||||
}
|
||||
|
||||
function resolveBindingConversationId(binding: AgentAcpBinding): string | null {
|
||||
const id = binding.match.peer?.id?.trim();
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
function parseConfiguredBindingSessionKey(params: {
|
||||
sessionKey: string;
|
||||
}): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
|
||||
const parsed = parseAgentSessionKey(params.sessionKey);
|
||||
const rest = parsed?.rest?.trim().toLowerCase() ?? "";
|
||||
if (!rest) {
|
||||
return null;
|
||||
}
|
||||
const tokens = rest.split(":");
|
||||
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
|
||||
return null;
|
||||
}
|
||||
const channel = normalizeBindingChannel(tokens[2]);
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
accountId: normalizeAccountId(tokens[3]),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
|
||||
acpAgentId?: string;
|
||||
mode?: string;
|
||||
cwd?: string;
|
||||
backend?: string;
|
||||
} {
|
||||
const agent = params.cfg.agents?.list?.find(
|
||||
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
|
||||
);
|
||||
if (!agent || agent.runtime?.type !== "acp") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
acpAgentId: normalizeText(agent.runtime.acp?.agent),
|
||||
mode: normalizeText(agent.runtime.acp?.mode),
|
||||
cwd: normalizeText(agent.runtime.acp?.cwd),
|
||||
backend: normalizeText(agent.runtime.acp?.backend),
|
||||
};
|
||||
}
|
||||
|
||||
function toConfiguredBindingSpec(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
binding: AgentAcpBinding;
|
||||
}): ConfiguredAcpBindingSpec {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
|
||||
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
|
||||
cfg: params.cfg,
|
||||
ownerAgentId: agentId,
|
||||
});
|
||||
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
|
||||
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
|
||||
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
|
||||
return {
|
||||
channel: params.channel,
|
||||
accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
agentId,
|
||||
acpAgentId,
|
||||
mode,
|
||||
cwd: bindingOverrides.cwd ?? runtimeDefaults.cwd,
|
||||
backend: bindingOverrides.backend ?? runtimeDefaults.backend,
|
||||
label: bindingOverrides.label,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredBindingRecord(params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindings: AgentAcpBinding[];
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
selectConversation: (binding: AgentAcpBinding) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority?: number;
|
||||
} | null;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
let wildcardMatch: {
|
||||
binding: AgentAcpBinding;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority: number;
|
||||
} | null = null;
|
||||
let exactMatch: {
|
||||
binding: AgentAcpBinding;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority: number;
|
||||
} | null = null;
|
||||
for (const binding of params.bindings) {
|
||||
if (normalizeBindingChannel(binding.match.channel) !== params.channel) {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(
|
||||
binding.match.accountId,
|
||||
params.accountId,
|
||||
);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const conversation = params.selectConversation(binding);
|
||||
if (!conversation) {
|
||||
continue;
|
||||
}
|
||||
const matchPriority = conversation.matchPriority ?? 0;
|
||||
if (accountMatchPriority === 2) {
|
||||
if (!exactMatch || matchPriority > exactMatch.matchPriority) {
|
||||
exactMatch = {
|
||||
binding,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
matchPriority,
|
||||
};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!wildcardMatch || matchPriority > wildcardMatch.matchPriority) {
|
||||
wildcardMatch = {
|
||||
binding,
|
||||
conversationId: conversation.conversationId,
|
||||
parentConversationId: conversation.parentConversationId,
|
||||
matchPriority,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (exactMatch) {
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: exactMatch.conversationId,
|
||||
parentConversationId: exactMatch.parentConversationId,
|
||||
binding: exactMatch.binding,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
return null;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: wildcardMatch.conversationId,
|
||||
parentConversationId: wildcardMatch.parentConversationId,
|
||||
binding: wildcardMatch.binding,
|
||||
});
|
||||
return {
|
||||
spec,
|
||||
record: toConfiguredAcpBindingRecord(spec),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): ConfiguredAcpBindingSpec | null {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
const parsedSessionKey = parseConfiguredBindingSessionKey({ sessionKey });
|
||||
if (!parsedSessionKey) {
|
||||
return null;
|
||||
}
|
||||
const plugin = getChannelPlugin(parsedSessionKey.channel);
|
||||
const acpBindings = plugin?.acpBindings;
|
||||
if (!acpBindings?.normalizeConfiguredBindingTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let wildcardMatch: ConfiguredAcpBindingSpec | null = null;
|
||||
for (const binding of listAcpBindings(params.cfg)) {
|
||||
const channel = normalizeBindingChannel(binding.match.channel);
|
||||
if (!channel || channel !== parsedSessionKey.channel) {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(
|
||||
binding.match.accountId,
|
||||
parsedSessionKey.accountId,
|
||||
);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const targetConversationId = resolveBindingConversationId(binding);
|
||||
if (!targetConversationId) {
|
||||
continue;
|
||||
}
|
||||
const target = acpBindings.normalizeConfiguredBindingTarget({
|
||||
binding,
|
||||
conversationId: targetConversationId,
|
||||
});
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
const spec = toConfiguredBindingSpec({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId: parsedSessionKey.accountId,
|
||||
conversationId: target.conversationId,
|
||||
parentConversationId: target.parentConversationId,
|
||||
binding,
|
||||
});
|
||||
if (buildConfiguredAcpSessionKey(spec) !== sessionKey) {
|
||||
continue;
|
||||
}
|
||||
if (accountMatchPriority === 2) {
|
||||
return spec;
|
||||
}
|
||||
if (!wildcardMatch) {
|
||||
wildcardMatch = spec;
|
||||
}
|
||||
}
|
||||
return wildcardMatch;
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingRecord(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
@@ -277,36 +19,22 @@ export function resolveConfiguredAcpBindingRecord(params: {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
const channel = normalizeBindingChannel(params.channel);
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const conversationId = params.conversationId.trim();
|
||||
const parentConversationId = params.parentConversationId?.trim() || undefined;
|
||||
if (!channel || !conversationId) {
|
||||
return null;
|
||||
}
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const acpBindings = plugin?.acpBindings;
|
||||
if (!acpBindings?.matchConfiguredBinding) {
|
||||
return null;
|
||||
}
|
||||
const matchConfiguredBinding = acpBindings.matchConfiguredBinding;
|
||||
|
||||
return resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
bindings: listAcpBindings(params.cfg),
|
||||
channel,
|
||||
accountId,
|
||||
selectConversation: (binding) => {
|
||||
const bindingConversationId = resolveBindingConversationId(binding);
|
||||
if (!bindingConversationId) {
|
||||
return null;
|
||||
}
|
||||
return matchConfiguredBinding({
|
||||
binding,
|
||||
bindingConversationId,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
});
|
||||
},
|
||||
});
|
||||
const resolved = resolveConfiguredBindingRecord(params);
|
||||
return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null;
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingRecordForConversation(params: {
|
||||
cfg: OpenClawConfig;
|
||||
conversation: ConversationRef;
|
||||
}): ResolvedConfiguredAcpBinding | null {
|
||||
const resolved = resolveConfiguredBindingRecordForConversation(params);
|
||||
return resolved ? toResolvedConfiguredAcpBinding(resolved.record) : null;
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingSpecBySessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): ConfiguredAcpBindingSpec | null {
|
||||
const resolved = resolveConfiguredBindingRecordBySessionKey(params);
|
||||
return resolved ? resolveConfiguredAcpBindingSpecFromRecord(resolved.record) : null;
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { ResolvedAgentRoute } from "../routing/resolve-route.js";
|
||||
import { deriveLastRoutePolicy } from "../routing/resolve-route.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import {
|
||||
ensureConfiguredAcpBindingSession,
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
type ConfiguredAcpBindingChannel,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} from "./persistent-bindings.js";
|
||||
|
||||
export function resolveConfiguredAcpRoute(params: {
|
||||
cfg: OpenClawConfig;
|
||||
route: ResolvedAgentRoute;
|
||||
channel: ConfiguredAcpBindingChannel;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): {
|
||||
configuredBinding: ResolvedConfiguredAcpBinding | null;
|
||||
route: ResolvedAgentRoute;
|
||||
boundSessionKey?: string;
|
||||
boundAgentId?: string;
|
||||
} {
|
||||
const configuredBinding = resolveConfiguredAcpBindingRecord({
|
||||
cfg: params.cfg,
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
});
|
||||
if (!configuredBinding) {
|
||||
return {
|
||||
configuredBinding: null,
|
||||
route: params.route,
|
||||
};
|
||||
}
|
||||
const boundSessionKey = configuredBinding.record.targetSessionKey?.trim() ?? "";
|
||||
if (!boundSessionKey) {
|
||||
return {
|
||||
configuredBinding,
|
||||
route: params.route,
|
||||
};
|
||||
}
|
||||
const boundAgentId = resolveAgentIdFromSessionKey(boundSessionKey) || params.route.agentId;
|
||||
return {
|
||||
configuredBinding,
|
||||
boundSessionKey,
|
||||
boundAgentId,
|
||||
route: {
|
||||
...params.route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: boundAgentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: params.route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureConfiguredAcpRouteReady(params: {
|
||||
cfg: OpenClawConfig;
|
||||
configuredBinding: ResolvedConfiguredAcpBinding | null;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (!params.configuredBinding) {
|
||||
return { ok: true };
|
||||
}
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
cfg: params.cfg,
|
||||
spec: params.configuredBinding.spec,
|
||||
});
|
||||
if (ensured.ok) {
|
||||
return { ok: true };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: ensured.error ?? "unknown error",
|
||||
};
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discordPlugin } from "../../extensions/discord/src/channel.js";
|
||||
import { feishuPlugin } from "../../extensions/feishu/src/channel.js";
|
||||
import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
|
||||
import { importFreshModule } from "../../test/helpers/import-fresh.js";
|
||||
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js";
|
||||
const managerMocks = vi.hoisted(() => ({
|
||||
resolveSession: vi.fn(),
|
||||
closeSession: vi.fn(),
|
||||
@@ -27,17 +30,24 @@ vi.mock("./runtime/session-meta.js", () => ({
|
||||
readAcpSessionEntry: sessionMetaMocks.readAcpSessionEntry,
|
||||
}));
|
||||
|
||||
type PersistentBindingsModule = typeof import("./persistent-bindings.js");
|
||||
|
||||
let buildConfiguredAcpSessionKey: PersistentBindingsModule["buildConfiguredAcpSessionKey"];
|
||||
let ensureConfiguredAcpBindingSession: PersistentBindingsModule["ensureConfiguredAcpBindingSession"];
|
||||
let resetAcpSessionInPlace: PersistentBindingsModule["resetAcpSessionInPlace"];
|
||||
let resolveConfiguredAcpBindingRecord: PersistentBindingsModule["resolveConfiguredAcpBindingRecord"];
|
||||
let resolveConfiguredAcpBindingSpecBySessionKey: PersistentBindingsModule["resolveConfiguredAcpBindingSpecBySessionKey"];
|
||||
type PersistentBindingsModule = Pick<
|
||||
typeof import("./persistent-bindings.resolve.js"),
|
||||
"resolveConfiguredAcpBindingRecord" | "resolveConfiguredAcpBindingSpecBySessionKey"
|
||||
> &
|
||||
Pick<
|
||||
typeof import("./persistent-bindings.lifecycle.js"),
|
||||
"ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace"
|
||||
>;
|
||||
let persistentBindings: PersistentBindingsModule;
|
||||
let persistentBindingsImportScope = 0;
|
||||
|
||||
type ConfiguredBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
||||
type BindingRecordInput = Parameters<typeof resolveConfiguredAcpBindingRecord>[0];
|
||||
type BindingSpec = Parameters<typeof ensureConfiguredAcpBindingSession>[0]["spec"];
|
||||
type BindingRecordInput = Parameters<
|
||||
PersistentBindingsModule["resolveConfiguredAcpBindingRecord"]
|
||||
>[0];
|
||||
type BindingSpec = Parameters<
|
||||
PersistentBindingsModule["ensureConfiguredAcpBindingSession"]
|
||||
>[0]["spec"];
|
||||
|
||||
const baseCfg = {
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
@@ -117,7 +127,7 @@ function createFeishuBinding(params: {
|
||||
}
|
||||
|
||||
function resolveBindingRecord(cfg: OpenClawConfig, overrides: Partial<BindingRecordInput> = {}) {
|
||||
return resolveConfiguredAcpBindingRecord({
|
||||
return persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
accountId: defaultDiscordAccountId,
|
||||
@@ -131,7 +141,7 @@ function resolveDiscordBindingSpecBySession(
|
||||
conversationId = defaultDiscordConversationId,
|
||||
) {
|
||||
const resolved = resolveBindingRecord(cfg, { conversationId });
|
||||
return resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
return persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
@@ -148,7 +158,11 @@ function createDiscordPersistentSpec(overrides: Partial<BindingSpec> = {}): Bind
|
||||
} as BindingSpec;
|
||||
}
|
||||
|
||||
function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
|
||||
function mockReadySession(params: {
|
||||
spec: BindingSpec;
|
||||
cwd: string;
|
||||
state?: "idle" | "running" | "error";
|
||||
}) {
|
||||
const sessionKey = buildConfiguredAcpSessionKey(params.spec);
|
||||
managerMocks.resolveSession.mockReturnValue({
|
||||
kind: "ready",
|
||||
@@ -159,14 +173,33 @@ function mockReadySession(params: { spec: BindingSpec; cwd: string }) {
|
||||
runtimeSessionName: "existing",
|
||||
mode: params.spec.mode,
|
||||
runtimeOptions: { cwd: params.cwd },
|
||||
state: "idle",
|
||||
state: params.state ?? "idle",
|
||||
lastActivityAt: Date.now(),
|
||||
},
|
||||
});
|
||||
return sessionKey;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
persistentBindingsImportScope += 1;
|
||||
const [resolveModule, lifecycleModule] = await Promise.all([
|
||||
importFreshModule<typeof import("./persistent-bindings.resolve.js")>(
|
||||
import.meta.url,
|
||||
`./persistent-bindings.resolve.js?scope=${persistentBindingsImportScope}`,
|
||||
),
|
||||
importFreshModule<typeof import("./persistent-bindings.lifecycle.js")>(
|
||||
import.meta.url,
|
||||
`./persistent-bindings.lifecycle.js?scope=${persistentBindingsImportScope}`,
|
||||
),
|
||||
]);
|
||||
persistentBindings = {
|
||||
resolveConfiguredAcpBindingRecord: resolveModule.resolveConfiguredAcpBindingRecord,
|
||||
resolveConfiguredAcpBindingSpecBySessionKey:
|
||||
resolveModule.resolveConfiguredAcpBindingSpecBySessionKey,
|
||||
ensureConfiguredAcpBindingSession: lifecycleModule.ensureConfiguredAcpBindingSession,
|
||||
resetAcpSessionInPlace: lifecycleModule.resetAcpSessionInPlace,
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: "discord", plugin: discordPlugin, source: "test" },
|
||||
@@ -184,17 +217,6 @@ beforeEach(() => {
|
||||
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({
|
||||
buildConfiguredAcpSessionKey,
|
||||
ensureConfiguredAcpBindingSession,
|
||||
resetAcpSessionInPlace,
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
resolveConfiguredAcpBindingSpecBySessionKey,
|
||||
} = await import("./persistent-bindings.js"));
|
||||
});
|
||||
|
||||
describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
it("resolves discord channel ACP binding from top-level typed bindings", () => {
|
||||
const cfg = createCfgWithBindings([
|
||||
@@ -263,7 +285,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "work",
|
||||
@@ -318,13 +340,13 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const canonical = resolveConfiguredAcpBindingRecord({
|
||||
const canonical = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "-1001234567890:topic:42",
|
||||
});
|
||||
const splitIds = resolveConfiguredAcpBindingRecord({
|
||||
const splitIds = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
@@ -347,7 +369,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
@@ -364,7 +386,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
@@ -384,7 +406,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
@@ -405,7 +427,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
@@ -427,7 +449,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
@@ -449,7 +471,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
@@ -468,7 +490,7 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
@@ -514,6 +536,25 @@ describe("resolveConfiguredAcpBindingRecord", () => {
|
||||
expect(resolved?.spec.cwd).toBe("/workspace/repo-a");
|
||||
expect(resolved?.spec.backend).toBe("acpx");
|
||||
});
|
||||
|
||||
it("derives configured binding cwd from an explicit agent workspace", () => {
|
||||
const cfg = createCfgWithBindings(
|
||||
[
|
||||
createDiscordBinding({
|
||||
agentId: "codex",
|
||||
conversationId: defaultDiscordConversationId,
|
||||
}),
|
||||
],
|
||||
{
|
||||
agents: {
|
||||
list: [{ id: "codex", workspace: "/workspace/openclaw" }, { id: "claude" }],
|
||||
},
|
||||
},
|
||||
);
|
||||
const resolved = resolveBindingRecord(cfg);
|
||||
|
||||
expect(resolved?.spec.cwd).toBe(resolveAgentWorkspaceDir(cfg, "codex"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||
@@ -534,7 +575,7 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||
});
|
||||
|
||||
it("returns null for unknown session keys", () => {
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg: baseCfg,
|
||||
sessionKey: "agent:main:acp:binding:discord:default:notfound",
|
||||
});
|
||||
@@ -568,13 +609,13 @@ describe("resolveConfiguredAcpBindingSpecBySessionKey", () => {
|
||||
acp: { backend: "acpx" },
|
||||
}),
|
||||
]);
|
||||
const resolved = resolveConfiguredAcpBindingRecord({
|
||||
const resolved = persistentBindings.resolveConfiguredAcpBindingRecord({
|
||||
cfg,
|
||||
channel: "feishu",
|
||||
accountId: "default",
|
||||
conversationId: "user_123",
|
||||
});
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
const spec = persistentBindings.resolveConfiguredAcpBindingSpecBySessionKey({
|
||||
cfg,
|
||||
sessionKey: resolved?.record.targetSessionKey ?? "",
|
||||
});
|
||||
@@ -614,7 +655,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
cwd: "/workspace/openclaw",
|
||||
});
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
@@ -633,7 +674,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
cwd: "/workspace/other-repo",
|
||||
});
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
@@ -649,6 +690,26 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps a matching ready session even when the stored ACP session is in error state", async () => {
|
||||
const spec = createDiscordPersistentSpec({
|
||||
cwd: "/home/bob/clawd",
|
||||
});
|
||||
const sessionKey = mockReadySession({
|
||||
spec,
|
||||
cwd: "/home/bob/clawd",
|
||||
state: "error",
|
||||
});
|
||||
|
||||
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
|
||||
expect(ensured).toEqual({ ok: true, sessionKey });
|
||||
expect(managerMocks.closeSession).not.toHaveBeenCalled();
|
||||
expect(managerMocks.initializeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("initializes ACP session with runtime agent override when provided", async () => {
|
||||
const spec = createDiscordPersistentSpec({
|
||||
agentId: "coding",
|
||||
@@ -656,7 +717,7 @@ describe("ensureConfiguredAcpBindingSession", () => {
|
||||
});
|
||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||
|
||||
const ensured = await ensureConfiguredAcpBindingSession({
|
||||
const ensured = await persistentBindings.ensureConfiguredAcpBindingSession({
|
||||
cfg: baseCfg,
|
||||
spec,
|
||||
});
|
||||
@@ -692,7 +753,7 @@ describe("resetAcpSessionInPlace", () => {
|
||||
});
|
||||
managerMocks.resolveSession.mockReturnValue({ kind: "none" });
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||
cfg,
|
||||
sessionKey,
|
||||
reason: "new",
|
||||
@@ -721,7 +782,7 @@ describe("resetAcpSessionInPlace", () => {
|
||||
});
|
||||
managerMocks.initializeSession.mockRejectedValueOnce(new Error("backend unavailable"));
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||
cfg: baseCfg,
|
||||
sessionKey,
|
||||
reason: "reset",
|
||||
@@ -752,7 +813,7 @@ describe("resetAcpSessionInPlace", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resetAcpSessionInPlace({
|
||||
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||
cfg,
|
||||
sessionKey,
|
||||
reason: "reset",
|
||||
@@ -766,4 +827,64 @@ describe("resetAcpSessionInPlace", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves configured ACP agent overrides during in-place reset when metadata omits the agent", async () => {
|
||||
const cfg = createCfgWithBindings(
|
||||
[
|
||||
createDiscordBinding({
|
||||
agentId: "coding",
|
||||
conversationId: "1478844424791396446",
|
||||
}),
|
||||
],
|
||||
{
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main" },
|
||||
{
|
||||
id: "coding",
|
||||
runtime: {
|
||||
type: "acp",
|
||||
acp: {
|
||||
agent: "codex",
|
||||
backend: "acpx",
|
||||
mode: "persistent",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ id: "claude" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
const sessionKey = buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1478844424791396446",
|
||||
agentId: "coding",
|
||||
acpAgentId: "codex",
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
});
|
||||
sessionMetaMocks.readAcpSessionEntry.mockReturnValue({
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await persistentBindings.resetAcpSessionInPlace({
|
||||
cfg,
|
||||
sessionKey,
|
||||
reason: "reset",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(managerMocks.initializeSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionKey,
|
||||
agent: "codex",
|
||||
backendId: "acpx",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
export {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeBindingConfig,
|
||||
normalizeMode,
|
||||
normalizeText,
|
||||
toConfiguredAcpBindingRecord,
|
||||
type AcpBindingConfigShape,
|
||||
type ConfiguredAcpBindingChannel,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
type ResolvedConfiguredAcpBinding,
|
||||
} from "./persistent-bindings.types.js";
|
||||
export {
|
||||
ensureConfiguredAcpBindingSession,
|
||||
resetAcpSessionInPlace,
|
||||
} from "./persistent-bindings.lifecycle.js";
|
||||
export {
|
||||
resolveConfiguredAcpBindingRecord,
|
||||
resolveConfiguredAcpBindingSpecBySessionKey,
|
||||
} from "./persistent-bindings.resolve.js";
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import type { SessionBindingRecord } from "../infra/outbound/session-binding-service.js";
|
||||
import { normalizeAccountId, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
||||
import { sanitizeAgentId } from "../routing/session-key.js";
|
||||
import type { AcpRuntimeSessionMode } from "./runtime/types.js";
|
||||
|
||||
@@ -104,3 +105,72 @@ export function toConfiguredAcpBindingRecord(spec: ConfiguredAcpBindingSpec): Se
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function parseConfiguredAcpSessionKey(
|
||||
sessionKey: string,
|
||||
): { channel: ConfiguredAcpBindingChannel; accountId: string } | null {
|
||||
const trimmed = sessionKey.trim();
|
||||
if (!trimmed.startsWith("agent:")) {
|
||||
return null;
|
||||
}
|
||||
const rest = trimmed.slice(trimmed.indexOf(":") + 1);
|
||||
const nextSeparator = rest.indexOf(":");
|
||||
if (nextSeparator === -1) {
|
||||
return null;
|
||||
}
|
||||
const tokens = rest.slice(nextSeparator + 1).split(":");
|
||||
if (tokens.length !== 5 || tokens[0] !== "acp" || tokens[1] !== "binding") {
|
||||
return null;
|
||||
}
|
||||
const channel = tokens[2]?.trim().toLowerCase();
|
||||
if (!channel) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: channel as ConfiguredAcpBindingChannel,
|
||||
accountId: normalizeAccountId(tokens[3] ?? "default"),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConfiguredAcpBindingSpecFromRecord(
|
||||
record: SessionBindingRecord,
|
||||
): ConfiguredAcpBindingSpec | null {
|
||||
if (record.targetKind !== "session") {
|
||||
return null;
|
||||
}
|
||||
const conversationId = record.conversation.conversationId.trim();
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
const agentId =
|
||||
normalizeText(record.metadata?.agentId) ??
|
||||
resolveAgentIdFromSessionKey(record.targetSessionKey);
|
||||
if (!agentId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: record.conversation.channel as ConfiguredAcpBindingChannel,
|
||||
accountId: normalizeAccountId(record.conversation.accountId),
|
||||
conversationId,
|
||||
parentConversationId: normalizeText(record.conversation.parentConversationId),
|
||||
agentId,
|
||||
acpAgentId: normalizeText(record.metadata?.acpAgentId),
|
||||
mode: normalizeMode(record.metadata?.mode),
|
||||
cwd: normalizeText(record.metadata?.cwd),
|
||||
backend: normalizeText(record.metadata?.backend),
|
||||
label: normalizeText(record.metadata?.label),
|
||||
};
|
||||
}
|
||||
|
||||
export function toResolvedConfiguredAcpBinding(
|
||||
record: SessionBindingRecord,
|
||||
): ResolvedConfiguredAcpBinding | null {
|
||||
const spec = resolveConfiguredAcpBindingSpecFromRecord(record);
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
spec,
|
||||
record,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -165,6 +165,7 @@ export async function upsertAcpSessionMeta(params: {
|
||||
},
|
||||
{
|
||||
activeSessionKey: sessionKey.toLowerCase(),
|
||||
allowDropAcpMetaSessionKeys: [sessionKey],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { resolveConfiguredAcpBindingRecord } from "../../acp/persistent-bindings.js";
|
||||
import { resolveConfiguredBindingRecord } from "../../channels/plugins/binding-registry.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import { DEFAULT_ACCOUNT_ID, isAcpSessionKey } from "../../routing/session-key.js";
|
||||
@@ -51,7 +51,7 @@ export function resolveEffectiveResetTargetSessionKey(params: {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const configuredBinding = resolveConfiguredAcpBindingRecord({
|
||||
const configuredBinding = resolveConfiguredBindingRecord({
|
||||
cfg: params.cfg,
|
||||
channel,
|
||||
accountId,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { resetAcpSessionInPlace } from "../../acp/persistent-bindings.js";
|
||||
import { resetConfiguredBindingTargetInPlace } from "../../channels/plugins/binding-targets.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
@@ -228,7 +228,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
|
||||
? boundAcpSessionKey.trim()
|
||||
: undefined;
|
||||
if (boundAcpKey) {
|
||||
const resetResult = await resetAcpSessionInPlace({
|
||||
const resetResult = await resetConfiguredBindingTargetInPlace({
|
||||
cfg: params.cfg,
|
||||
sessionKey: boundAcpKey,
|
||||
reason: commandAction,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { discordPlugin } from "../../../extensions/discord/src/channel.js";
|
||||
import { AcpRuntimeError } from "../../acp/runtime/errors.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||
import type { PluginTargetedInboundClaimOutcome } from "../../plugins/hooks.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js";
|
||||
import type { MsgContext } from "../templating.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
@@ -192,14 +193,16 @@ vi.mock("../../tts/tts.js", () => ({
|
||||
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
|
||||
}));
|
||||
|
||||
const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js");
|
||||
const { resetInboundDedupe } = await import("./inbound-dedupe.js");
|
||||
const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js");
|
||||
const { __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js");
|
||||
|
||||
const noAbortResult = { handled: false, aborted: false } as const;
|
||||
const emptyConfig = {} as OpenClawConfig;
|
||||
type DispatchReplyArgs = Parameters<typeof dispatchReplyFromConfig>[0];
|
||||
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
|
||||
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
|
||||
let acpManagerTesting: typeof import("../../acp/control-plane/manager.js").__testing;
|
||||
let pluginBindingTesting: typeof import("../../plugins/conversation-binding.js").__testing;
|
||||
let AcpRuntimeErrorClass: typeof import("../../acp/runtime/errors.js").AcpRuntimeError;
|
||||
type DispatchReplyArgs = Parameters<
|
||||
typeof import("./dispatch-from-config.js").dispatchReplyFromConfig
|
||||
>[0];
|
||||
|
||||
function createDispatcher(): ReplyDispatcher {
|
||||
return {
|
||||
@@ -254,9 +257,39 @@ async function dispatchTwiceWithFreshDispatchers(params: Omit<DispatchReplyArgs,
|
||||
}
|
||||
|
||||
describe("dispatchReplyFromConfig", () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
({ dispatchReplyFromConfig } = await import("./dispatch-from-config.js"));
|
||||
({ resetInboundDedupe } = await import("./inbound-dedupe.js"));
|
||||
({ __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"));
|
||||
({ __testing: pluginBindingTesting } = await import("../../plugins/conversation-binding.js"));
|
||||
({ AcpRuntimeError: AcpRuntimeErrorClass } = await import("../../acp/runtime/errors.js"));
|
||||
const discordTestPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "discord",
|
||||
capabilities: {
|
||||
chatTypes: ["direct"],
|
||||
nativeCommands: true,
|
||||
},
|
||||
}),
|
||||
execApprovals: {
|
||||
shouldSuppressLocalPrompt: ({ payload }: { payload: ReplyPayload }) =>
|
||||
Boolean(
|
||||
payload.channelData &&
|
||||
typeof payload.channelData === "object" &&
|
||||
!Array.isArray(payload.channelData) &&
|
||||
payload.channelData.execApproval,
|
||||
),
|
||||
},
|
||||
};
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]),
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "discord",
|
||||
source: "test",
|
||||
plugin: discordTestPlugin,
|
||||
},
|
||||
]),
|
||||
);
|
||||
acpManagerTesting.resetAcpSessionManagerForTests();
|
||||
resetInboundDedupe();
|
||||
@@ -1733,7 +1766,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
},
|
||||
});
|
||||
acpMocks.requireAcpRuntimeBackend.mockImplementation(() => {
|
||||
throw new AcpRuntimeError(
|
||||
throw new AcpRuntimeErrorClass(
|
||||
"ACP_BACKEND_MISSING",
|
||||
"ACP runtime backend is not configured. Install and enable the acpx runtime plugin.",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
|
||||
import {
|
||||
resolveConversationBindingRecord,
|
||||
touchConversationBindingRecord,
|
||||
} from "../../bindings/records.js";
|
||||
import { shouldSuppressLocalExecApprovalPrompt } from "../../channels/plugins/exec-approval-local.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import {
|
||||
@@ -20,7 +24,6 @@ import {
|
||||
toPluginMessageReceivedEvent,
|
||||
} from "../../hooks/message-hook-mappers.js";
|
||||
import { isDiagnosticsEnabled } from "../../infra/diagnostic-events.js";
|
||||
import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js";
|
||||
import {
|
||||
logMessageProcessed,
|
||||
logMessageQueued,
|
||||
@@ -303,7 +306,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
|
||||
const pluginOwnedBindingRecord =
|
||||
inboundClaimContext.conversationId && inboundClaimContext.channelId
|
||||
? getSessionBindingService().resolveByConversation({
|
||||
? resolveConversationBindingRecord({
|
||||
channel: inboundClaimContext.channelId,
|
||||
accountId: inboundClaimContext.accountId ?? "default",
|
||||
conversationId: inboundClaimContext.conversationId,
|
||||
@@ -320,7 +323,7 @@ export async function dispatchReplyFromConfig(params: {
|
||||
| undefined;
|
||||
|
||||
if (pluginOwnedBinding) {
|
||||
getSessionBindingService().touch(pluginOwnedBinding.bindingId);
|
||||
touchConversationBindingRecord(pluginOwnedBinding.bindingId);
|
||||
logVerbose(
|
||||
`plugin-bound inbound routed to ${pluginOwnedBinding.pluginId} conversation=${pluginOwnedBinding.conversationId}`,
|
||||
);
|
||||
|
||||
@@ -99,6 +99,7 @@ const createRegistry = (channels: PluginRegistry["channels"]): PluginRegistry =>
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
@@ -300,7 +301,7 @@ describe("routeReply", () => {
|
||||
});
|
||||
|
||||
it("passes thread id to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi" },
|
||||
channel: "telegram",
|
||||
@@ -308,10 +309,12 @@ describe("routeReply", () => {
|
||||
threadId: 42,
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:123",
|
||||
"hi",
|
||||
expect.objectContaining({ messageThreadId: 42 }),
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
threadId: 42,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -346,17 +349,19 @@ describe("routeReply", () => {
|
||||
});
|
||||
|
||||
it("passes replyToId to Telegram sends", async () => {
|
||||
mocks.sendMessageTelegram.mockClear();
|
||||
mocks.deliverOutboundPayloads.mockResolvedValue([]);
|
||||
await routeReply({
|
||||
payload: { text: "hi", replyToId: "123" },
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
cfg: {} as never,
|
||||
});
|
||||
expect(mocks.sendMessageTelegram).toHaveBeenCalledWith(
|
||||
"telegram:123",
|
||||
"hi",
|
||||
expect.objectContaining({ replyToMessageId: 123 }),
|
||||
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
to: "telegram:123",
|
||||
replyToId: "123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
48
src/bindings/records.ts
Normal file
48
src/bindings/records.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type ConversationRef,
|
||||
type SessionBindingBindInput,
|
||||
type SessionBindingCapabilities,
|
||||
type SessionBindingRecord,
|
||||
type SessionBindingUnbindInput,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
|
||||
// Shared binding record helpers used by both configured bindings and
|
||||
// runtime-created plugin conversation bindings.
|
||||
export async function createConversationBindingRecord(
|
||||
input: SessionBindingBindInput,
|
||||
): Promise<SessionBindingRecord> {
|
||||
return await getSessionBindingService().bind(input);
|
||||
}
|
||||
|
||||
export function getConversationBindingCapabilities(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
}): SessionBindingCapabilities {
|
||||
return getSessionBindingService().getCapabilities(params);
|
||||
}
|
||||
|
||||
export function listSessionBindingRecords(targetSessionKey: string): SessionBindingRecord[] {
|
||||
return getSessionBindingService().listBySession(targetSessionKey);
|
||||
}
|
||||
|
||||
export function resolveConversationBindingRecord(
|
||||
conversation: ConversationRef,
|
||||
): SessionBindingRecord | null {
|
||||
return getSessionBindingService().resolveByConversation(conversation);
|
||||
}
|
||||
|
||||
export function touchConversationBindingRecord(bindingId: string, at?: number): void {
|
||||
const service = getSessionBindingService();
|
||||
if (typeof at === "number") {
|
||||
service.touch(bindingId, at);
|
||||
return;
|
||||
}
|
||||
service.touch(bindingId);
|
||||
}
|
||||
|
||||
export async function unbindConversationBindingRecord(
|
||||
input: SessionBindingUnbindInput,
|
||||
): Promise<SessionBindingRecord[]> {
|
||||
return await getSessionBindingService().unbind(input);
|
||||
}
|
||||
252
src/channels/plugins/acp-bindings.test.ts
Normal file
252
src/channels/plugins/acp-bindings.test.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildConfiguredAcpSessionKey } from "../../acp/persistent-bindings.types.js";
|
||||
|
||||
const resolveAgentConfigMock = vi.hoisted(() => vi.fn());
|
||||
const resolveDefaultAgentIdMock = vi.hoisted(() => vi.fn());
|
||||
const resolveAgentWorkspaceDirMock = vi.hoisted(() => vi.fn());
|
||||
const getChannelPluginMock = vi.hoisted(() => vi.fn());
|
||||
const getActivePluginRegistryMock = vi.hoisted(() => vi.fn());
|
||||
const getActivePluginRegistryVersionMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../agents/agent-scope.js", () => ({
|
||||
resolveAgentConfig: (...args: unknown[]) => resolveAgentConfigMock(...args),
|
||||
resolveDefaultAgentId: (...args: unknown[]) => resolveDefaultAgentIdMock(...args),
|
||||
resolveAgentWorkspaceDir: (...args: unknown[]) => resolveAgentWorkspaceDirMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./index.js", () => ({
|
||||
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/runtime.js", () => ({
|
||||
getActivePluginRegistry: (...args: unknown[]) => getActivePluginRegistryMock(...args),
|
||||
getActivePluginRegistryVersion: (...args: unknown[]) =>
|
||||
getActivePluginRegistryVersionMock(...args),
|
||||
}));
|
||||
|
||||
async function importConfiguredBindings() {
|
||||
const builtins = await import("./configured-binding-builtins.js");
|
||||
builtins.ensureConfiguredBindingBuiltinsRegistered();
|
||||
return await import("./configured-binding-registry.js");
|
||||
}
|
||||
|
||||
function createConfig(options?: { bindingAgentId?: string; accountId?: string }) {
|
||||
return {
|
||||
agents: {
|
||||
list: [{ id: "main" }, { id: "codex" }],
|
||||
},
|
||||
bindings: [
|
||||
{
|
||||
type: "acp",
|
||||
agentId: options?.bindingAgentId ?? "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
accountId: options?.accountId ?? "default",
|
||||
peer: {
|
||||
kind: "channel",
|
||||
id: "1479098716916023408",
|
||||
},
|
||||
},
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createDiscordAcpPlugin(overrides?: {
|
||||
compileConfiguredBinding?: ReturnType<typeof vi.fn>;
|
||||
matchInboundConversation?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const compileConfiguredBinding =
|
||||
overrides?.compileConfiguredBinding ??
|
||||
vi.fn(({ conversationId }: { conversationId: string }) => ({
|
||||
conversationId,
|
||||
}));
|
||||
const matchInboundConversation =
|
||||
overrides?.matchInboundConversation ??
|
||||
vi.fn(
|
||||
({
|
||||
compiledBinding,
|
||||
conversationId,
|
||||
parentConversationId,
|
||||
}: {
|
||||
compiledBinding: { conversationId: string };
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) => {
|
||||
if (compiledBinding.conversationId === conversationId) {
|
||||
return { conversationId, matchPriority: 2 };
|
||||
}
|
||||
if (parentConversationId && compiledBinding.conversationId === parentConversationId) {
|
||||
return { conversationId: parentConversationId, matchPriority: 1 };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
return {
|
||||
id: "discord",
|
||||
bindings: {
|
||||
compileConfiguredBinding,
|
||||
matchInboundConversation,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("configured binding registry", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
resolveAgentConfigMock.mockReset().mockReturnValue(undefined);
|
||||
resolveDefaultAgentIdMock.mockReset().mockReturnValue("main");
|
||||
resolveAgentWorkspaceDirMock.mockReset().mockReturnValue("/tmp/workspace");
|
||||
getChannelPluginMock.mockReset();
|
||||
getActivePluginRegistryMock.mockReset().mockReturnValue({ channels: [] });
|
||||
getActivePluginRegistryVersionMock.mockReset().mockReturnValue(1);
|
||||
});
|
||||
|
||||
it("resolves configured ACP bindings from an already loaded channel plugin", async () => {
|
||||
const plugin = createDiscordAcpPlugin();
|
||||
getChannelPluginMock.mockReturnValue(plugin);
|
||||
const bindingRegistry = await importConfiguredBindings();
|
||||
|
||||
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
|
||||
cfg: createConfig() as never,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
});
|
||||
|
||||
expect(resolved?.record.conversation.channel).toBe("discord");
|
||||
expect(resolved?.record.metadata?.backend).toBe("acpx");
|
||||
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves configured ACP bindings from canonical conversation refs", async () => {
|
||||
const plugin = createDiscordAcpPlugin();
|
||||
getChannelPluginMock.mockReturnValue(plugin);
|
||||
const bindingRegistry = await importConfiguredBindings();
|
||||
|
||||
const resolved = bindingRegistry.resolveConfiguredBinding({
|
||||
cfg: createConfig() as never,
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved?.conversation).toEqual({
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
});
|
||||
expect(resolved?.record.conversation.channel).toBe("discord");
|
||||
expect(resolved?.statefulTarget).toEqual({
|
||||
kind: "stateful",
|
||||
driverId: "acp",
|
||||
sessionKey: resolved?.record.targetSessionKey,
|
||||
agentId: "codex",
|
||||
label: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("primes compiled ACP bindings from the already loaded active registry once", async () => {
|
||||
const plugin = createDiscordAcpPlugin();
|
||||
const cfg = createConfig({ bindingAgentId: "codex" });
|
||||
getChannelPluginMock.mockReturnValue(undefined);
|
||||
getActivePluginRegistryMock.mockReturnValue({
|
||||
channels: [{ plugin }],
|
||||
});
|
||||
const bindingRegistry = await importConfiguredBindings();
|
||||
|
||||
const primed = bindingRegistry.primeConfiguredBindingRegistry({
|
||||
cfg: cfg as never,
|
||||
});
|
||||
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
|
||||
cfg: cfg as never,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
});
|
||||
|
||||
expect(primed).toEqual({ bindingCount: 1, channelCount: 1 });
|
||||
expect(resolved?.statefulTarget.agentId).toBe("codex");
|
||||
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(1);
|
||||
|
||||
const second = bindingRegistry.resolveConfiguredBindingRecord({
|
||||
cfg: cfg as never,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
});
|
||||
|
||||
expect(second?.statefulTarget.agentId).toBe("codex");
|
||||
});
|
||||
|
||||
it("resolves wildcard binding session keys from the compiled registry", async () => {
|
||||
const plugin = createDiscordAcpPlugin();
|
||||
getChannelPluginMock.mockReturnValue(plugin);
|
||||
const bindingRegistry = await importConfiguredBindings();
|
||||
|
||||
const resolved = bindingRegistry.resolveConfiguredBindingRecordBySessionKey({
|
||||
cfg: createConfig({ accountId: "*" }) as never,
|
||||
sessionKey: buildConfiguredAcpSessionKey({
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
conversationId: "1479098716916023408",
|
||||
agentId: "codex",
|
||||
mode: "persistent",
|
||||
backend: "acpx",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(resolved?.record.conversation.channel).toBe("discord");
|
||||
expect(resolved?.record.conversation.accountId).toBe("work");
|
||||
expect(resolved?.record.metadata?.backend).toBe("acpx");
|
||||
});
|
||||
|
||||
it("does not perform late plugin discovery when a channel plugin is unavailable", async () => {
|
||||
const bindingRegistry = await importConfiguredBindings();
|
||||
|
||||
const resolved = bindingRegistry.resolveConfiguredBindingRecord({
|
||||
cfg: createConfig() as never,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
});
|
||||
|
||||
expect(resolved).toBeNull();
|
||||
});
|
||||
|
||||
it("rebuilds the compiled registry when the active plugin registry version changes", async () => {
|
||||
const plugin = createDiscordAcpPlugin();
|
||||
getChannelPluginMock.mockReturnValue(plugin);
|
||||
getActivePluginRegistryVersionMock.mockReturnValue(10);
|
||||
const cfg = createConfig();
|
||||
const bindingRegistry = await importConfiguredBindings();
|
||||
|
||||
bindingRegistry.resolveConfiguredBindingRecord({
|
||||
cfg: cfg as never,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
});
|
||||
bindingRegistry.resolveConfiguredBindingRecord({
|
||||
cfg: cfg as never,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
});
|
||||
|
||||
getActivePluginRegistryVersionMock.mockReturnValue(11);
|
||||
bindingRegistry.resolveConfiguredBindingRecord({
|
||||
cfg: cfg as never,
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "1479098716916023408",
|
||||
});
|
||||
|
||||
expect(plugin.bindings?.compileConfiguredBinding).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
155
src/channels/plugins/acp-configured-binding-consumer.ts
Normal file
155
src/channels/plugins/acp-configured-binding-consumer.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
buildConfiguredAcpSessionKey,
|
||||
normalizeBindingConfig,
|
||||
normalizeMode,
|
||||
normalizeText,
|
||||
parseConfiguredAcpSessionKey,
|
||||
toConfiguredAcpBindingRecord,
|
||||
type ConfiguredAcpBindingSpec,
|
||||
} from "../../acp/persistent-bindings.types.js";
|
||||
import {
|
||||
resolveAgentConfig,
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentId,
|
||||
} from "../../agents/agent-scope.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type {
|
||||
ConfiguredBindingRuleConfig,
|
||||
ConfiguredBindingTargetFactory,
|
||||
} from "./binding-types.js";
|
||||
import type { ConfiguredBindingConsumer } from "./configured-binding-consumers.js";
|
||||
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
|
||||
|
||||
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
|
||||
acpAgentId?: string;
|
||||
mode?: string;
|
||||
cwd?: string;
|
||||
backend?: string;
|
||||
} {
|
||||
const agent = params.cfg.agents?.list?.find(
|
||||
(entry) => entry.id?.trim().toLowerCase() === params.ownerAgentId.toLowerCase(),
|
||||
);
|
||||
if (!agent || agent.runtime?.type !== "acp") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
acpAgentId: normalizeText(agent.runtime.acp?.agent),
|
||||
mode: normalizeText(agent.runtime.acp?.mode),
|
||||
cwd: normalizeText(agent.runtime.acp?.cwd),
|
||||
backend: normalizeText(agent.runtime.acp?.backend),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredBindingWorkspaceCwd(params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
}): string | undefined {
|
||||
const explicitAgentWorkspace = normalizeText(
|
||||
resolveAgentConfig(params.cfg, params.agentId)?.workspace,
|
||||
);
|
||||
if (explicitAgentWorkspace) {
|
||||
return resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||
}
|
||||
if (params.agentId === resolveDefaultAgentId(params.cfg)) {
|
||||
const defaultWorkspace = normalizeText(params.cfg.agents?.defaults?.workspace);
|
||||
if (defaultWorkspace) {
|
||||
return resolveAgentWorkspaceDir(params.cfg, params.agentId);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildConfiguredAcpSpec(params: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversation: ChannelConfiguredBindingConversationRef;
|
||||
agentId: string;
|
||||
acpAgentId?: string;
|
||||
mode: "persistent" | "oneshot";
|
||||
cwd?: string;
|
||||
backend?: string;
|
||||
label?: string;
|
||||
}): ConfiguredAcpBindingSpec {
|
||||
return {
|
||||
channel: params.channel as ConfiguredAcpBindingSpec["channel"],
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversation.conversationId,
|
||||
parentConversationId: params.conversation.parentConversationId,
|
||||
agentId: params.agentId,
|
||||
acpAgentId: params.acpAgentId,
|
||||
mode: params.mode,
|
||||
cwd: params.cwd,
|
||||
backend: params.backend,
|
||||
label: params.label,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAcpTargetFactory(params: {
|
||||
cfg: OpenClawConfig;
|
||||
binding: ConfiguredBindingRuleConfig;
|
||||
channel: string;
|
||||
agentId: string;
|
||||
}): ConfiguredBindingTargetFactory | null {
|
||||
if (params.binding.type !== "acp") {
|
||||
return null;
|
||||
}
|
||||
const runtimeDefaults = resolveAgentRuntimeAcpDefaults({
|
||||
cfg: params.cfg,
|
||||
ownerAgentId: params.agentId,
|
||||
});
|
||||
const bindingOverrides = normalizeBindingConfig(params.binding.acp);
|
||||
const mode = normalizeMode(bindingOverrides.mode ?? runtimeDefaults.mode);
|
||||
const cwd =
|
||||
bindingOverrides.cwd ??
|
||||
runtimeDefaults.cwd ??
|
||||
resolveConfiguredBindingWorkspaceCwd({
|
||||
cfg: params.cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const backend = bindingOverrides.backend ?? runtimeDefaults.backend;
|
||||
const label = bindingOverrides.label;
|
||||
const acpAgentId = normalizeText(runtimeDefaults.acpAgentId);
|
||||
|
||||
return {
|
||||
driverId: "acp",
|
||||
materialize: ({ accountId, conversation }) => {
|
||||
const spec = buildConfiguredAcpSpec({
|
||||
channel: params.channel,
|
||||
accountId,
|
||||
conversation,
|
||||
agentId: params.agentId,
|
||||
acpAgentId,
|
||||
mode,
|
||||
cwd,
|
||||
backend,
|
||||
label,
|
||||
});
|
||||
const record = toConfiguredAcpBindingRecord(spec);
|
||||
return {
|
||||
record,
|
||||
statefulTarget: {
|
||||
kind: "stateful",
|
||||
driverId: "acp",
|
||||
sessionKey: buildConfiguredAcpSessionKey(spec),
|
||||
agentId: params.agentId,
|
||||
...(label ? { label } : {}),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = {
|
||||
id: "acp",
|
||||
supports: (binding) => binding.type === "acp",
|
||||
buildTargetFactory: (params) =>
|
||||
buildAcpTargetFactory({
|
||||
cfg: params.cfg,
|
||||
binding: params.binding,
|
||||
channel: params.channel,
|
||||
agentId: params.agentId,
|
||||
}),
|
||||
parseSessionKey: ({ sessionKey }) => parseConfiguredAcpSessionKey(sessionKey),
|
||||
matchesSessionKey: ({ sessionKey, materializedTarget }) =>
|
||||
materializedTarget.record.targetSessionKey === sessionKey,
|
||||
};
|
||||
102
src/channels/plugins/acp-stateful-target-driver.ts
Normal file
102
src/channels/plugins/acp-stateful-target-driver.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ensureConfiguredAcpBindingReady,
|
||||
ensureConfiguredAcpBindingSession,
|
||||
resetAcpSessionInPlace,
|
||||
} from "../../acp/persistent-bindings.lifecycle.js";
|
||||
import { resolveConfiguredAcpBindingSpecBySessionKey } from "../../acp/persistent-bindings.resolve.js";
|
||||
import { resolveConfiguredAcpBindingSpecFromRecord } from "../../acp/persistent-bindings.types.js";
|
||||
import { readAcpSessionEntry } from "../../acp/runtime/session-meta.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type {
|
||||
ConfiguredBindingResolution,
|
||||
StatefulBindingTargetDescriptor,
|
||||
} from "./binding-types.js";
|
||||
import type {
|
||||
StatefulBindingTargetDriver,
|
||||
StatefulBindingTargetResetResult,
|
||||
StatefulBindingTargetReadyResult,
|
||||
StatefulBindingTargetSessionResult,
|
||||
} from "./stateful-target-drivers.js";
|
||||
|
||||
function toAcpStatefulBindingTargetDescriptor(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): StatefulBindingTargetDescriptor | null {
|
||||
const meta = readAcpSessionEntry(params)?.acp;
|
||||
const metaAgentId = meta?.agent?.trim();
|
||||
if (metaAgentId) {
|
||||
return {
|
||||
kind: "stateful",
|
||||
driverId: "acp",
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: metaAgentId,
|
||||
};
|
||||
}
|
||||
const spec = resolveConfiguredAcpBindingSpecBySessionKey(params);
|
||||
if (!spec) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
kind: "stateful",
|
||||
driverId: "acp",
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: spec.agentId,
|
||||
...(spec.label ? { label: spec.label } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureAcpTargetReady(params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindingResolution: ConfiguredBindingResolution;
|
||||
}): Promise<StatefulBindingTargetReadyResult> {
|
||||
const configuredBinding = resolveConfiguredAcpBindingSpecFromRecord(
|
||||
params.bindingResolution.record,
|
||||
);
|
||||
if (!configuredBinding) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "Configured ACP binding unavailable",
|
||||
};
|
||||
}
|
||||
return await ensureConfiguredAcpBindingReady({
|
||||
cfg: params.cfg,
|
||||
configuredBinding: {
|
||||
spec: configuredBinding,
|
||||
record: params.bindingResolution.record,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureAcpTargetSession(params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindingResolution: ConfiguredBindingResolution;
|
||||
}): Promise<StatefulBindingTargetSessionResult> {
|
||||
const spec = resolveConfiguredAcpBindingSpecFromRecord(params.bindingResolution.record);
|
||||
if (!spec) {
|
||||
return {
|
||||
ok: false,
|
||||
sessionKey: params.bindingResolution.statefulTarget.sessionKey,
|
||||
error: "Configured ACP binding unavailable",
|
||||
};
|
||||
}
|
||||
return await ensureConfiguredAcpBindingSession({
|
||||
cfg: params.cfg,
|
||||
spec,
|
||||
});
|
||||
}
|
||||
|
||||
async function resetAcpTargetInPlace(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
reason: "new" | "reset";
|
||||
}): Promise<StatefulBindingTargetResetResult> {
|
||||
return await resetAcpSessionInPlace(params);
|
||||
}
|
||||
|
||||
export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = {
|
||||
id: "acp",
|
||||
ensureReady: ensureAcpTargetReady,
|
||||
ensureSession: ensureAcpTargetSession,
|
||||
resolveTargetBySessionKey: toAcpStatefulBindingTargetDescriptor,
|
||||
resetInPlace: resetAcpTargetInPlace,
|
||||
};
|
||||
14
src/channels/plugins/binding-provider.ts
Normal file
14
src/channels/plugins/binding-provider.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ChannelConfiguredBindingProvider } from "./types.adapters.js";
|
||||
import type { ChannelPlugin } from "./types.plugin.js";
|
||||
|
||||
export function resolveChannelConfiguredBindingProvider(
|
||||
plugin:
|
||||
| Pick<ChannelPlugin, "bindings">
|
||||
| {
|
||||
bindings?: ChannelConfiguredBindingProvider;
|
||||
}
|
||||
| null
|
||||
| undefined,
|
||||
): ChannelConfiguredBindingProvider | undefined {
|
||||
return plugin?.bindings;
|
||||
}
|
||||
46
src/channels/plugins/binding-registry.ts
Normal file
46
src/channels/plugins/binding-registry.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { ensureConfiguredBindingBuiltinsRegistered } from "./configured-binding-builtins.js";
|
||||
import {
|
||||
primeConfiguredBindingRegistry as primeConfiguredBindingRegistryRaw,
|
||||
resolveConfiguredBinding as resolveConfiguredBindingRaw,
|
||||
resolveConfiguredBindingRecord as resolveConfiguredBindingRecordRaw,
|
||||
resolveConfiguredBindingRecordBySessionKey as resolveConfiguredBindingRecordBySessionKeyRaw,
|
||||
resolveConfiguredBindingRecordForConversation as resolveConfiguredBindingRecordForConversationRaw,
|
||||
} from "./configured-binding-registry.js";
|
||||
|
||||
// Thin public wrapper around the configured-binding registry. Runtime plugin
|
||||
// conversation bindings use a separate approval-driven path in src/plugins/.
|
||||
|
||||
export function primeConfiguredBindingRegistry(
|
||||
...args: Parameters<typeof primeConfiguredBindingRegistryRaw>
|
||||
): ReturnType<typeof primeConfiguredBindingRegistryRaw> {
|
||||
ensureConfiguredBindingBuiltinsRegistered();
|
||||
return primeConfiguredBindingRegistryRaw(...args);
|
||||
}
|
||||
|
||||
export function resolveConfiguredBindingRecord(
|
||||
...args: Parameters<typeof resolveConfiguredBindingRecordRaw>
|
||||
): ReturnType<typeof resolveConfiguredBindingRecordRaw> {
|
||||
ensureConfiguredBindingBuiltinsRegistered();
|
||||
return resolveConfiguredBindingRecordRaw(...args);
|
||||
}
|
||||
|
||||
export function resolveConfiguredBindingRecordForConversation(
|
||||
...args: Parameters<typeof resolveConfiguredBindingRecordForConversationRaw>
|
||||
): ReturnType<typeof resolveConfiguredBindingRecordForConversationRaw> {
|
||||
ensureConfiguredBindingBuiltinsRegistered();
|
||||
return resolveConfiguredBindingRecordForConversationRaw(...args);
|
||||
}
|
||||
|
||||
export function resolveConfiguredBinding(
|
||||
...args: Parameters<typeof resolveConfiguredBindingRaw>
|
||||
): ReturnType<typeof resolveConfiguredBindingRaw> {
|
||||
ensureConfiguredBindingBuiltinsRegistered();
|
||||
return resolveConfiguredBindingRaw(...args);
|
||||
}
|
||||
|
||||
export function resolveConfiguredBindingRecordBySessionKey(
|
||||
...args: Parameters<typeof resolveConfiguredBindingRecordBySessionKeyRaw>
|
||||
): ReturnType<typeof resolveConfiguredBindingRecordBySessionKeyRaw> {
|
||||
ensureConfiguredBindingBuiltinsRegistered();
|
||||
return resolveConfiguredBindingRecordBySessionKeyRaw(...args);
|
||||
}
|
||||
91
src/channels/plugins/binding-routing.ts
Normal file
91
src/channels/plugins/binding-routing.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
|
||||
import type { ResolvedAgentRoute } from "../../routing/resolve-route.js";
|
||||
import { deriveLastRoutePolicy } from "../../routing/resolve-route.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { resolveConfiguredBinding } from "./binding-registry.js";
|
||||
import { ensureConfiguredBindingTargetReady } from "./binding-targets.js";
|
||||
import type { ConfiguredBindingResolution } from "./binding-types.js";
|
||||
|
||||
export type ConfiguredBindingRouteResult = {
|
||||
bindingResolution: ConfiguredBindingResolution | null;
|
||||
route: ResolvedAgentRoute;
|
||||
boundSessionKey?: string;
|
||||
boundAgentId?: string;
|
||||
};
|
||||
|
||||
type ConfiguredBindingRouteConversationInput =
|
||||
| {
|
||||
conversation: ConversationRef;
|
||||
}
|
||||
| {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
|
||||
function resolveConfiguredBindingConversationRef(
|
||||
params: ConfiguredBindingRouteConversationInput,
|
||||
): ConversationRef {
|
||||
if ("conversation" in params) {
|
||||
return params.conversation;
|
||||
}
|
||||
return {
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConfiguredBindingRoute(
|
||||
params: {
|
||||
cfg: OpenClawConfig;
|
||||
route: ResolvedAgentRoute;
|
||||
} & ConfiguredBindingRouteConversationInput,
|
||||
): ConfiguredBindingRouteResult {
|
||||
const bindingResolution =
|
||||
resolveConfiguredBinding({
|
||||
cfg: params.cfg,
|
||||
conversation: resolveConfiguredBindingConversationRef(params),
|
||||
}) ?? null;
|
||||
if (!bindingResolution) {
|
||||
return {
|
||||
bindingResolution: null,
|
||||
route: params.route,
|
||||
};
|
||||
}
|
||||
|
||||
const boundSessionKey = bindingResolution.statefulTarget.sessionKey.trim();
|
||||
if (!boundSessionKey) {
|
||||
return {
|
||||
bindingResolution,
|
||||
route: params.route,
|
||||
};
|
||||
}
|
||||
const boundAgentId =
|
||||
resolveAgentIdFromSessionKey(boundSessionKey) || bindingResolution.statefulTarget.agentId;
|
||||
return {
|
||||
bindingResolution,
|
||||
boundSessionKey,
|
||||
boundAgentId,
|
||||
route: {
|
||||
...params.route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: boundAgentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: params.route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureConfiguredBindingRouteReady(params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindingResolution: ConfiguredBindingResolution | null;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
return await ensureConfiguredBindingTargetReady(params);
|
||||
}
|
||||
209
src/channels/plugins/binding-targets.test.ts
Normal file
209
src/channels/plugins/binding-targets.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
ensureConfiguredBindingTargetReady,
|
||||
ensureConfiguredBindingTargetSession,
|
||||
resetConfiguredBindingTargetInPlace,
|
||||
} from "./binding-targets.js";
|
||||
import type { ConfiguredBindingResolution } from "./binding-types.js";
|
||||
import {
|
||||
registerStatefulBindingTargetDriver,
|
||||
unregisterStatefulBindingTargetDriver,
|
||||
type StatefulBindingTargetDriver,
|
||||
} from "./stateful-target-drivers.js";
|
||||
|
||||
function createBindingResolution(driverId: string): ConfiguredBindingResolution {
|
||||
return {
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "123",
|
||||
},
|
||||
compiledBinding: {
|
||||
channel: "discord",
|
||||
binding: {
|
||||
type: "acp" as const,
|
||||
agentId: "codex",
|
||||
match: {
|
||||
channel: "discord",
|
||||
peer: {
|
||||
kind: "channel" as const,
|
||||
id: "123",
|
||||
},
|
||||
},
|
||||
acp: {
|
||||
mode: "persistent",
|
||||
},
|
||||
},
|
||||
bindingConversationId: "123",
|
||||
target: {
|
||||
conversationId: "123",
|
||||
},
|
||||
agentId: "codex",
|
||||
provider: {
|
||||
compileConfiguredBinding: () => ({
|
||||
conversationId: "123",
|
||||
}),
|
||||
matchInboundConversation: () => ({
|
||||
conversationId: "123",
|
||||
}),
|
||||
},
|
||||
targetFactory: {
|
||||
driverId,
|
||||
materialize: () => ({
|
||||
record: {
|
||||
bindingId: "binding:123",
|
||||
targetSessionKey: `agent:codex:${driverId}`,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "123",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
statefulTarget: {
|
||||
kind: "stateful",
|
||||
driverId,
|
||||
sessionKey: `agent:codex:${driverId}`,
|
||||
agentId: "codex",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
match: {
|
||||
conversationId: "123",
|
||||
},
|
||||
record: {
|
||||
bindingId: "binding:123",
|
||||
targetSessionKey: `agent:codex:${driverId}`,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "default",
|
||||
conversationId: "123",
|
||||
},
|
||||
status: "active",
|
||||
boundAt: 0,
|
||||
},
|
||||
statefulTarget: {
|
||||
kind: "stateful",
|
||||
driverId,
|
||||
sessionKey: `agent:codex:${driverId}`,
|
||||
agentId: "codex",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
unregisterStatefulBindingTargetDriver("test-driver");
|
||||
});
|
||||
|
||||
describe("binding target drivers", () => {
|
||||
it("delegates ensureReady and ensureSession to the resolved driver", async () => {
|
||||
const ensureReady = vi.fn(async () => ({ ok: true as const }));
|
||||
const ensureSession = vi.fn(async () => ({
|
||||
ok: true as const,
|
||||
sessionKey: "agent:codex:test-driver",
|
||||
}));
|
||||
const driver: StatefulBindingTargetDriver = {
|
||||
id: "test-driver",
|
||||
ensureReady,
|
||||
ensureSession,
|
||||
};
|
||||
registerStatefulBindingTargetDriver(driver);
|
||||
|
||||
const bindingResolution = createBindingResolution("test-driver");
|
||||
await expect(
|
||||
ensureConfiguredBindingTargetReady({
|
||||
cfg: {} as never,
|
||||
bindingResolution,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
await expect(
|
||||
ensureConfiguredBindingTargetSession({
|
||||
cfg: {} as never,
|
||||
bindingResolution,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:test-driver",
|
||||
});
|
||||
|
||||
expect(ensureReady).toHaveBeenCalledTimes(1);
|
||||
expect(ensureReady).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
bindingResolution,
|
||||
});
|
||||
expect(ensureSession).toHaveBeenCalledTimes(1);
|
||||
expect(ensureSession).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
bindingResolution,
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves resetInPlace through the driver session-key lookup", async () => {
|
||||
const resetInPlace = vi.fn(async () => ({ ok: true as const }));
|
||||
const driver: StatefulBindingTargetDriver = {
|
||||
id: "test-driver",
|
||||
ensureReady: async () => ({ ok: true }),
|
||||
ensureSession: async () => ({
|
||||
ok: true,
|
||||
sessionKey: "agent:codex:test-driver",
|
||||
}),
|
||||
resolveTargetBySessionKey: ({ sessionKey }) => ({
|
||||
kind: "stateful",
|
||||
driverId: "test-driver",
|
||||
sessionKey,
|
||||
agentId: "codex",
|
||||
}),
|
||||
resetInPlace,
|
||||
};
|
||||
registerStatefulBindingTargetDriver(driver);
|
||||
|
||||
await expect(
|
||||
resetConfiguredBindingTargetInPlace({
|
||||
cfg: {} as never,
|
||||
sessionKey: "agent:codex:test-driver",
|
||||
reason: "reset",
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(resetInPlace).toHaveBeenCalledTimes(1);
|
||||
expect(resetInPlace).toHaveBeenCalledWith({
|
||||
cfg: {} as never,
|
||||
sessionKey: "agent:codex:test-driver",
|
||||
reason: "reset",
|
||||
bindingTarget: {
|
||||
kind: "stateful",
|
||||
driverId: "test-driver",
|
||||
sessionKey: "agent:codex:test-driver",
|
||||
agentId: "codex",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a typed error when no driver is registered", async () => {
|
||||
const bindingResolution = createBindingResolution("missing-driver");
|
||||
|
||||
await expect(
|
||||
ensureConfiguredBindingTargetReady({
|
||||
cfg: {} as never,
|
||||
bindingResolution,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "Configured binding target driver unavailable: missing-driver",
|
||||
});
|
||||
await expect(
|
||||
ensureConfiguredBindingTargetSession({
|
||||
cfg: {} as never,
|
||||
bindingResolution,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: false,
|
||||
sessionKey: "agent:codex:missing-driver",
|
||||
error: "Configured binding target driver unavailable: missing-driver",
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/channels/plugins/binding-targets.ts
Normal file
69
src/channels/plugins/binding-targets.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { ConfiguredBindingResolution } from "./binding-types.js";
|
||||
import { ensureStatefulTargetBuiltinsRegistered } from "./stateful-target-builtins.js";
|
||||
import {
|
||||
getStatefulBindingTargetDriver,
|
||||
resolveStatefulBindingTargetBySessionKey,
|
||||
} from "./stateful-target-drivers.js";
|
||||
|
||||
export async function ensureConfiguredBindingTargetReady(params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindingResolution: ConfiguredBindingResolution | null;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
ensureStatefulTargetBuiltinsRegistered();
|
||||
if (!params.bindingResolution) {
|
||||
return { ok: true };
|
||||
}
|
||||
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
|
||||
if (!driver) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`,
|
||||
};
|
||||
}
|
||||
return await driver.ensureReady({
|
||||
cfg: params.cfg,
|
||||
bindingResolution: params.bindingResolution,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resetConfiguredBindingTargetInPlace(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
reason: "new" | "reset";
|
||||
}): Promise<{ ok: true } | { ok: false; skipped?: boolean; error?: string }> {
|
||||
ensureStatefulTargetBuiltinsRegistered();
|
||||
const resolved = resolveStatefulBindingTargetBySessionKey({
|
||||
cfg: params.cfg,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!resolved?.driver.resetInPlace) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
return await resolved.driver.resetInPlace({
|
||||
...params,
|
||||
bindingTarget: resolved.bindingTarget,
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureConfiguredBindingTargetSession(params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindingResolution: ConfiguredBindingResolution;
|
||||
}): Promise<{ ok: true; sessionKey: string } | { ok: false; sessionKey: string; error: string }> {
|
||||
ensureStatefulTargetBuiltinsRegistered();
|
||||
const driver = getStatefulBindingTargetDriver(params.bindingResolution.statefulTarget.driverId);
|
||||
if (!driver) {
|
||||
return {
|
||||
ok: false,
|
||||
sessionKey: params.bindingResolution.statefulTarget.sessionKey,
|
||||
error: `Configured binding target driver unavailable: ${params.bindingResolution.statefulTarget.driverId}`,
|
||||
};
|
||||
}
|
||||
return await driver.ensureSession({
|
||||
cfg: params.cfg,
|
||||
bindingResolution: params.bindingResolution,
|
||||
});
|
||||
}
|
||||
53
src/channels/plugins/binding-types.ts
Normal file
53
src/channels/plugins/binding-types.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { AgentBinding } from "../../config/types.js";
|
||||
import type {
|
||||
ConversationRef,
|
||||
SessionBindingRecord,
|
||||
} from "../../infra/outbound/session-binding-service.js";
|
||||
import type {
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
ChannelConfiguredBindingProvider,
|
||||
} from "./types.adapters.js";
|
||||
import type { ChannelId } from "./types.js";
|
||||
|
||||
export type ConfiguredBindingConversation = ConversationRef;
|
||||
export type ConfiguredBindingChannel = ChannelId;
|
||||
export type ConfiguredBindingRuleConfig = AgentBinding;
|
||||
|
||||
export type StatefulBindingTargetDescriptor = {
|
||||
kind: "stateful";
|
||||
driverId: string;
|
||||
sessionKey: string;
|
||||
agentId: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type ConfiguredBindingRecordResolution = {
|
||||
record: SessionBindingRecord;
|
||||
statefulTarget: StatefulBindingTargetDescriptor;
|
||||
};
|
||||
|
||||
export type ConfiguredBindingTargetFactory = {
|
||||
driverId: string;
|
||||
materialize: (params: {
|
||||
accountId: string;
|
||||
conversation: ChannelConfiguredBindingConversationRef;
|
||||
}) => ConfiguredBindingRecordResolution;
|
||||
};
|
||||
|
||||
export type CompiledConfiguredBinding = {
|
||||
channel: ConfiguredBindingChannel;
|
||||
accountPattern?: string;
|
||||
binding: ConfiguredBindingRuleConfig;
|
||||
bindingConversationId: string;
|
||||
target: ChannelConfiguredBindingConversationRef;
|
||||
agentId: string;
|
||||
provider: ChannelConfiguredBindingProvider;
|
||||
targetFactory: ConfiguredBindingTargetFactory;
|
||||
};
|
||||
|
||||
export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & {
|
||||
conversation: ConfiguredBindingConversation;
|
||||
compiledBinding: CompiledConfiguredBinding;
|
||||
match: ChannelConfiguredBindingMatch;
|
||||
};
|
||||
13
src/channels/plugins/configured-binding-builtins.ts
Normal file
13
src/channels/plugins/configured-binding-builtins.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { acpConfiguredBindingConsumer } from "./acp-configured-binding-consumer.js";
|
||||
import {
|
||||
registerConfiguredBindingConsumer,
|
||||
unregisterConfiguredBindingConsumer,
|
||||
} from "./configured-binding-consumers.js";
|
||||
|
||||
export function ensureConfiguredBindingBuiltinsRegistered(): void {
|
||||
registerConfiguredBindingConsumer(acpConfiguredBindingConsumer);
|
||||
}
|
||||
|
||||
export function resetConfiguredBindingBuiltinsForTesting(): void {
|
||||
unregisterConfiguredBindingConsumer(acpConfiguredBindingConsumer.id);
|
||||
}
|
||||
240
src/channels/plugins/configured-binding-compiler.ts
Normal file
240
src/channels/plugins/configured-binding-compiler.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { listConfiguredBindings } from "../../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { getActivePluginRegistry, getActivePluginRegistryVersion } from "../../plugins/runtime.js";
|
||||
import { pickFirstExistingAgentId } from "../../routing/resolve-route.js";
|
||||
import { resolveChannelConfiguredBindingProvider } from "./binding-provider.js";
|
||||
import type { CompiledConfiguredBinding, ConfiguredBindingChannel } from "./binding-types.js";
|
||||
import { resolveConfiguredBindingConsumer } from "./configured-binding-consumers.js";
|
||||
import { getChannelPlugin } from "./index.js";
|
||||
import type {
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingProvider,
|
||||
} from "./types.adapters.js";
|
||||
|
||||
// Configured bindings are channel-owned rules compiled from config, separate
|
||||
// from runtime plugin-owned conversation bindings.
|
||||
|
||||
type ChannelPluginLike = NonNullable<ReturnType<typeof getChannelPlugin>>;
|
||||
|
||||
export type CompiledConfiguredBindingRegistry = {
|
||||
rulesByChannel: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>;
|
||||
};
|
||||
|
||||
type CachedCompiledConfiguredBindingRegistry = {
|
||||
registryVersion: number;
|
||||
registry: CompiledConfiguredBindingRegistry;
|
||||
};
|
||||
|
||||
const compiledRegistryCache = new WeakMap<
|
||||
OpenClawConfig,
|
||||
CachedCompiledConfiguredBindingRegistry
|
||||
>();
|
||||
|
||||
function findChannelPlugin(params: {
|
||||
registry:
|
||||
| {
|
||||
channels?: Array<{ plugin?: ChannelPluginLike | null } | null> | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
channel: string;
|
||||
}): ChannelPluginLike | undefined {
|
||||
return (
|
||||
params.registry?.channels?.find((entry) => entry?.plugin?.id === params.channel)?.plugin ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLoadedChannelPlugin(channel: string) {
|
||||
const normalized = channel.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const current = getChannelPlugin(normalized as ConfiguredBindingChannel);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return findChannelPlugin({
|
||||
registry: getActivePluginRegistry(),
|
||||
channel: normalized,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveConfiguredBindingAdapter(channel: string): {
|
||||
channel: ConfiguredBindingChannel;
|
||||
provider: ChannelConfiguredBindingProvider;
|
||||
} | null {
|
||||
const normalized = channel.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
const plugin = resolveLoadedChannelPlugin(normalized);
|
||||
const provider = resolveChannelConfiguredBindingProvider(plugin);
|
||||
if (
|
||||
!plugin ||
|
||||
!provider ||
|
||||
!provider.compileConfiguredBinding ||
|
||||
!provider.matchInboundConversation
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: plugin.id,
|
||||
provider,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveBindingConversationId(binding: {
|
||||
match?: { peer?: { id?: string } };
|
||||
}): string | null {
|
||||
const id = binding.match?.peer?.id?.trim();
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
function compileConfiguredBindingTarget(params: {
|
||||
provider: ChannelConfiguredBindingProvider;
|
||||
binding: CompiledConfiguredBinding["binding"];
|
||||
conversationId: string;
|
||||
}): ChannelConfiguredBindingConversationRef | null {
|
||||
return params.provider.compileConfiguredBinding({
|
||||
binding: params.binding,
|
||||
conversationId: params.conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
function compileConfiguredBindingRule(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: ConfiguredBindingChannel;
|
||||
binding: CompiledConfiguredBinding["binding"];
|
||||
target: ChannelConfiguredBindingConversationRef;
|
||||
bindingConversationId: string;
|
||||
provider: ChannelConfiguredBindingProvider;
|
||||
}): CompiledConfiguredBinding | null {
|
||||
const agentId = pickFirstExistingAgentId(params.cfg, params.binding.agentId ?? "main");
|
||||
const consumer = resolveConfiguredBindingConsumer(params.binding);
|
||||
if (!consumer) {
|
||||
return null;
|
||||
}
|
||||
const targetFactory = consumer.buildTargetFactory({
|
||||
cfg: params.cfg,
|
||||
binding: params.binding,
|
||||
channel: params.channel,
|
||||
agentId,
|
||||
target: params.target,
|
||||
bindingConversationId: params.bindingConversationId,
|
||||
});
|
||||
if (!targetFactory) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel: params.channel,
|
||||
accountPattern: params.binding.match.accountId?.trim() || undefined,
|
||||
binding: params.binding,
|
||||
bindingConversationId: params.bindingConversationId,
|
||||
target: params.target,
|
||||
agentId,
|
||||
provider: params.provider,
|
||||
targetFactory,
|
||||
};
|
||||
}
|
||||
|
||||
function pushCompiledRule(
|
||||
target: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>,
|
||||
rule: CompiledConfiguredBinding,
|
||||
) {
|
||||
const existing = target.get(rule.channel);
|
||||
if (existing) {
|
||||
existing.push(rule);
|
||||
return;
|
||||
}
|
||||
target.set(rule.channel, [rule]);
|
||||
}
|
||||
|
||||
function compileConfiguredBindingRegistry(params: {
|
||||
cfg: OpenClawConfig;
|
||||
}): CompiledConfiguredBindingRegistry {
|
||||
const rulesByChannel = new Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>();
|
||||
|
||||
for (const binding of listConfiguredBindings(params.cfg)) {
|
||||
const bindingConversationId = resolveBindingConversationId(binding);
|
||||
if (!bindingConversationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const resolvedChannel = resolveConfiguredBindingAdapter(binding.match.channel);
|
||||
if (!resolvedChannel) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const target = compileConfiguredBindingTarget({
|
||||
provider: resolvedChannel.provider,
|
||||
binding,
|
||||
conversationId: bindingConversationId,
|
||||
});
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rule = compileConfiguredBindingRule({
|
||||
cfg: params.cfg,
|
||||
channel: resolvedChannel.channel,
|
||||
binding,
|
||||
target,
|
||||
bindingConversationId,
|
||||
provider: resolvedChannel.provider,
|
||||
});
|
||||
if (!rule) {
|
||||
continue;
|
||||
}
|
||||
pushCompiledRule(rulesByChannel, rule);
|
||||
}
|
||||
|
||||
return {
|
||||
rulesByChannel,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCompiledBindingRegistry(
|
||||
cfg: OpenClawConfig,
|
||||
): CompiledConfiguredBindingRegistry {
|
||||
const registryVersion = getActivePluginRegistryVersion();
|
||||
const cached = compiledRegistryCache.get(cfg);
|
||||
if (cached?.registryVersion === registryVersion) {
|
||||
return cached.registry;
|
||||
}
|
||||
|
||||
const registry = compileConfiguredBindingRegistry({
|
||||
cfg,
|
||||
});
|
||||
compiledRegistryCache.set(cfg, {
|
||||
registryVersion,
|
||||
registry,
|
||||
});
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function primeCompiledBindingRegistry(
|
||||
cfg: OpenClawConfig,
|
||||
): CompiledConfiguredBindingRegistry {
|
||||
const registry = compileConfiguredBindingRegistry({ cfg });
|
||||
compiledRegistryCache.set(cfg, {
|
||||
registryVersion: getActivePluginRegistryVersion(),
|
||||
registry,
|
||||
});
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): {
|
||||
bindingCount: number;
|
||||
channelCount: number;
|
||||
} {
|
||||
return {
|
||||
bindingCount: [...registry.rulesByChannel.values()].reduce(
|
||||
(sum, rules) => sum + rules.length,
|
||||
0,
|
||||
),
|
||||
channelCount: registry.rulesByChannel.size,
|
||||
};
|
||||
}
|
||||
69
src/channels/plugins/configured-binding-consumers.ts
Normal file
69
src/channels/plugins/configured-binding-consumers.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type {
|
||||
CompiledConfiguredBinding,
|
||||
ConfiguredBindingRecordResolution,
|
||||
ConfiguredBindingRuleConfig,
|
||||
ConfiguredBindingTargetFactory,
|
||||
} from "./binding-types.js";
|
||||
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
|
||||
|
||||
export type ParsedConfiguredBindingSessionKey = {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
export type ConfiguredBindingConsumer = {
|
||||
id: string;
|
||||
supports: (binding: ConfiguredBindingRuleConfig) => boolean;
|
||||
buildTargetFactory: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
binding: ConfiguredBindingRuleConfig;
|
||||
channel: string;
|
||||
agentId: string;
|
||||
target: ChannelConfiguredBindingConversationRef;
|
||||
bindingConversationId: string;
|
||||
}) => ConfiguredBindingTargetFactory | null;
|
||||
parseSessionKey?: (params: { sessionKey: string }) => ParsedConfiguredBindingSessionKey | null;
|
||||
matchesSessionKey?: (params: {
|
||||
sessionKey: string;
|
||||
compiledBinding: CompiledConfiguredBinding;
|
||||
accountId: string;
|
||||
materializedTarget: ConfiguredBindingRecordResolution;
|
||||
}) => boolean;
|
||||
};
|
||||
|
||||
const registeredConfiguredBindingConsumers = new Map<string, ConfiguredBindingConsumer>();
|
||||
|
||||
export function listConfiguredBindingConsumers(): ConfiguredBindingConsumer[] {
|
||||
return [...registeredConfiguredBindingConsumers.values()];
|
||||
}
|
||||
|
||||
export function resolveConfiguredBindingConsumer(
|
||||
binding: ConfiguredBindingRuleConfig,
|
||||
): ConfiguredBindingConsumer | null {
|
||||
for (const consumer of listConfiguredBindingConsumers()) {
|
||||
if (consumer.supports(binding)) {
|
||||
return consumer;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerConfiguredBindingConsumer(consumer: ConfiguredBindingConsumer): void {
|
||||
const id = consumer.id.trim();
|
||||
if (!id) {
|
||||
throw new Error("Configured binding consumer id is required");
|
||||
}
|
||||
const existing = registeredConfiguredBindingConsumers.get(id);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
registeredConfiguredBindingConsumers.set(id, {
|
||||
...consumer,
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export function unregisterConfiguredBindingConsumer(id: string): void {
|
||||
registeredConfiguredBindingConsumers.delete(id.trim());
|
||||
}
|
||||
116
src/channels/plugins/configured-binding-match.ts
Normal file
116
src/channels/plugins/configured-binding-match.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import type {
|
||||
CompiledConfiguredBinding,
|
||||
ConfiguredBindingChannel,
|
||||
ConfiguredBindingRecordResolution,
|
||||
} from "./binding-types.js";
|
||||
import type {
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
} from "./types.adapters.js";
|
||||
|
||||
export function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
|
||||
const trimmed = (match ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return actual === DEFAULT_ACCOUNT_ID ? 2 : 0;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return 1;
|
||||
}
|
||||
return normalizeAccountId(trimmed) === actual ? 2 : 0;
|
||||
}
|
||||
|
||||
function matchCompiledBindingConversation(params: {
|
||||
rule: CompiledConfiguredBinding;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): ChannelConfiguredBindingMatch | null {
|
||||
return params.rule.provider.matchInboundConversation({
|
||||
binding: params.rule.binding,
|
||||
compiledBinding: params.rule.target,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveCompiledBindingChannel(raw: string): ConfiguredBindingChannel | null {
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
return normalized ? (normalized as ConfiguredBindingChannel) : null;
|
||||
}
|
||||
|
||||
export function toConfiguredBindingConversationRef(conversation: ConversationRef): {
|
||||
channel: ConfiguredBindingChannel;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null {
|
||||
const channel = resolveCompiledBindingChannel(conversation.channel);
|
||||
const conversationId = conversation.conversationId.trim();
|
||||
if (!channel || !conversationId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
accountId: normalizeAccountId(conversation.accountId),
|
||||
conversationId,
|
||||
parentConversationId: conversation.parentConversationId?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function materializeConfiguredBindingRecord(params: {
|
||||
rule: CompiledConfiguredBinding;
|
||||
accountId: string;
|
||||
conversation: ChannelConfiguredBindingConversationRef;
|
||||
}): ConfiguredBindingRecordResolution {
|
||||
return params.rule.targetFactory.materialize({
|
||||
accountId: normalizeAccountId(params.accountId),
|
||||
conversation: params.conversation,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveMatchingConfiguredBinding(params: {
|
||||
rules: CompiledConfiguredBinding[];
|
||||
conversation: ReturnType<typeof toConfiguredBindingConversationRef>;
|
||||
}): { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null {
|
||||
if (!params.conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let wildcardMatch: {
|
||||
rule: CompiledConfiguredBinding;
|
||||
match: ChannelConfiguredBindingMatch;
|
||||
} | null = null;
|
||||
let exactMatch: { rule: CompiledConfiguredBinding; match: ChannelConfiguredBindingMatch } | null =
|
||||
null;
|
||||
|
||||
for (const rule of params.rules) {
|
||||
const accountMatchPriority = resolveAccountMatchPriority(
|
||||
rule.accountPattern,
|
||||
params.conversation.accountId,
|
||||
);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const match = matchCompiledBindingConversation({
|
||||
rule,
|
||||
conversationId: params.conversation.conversationId,
|
||||
parentConversationId: params.conversation.parentConversationId,
|
||||
});
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const matchPriority = match.matchPriority ?? 0;
|
||||
if (accountMatchPriority === 2) {
|
||||
if (!exactMatch || matchPriority > (exactMatch.match.matchPriority ?? 0)) {
|
||||
exactMatch = { rule, match };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!wildcardMatch || matchPriority > (wildcardMatch.match.matchPriority ?? 0)) {
|
||||
wildcardMatch = { rule, match };
|
||||
}
|
||||
}
|
||||
|
||||
return exactMatch ?? wildcardMatch;
|
||||
}
|
||||
116
src/channels/plugins/configured-binding-registry.ts
Normal file
116
src/channels/plugins/configured-binding-registry.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { ConversationRef } from "../../infra/outbound/session-binding-service.js";
|
||||
import type {
|
||||
ConfiguredBindingRecordResolution,
|
||||
ConfiguredBindingResolution,
|
||||
} from "./binding-types.js";
|
||||
import {
|
||||
countCompiledBindingRegistry,
|
||||
primeCompiledBindingRegistry,
|
||||
resolveCompiledBindingRegistry,
|
||||
} from "./configured-binding-compiler.js";
|
||||
import {
|
||||
materializeConfiguredBindingRecord,
|
||||
resolveMatchingConfiguredBinding,
|
||||
toConfiguredBindingConversationRef,
|
||||
} from "./configured-binding-match.js";
|
||||
import { resolveConfiguredBindingRecordBySessionKeyFromRegistry } from "./configured-binding-session-lookup.js";
|
||||
|
||||
export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): {
|
||||
bindingCount: number;
|
||||
channelCount: number;
|
||||
} {
|
||||
return countCompiledBindingRegistry(primeCompiledBindingRegistry(params.cfg));
|
||||
}
|
||||
|
||||
export function resolveConfiguredBindingRecord(params: {
|
||||
cfg: OpenClawConfig;
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}): ConfiguredBindingRecordResolution | null {
|
||||
const conversation = toConfiguredBindingConversationRef({
|
||||
channel: params.channel,
|
||||
accountId: params.accountId,
|
||||
conversationId: params.conversationId,
|
||||
parentConversationId: params.parentConversationId,
|
||||
});
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
return resolveConfiguredBindingRecordForConversation({
|
||||
cfg: params.cfg,
|
||||
conversation,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveConfiguredBindingRecordForConversation(params: {
|
||||
cfg: OpenClawConfig;
|
||||
conversation: ConversationRef;
|
||||
}): ConfiguredBindingRecordResolution | null {
|
||||
const conversation = toConfiguredBindingConversationRef(params.conversation);
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
const registry = resolveCompiledBindingRegistry(params.cfg);
|
||||
const rules = registry.rulesByChannel.get(conversation.channel);
|
||||
if (!rules || rules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveMatchingConfiguredBinding({
|
||||
rules,
|
||||
conversation,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
return materializeConfiguredBindingRecord({
|
||||
rule: resolved.rule,
|
||||
accountId: conversation.accountId,
|
||||
conversation: resolved.match,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveConfiguredBinding(params: {
|
||||
cfg: OpenClawConfig;
|
||||
conversation: ConversationRef;
|
||||
}): ConfiguredBindingResolution | null {
|
||||
const conversation = toConfiguredBindingConversationRef(params.conversation);
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
const registry = resolveCompiledBindingRegistry(params.cfg);
|
||||
const rules = registry.rulesByChannel.get(conversation.channel);
|
||||
if (!rules || rules.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const resolved = resolveMatchingConfiguredBinding({
|
||||
rules,
|
||||
conversation,
|
||||
});
|
||||
if (!resolved) {
|
||||
return null;
|
||||
}
|
||||
const materializedTarget = materializeConfiguredBindingRecord({
|
||||
rule: resolved.rule,
|
||||
accountId: conversation.accountId,
|
||||
conversation: resolved.match,
|
||||
});
|
||||
return {
|
||||
conversation,
|
||||
compiledBinding: resolved.rule,
|
||||
match: resolved.match,
|
||||
...materializedTarget,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveConfiguredBindingRecordBySessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): ConfiguredBindingRecordResolution | null {
|
||||
return resolveConfiguredBindingRecordBySessionKeyFromRegistry({
|
||||
registry: resolveCompiledBindingRegistry(params.cfg),
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
}
|
||||
74
src/channels/plugins/configured-binding-session-lookup.ts
Normal file
74
src/channels/plugins/configured-binding-session-lookup.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { ConfiguredBindingRecordResolution } from "./binding-types.js";
|
||||
import type { CompiledConfiguredBindingRegistry } from "./configured-binding-compiler.js";
|
||||
import { listConfiguredBindingConsumers } from "./configured-binding-consumers.js";
|
||||
import {
|
||||
materializeConfiguredBindingRecord,
|
||||
resolveAccountMatchPriority,
|
||||
resolveCompiledBindingChannel,
|
||||
} from "./configured-binding-match.js";
|
||||
|
||||
export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: {
|
||||
registry: CompiledConfiguredBindingRegistry;
|
||||
sessionKey: string;
|
||||
}): ConfiguredBindingRecordResolution | null {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const consumer of listConfiguredBindingConsumers()) {
|
||||
const parsed = consumer.parseSessionKey?.({ sessionKey });
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const channel = resolveCompiledBindingChannel(parsed.channel);
|
||||
if (!channel) {
|
||||
continue;
|
||||
}
|
||||
const rules = params.registry.rulesByChannel.get(channel);
|
||||
if (!rules || rules.length === 0) {
|
||||
continue;
|
||||
}
|
||||
let wildcardMatch: ConfiguredBindingRecordResolution | null = null;
|
||||
let exactMatch: ConfiguredBindingRecordResolution | null = null;
|
||||
for (const rule of rules) {
|
||||
if (rule.targetFactory.driverId !== consumer.id) {
|
||||
continue;
|
||||
}
|
||||
const accountMatchPriority = resolveAccountMatchPriority(
|
||||
rule.accountPattern,
|
||||
parsed.accountId,
|
||||
);
|
||||
if (accountMatchPriority === 0) {
|
||||
continue;
|
||||
}
|
||||
const materializedTarget = materializeConfiguredBindingRecord({
|
||||
rule,
|
||||
accountId: parsed.accountId,
|
||||
conversation: rule.target,
|
||||
});
|
||||
const matchesSessionKey =
|
||||
consumer.matchesSessionKey?.({
|
||||
sessionKey,
|
||||
compiledBinding: rule,
|
||||
accountId: parsed.accountId,
|
||||
materializedTarget,
|
||||
}) ?? materializedTarget.record.targetSessionKey === sessionKey;
|
||||
if (matchesSessionKey) {
|
||||
if (accountMatchPriority === 2) {
|
||||
exactMatch = materializedTarget;
|
||||
break;
|
||||
}
|
||||
wildcardMatch = materializedTarget;
|
||||
}
|
||||
}
|
||||
if (exactMatch) {
|
||||
return exactMatch;
|
||||
}
|
||||
if (wildcardMatch) {
|
||||
return wildcardMatch;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
13
src/channels/plugins/stateful-target-builtins.ts
Normal file
13
src/channels/plugins/stateful-target-builtins.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { acpStatefulBindingTargetDriver } from "./acp-stateful-target-driver.js";
|
||||
import {
|
||||
registerStatefulBindingTargetDriver,
|
||||
unregisterStatefulBindingTargetDriver,
|
||||
} from "./stateful-target-drivers.js";
|
||||
|
||||
export function ensureStatefulTargetBuiltinsRegistered(): void {
|
||||
registerStatefulBindingTargetDriver(acpStatefulBindingTargetDriver);
|
||||
}
|
||||
|
||||
export function resetStatefulTargetBuiltinsForTesting(): void {
|
||||
unregisterStatefulBindingTargetDriver(acpStatefulBindingTargetDriver.id);
|
||||
}
|
||||
89
src/channels/plugins/stateful-target-drivers.ts
Normal file
89
src/channels/plugins/stateful-target-drivers.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type {
|
||||
ConfiguredBindingResolution,
|
||||
StatefulBindingTargetDescriptor,
|
||||
} from "./binding-types.js";
|
||||
|
||||
export type StatefulBindingTargetReadyResult = { ok: true } | { ok: false; error: string };
|
||||
export type StatefulBindingTargetSessionResult =
|
||||
| { ok: true; sessionKey: string }
|
||||
| { ok: false; sessionKey: string; error: string };
|
||||
export type StatefulBindingTargetResetResult =
|
||||
| { ok: true }
|
||||
| { ok: false; skipped?: boolean; error?: string };
|
||||
|
||||
export type StatefulBindingTargetDriver = {
|
||||
id: string;
|
||||
ensureReady: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindingResolution: ConfiguredBindingResolution;
|
||||
}) => Promise<StatefulBindingTargetReadyResult>;
|
||||
ensureSession: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
bindingResolution: ConfiguredBindingResolution;
|
||||
}) => Promise<StatefulBindingTargetSessionResult>;
|
||||
resolveTargetBySessionKey?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}) => StatefulBindingTargetDescriptor | null;
|
||||
resetInPlace?: (params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
bindingTarget: StatefulBindingTargetDescriptor;
|
||||
reason: "new" | "reset";
|
||||
}) => Promise<StatefulBindingTargetResetResult>;
|
||||
};
|
||||
|
||||
const registeredStatefulBindingTargetDrivers = new Map<string, StatefulBindingTargetDriver>();
|
||||
|
||||
function listStatefulBindingTargetDrivers(): StatefulBindingTargetDriver[] {
|
||||
return [...registeredStatefulBindingTargetDrivers.values()];
|
||||
}
|
||||
|
||||
export function registerStatefulBindingTargetDriver(driver: StatefulBindingTargetDriver): void {
|
||||
const id = driver.id.trim();
|
||||
if (!id) {
|
||||
throw new Error("Stateful binding target driver id is required");
|
||||
}
|
||||
const normalized = { ...driver, id };
|
||||
const existing = registeredStatefulBindingTargetDrivers.get(id);
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
registeredStatefulBindingTargetDrivers.set(id, normalized);
|
||||
}
|
||||
|
||||
export function unregisterStatefulBindingTargetDriver(id: string): void {
|
||||
registeredStatefulBindingTargetDrivers.delete(id.trim());
|
||||
}
|
||||
|
||||
export function getStatefulBindingTargetDriver(id: string): StatefulBindingTargetDriver | null {
|
||||
const normalizedId = id.trim();
|
||||
if (!normalizedId) {
|
||||
return null;
|
||||
}
|
||||
return registeredStatefulBindingTargetDrivers.get(normalizedId) ?? null;
|
||||
}
|
||||
|
||||
export function resolveStatefulBindingTargetBySessionKey(params: {
|
||||
cfg: OpenClawConfig;
|
||||
sessionKey: string;
|
||||
}): { driver: StatefulBindingTargetDriver; bindingTarget: StatefulBindingTargetDescriptor } | null {
|
||||
const sessionKey = params.sessionKey.trim();
|
||||
if (!sessionKey) {
|
||||
return null;
|
||||
}
|
||||
for (const driver of listStatefulBindingTargetDrivers()) {
|
||||
const bindingTarget = driver.resolveTargetBySessionKey?.({
|
||||
cfg: params.cfg,
|
||||
sessionKey,
|
||||
});
|
||||
if (bindingTarget) {
|
||||
return {
|
||||
driver,
|
||||
bindingTarget,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReplyPayload } from "../../auto-reply/types.js";
|
||||
import type { ConfiguredBindingRule } from "../../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { AgentAcpBinding } from "../../config/types.js";
|
||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||
import type { ExecApprovalRequest, ExecApprovalResolved } from "../../infra/exec-approvals.js";
|
||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||
@@ -541,24 +541,26 @@ export type ChannelAllowlistAdapter = {
|
||||
supportsScope?: (params: { scope: "dm" | "group" | "all" }) => boolean;
|
||||
};
|
||||
|
||||
export type ChannelAcpBindingAdapter = {
|
||||
normalizeConfiguredBindingTarget?: (params: {
|
||||
binding: AgentAcpBinding;
|
||||
export type ChannelConfiguredBindingConversationRef = {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
|
||||
export type ChannelConfiguredBindingMatch = ChannelConfiguredBindingConversationRef & {
|
||||
matchPriority?: number;
|
||||
};
|
||||
|
||||
export type ChannelConfiguredBindingProvider = {
|
||||
compileConfiguredBinding: (params: {
|
||||
binding: ConfiguredBindingRule;
|
||||
conversationId: string;
|
||||
}) => {
|
||||
}) => ChannelConfiguredBindingConversationRef | null;
|
||||
matchInboundConversation: (params: {
|
||||
binding: ConfiguredBindingRule;
|
||||
compiledBinding: ChannelConfiguredBindingConversationRef;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
} | null;
|
||||
matchConfiguredBinding?: (params: {
|
||||
binding: AgentAcpBinding;
|
||||
bindingConversationId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) => {
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
matchPriority?: number;
|
||||
} | null;
|
||||
}) => ChannelConfiguredBindingMatch | null;
|
||||
};
|
||||
|
||||
export type ChannelSecurityAdapter<ResolvedAccount = unknown> = {
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
ChannelSetupAdapter,
|
||||
ChannelStatusAdapter,
|
||||
ChannelAllowlistAdapter,
|
||||
ChannelAcpBindingAdapter,
|
||||
ChannelConfiguredBindingProvider,
|
||||
} from "./types.adapters.js";
|
||||
import type {
|
||||
ChannelAgentTool,
|
||||
@@ -78,7 +78,7 @@ export type ChannelPlugin<ResolvedAccount = any, Probe = unknown, Audit = unknow
|
||||
lifecycle?: ChannelLifecycleAdapter;
|
||||
execApprovals?: ChannelExecApprovalAdapter;
|
||||
allowlist?: ChannelAllowlistAdapter;
|
||||
acpBindings?: ChannelAcpBindingAdapter;
|
||||
bindings?: ChannelConfiguredBindingProvider;
|
||||
streaming?: ChannelStreamingAdapter;
|
||||
threading?: ChannelThreadingAdapter;
|
||||
messaging?: ChannelMessagingAdapter;
|
||||
|
||||
@@ -33,7 +33,9 @@ export type {
|
||||
ChannelOutboundAdapter,
|
||||
ChannelOutboundContext,
|
||||
ChannelAllowlistAdapter,
|
||||
ChannelAcpBindingAdapter,
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
ChannelConfiguredBindingProvider,
|
||||
ChannelPairingAdapter,
|
||||
ChannelSecurityAdapter,
|
||||
ChannelSetupAdapter,
|
||||
|
||||
@@ -96,6 +96,30 @@ describe("buildGatewayInstallPlan", () => {
|
||||
expect(plan.workingDirectory).toBe("/Users/me");
|
||||
expect(plan.environment).toEqual({ OPENCLAW_PORT: "3000" });
|
||||
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
|
||||
expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: {},
|
||||
port: 3000,
|
||||
extraPathDirs: ["/custom"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not prepend '.' when nodePath is a bare executable name", async () => {
|
||||
mockNodeGatewayPlanFixture();
|
||||
|
||||
await buildGatewayInstallPlan({
|
||||
env: {},
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
nodePath: "node",
|
||||
});
|
||||
|
||||
expect(mocks.buildServiceEnvironment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
extraPathDirs: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits warnings when renderSystemNodeWarning returns one", async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { buildServiceEnvironment } from "../daemon/service-env.js";
|
||||
import {
|
||||
emitDaemonInstallRuntimeWarning,
|
||||
resolveDaemonInstallRuntimeInputs,
|
||||
resolveDaemonNodeBinDir,
|
||||
} from "./daemon-install-plan.shared.js";
|
||||
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
|
||||
import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
|
||||
@@ -87,6 +88,9 @@ export async function buildGatewayInstallPlan(params: {
|
||||
process.platform === "darwin"
|
||||
? resolveGatewayLaunchAgentLabel(params.env.OPENCLAW_PROFILE)
|
||||
: undefined,
|
||||
// Keep npm/pnpm available to the service when the selected daemon node comes from
|
||||
// a version-manager bin directory that isn't covered by static PATH guesses.
|
||||
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
|
||||
});
|
||||
|
||||
// Merge config env vars into the service environment (vars + inline env keys).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveDaemonInstallRuntimeInputs,
|
||||
resolveDaemonNodeBinDir,
|
||||
resolveGatewayDevMode,
|
||||
} from "./daemon-install-plan.shared.js";
|
||||
|
||||
@@ -29,3 +30,13 @@ describe("resolveDaemonInstallRuntimeInputs", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDaemonNodeBinDir", () => {
|
||||
it("returns the absolute node bin directory", () => {
|
||||
expect(resolveDaemonNodeBinDir("/custom/node/bin/node")).toEqual(["/custom/node/bin"]);
|
||||
});
|
||||
|
||||
it("ignores bare executable names", () => {
|
||||
expect(resolveDaemonNodeBinDir("node")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { resolvePreferredNodePath } from "../daemon/runtime-paths.js";
|
||||
import {
|
||||
emitNodeRuntimeWarning,
|
||||
@@ -42,3 +43,11 @@ export async function emitDaemonInstallRuntimeWarning(params: {
|
||||
title: params.title,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDaemonNodeBinDir(nodePath?: string): string[] | undefined {
|
||||
const trimmed = nodePath?.trim();
|
||||
if (!trimmed || !path.isAbsolute(trimmed)) {
|
||||
return undefined;
|
||||
}
|
||||
return [path.dirname(trimmed)];
|
||||
}
|
||||
|
||||
93
src/commands/node-daemon-install-helpers.test.ts
Normal file
93
src/commands/node-daemon-install-helpers.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
resolvePreferredNodePath: vi.fn(),
|
||||
resolveNodeProgramArguments: vi.fn(),
|
||||
resolveSystemNodeInfo: vi.fn(),
|
||||
renderSystemNodeWarning: vi.fn(),
|
||||
buildNodeServiceEnvironment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/runtime-paths.js", () => ({
|
||||
resolvePreferredNodePath: mocks.resolvePreferredNodePath,
|
||||
resolveSystemNodeInfo: mocks.resolveSystemNodeInfo,
|
||||
renderSystemNodeWarning: mocks.renderSystemNodeWarning,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/program-args.js", () => ({
|
||||
resolveNodeProgramArguments: mocks.resolveNodeProgramArguments,
|
||||
}));
|
||||
|
||||
vi.mock("../daemon/service-env.js", () => ({
|
||||
buildNodeServiceEnvironment: mocks.buildNodeServiceEnvironment,
|
||||
}));
|
||||
|
||||
import { buildNodeInstallPlan } from "./node-daemon-install-helpers.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("buildNodeInstallPlan", () => {
|
||||
it("passes the selected node bin directory into the node service environment", async () => {
|
||||
mocks.resolveNodeProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "node-host"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/opt/node/bin/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
|
||||
mocks.buildNodeServiceEnvironment.mockReturnValue({
|
||||
OPENCLAW_SERVICE_VERSION: "2026.3.14",
|
||||
});
|
||||
|
||||
const plan = await buildNodeInstallPlan({
|
||||
env: {},
|
||||
host: "127.0.0.1",
|
||||
port: 18789,
|
||||
runtime: "node",
|
||||
nodePath: "/custom/node/bin/node",
|
||||
});
|
||||
|
||||
expect(plan.environment).toEqual({
|
||||
OPENCLAW_SERVICE_VERSION: "2026.3.14",
|
||||
});
|
||||
expect(mocks.resolvePreferredNodePath).not.toHaveBeenCalled();
|
||||
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
extraPathDirs: ["/custom/node/bin"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not prepend '.' when nodePath is a bare executable name", async () => {
|
||||
mocks.resolveNodeProgramArguments.mockResolvedValue({
|
||||
programArguments: ["node", "node-host"],
|
||||
workingDirectory: "/Users/me",
|
||||
});
|
||||
mocks.resolveSystemNodeInfo.mockResolvedValue({
|
||||
path: "/usr/bin/node",
|
||||
version: "22.0.0",
|
||||
supported: true,
|
||||
});
|
||||
mocks.renderSystemNodeWarning.mockReturnValue(undefined);
|
||||
mocks.buildNodeServiceEnvironment.mockReturnValue({
|
||||
OPENCLAW_SERVICE_VERSION: "2026.3.14",
|
||||
});
|
||||
|
||||
await buildNodeInstallPlan({
|
||||
env: {},
|
||||
host: "127.0.0.1",
|
||||
port: 18789,
|
||||
runtime: "node",
|
||||
nodePath: "node",
|
||||
});
|
||||
|
||||
expect(mocks.buildNodeServiceEnvironment).toHaveBeenCalledWith({
|
||||
env: {},
|
||||
extraPathDirs: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { buildNodeServiceEnvironment } from "../daemon/service-env.js";
|
||||
import {
|
||||
emitDaemonInstallRuntimeWarning,
|
||||
resolveDaemonInstallRuntimeInputs,
|
||||
resolveDaemonNodeBinDir,
|
||||
} from "./daemon-install-plan.shared.js";
|
||||
import type { DaemonInstallWarnFn } from "./daemon-install-runtime-warning.js";
|
||||
import type { NodeDaemonRuntime } from "./node-daemon-runtime.js";
|
||||
@@ -54,7 +55,12 @@ export async function buildNodeInstallPlan(params: {
|
||||
title: "Node daemon runtime",
|
||||
});
|
||||
|
||||
const environment = buildNodeServiceEnvironment({ env: params.env });
|
||||
const environment = buildNodeServiceEnvironment({
|
||||
env: params.env,
|
||||
// Match the gateway install path so supervised node services keep the chosen
|
||||
// node toolchain on PATH for sibling binaries like npm/pnpm when needed.
|
||||
extraPathDirs: resolveDaemonNodeBinDir(nodePath),
|
||||
});
|
||||
const description = formatNodeServiceDescription({
|
||||
version: environment.OPENCLAW_SERVICE_VERSION,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { OpenClawConfig } from "./config.js";
|
||||
import type { AgentAcpBinding, AgentBinding, AgentRouteBinding } from "./types.agents.js";
|
||||
|
||||
export type ConfiguredBindingRule = AgentBinding;
|
||||
|
||||
function normalizeBindingType(binding: AgentBinding): "route" | "acp" {
|
||||
return binding.type === "acp" ? "acp" : "route";
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import fsPromises from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { upsertAcpSessionMeta } from "../../acp/runtime/session-meta.js";
|
||||
import * as jsonFiles from "../../infra/json-files.js";
|
||||
import type { OpenClawConfig } from "../config.js";
|
||||
import {
|
||||
clearSessionStoreCacheForTest,
|
||||
loadSessionStore,
|
||||
@@ -279,6 +281,72 @@ describe("session store lock (Promise chain mutex)", () => {
|
||||
expect(store[key]?.modelProvider).toBeUndefined();
|
||||
expect(store[key]?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves ACP metadata when replacing a session entry wholesale", async () => {
|
||||
const key = "agent:codex:acp:binding:discord:default:feedface";
|
||||
const acp = {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "codex-discord",
|
||||
mode: "persistent" as const,
|
||||
state: "idle" as const,
|
||||
lastActivityAt: 100,
|
||||
};
|
||||
const { storePath } = await makeTmpStore({
|
||||
[key]: {
|
||||
sessionId: "sess-acp",
|
||||
updatedAt: 100,
|
||||
acp,
|
||||
},
|
||||
});
|
||||
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[key] = {
|
||||
sessionId: "sess-acp",
|
||||
updatedAt: 200,
|
||||
modelProvider: "openai-codex",
|
||||
model: "gpt-5.4",
|
||||
};
|
||||
});
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store[key]?.acp).toEqual(acp);
|
||||
expect(store[key]?.modelProvider).toBe("openai-codex");
|
||||
expect(store[key]?.model).toBe("gpt-5.4");
|
||||
});
|
||||
|
||||
it("allows explicit ACP metadata removal through the ACP session helper", async () => {
|
||||
const key = "agent:codex:acp:binding:discord:default:deadbeef";
|
||||
const { storePath } = await makeTmpStore({
|
||||
[key]: {
|
||||
sessionId: "sess-acp-clear",
|
||||
updatedAt: 100,
|
||||
acp: {
|
||||
backend: "acpx",
|
||||
agent: "codex",
|
||||
runtimeSessionName: "codex-discord",
|
||||
mode: "persistent",
|
||||
state: "idle",
|
||||
lastActivityAt: 100,
|
||||
},
|
||||
},
|
||||
});
|
||||
const cfg = {
|
||||
session: {
|
||||
store: storePath,
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = await upsertAcpSessionMeta({
|
||||
cfg,
|
||||
sessionKey: key,
|
||||
mutate: () => null,
|
||||
});
|
||||
|
||||
expect(result?.acp).toBeUndefined();
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store[key]?.acp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendAssistantMessageToSessionTranscript", () => {
|
||||
|
||||
@@ -309,6 +309,12 @@ type SaveSessionStoreOptions = {
|
||||
skipMaintenance?: boolean;
|
||||
/** Active session key for warn-only maintenance. */
|
||||
activeSessionKey?: string;
|
||||
/**
|
||||
* Session keys that are allowed to drop persisted ACP metadata during this update.
|
||||
* All other updates preserve existing `entry.acp` blocks when callers replace the
|
||||
* whole session entry without carrying ACP state forward.
|
||||
*/
|
||||
allowDropAcpMetaSessionKeys?: string[];
|
||||
/** Optional callback for warn-only maintenance. */
|
||||
onWarn?: (warning: SessionMaintenanceWarning) => void | Promise<void>;
|
||||
/** Optional callback with maintenance stats after a save. */
|
||||
@@ -337,6 +343,64 @@ function updateSessionStoreWriteCaches(params: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMutableSessionStoreKey(
|
||||
store: Record<string, SessionEntry>,
|
||||
sessionKey: string,
|
||||
): string | undefined {
|
||||
const trimmed = sessionKey.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(store, trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
const normalized = normalizeStoreSessionKey(trimmed);
|
||||
if (Object.prototype.hasOwnProperty.call(store, normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return Object.keys(store).find((key) => normalizeStoreSessionKey(key) === normalized);
|
||||
}
|
||||
|
||||
function collectAcpMetadataSnapshot(
|
||||
store: Record<string, SessionEntry>,
|
||||
): Map<string, NonNullable<SessionEntry["acp"]>> {
|
||||
const snapshot = new Map<string, NonNullable<SessionEntry["acp"]>>();
|
||||
for (const [sessionKey, entry] of Object.entries(store)) {
|
||||
if (entry?.acp) {
|
||||
snapshot.set(sessionKey, entry.acp);
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function preserveExistingAcpMetadata(params: {
|
||||
previousAcpByKey: Map<string, NonNullable<SessionEntry["acp"]>>;
|
||||
nextStore: Record<string, SessionEntry>;
|
||||
allowDropSessionKeys?: string[];
|
||||
}): void {
|
||||
const allowDrop = new Set(
|
||||
(params.allowDropSessionKeys ?? []).map((key) => normalizeStoreSessionKey(key)),
|
||||
);
|
||||
for (const [previousKey, previousAcp] of params.previousAcpByKey.entries()) {
|
||||
const normalizedKey = normalizeStoreSessionKey(previousKey);
|
||||
if (allowDrop.has(normalizedKey)) {
|
||||
continue;
|
||||
}
|
||||
const nextKey = resolveMutableSessionStoreKey(params.nextStore, previousKey);
|
||||
if (!nextKey) {
|
||||
continue;
|
||||
}
|
||||
const nextEntry = params.nextStore[nextKey];
|
||||
if (!nextEntry || nextEntry.acp) {
|
||||
continue;
|
||||
}
|
||||
params.nextStore[nextKey] = {
|
||||
...nextEntry,
|
||||
acp: previousAcp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSessionStoreUnlocked(
|
||||
storePath: string,
|
||||
store: Record<string, SessionEntry>,
|
||||
@@ -526,7 +590,13 @@ export async function updateSessionStore<T>(
|
||||
return await withSessionStoreLock(storePath, async () => {
|
||||
// Always re-read inside the lock to avoid clobbering concurrent writers.
|
||||
const store = loadSessionStore(storePath, { skipCache: true });
|
||||
const previousAcpByKey = collectAcpMetadataSnapshot(store);
|
||||
const result = await mutator(store);
|
||||
preserveExistingAcpMetadata({
|
||||
previousAcpByKey,
|
||||
nextStore: store,
|
||||
allowDropSessionKeys: opts?.allowDropAcpMetaSessionKeys,
|
||||
});
|
||||
await saveSessionStoreUnlocked(storePath, store, opts);
|
||||
return result;
|
||||
});
|
||||
|
||||
@@ -257,6 +257,18 @@ describe("buildMinimalServicePath", () => {
|
||||
const unique = [...new Set(parts)];
|
||||
expect(parts.length).toBe(unique.length);
|
||||
});
|
||||
|
||||
it("prepends explicit runtime bin directories before guessed user paths", () => {
|
||||
const result = buildMinimalServicePath({
|
||||
platform: "linux",
|
||||
extraDirs: ["/home/alice/.nvm/versions/node/v22.22.0/bin"],
|
||||
env: { HOME: "/home/alice" },
|
||||
});
|
||||
const parts = splitPath(result, "linux");
|
||||
|
||||
expect(parts[0]).toBe("/home/alice/.nvm/versions/node/v22.22.0/bin");
|
||||
expect(parts).toContain("/home/alice/.nvm/current/bin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildServiceEnvironment", () => {
|
||||
@@ -344,6 +356,19 @@ describe("buildServiceEnvironment", () => {
|
||||
expect(env).not.toHaveProperty("PATH");
|
||||
expect(env.OPENCLAW_WINDOWS_TASK_NAME).toBe("OpenClaw Gateway");
|
||||
});
|
||||
|
||||
it("prepends extra runtime directories to the gateway service PATH", () => {
|
||||
const env = buildServiceEnvironment({
|
||||
env: { HOME: "/home/user" },
|
||||
port: 18789,
|
||||
platform: "linux",
|
||||
extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"],
|
||||
});
|
||||
|
||||
expect(env.PATH?.split(path.posix.delimiter)[0]).toBe(
|
||||
"/home/user/.nvm/versions/node/v22.22.0/bin",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildNodeServiceEnvironment", () => {
|
||||
@@ -416,6 +441,18 @@ describe("buildNodeServiceEnvironment", () => {
|
||||
});
|
||||
expect(env.TMPDIR).toBe(os.tmpdir());
|
||||
});
|
||||
|
||||
it("prepends extra runtime directories to the node service PATH", () => {
|
||||
const env = buildNodeServiceEnvironment({
|
||||
env: { HOME: "/home/user" },
|
||||
platform: "linux",
|
||||
extraPathDirs: ["/home/user/.nvm/versions/node/v22.22.0/bin"],
|
||||
});
|
||||
|
||||
expect(env.PATH?.split(path.posix.delimiter)[0]).toBe(
|
||||
"/home/user/.nvm/versions/node/v22.22.0/bin",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared Node TLS env defaults", () => {
|
||||
|
||||
@@ -247,10 +247,11 @@ export function buildServiceEnvironment(params: {
|
||||
port: number;
|
||||
launchdLabel?: string;
|
||||
platform?: NodeJS.Platform;
|
||||
extraPathDirs?: string[];
|
||||
}): Record<string, string | undefined> {
|
||||
const { env, port, launchdLabel } = params;
|
||||
const { env, port, launchdLabel, extraPathDirs } = params;
|
||||
const platform = params.platform ?? process.platform;
|
||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
|
||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs);
|
||||
const profile = env.OPENCLAW_PROFILE;
|
||||
const resolvedLaunchdLabel =
|
||||
launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined);
|
||||
@@ -271,10 +272,11 @@ export function buildServiceEnvironment(params: {
|
||||
export function buildNodeServiceEnvironment(params: {
|
||||
env: Record<string, string | undefined>;
|
||||
platform?: NodeJS.Platform;
|
||||
extraPathDirs?: string[];
|
||||
}): Record<string, string | undefined> {
|
||||
const { env } = params;
|
||||
const { env, extraPathDirs } = params;
|
||||
const platform = params.platform ?? process.platform;
|
||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform);
|
||||
const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform, extraPathDirs);
|
||||
const gatewayToken =
|
||||
env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined;
|
||||
return {
|
||||
@@ -313,6 +315,7 @@ function buildCommonServiceEnvironment(
|
||||
function resolveSharedServiceEnvironmentFields(
|
||||
env: Record<string, string | undefined>,
|
||||
platform: NodeJS.Platform,
|
||||
extraPathDirs: string[] | undefined,
|
||||
): SharedServiceEnvironmentFields {
|
||||
const stateDir = env.OPENCLAW_STATE_DIR;
|
||||
const configPath = env.OPENCLAW_CONFIG_PATH;
|
||||
@@ -331,7 +334,10 @@ function resolveSharedServiceEnvironmentFields(
|
||||
tmpDir,
|
||||
// On Windows, Scheduled Tasks should inherit the current task PATH instead of
|
||||
// freezing the install-time snapshot into gateway.cmd/node-host.cmd.
|
||||
minimalPath: platform === "win32" ? undefined : buildMinimalServicePath({ env, platform }),
|
||||
minimalPath:
|
||||
platform === "win32"
|
||||
? undefined
|
||||
: buildMinimalServicePath({ env, platform, extraDirs: extraPathDirs }),
|
||||
proxyEnv,
|
||||
nodeCaCerts,
|
||||
nodeUseSystemCa,
|
||||
|
||||
@@ -6,6 +6,9 @@ import type { PluginDiagnostic } from "../plugins/types.js";
|
||||
import type { GatewayRequestContext, GatewayRequestOptions } from "./server-methods/types.js";
|
||||
|
||||
const loadOpenClawPlugins = vi.hoisted(() => vi.fn());
|
||||
const primeConfiguredBindingRegistry = vi.hoisted(() =>
|
||||
vi.fn(() => ({ bindingCount: 0, channelCount: 0 })),
|
||||
);
|
||||
type HandleGatewayRequestOptions = GatewayRequestOptions & {
|
||||
extraHandlers?: Record<string, unknown>;
|
||||
};
|
||||
@@ -17,6 +20,10 @@ vi.mock("../plugins/loader.js", () => ({
|
||||
loadOpenClawPlugins,
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/binding-registry.js", () => ({
|
||||
primeConfiguredBindingRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("./server-methods.js", () => ({
|
||||
handleGatewayRequest,
|
||||
}));
|
||||
@@ -51,6 +58,7 @@ const createRegistry = (diagnostics: PluginDiagnostic[]): PluginRegistry => ({
|
||||
httpRoutes: [],
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics,
|
||||
});
|
||||
|
||||
@@ -110,6 +118,7 @@ async function createSubagentRuntime(
|
||||
|
||||
beforeEach(async () => {
|
||||
loadOpenClawPlugins.mockReset();
|
||||
primeConfiguredBindingRegistry.mockClear().mockReturnValue({ bindingCount: 0, channelCount: 0 });
|
||||
handleGatewayRequest.mockReset();
|
||||
const runtimeModule = await import("../plugins/runtime/index.js");
|
||||
runtimeModule.clearGatewaySubagentRuntime();
|
||||
@@ -440,6 +449,29 @@ describe("loadGatewayPlugins", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("primes configured bindings during gateway startup", async () => {
|
||||
const { loadGatewayPlugins } = await importServerPluginsModule();
|
||||
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
|
||||
|
||||
const log = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
};
|
||||
|
||||
const cfg = {};
|
||||
loadGatewayPlugins({
|
||||
cfg,
|
||||
workspaceDir: "/tmp",
|
||||
log,
|
||||
coreGatewayHandlers: {},
|
||||
baseMethods: [],
|
||||
});
|
||||
|
||||
expect(primeConfiguredBindingRegistry).toHaveBeenCalledWith({ cfg });
|
||||
});
|
||||
|
||||
test("can suppress duplicate diagnostics when reloading full runtime plugins", async () => {
|
||||
const { loadGatewayPlugins } = await importServerPluginsModule();
|
||||
const diagnostics: PluginDiagnostic[] = [
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { normalizeModelRef, parseModelRef } from "../agents/model-selection.js";
|
||||
import { primeConfiguredBindingRegistry } from "../channels/plugins/binding-registry.js";
|
||||
import type { loadConfig } from "../config/config.js";
|
||||
import { normalizePluginsConfig } from "../plugins/config-state.js";
|
||||
import { loadOpenClawPlugins } from "../plugins/loader.js";
|
||||
@@ -416,6 +417,7 @@ export function loadGatewayPlugins(params: {
|
||||
},
|
||||
preferSetupRuntimeForChannelPlugins: params.preferSetupRuntimeForChannelPlugins,
|
||||
});
|
||||
primeConfiguredBindingRegistry({ cfg: params.cfg });
|
||||
const pluginMethods = Object.keys(pluginRegistry.gatewayHandlers);
|
||||
const gatewayMethods = Array.from(new Set([...params.baseMethods, ...pluginMethods]));
|
||||
if ((params.logDiagnostics ?? true) && pluginRegistry.diagnostics.length > 0) {
|
||||
|
||||
@@ -155,6 +155,7 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,43 @@
|
||||
// Public pairing/session-binding helpers for plugins that manage conversation ownership.
|
||||
// Public binding helpers for both runtime plugin-owned bindings and
|
||||
// config-driven channel bindings.
|
||||
|
||||
export * from "../acp/persistent-bindings.route.js";
|
||||
export {
|
||||
createConversationBindingRecord,
|
||||
getConversationBindingCapabilities,
|
||||
listSessionBindingRecords,
|
||||
resolveConversationBindingRecord,
|
||||
touchConversationBindingRecord,
|
||||
unbindConversationBindingRecord,
|
||||
} from "../bindings/records.js";
|
||||
export {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
resolveConfiguredBindingRoute,
|
||||
type ConfiguredBindingRouteResult,
|
||||
} from "../channels/plugins/binding-routing.js";
|
||||
export {
|
||||
primeConfiguredBindingRegistry,
|
||||
resolveConfiguredBinding,
|
||||
resolveConfiguredBindingRecord,
|
||||
resolveConfiguredBindingRecordBySessionKey,
|
||||
resolveConfiguredBindingRecordForConversation,
|
||||
} from "../channels/plugins/binding-registry.js";
|
||||
export {
|
||||
ensureConfiguredBindingTargetReady,
|
||||
ensureConfiguredBindingTargetSession,
|
||||
resetConfiguredBindingTargetInPlace,
|
||||
} from "../channels/plugins/binding-targets.js";
|
||||
export type {
|
||||
ConfiguredBindingConversation,
|
||||
ConfiguredBindingResolution,
|
||||
CompiledConfiguredBinding,
|
||||
StatefulBindingTargetDescriptor,
|
||||
} from "../channels/plugins/binding-types.js";
|
||||
export type {
|
||||
StatefulBindingTargetDriver,
|
||||
StatefulBindingTargetReadyResult,
|
||||
StatefulBindingTargetResetResult,
|
||||
StatefulBindingTargetSessionResult,
|
||||
} from "../channels/plugins/stateful-target-drivers.js";
|
||||
export {
|
||||
type BindingStatus,
|
||||
type BindingTargetKind,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export type { ChannelMessageActionAdapter } from "../channels/plugins/types.js";
|
||||
export type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelGatewayContext,
|
||||
ChannelMessageActionAdapter,
|
||||
} from "../channels/plugins/types.js";
|
||||
export type { OpenClawConfig } from "../config/config.js";
|
||||
export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js";
|
||||
export type { DiscordPluralKitConfig } from "../../extensions/discord/src/pluralkit.js";
|
||||
@@ -13,6 +17,11 @@ export type {
|
||||
ThreadBindingRecord,
|
||||
ThreadBindingTargetKind,
|
||||
} from "../../extensions/discord/src/monitor/thread-bindings.js";
|
||||
export type {
|
||||
ChannelConfiguredBindingProvider,
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
export type {
|
||||
ChannelMessageActionContext,
|
||||
ChannelPlugin,
|
||||
|
||||
@@ -31,6 +31,11 @@ export type {
|
||||
ChannelMeta,
|
||||
ChannelOutboundAdapter,
|
||||
} from "../channels/plugins/types.js";
|
||||
export type {
|
||||
ChannelConfiguredBindingProvider,
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
export type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export { createReplyPrefixContext } from "../channels/reply-prefix.js";
|
||||
export { createTypingCallbacks } from "../channels/typing.js";
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildPluginSdkEntrySources,
|
||||
buildPluginSdkPackageExports,
|
||||
buildPluginSdkSpecifiers,
|
||||
pluginSdkEntrypoints,
|
||||
@@ -11,6 +15,9 @@ import {
|
||||
import * as sdk from "./index.js";
|
||||
|
||||
const pluginSdkSpecifiers = buildPluginSdkSpecifiers();
|
||||
const execFileAsync = promisify(execFile);
|
||||
const require = createRequire(import.meta.url);
|
||||
const tsdownModuleUrl = pathToFileURL(require.resolve("tsdown")).href;
|
||||
|
||||
describe("plugin-sdk exports", () => {
|
||||
it("does not expose runtime modules", () => {
|
||||
@@ -63,16 +70,33 @@ describe("plugin-sdk exports", () => {
|
||||
});
|
||||
|
||||
it("emits importable bundled subpath entries", { timeout: 240_000 }, async () => {
|
||||
const outDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-build-"));
|
||||
const fixtureDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-sdk-consumer-"));
|
||||
const repoDistDir = path.join(process.cwd(), "dist");
|
||||
|
||||
try {
|
||||
await expect(fs.access(path.join(repoDistDir, "plugin-sdk"))).resolves.toBeUndefined();
|
||||
const buildScriptPath = path.join(fixtureDir, "build-plugin-sdk.mjs");
|
||||
await fs.writeFile(
|
||||
buildScriptPath,
|
||||
`import { build } from ${JSON.stringify(tsdownModuleUrl)};
|
||||
await build(${JSON.stringify({
|
||||
clean: true,
|
||||
config: false,
|
||||
dts: false,
|
||||
entry: buildPluginSdkEntrySources(),
|
||||
env: { NODE_ENV: "production" },
|
||||
fixedExtension: false,
|
||||
logLevel: "error",
|
||||
outDir,
|
||||
platform: "node",
|
||||
})});
|
||||
`,
|
||||
);
|
||||
await execFileAsync(process.execPath, [buildScriptPath], {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
for (const entry of pluginSdkEntrypoints) {
|
||||
const module = await import(
|
||||
pathToFileURL(path.join(repoDistDir, "plugin-sdk", `${entry}.js`)).href
|
||||
);
|
||||
const module = await import(pathToFileURL(path.join(outDir, `${entry}.js`)).href);
|
||||
expect(module).toBeTypeOf("object");
|
||||
}
|
||||
|
||||
@@ -80,8 +104,8 @@ describe("plugin-sdk exports", () => {
|
||||
const consumerDir = path.join(fixtureDir, "consumer");
|
||||
const consumerEntry = path.join(consumerDir, "import-plugin-sdk.mjs");
|
||||
|
||||
await fs.mkdir(packageDir, { recursive: true });
|
||||
await fs.symlink(repoDistDir, path.join(packageDir, "dist"), "dir");
|
||||
await fs.mkdir(path.join(packageDir, "dist"), { recursive: true });
|
||||
await fs.symlink(outDir, path.join(packageDir, "dist", "plugin-sdk"), "dir");
|
||||
await fs.writeFile(
|
||||
path.join(packageDir, "package.json"),
|
||||
JSON.stringify(
|
||||
@@ -114,6 +138,7 @@ describe("plugin-sdk exports", () => {
|
||||
Object.fromEntries(pluginSdkSpecifiers.map((specifier: string) => [specifier, "object"])),
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(outDir, { recursive: true, force: true });
|
||||
await fs.rm(fixtureDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,8 +14,25 @@ export type {
|
||||
ChannelMessageActionName,
|
||||
ChannelStatusIssue,
|
||||
} from "../channels/plugins/types.js";
|
||||
export type {
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
ChannelConfiguredBindingProvider,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
export type { ChannelConfigSchema, ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
export type { ChannelSetupAdapter, ChannelSetupInput } from "../channels/plugins/types.js";
|
||||
export type {
|
||||
ConfiguredBindingConversation,
|
||||
ConfiguredBindingResolution,
|
||||
CompiledConfiguredBinding,
|
||||
StatefulBindingTargetDescriptor,
|
||||
} from "../channels/plugins/binding-types.js";
|
||||
export type {
|
||||
StatefulBindingTargetDriver,
|
||||
StatefulBindingTargetReadyResult,
|
||||
StatefulBindingTargetResetResult,
|
||||
StatefulBindingTargetSessionResult,
|
||||
} from "../channels/plugins/stateful-target-drivers.js";
|
||||
export type {
|
||||
ChannelSetupWizard,
|
||||
ChannelSetupWizardAllowFromEntry,
|
||||
|
||||
@@ -12,6 +12,11 @@ export type {
|
||||
TelegramActionConfig,
|
||||
TelegramNetworkConfig,
|
||||
} from "../config/types.js";
|
||||
export type {
|
||||
ChannelConfiguredBindingProvider,
|
||||
ChannelConfiguredBindingConversationRef,
|
||||
ChannelConfiguredBindingMatch,
|
||||
} from "../channels/plugins/types.adapters.js";
|
||||
export type { InspectedTelegramAccount } from "../../extensions/telegram/src/account-inspect.js";
|
||||
export type { ResolvedTelegramAccount } from "../../extensions/telegram/src/accounts.js";
|
||||
export type { TelegramProbe } from "../../extensions/telegram/src/probe.js";
|
||||
@@ -26,7 +31,6 @@ export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.j
|
||||
export { parseTelegramTopicConversation } from "../acp/conversation-id.js";
|
||||
export { formatCliCommand } from "../cli/command-format.js";
|
||||
export { formatDocsLink } from "../terminal/links.js";
|
||||
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
applyAccountNameToChannelSection,
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
SessionBindingAdapter,
|
||||
SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import { createEmptyPluginRegistry } from "./registry.js";
|
||||
import { setActivePluginRegistry } from "./runtime.js";
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-binding-"));
|
||||
const approvalsPath = path.join(tempRoot, "plugin-binding-approvals.json");
|
||||
@@ -145,6 +147,7 @@ describe("plugin conversation binding approvals", () => {
|
||||
beforeEach(() => {
|
||||
sessionBindingState.reset();
|
||||
__testing.reset();
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
fs.rmSync(approvalsPath, { force: true });
|
||||
unregisterSessionBindingAdapter({ channel: "discord", accountId: "default" });
|
||||
unregisterSessionBindingAdapter({ channel: "discord", accountId: "work" });
|
||||
@@ -366,6 +369,118 @@ describe("plugin conversation binding approvals", () => {
|
||||
expect(currentBinding?.detachHint).toBe("/codex_detach");
|
||||
});
|
||||
|
||||
it("notifies the owning plugin when a bind approval is approved", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const onResolved = vi.fn(async () => undefined);
|
||||
registry.conversationBindingResolvedHandlers.push({
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugins/callback-test",
|
||||
handler: onResolved,
|
||||
source: "/plugins/callback-test/index.ts",
|
||||
rootDir: "/plugins/callback-test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const request = await requestPluginConversationBinding({
|
||||
pluginId: "codex",
|
||||
pluginName: "Codex App Server",
|
||||
pluginRoot: "/plugins/callback-test",
|
||||
requestedBySenderId: "user-1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "isolated",
|
||||
conversationId: "channel:callback-test",
|
||||
},
|
||||
binding: { summary: "Bind this conversation to Codex thread abc." },
|
||||
});
|
||||
|
||||
expect(request.status).toBe("pending");
|
||||
if (request.status !== "pending") {
|
||||
throw new Error("expected pending bind request");
|
||||
}
|
||||
|
||||
const approved = await resolvePluginConversationBindingApproval({
|
||||
approvalId: request.approvalId,
|
||||
decision: "allow-once",
|
||||
senderId: "user-1",
|
||||
});
|
||||
|
||||
expect(approved.status).toBe("approved");
|
||||
expect(onResolved).toHaveBeenCalledWith({
|
||||
status: "approved",
|
||||
binding: expect.objectContaining({
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugins/callback-test",
|
||||
conversationId: "channel:callback-test",
|
||||
}),
|
||||
decision: "allow-once",
|
||||
request: {
|
||||
summary: "Bind this conversation to Codex thread abc.",
|
||||
detachHint: undefined,
|
||||
requestedBySenderId: "user-1",
|
||||
conversation: {
|
||||
channel: "discord",
|
||||
accountId: "isolated",
|
||||
conversationId: "channel:callback-test",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("notifies the owning plugin when a bind approval is denied", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const onResolved = vi.fn(async () => undefined);
|
||||
registry.conversationBindingResolvedHandlers.push({
|
||||
pluginId: "codex",
|
||||
pluginRoot: "/plugins/callback-deny",
|
||||
handler: onResolved,
|
||||
source: "/plugins/callback-deny/index.ts",
|
||||
rootDir: "/plugins/callback-deny",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
const request = await requestPluginConversationBinding({
|
||||
pluginId: "codex",
|
||||
pluginName: "Codex App Server",
|
||||
pluginRoot: "/plugins/callback-deny",
|
||||
requestedBySenderId: "user-1",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "8460800771",
|
||||
},
|
||||
binding: { summary: "Bind this conversation to Codex thread deny." },
|
||||
});
|
||||
|
||||
expect(request.status).toBe("pending");
|
||||
if (request.status !== "pending") {
|
||||
throw new Error("expected pending bind request");
|
||||
}
|
||||
|
||||
const denied = await resolvePluginConversationBindingApproval({
|
||||
approvalId: request.approvalId,
|
||||
decision: "deny",
|
||||
senderId: "user-1",
|
||||
});
|
||||
|
||||
expect(denied.status).toBe("denied");
|
||||
expect(onResolved).toHaveBeenCalledWith({
|
||||
status: "denied",
|
||||
binding: undefined,
|
||||
decision: "deny",
|
||||
request: {
|
||||
summary: "Bind this conversation to Codex thread deny.",
|
||||
detachHint: undefined,
|
||||
requestedBySenderId: "user-1",
|
||||
conversation: {
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "8460800771",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns and detaches only bindings owned by the requesting plugin root", async () => {
|
||||
const request = await requestPluginConversationBinding({
|
||||
pluginId: "codex",
|
||||
|
||||
@@ -2,15 +2,20 @@ import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||
import {
|
||||
createConversationBindingRecord,
|
||||
resolveConversationBindingRecord,
|
||||
unbindConversationBindingRecord,
|
||||
} from "../bindings/records.js";
|
||||
import { expandHomePrefix } from "../infra/home-dir.js";
|
||||
import { writeJsonAtomic } from "../infra/json-files.js";
|
||||
import {
|
||||
getSessionBindingService,
|
||||
type ConversationRef,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import { type ConversationRef } from "../infra/outbound/session-binding-service.js";
|
||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||
import { getActivePluginRegistry } from "./runtime.js";
|
||||
import type {
|
||||
PluginConversationBinding,
|
||||
PluginConversationBindingResolvedEvent,
|
||||
PluginConversationBindingResolutionDecision,
|
||||
PluginConversationBindingRequestParams,
|
||||
PluginConversationBindingRequestResult,
|
||||
} from "./types.js";
|
||||
@@ -26,7 +31,9 @@ const LEGACY_CODEX_PLUGIN_SESSION_PREFIXES = [
|
||||
"openclaw-codex-app-server:thread:",
|
||||
] as const;
|
||||
|
||||
type PluginBindingApprovalDecision = "allow-once" | "allow-always" | "deny";
|
||||
// Runtime plugin conversation bindings are approval-driven and distinct from
|
||||
// configured channel bindings compiled from config.
|
||||
type PluginBindingApprovalDecision = PluginConversationBindingResolutionDecision;
|
||||
|
||||
type PluginBindingApprovalEntry = {
|
||||
pluginRoot: string;
|
||||
@@ -87,7 +94,7 @@ type PluginBindingResolveResult =
|
||||
status: "approved";
|
||||
binding: PluginConversationBinding;
|
||||
request: PendingPluginBindingRequest;
|
||||
decision: PluginBindingApprovalDecision;
|
||||
decision: Exclude<PluginBindingApprovalDecision, "deny">;
|
||||
}
|
||||
| {
|
||||
status: "denied";
|
||||
@@ -423,7 +430,7 @@ async function bindConversationNow(params: {
|
||||
accountId: ref.accountId,
|
||||
conversationId: ref.conversationId,
|
||||
});
|
||||
const record = await getSessionBindingService().bind({
|
||||
const record = await createConversationBindingRecord({
|
||||
targetSessionKey,
|
||||
targetKind: "session",
|
||||
conversation: ref,
|
||||
@@ -574,7 +581,7 @@ export async function requestPluginConversationBinding(params: {
|
||||
}): Promise<PluginConversationBindingRequestResult> {
|
||||
const conversation = normalizeConversation(params.conversation);
|
||||
const ref = toConversationRef(conversation);
|
||||
const existing = getSessionBindingService().resolveByConversation(ref);
|
||||
const existing = resolveConversationBindingRecord(ref);
|
||||
const existingPluginBinding = toPluginConversationBinding(existing);
|
||||
const existingLegacyPluginBinding = isLegacyPluginBindingRecord({
|
||||
record: existing,
|
||||
@@ -665,9 +672,7 @@ export async function getCurrentPluginConversationBinding(params: {
|
||||
pluginRoot: string;
|
||||
conversation: PluginBindingConversation;
|
||||
}): Promise<PluginConversationBinding | null> {
|
||||
const record = getSessionBindingService().resolveByConversation(
|
||||
toConversationRef(params.conversation),
|
||||
);
|
||||
const record = resolveConversationBindingRecord(toConversationRef(params.conversation));
|
||||
const binding = toPluginConversationBinding(record);
|
||||
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
||||
return null;
|
||||
@@ -684,12 +689,12 @@ export async function detachPluginConversationBinding(params: {
|
||||
conversation: PluginBindingConversation;
|
||||
}): Promise<{ removed: boolean }> {
|
||||
const ref = toConversationRef(params.conversation);
|
||||
const record = getSessionBindingService().resolveByConversation(ref);
|
||||
const record = resolveConversationBindingRecord(ref);
|
||||
const binding = toPluginConversationBinding(record);
|
||||
if (!binding || binding.pluginRoot !== params.pluginRoot) {
|
||||
return { removed: false };
|
||||
}
|
||||
await getSessionBindingService().unbind({
|
||||
await unbindConversationBindingRecord({
|
||||
bindingId: binding.bindingId,
|
||||
reason: "plugin-detach",
|
||||
});
|
||||
@@ -717,6 +722,11 @@ export async function resolvePluginConversationBindingApproval(params: {
|
||||
}
|
||||
pendingRequests.delete(params.approvalId);
|
||||
if (params.decision === "deny") {
|
||||
await notifyPluginConversationBindingResolved({
|
||||
status: "denied",
|
||||
decision: "deny",
|
||||
request,
|
||||
});
|
||||
log.info(
|
||||
`plugin binding denied plugin=${request.pluginId} root=${request.pluginRoot} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
|
||||
);
|
||||
@@ -745,6 +755,12 @@ export async function resolvePluginConversationBindingApproval(params: {
|
||||
log.info(
|
||||
`plugin binding approved plugin=${request.pluginId} root=${request.pluginRoot} decision=${params.decision} channel=${request.conversation.channel} account=${request.conversation.accountId} conversation=${request.conversation.conversationId}`,
|
||||
);
|
||||
await notifyPluginConversationBindingResolved({
|
||||
status: "approved",
|
||||
binding,
|
||||
decision: params.decision,
|
||||
request,
|
||||
});
|
||||
return {
|
||||
status: "approved",
|
||||
binding,
|
||||
@@ -753,6 +769,42 @@ export async function resolvePluginConversationBindingApproval(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function notifyPluginConversationBindingResolved(params: {
|
||||
status: "approved" | "denied";
|
||||
binding?: PluginConversationBinding;
|
||||
decision: PluginConversationBindingResolutionDecision;
|
||||
request: PendingPluginBindingRequest;
|
||||
}): Promise<void> {
|
||||
const registrations = getActivePluginRegistry()?.conversationBindingResolvedHandlers ?? [];
|
||||
for (const registration of registrations) {
|
||||
if (registration.pluginId !== params.request.pluginId) {
|
||||
continue;
|
||||
}
|
||||
const registeredRoot = registration.pluginRoot?.trim();
|
||||
if (registeredRoot && registeredRoot !== params.request.pluginRoot) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const event: PluginConversationBindingResolvedEvent = {
|
||||
status: params.status,
|
||||
binding: params.binding,
|
||||
decision: params.decision,
|
||||
request: {
|
||||
summary: params.request.summary,
|
||||
detachHint: params.request.detachHint,
|
||||
requestedBySenderId: params.request.requestedBySenderId,
|
||||
conversation: params.request.conversation,
|
||||
},
|
||||
};
|
||||
await registration.handler(event);
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
`plugin binding resolved callback failed plugin=${registration.pluginId} root=${registration.pluginRoot ?? "<none>"}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPluginBindingResolvedText(params: PluginBindingResolveResult): string {
|
||||
if (params.status === "expired") {
|
||||
return "That plugin bind approval expired. Retry the bind command.";
|
||||
|
||||
@@ -28,6 +28,7 @@ import type {
|
||||
OpenClawPluginChannelRegistration,
|
||||
OpenClawPluginCliRegistrar,
|
||||
OpenClawPluginCommandDefinition,
|
||||
PluginConversationBindingResolvedEvent,
|
||||
OpenClawPluginHttpRouteAuth,
|
||||
OpenClawPluginHttpRouteMatch,
|
||||
OpenClawPluginHttpRouteHandler,
|
||||
@@ -147,6 +148,15 @@ export type PluginCommandRegistration = {
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginConversationBindingResolvedHandlerRegistration = {
|
||||
pluginId: string;
|
||||
pluginName?: string;
|
||||
pluginRoot?: string;
|
||||
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
};
|
||||
|
||||
export type PluginRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -199,6 +209,7 @@ export type PluginRegistry = {
|
||||
cliRegistrars: PluginCliRegistration[];
|
||||
services: PluginServiceRegistration[];
|
||||
commands: PluginCommandRegistration[];
|
||||
conversationBindingResolvedHandlers: PluginConversationBindingResolvedHandlerRegistration[];
|
||||
diagnostics: PluginDiagnostic[];
|
||||
};
|
||||
|
||||
@@ -247,6 +258,7 @@ export function createEmptyPluginRegistry(): PluginRegistry {
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
@@ -829,6 +841,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
} as TypedPluginHookRegistration);
|
||||
};
|
||||
|
||||
const registerConversationBindingResolvedHandler = (
|
||||
record: PluginRecord,
|
||||
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>,
|
||||
) => {
|
||||
registry.conversationBindingResolvedHandlers.push({
|
||||
pluginId: record.id,
|
||||
pluginName: record.name,
|
||||
pluginRoot: record.rootDir,
|
||||
handler,
|
||||
source: record.source,
|
||||
rootDir: record.rootDir,
|
||||
});
|
||||
};
|
||||
|
||||
const normalizeLogger = (logger: PluginLogger): PluginLogger => ({
|
||||
info: logger.info,
|
||||
warn: logger.warn,
|
||||
@@ -942,6 +968,10 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
}
|
||||
}
|
||||
: () => {},
|
||||
onConversationBindingResolved:
|
||||
registrationMode === "full"
|
||||
? (handler) => registerConversationBindingResolvedHandler(record, handler)
|
||||
: () => {},
|
||||
registerCommand:
|
||||
registrationMode === "full" ? (command) => registerCommand(record, command) : () => {},
|
||||
registerContextEngine: (id, factory) => {
|
||||
|
||||
@@ -940,6 +940,8 @@ export type PluginConversationBindingRequestParams = {
|
||||
detachHint?: string;
|
||||
};
|
||||
|
||||
export type PluginConversationBindingResolutionDecision = "allow-once" | "allow-always" | "deny";
|
||||
|
||||
export type PluginConversationBinding = {
|
||||
bindingId: string;
|
||||
pluginId: string;
|
||||
@@ -970,6 +972,24 @@ export type PluginConversationBindingRequestResult =
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type PluginConversationBindingResolvedEvent = {
|
||||
status: "approved" | "denied";
|
||||
binding?: PluginConversationBinding;
|
||||
decision: PluginConversationBindingResolutionDecision;
|
||||
request: {
|
||||
summary?: string;
|
||||
detachHint?: string;
|
||||
requestedBySenderId?: string;
|
||||
conversation: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Result returned by a plugin command handler.
|
||||
*/
|
||||
@@ -1256,6 +1276,9 @@ export type OpenClawPluginApi = {
|
||||
registerImageGenerationProvider: (provider: ImageGenerationProviderPlugin) => void;
|
||||
registerWebSearchProvider: (provider: WebSearchProviderPlugin) => void;
|
||||
registerInteractiveHandler: (registration: PluginInteractiveHandlerRegistration) => void;
|
||||
onConversationBindingResolved: (
|
||||
handler: (event: PluginConversationBindingResolvedEvent) => void | Promise<void>,
|
||||
) => void;
|
||||
/**
|
||||
* Register a custom command that bypasses the LLM agent.
|
||||
* Plugin commands are processed before built-in commands and before agent invocation.
|
||||
|
||||
@@ -35,6 +35,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl
|
||||
cliRegistrars: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
conversationBindingResolvedHandlers: [],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user