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:
Tak Hoffman
2026-03-28 20:52:31 -05:00
committed by GitHub
parent 9449e54f4f
commit 6f7ff545dd
12 changed files with 484 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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