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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 484 additions and 16 deletions

View File

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

View File

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

View File

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

View File

@ -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: () => [
"",

View File

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

View File

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

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