From 6f7ff545dde4e2a17fb1616b9de323ddd9c7b9bc Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sat, 28 Mar 2026 20:52:31 -0500 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../line/src/bot-message-context.test.ts | 69 ++++++++ extensions/line/src/bot-message-context.ts | 72 +++++++- extensions/line/src/channel.ts | 43 +++++ extensions/line/src/config-schema.ts | 10 ++ extensions/line/src/types.ts | 9 + src/agents/acp-spawn.test.ts | 165 ++++++++++++++++++ src/agents/acp-spawn.ts | 57 +++++- src/auto-reply/reply/commands-acp.test.ts | 32 ++++ .../reply/commands-acp/context.test.ts | 34 ++++ src/channels/thread-bindings-policy.test.ts | 6 + src/channels/thread-bindings-policy.ts | 2 +- 12 files changed, 484 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fdb585a846..ffb9fd32630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/extensions/line/src/bot-message-context.test.ts b/extensions/line/src/bot-message-context.test.ts index 3f62beff114..c3fb0efcae5 100644 --- a/extensions/line/src/bot-message-context.test.ts +++ b/extensions/line/src/bot-message-context.test.ts @@ -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[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"); + }); }); diff --git a/extensions/line/src/bot-message-context.ts b/extensions/line/src/bot-message-context.ts index fbff643df87..108aa168e48 100644 --- a/extensions/line/src/bot-message-context.ts +++ b/extensions/line/src/bot-message-context.ts @@ -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; -} { +}> { 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, diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 2241669c997..d5dd6506ec3 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -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({ channelKey: "line", resolveDmPolicy: (account) => account.config.dmPolicy, @@ -58,6 +79,28 @@ export const linePlugin: ChannelPlugin = 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: () => [ "", diff --git a/extensions/line/src/config-schema.ts b/extensions/line/src/config-schema.ts index fd5518d612d..29c77d1ac6e 100644 --- a/extensions/line/src/config-schema.ts +++ b/extensions/line/src/config-schema.ts @@ -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 diff --git a/extensions/line/src/types.ts b/extensions/line/src/types.ts index df3d1a6a492..397dee790c8 100644 --- a/extensions/line/src/types.ts +++ b/extensions/line/src/types.ts @@ -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; } diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index d14cb74ae8a..1bee7537ad5 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -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; + }) => + 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; + }) => + 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", diff --git a/src/agents/acp-spawn.ts b/src/agents/acp-spawn.ts index f7b29e0263d..b3c5698dd0f 100644 --- a/src/agents/acp-spawn.ts +++ b/src/agents/acp-spawn.ts @@ -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. diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 22c622b1737..38937526f2a 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -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"); diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index ad33db8e610..b200e0eb251 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -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", diff --git a/src/channels/thread-bindings-policy.test.ts b/src/channels/thread-bindings-policy.test.ts index bb58c6308cf..6dad6cf5cd5 100644 --- a/src/channels/thread-bindings-policy.test.ts +++ b/src/channels/thread-bindings-policy.test.ts @@ -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"); }); }); diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 64a0fe6df40..9c1cde5df06 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -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: {