mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-31 03:41:51 +00:00
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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user