mirror of https://github.com/openclaw/openclaw.git
fix(line): add ACP binding parity (#56700)
* fix(line): support ACP current-conversation binding * fix(line): add ACP binding routing parity * docs(changelog): note LINE ACP parity * fix(line): accept canonical ACP binding targets
This commit is contained in:
parent
9449e54f4f
commit
6f7ff545dd
|
|
@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
### Fixes
|
||||
|
||||
- LINE/ACP: add current-conversation binding and inbound binding-routing parity so `/acp spawn ... --thread here`, configured ACP bindings, and active conversation-bound ACP sessions work on LINE like the other conversation channels.
|
||||
- macOS/local gateway: stop OpenClaw.app from killing healthy local gateway listeners after startup by recognizing the current `openclaw-gateway` process title and using the current `openclaw gateway` launch shape.
|
||||
- Memory/QMD: resolve slugified `memory_search` file hints back to the indexed filesystem path before returning search hits, so `memory_get` works again for mixed-case and spaced paths. (#50313) Thanks @erra9x.
|
||||
- Memory/QMD: weight CJK-heavy text correctly when estimating chunk sizes, preserve surrogate-pair characters during fine splits, and keep long Latin lines on the old chunk boundaries so memory indexing produces better-sized chunks for CJK notes. (#40271) Thanks @AaronLuo00.
|
||||
|
|
|
|||
|
|
@ -3,10 +3,16 @@ import os from "node:os";
|
|||
import path from "node:path";
|
||||
import type { MessageEvent, PostbackEvent } from "@line/bot-sdk";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { getSessionBindingService } from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { setDefaultChannelPluginRegistryForTests } from "../../../src/commands/channel-test-helpers.js";
|
||||
import { __testing as sessionBindingTesting } from "../../../src/infra/outbound/session-binding-service.js";
|
||||
import { buildLineMessageContext, buildLinePostbackContext } from "./bot-message-context.js";
|
||||
import { linePlugin } from "./channel.js";
|
||||
import type { ResolvedLineAccount } from "./types.js";
|
||||
|
||||
type AgentBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
||||
|
||||
describe("buildLineMessageContext", () => {
|
||||
let tmpDir: string;
|
||||
let storePath: string;
|
||||
|
|
@ -53,12 +59,15 @@ describe("buildLineMessageContext", () => {
|
|||
}) as PostbackEvent;
|
||||
|
||||
beforeEach(async () => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-line-context-"));
|
||||
storePath = path.join(tmpDir, "sessions.json");
|
||||
cfg = { session: { store: storePath } };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
sessionBindingTesting.resetSessionBindingAdaptersForTests();
|
||||
await fs.rm(tmpDir, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
|
|
@ -296,4 +305,64 @@ describe("buildLineMessageContext", () => {
|
|||
expect(context!.route.agentId).toBe("line-room-agent");
|
||||
expect(context!.route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
|
||||
it("normalizes LINE ACP binding conversation ids through the plugin bindings surface", async () => {
|
||||
const compiled = linePlugin.bindings?.compileConfiguredBinding({
|
||||
binding: {
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: { channel: "line", accountId: "default", peer: { kind: "direct", id: "unused" } },
|
||||
} as AgentBinding,
|
||||
conversationId: "line:user:U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
|
||||
expect(compiled).toEqual({
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
expect(
|
||||
linePlugin.bindings?.matchInboundConversation({
|
||||
binding: {
|
||||
type: "acp",
|
||||
agentId: "codex",
|
||||
match: { channel: "line", accountId: "default", peer: { kind: "direct", id: "unused" } },
|
||||
} as AgentBinding,
|
||||
compiledBinding: compiled!,
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
}),
|
||||
).toEqual({
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
matchPriority: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("routes LINE conversations through active ACP session bindings", async () => {
|
||||
const userId = "U1234567890abcdef1234567890abcdef";
|
||||
await getSessionBindingService().bind({
|
||||
targetSessionKey: "agent:codex:acp:binding:line:default:test123",
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
conversationId: userId,
|
||||
},
|
||||
placement: "current",
|
||||
metadata: {
|
||||
agentId: "codex",
|
||||
},
|
||||
});
|
||||
|
||||
const event = createMessageEvent({ type: "user", userId });
|
||||
const context = await buildLineMessageContext({
|
||||
event,
|
||||
allMedia: [],
|
||||
cfg,
|
||||
account,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(context).not.toBeNull();
|
||||
expect(context!.route.agentId).toBe("codex");
|
||||
expect(context!.route.sessionKey).toBe("agent:codex:acp:binding:line:default:test123");
|
||||
expect(context!.route.matchedBy).toBe("binding.channel");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,12 +8,19 @@ import {
|
|||
import { recordChannelActivity } from "openclaw/plugin-sdk/channel-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
ensureConfiguredBindingRouteReady,
|
||||
getSessionBindingService,
|
||||
recordInboundSession,
|
||||
resolvePinnedMainDmOwnerFromAllowlist,
|
||||
resolveConfiguredBindingRoute,
|
||||
} from "openclaw/plugin-sdk/conversation-runtime";
|
||||
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
||||
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
deriveLastRoutePolicy,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveAgentRoute,
|
||||
} from "openclaw/plugin-sdk/routing";
|
||||
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeAllowFrom } from "./bot-access.js";
|
||||
import { resolveLineGroupConfigEntry, resolveLineGroupHistoryKey } from "./group-keys.js";
|
||||
|
|
@ -71,18 +78,18 @@ function buildPeerId(source: EventSource): string {
|
|||
return "unknown";
|
||||
}
|
||||
|
||||
function resolveLineInboundRoute(params: {
|
||||
async function resolveLineInboundRoute(params: {
|
||||
source: EventSource;
|
||||
cfg: OpenClawConfig;
|
||||
account: ResolvedLineAccount;
|
||||
}): {
|
||||
}): Promise<{
|
||||
userId?: string;
|
||||
groupId?: string;
|
||||
roomId?: string;
|
||||
isGroup: boolean;
|
||||
peerId: string;
|
||||
route: ReturnType<typeof resolveAgentRoute>;
|
||||
} {
|
||||
}> {
|
||||
recordChannelActivity({
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
|
|
@ -91,7 +98,7 @@ function resolveLineInboundRoute(params: {
|
|||
|
||||
const { userId, groupId, roomId, isGroup } = getLineSourceInfo(params.source);
|
||||
const peerId = buildPeerId(params.source);
|
||||
const route = resolveAgentRoute({
|
||||
let route = resolveAgentRoute({
|
||||
cfg: params.cfg,
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
|
|
@ -101,6 +108,57 @@ function resolveLineInboundRoute(params: {
|
|||
},
|
||||
});
|
||||
|
||||
const configuredRoute = resolveConfiguredBindingRoute({
|
||||
cfg: params.cfg,
|
||||
route,
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
conversationId: peerId,
|
||||
},
|
||||
});
|
||||
let configuredBinding = configuredRoute.bindingResolution;
|
||||
const configuredBindingSessionKey = configuredRoute.boundSessionKey ?? "";
|
||||
route = configuredRoute.route;
|
||||
|
||||
const boundConversation = getSessionBindingService().resolveByConversation({
|
||||
channel: "line",
|
||||
accountId: params.account.accountId,
|
||||
conversationId: peerId,
|
||||
});
|
||||
const boundSessionKey = boundConversation?.targetSessionKey?.trim();
|
||||
if (boundConversation && boundSessionKey) {
|
||||
route = {
|
||||
...route,
|
||||
sessionKey: boundSessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(boundSessionKey) || route.agentId,
|
||||
lastRoutePolicy: deriveLastRoutePolicy({
|
||||
sessionKey: boundSessionKey,
|
||||
mainSessionKey: route.mainSessionKey,
|
||||
}),
|
||||
matchedBy: "binding.channel",
|
||||
};
|
||||
configuredBinding = null;
|
||||
getSessionBindingService().touch(boundConversation.bindingId);
|
||||
logVerbose(`line: routed via bound conversation ${peerId} -> ${boundSessionKey}`);
|
||||
}
|
||||
|
||||
if (configuredBinding) {
|
||||
const ensured = await ensureConfiguredBindingRouteReady({
|
||||
cfg: params.cfg,
|
||||
bindingResolution: configuredBinding,
|
||||
});
|
||||
if (!ensured.ok) {
|
||||
logVerbose(
|
||||
`line: configured ACP binding unavailable for ${peerId} -> ${configuredBindingSessionKey}: ${ensured.error}`,
|
||||
);
|
||||
throw new Error(`Configured ACP binding unavailable: ${ensured.error}`);
|
||||
}
|
||||
logVerbose(
|
||||
`line: using configured ACP binding for ${peerId} -> ${configuredBindingSessionKey}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { userId, groupId, roomId, isGroup, peerId, route };
|
||||
}
|
||||
|
||||
|
|
@ -371,7 +429,7 @@ export async function buildLineMessageContext(params: BuildLineMessageContextPar
|
|||
const { event, allMedia, cfg, account, commandAuthorized, groupHistories, historyLimit } = params;
|
||||
|
||||
const source = event.source;
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
|
||||
source,
|
||||
cfg,
|
||||
account,
|
||||
|
|
@ -460,7 +518,7 @@ export async function buildLinePostbackContext(params: {
|
|||
const { event, cfg, account, commandAuthorized } = params;
|
||||
|
||||
const source = event.source;
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = resolveLineInboundRoute({
|
||||
const { userId, groupId, roomId, isGroup, peerId, route } = await resolveLineInboundRoute({
|
||||
source,
|
||||
cfg,
|
||||
account,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,27 @@ import { lineSetupAdapter } from "./setup-core.js";
|
|||
import { lineSetupWizard } from "./setup-surface.js";
|
||||
import { lineStatusAdapter } from "./status.js";
|
||||
|
||||
function normalizeLineConversationId(raw?: string | null): string | null {
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const prefixed = trimmed.match(/^line:(?:user|group|room):(.+)$/i)?.[1];
|
||||
return (prefixed ?? trimmed).trim() || null;
|
||||
}
|
||||
|
||||
function resolveLineCommandConversation(params: {
|
||||
originatingTo?: string;
|
||||
commandTo?: string;
|
||||
fallbackTo?: string;
|
||||
}) {
|
||||
const conversationId =
|
||||
normalizeLineConversationId(params.originatingTo) ??
|
||||
normalizeLineConversationId(params.commandTo) ??
|
||||
normalizeLineConversationId(params.fallbackTo);
|
||||
return conversationId ? { conversationId } : null;
|
||||
}
|
||||
|
||||
const lineSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedLineAccount>({
|
||||
channelKey: "line",
|
||||
resolveDmPolicy: (account) => account.config.dmPolicy,
|
||||
|
|
@ -58,6 +79,28 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = createChatChannelP
|
|||
setup: lineSetupAdapter,
|
||||
status: lineStatusAdapter,
|
||||
gateway: lineGatewayAdapter,
|
||||
bindings: {
|
||||
compileConfiguredBinding: ({ conversationId }) => {
|
||||
const normalized = normalizeLineConversationId(conversationId);
|
||||
return normalized ? { conversationId: normalized } : null;
|
||||
},
|
||||
matchInboundConversation: ({ compiledBinding, conversationId }) => {
|
||||
const normalizedIncoming = normalizeLineConversationId(conversationId);
|
||||
if (!normalizedIncoming || compiledBinding.conversationId !== normalizedIncoming) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
conversationId: normalizedIncoming,
|
||||
matchPriority: 2,
|
||||
};
|
||||
},
|
||||
resolveCommandConversation: ({ originatingTo, commandTo, fallbackTo }) =>
|
||||
resolveLineCommandConversation({
|
||||
originatingTo,
|
||||
commandTo,
|
||||
fallbackTo,
|
||||
}),
|
||||
},
|
||||
agentPrompt: {
|
||||
messageToolHints: () => [
|
||||
"",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,15 @@ import { z } from "openclaw/plugin-sdk/zod";
|
|||
|
||||
const DmPolicySchema = z.enum(["open", "allowlist", "pairing", "disabled"]);
|
||||
const GroupPolicySchema = z.enum(["open", "allowlist", "disabled"]);
|
||||
const ThreadBindingsSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
idleHours: z.number().optional(),
|
||||
maxAgeHours: z.number().optional(),
|
||||
spawnSubagentSessions: z.boolean().optional(),
|
||||
spawnAcpSessions: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const LineCommonConfigSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
|
@ -18,6 +27,7 @@ const LineCommonConfigSchema = z.object({
|
|||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().optional(),
|
||||
webhookPath: z.string().optional(),
|
||||
threadBindings: ThreadBindingsSchema.optional(),
|
||||
});
|
||||
|
||||
const LineGroupConfigSchema = z
|
||||
|
|
|
|||
|
|
@ -11,6 +11,14 @@ import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
|||
|
||||
export type LineTokenSource = "config" | "env" | "file" | "none";
|
||||
|
||||
export interface LineThreadBindingsConfig {
|
||||
enabled?: boolean;
|
||||
idleHours?: number;
|
||||
maxAgeHours?: number;
|
||||
spawnSubagentSessions?: boolean;
|
||||
spawnAcpSessions?: boolean;
|
||||
}
|
||||
|
||||
interface LineAccountBaseConfig {
|
||||
enabled?: boolean;
|
||||
channelAccessToken?: string;
|
||||
|
|
@ -25,6 +33,7 @@ interface LineAccountBaseConfig {
|
|||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
webhookPath?: string;
|
||||
threadBindings?: LineThreadBindingsConfig;
|
||||
groups?: Record<string, LineGroupConfig>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -219,6 +219,34 @@ function enableMatrixAcpThreadBindings(): void {
|
|||
});
|
||||
}
|
||||
|
||||
function enableLineCurrentConversationBindings(): void {
|
||||
replaceSpawnConfig({
|
||||
...hoisted.state.cfg,
|
||||
channels: {
|
||||
...hoisted.state.cfg.channels,
|
||||
line: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
registerSessionBindingAdapter({
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
capabilities: {
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current"] satisfies SessionBindingPlacement[],
|
||||
},
|
||||
bind: async (input) => await hoisted.sessionBindingBindMock(input),
|
||||
listBySession: (targetSessionKey) => hoisted.sessionBindingListBySessionMock(targetSessionKey),
|
||||
resolveByConversation: (ref) => hoisted.sessionBindingResolveByConversationMock(ref),
|
||||
unbind: async (input) => await hoisted.sessionBindingUnbindMock(input),
|
||||
});
|
||||
}
|
||||
|
||||
describe("spawnAcpDirect", () => {
|
||||
beforeEach(() => {
|
||||
replaceSpawnConfig(createDefaultSpawnConfig());
|
||||
|
|
@ -506,6 +534,143 @@ describe("spawnAcpDirect", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("binds LINE ACP sessions to the current conversation when the channel has no native threads", async () => {
|
||||
enableLineCurrentConversationBindings();
|
||||
hoisted.sessionBindingBindMock.mockImplementationOnce(
|
||||
async (input: {
|
||||
targetSessionKey: string;
|
||||
conversation: { accountId: string; conversationId: string };
|
||||
metadata?: Record<string, unknown>;
|
||||
}) =>
|
||||
createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: input.conversation.conversationId,
|
||||
},
|
||||
metadata: {
|
||||
boundBy:
|
||||
typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system",
|
||||
agentId: "codex",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
thread: true,
|
||||
},
|
||||
{
|
||||
agentSessionKey: "agent:main:line:direct:U1234567890abcdef1234567890abcdef",
|
||||
agentChannel: "line",
|
||||
agentAccountId: "default",
|
||||
agentTo: "U1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expectAgentGatewayCall({
|
||||
deliver: true,
|
||||
channel: "line",
|
||||
to: "U1234567890abcdef1234567890abcdef",
|
||||
threadId: undefined,
|
||||
});
|
||||
const transcriptCalls = hoisted.resolveSessionTranscriptFileMock.mock.calls.map(
|
||||
(call: unknown[]) => call[0] as { threadId?: string },
|
||||
);
|
||||
expect(transcriptCalls).toHaveLength(1);
|
||||
expect(transcriptCalls[0]?.threadId).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "canonical line target",
|
||||
agentTo: "line:U1234567890abcdef1234567890abcdef",
|
||||
expectedConversationId: "U1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
{
|
||||
name: "typed line user target",
|
||||
agentTo: "line:user:U1234567890abcdef1234567890abcdef",
|
||||
expectedConversationId: "U1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
{
|
||||
name: "typed line group target",
|
||||
agentTo: "line:group:C1234567890abcdef1234567890abcdef",
|
||||
expectedConversationId: "C1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
{
|
||||
name: "typed line room target",
|
||||
agentTo: "line:room:R1234567890abcdef1234567890abcdef",
|
||||
expectedConversationId: "R1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
])(
|
||||
"resolves LINE ACP conversation ids from $name",
|
||||
async ({ agentTo, expectedConversationId }) => {
|
||||
enableLineCurrentConversationBindings();
|
||||
hoisted.sessionBindingBindMock.mockImplementationOnce(
|
||||
async (input: {
|
||||
targetSessionKey: string;
|
||||
conversation: { accountId: string; conversationId: string };
|
||||
metadata?: Record<string, unknown>;
|
||||
}) =>
|
||||
createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation: {
|
||||
channel: "line",
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: input.conversation.conversationId,
|
||||
},
|
||||
metadata: {
|
||||
boundBy:
|
||||
typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "system",
|
||||
agentId: "codex",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await spawnAcpDirect(
|
||||
{
|
||||
task: "Investigate flaky tests",
|
||||
agentId: "codex",
|
||||
mode: "session",
|
||||
thread: true,
|
||||
},
|
||||
{
|
||||
agentSessionKey: `agent:main:line:direct:${expectedConversationId}`,
|
||||
agentChannel: "line",
|
||||
agentAccountId: "default",
|
||||
agentTo,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe("accepted");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
conversationId: expectedConversationId,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "inlines delivery for run-mode spawns from non-subagent requester sessions",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
import {
|
||||
formatThreadBindingDisabledError,
|
||||
formatThreadBindingSpawnDisabledError,
|
||||
requiresNativeThreadContextForThreadHere,
|
||||
resolveThreadBindingIdleTimeoutMsForChannel,
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
resolveThreadBindingSpawnPolicy,
|
||||
|
|
@ -125,6 +126,7 @@ export function resolveAcpSpawnRuntimePolicyError(params: {
|
|||
type PreparedAcpThreadBinding = {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
placement: "current" | "child";
|
||||
conversationId: string;
|
||||
};
|
||||
|
||||
|
|
@ -352,13 +354,31 @@ async function persistAcpSpawnSessionFileBestEffort(params: {
|
|||
}
|
||||
|
||||
function resolveConversationIdForThreadBinding(params: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
threadId?: string | number;
|
||||
}): string | undefined {
|
||||
return resolveConversationIdFromTargets({
|
||||
const genericConversationId = resolveConversationIdFromTargets({
|
||||
threadId: params.threadId,
|
||||
targets: [params.to],
|
||||
});
|
||||
if (genericConversationId) {
|
||||
return genericConversationId;
|
||||
}
|
||||
|
||||
const channel = params.channel?.trim().toLowerCase();
|
||||
const target = params.to?.trim() || "";
|
||||
if (channel === "line") {
|
||||
const prefixed = target.match(/^line:(?:(?:user|group|room):)?([UCR][a-f0-9]{32})$/i)?.[1];
|
||||
if (prefixed) {
|
||||
return prefixed;
|
||||
}
|
||||
if (/^[UCR][a-f0-9]{32}$/i.test(target)) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function prepareAcpThreadBinding(params: {
|
||||
|
|
@ -414,13 +434,15 @@ function prepareAcpThreadBinding(params: {
|
|||
error: `Thread bindings are unavailable for ${policy.channel}.`,
|
||||
};
|
||||
}
|
||||
if (!capabilities.bindSupported || !capabilities.placements.includes("child")) {
|
||||
const placement = requiresNativeThreadContextForThreadHere(policy.channel) ? "child" : "current";
|
||||
if (!capabilities.bindSupported || !capabilities.placements.includes(placement)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Thread bindings do not support ACP thread spawn for ${policy.channel}.`,
|
||||
error: `Thread bindings do not support ${placement} placement for ${policy.channel}.`,
|
||||
};
|
||||
}
|
||||
const conversationId = resolveConversationIdForThreadBinding({
|
||||
channel: policy.channel,
|
||||
to: params.to,
|
||||
threadId: params.threadId,
|
||||
});
|
||||
|
|
@ -436,6 +458,7 @@ function prepareAcpThreadBinding(params: {
|
|||
binding: {
|
||||
channel: policy.channel,
|
||||
accountId: policy.accountId,
|
||||
placement,
|
||||
conversationId,
|
||||
},
|
||||
};
|
||||
|
|
@ -583,7 +606,7 @@ async function bindPreparedAcpThread(params: {
|
|||
accountId: params.preparedBinding.accountId,
|
||||
conversationId: params.preparedBinding.conversationId,
|
||||
},
|
||||
placement: "child",
|
||||
placement: params.preparedBinding.placement,
|
||||
metadata: {
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
agentId: params.targetAgentId,
|
||||
|
|
@ -615,12 +638,14 @@ async function bindPreparedAcpThread(params: {
|
|||
});
|
||||
if (!binding.conversation.conversationId) {
|
||||
throw new Error(
|
||||
`Failed to create and bind a ${params.preparedBinding.channel} thread for this ACP session.`,
|
||||
params.preparedBinding.placement === "child"
|
||||
? `Failed to create and bind a ${params.preparedBinding.channel} thread for this ACP session.`
|
||||
: `Failed to bind the current ${params.preparedBinding.channel} conversation for this ACP session.`,
|
||||
);
|
||||
}
|
||||
|
||||
let sessionEntry = params.initializedRuntime.sessionEntry;
|
||||
if (params.initializedRuntime.sessionId) {
|
||||
if (params.initializedRuntime.sessionId && params.preparedBinding.placement === "child") {
|
||||
const boundThreadId = String(binding.conversation.conversationId).trim() || undefined;
|
||||
if (boundThreadId) {
|
||||
sessionEntry = await persistAcpSpawnSessionFileBestEffort({
|
||||
|
|
@ -646,26 +671,42 @@ function resolveAcpSpawnBootstrapDeliveryPlan(params: {
|
|||
requester: AcpSpawnRequesterState;
|
||||
binding: SessionBindingRecord | null;
|
||||
}): AcpSpawnBootstrapDeliveryPlan {
|
||||
// For thread-bound ACP spawns, force bootstrap delivery to the new child thread.
|
||||
// Child-thread ACP spawns deliver bootstrap output to the new thread; current-conversation
|
||||
// binds deliver back to the originating target.
|
||||
const boundThreadIdRaw = params.binding?.conversation.conversationId;
|
||||
const boundThreadId = boundThreadIdRaw ? String(boundThreadIdRaw).trim() || undefined : undefined;
|
||||
const fallbackThreadIdRaw = params.requester.origin?.threadId;
|
||||
const fallbackThreadId =
|
||||
fallbackThreadIdRaw != null ? String(fallbackThreadIdRaw).trim() || undefined : undefined;
|
||||
const deliveryThreadId = boundThreadId ?? fallbackThreadId;
|
||||
const requesterConversationId = resolveConversationIdForThreadBinding({
|
||||
channel: params.requester.origin?.channel,
|
||||
threadId: fallbackThreadId,
|
||||
to: params.requester.origin?.to,
|
||||
});
|
||||
const bindingMatchesRequesterConversation = Boolean(
|
||||
params.requester.origin?.channel &&
|
||||
params.binding?.conversation.channel === params.requester.origin.channel &&
|
||||
params.binding?.conversation.accountId === (params.requester.origin.accountId ?? "default") &&
|
||||
requesterConversationId &&
|
||||
params.binding?.conversation.conversationId === requesterConversationId,
|
||||
);
|
||||
const boundDeliveryTarget = resolveConversationDeliveryTarget({
|
||||
channel: params.requester.origin?.channel ?? params.binding?.conversation.channel,
|
||||
conversationId: params.binding?.conversation.conversationId,
|
||||
parentConversationId: params.binding?.conversation.parentConversationId,
|
||||
});
|
||||
const inferredDeliveryTo =
|
||||
(bindingMatchesRequesterConversation ? params.requester.origin?.to?.trim() : undefined) ??
|
||||
boundDeliveryTarget.to ??
|
||||
params.requester.origin?.to?.trim() ??
|
||||
formatConversationTarget({
|
||||
channel: params.requester.origin?.channel,
|
||||
conversationId: deliveryThreadId,
|
||||
});
|
||||
const resolvedDeliveryThreadId = boundDeliveryTarget.threadId ?? deliveryThreadId;
|
||||
const resolvedDeliveryThreadId = bindingMatchesRequesterConversation
|
||||
? fallbackThreadId
|
||||
: (boundDeliveryTarget.threadId ?? deliveryThreadId);
|
||||
const hasDeliveryTarget = Boolean(params.requester.origin?.channel && inferredDeliveryTo);
|
||||
|
||||
// Thread-bound session spawns always deliver inline to their bound thread.
|
||||
|
|
|
|||
|
|
@ -434,6 +434,21 @@ async function runFeishuDmAcpCommand(commandBody: string, cfg: OpenClawConfig =
|
|||
);
|
||||
}
|
||||
|
||||
async function runLineDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(
|
||||
createConversationParams(
|
||||
commandBody,
|
||||
{
|
||||
channel: "line",
|
||||
originatingTo: "U1234567890abcdef1234567890abcdef",
|
||||
senderId: "U1234567890abcdef1234567890abcdef",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function runBlueBubblesDmAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(
|
||||
createConversationParams(
|
||||
|
|
@ -1022,6 +1037,23 @@ describe("/acp command", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("binds LINE DM ACP spawns to the current conversation", async () => {
|
||||
const result = await runLineDmAcpCommand("/acp spawn codex --thread here");
|
||||
|
||||
expect(result?.reply?.text).toContain("Spawned ACP session agent:codex:acp:");
|
||||
expect(result?.reply?.text).toContain("Bound this conversation to");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("requires explicit ACP target when acp.defaultAgent is not configured", async () => {
|
||||
const result = await runDiscordAcpCommand("/acp spawn");
|
||||
|
||||
|
|
|
|||
|
|
@ -167,6 +167,40 @@ describe("commands-acp context", () => {
|
|||
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
|
||||
});
|
||||
|
||||
it("resolves LINE DM conversation ids from raw LINE targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "line",
|
||||
Surface: "line",
|
||||
OriginatingChannel: "line",
|
||||
OriginatingTo: "U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "line",
|
||||
accountId: "default",
|
||||
threadId: undefined,
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
expect(resolveAcpCommandConversationId(params)).toBe("U1234567890abcdef1234567890abcdef");
|
||||
});
|
||||
|
||||
it("resolves LINE conversation ids from prefixed LINE targets", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "line",
|
||||
Surface: "line",
|
||||
OriginatingChannel: "line",
|
||||
OriginatingTo: "line:user:U1234567890abcdef1234567890abcdef",
|
||||
AccountId: "work",
|
||||
});
|
||||
|
||||
expect(resolveAcpCommandBindingContext(params)).toEqual({
|
||||
channel: "line",
|
||||
accountId: "work",
|
||||
threadId: undefined,
|
||||
conversationId: "U1234567890abcdef1234567890abcdef",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Matrix thread context from the current room and thread root", () => {
|
||||
const params = buildCommandTestParams("/acp status", baseCfg, {
|
||||
Provider: "matrix",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ describe("thread binding spawn policy helpers", () => {
|
|||
it("allows thread-here on threadless conversation channels without a native thread id", () => {
|
||||
expect(requiresNativeThreadContextForThreadHere("telegram")).toBe(false);
|
||||
expect(requiresNativeThreadContextForThreadHere("feishu")).toBe(false);
|
||||
expect(requiresNativeThreadContextForThreadHere("line")).toBe(false);
|
||||
expect(requiresNativeThreadContextForThreadHere("discord")).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -35,5 +36,10 @@ describe("thread binding spawn policy helpers", () => {
|
|||
channel: "telegram",
|
||||
}),
|
||||
).toBe("current");
|
||||
expect(
|
||||
resolveThreadBindingPlacementForCurrentContext({
|
||||
channel: "line",
|
||||
}),
|
||||
).toBe("current");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ export function supportsAutomaticThreadBindingSpawn(channel: string): boolean {
|
|||
|
||||
export function requiresNativeThreadContextForThreadHere(channel: string): boolean {
|
||||
const normalized = normalizeChannelId(channel);
|
||||
return normalized !== "telegram" && normalized !== "feishu";
|
||||
return normalized !== "telegram" && normalized !== "feishu" && normalized !== "line";
|
||||
}
|
||||
|
||||
export function resolveThreadBindingPlacementForCurrentContext(params: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue