fix(googlechat): preserve thread for message tool replies (#80996)

Use the Google Chat thread resource as the ambient message-tool reply target so replies stay in the inbound thread. Normalize the current Google Chat space target and let plugin threading adapters explicitly suppress the generic message-id fallback when a provider needs a thread resource instead of a message resource.

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Franco Viotti <franco-viotti@users.noreply.github.com>
This commit is contained in:
Franco Viotti
2026-05-31 12:43:46 -03:00
committed by GitHub
parent ed74fa692b
commit a71b121c69
4 changed files with 126 additions and 1 deletions

View File

@@ -1,4 +1,8 @@
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
import type {
ChannelThreadingContext,
ChannelThreadingToolContext,
} from "openclaw/plugin-sdk/channel-contract";
import {
createMessageReceiptFromOutboundResults,
defineChannelMessageAdapter,
@@ -127,6 +131,29 @@ export const googlechatThreadingAdapter = {
account.config.replyToMode,
fallback: "off" as const,
},
buildToolContext: ({
cfg,
accountId,
context,
hasRepliedRef,
}: {
cfg: OpenClawConfig;
accountId?: string | null;
context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean };
}): ChannelThreadingToolContext => {
const currentChannelId = normalizeGoogleChatTarget(context.To);
const replyToId =
normalizeOptionalString(context.ReplyToIdFull) ?? normalizeOptionalString(context.ReplyToId);
return {
currentChannelId,
currentMessageId: replyToId,
currentThreadTs: replyToId,
replyToMode: resolveGoogleChatAccount({ cfg, accountId }).config.replyToMode,
hasRepliedRef,
};
},
};
export const googlechatPairingTextAdapter = {

View File

@@ -439,6 +439,62 @@ describe("googlechatPlugin threading", () => {
googlechatThreadingAdapter.scopedAccountReplyToMode.resolveReplyToMode(defaultAccount),
).toBe("all");
});
it("uses the inbound thread resource as the current tool reply target", () => {
const cfg = {
channels: {
googlechat: {
replyToMode: "all",
},
},
} as OpenClawConfig;
const hasRepliedRef = { value: false };
const context = googlechatThreadingAdapter.buildToolContext({
cfg,
accountId: "default",
context: {
To: "googlechat:spaces/AAA",
CurrentMessageId: "spaces/AAA/messages/msg-1",
ReplyToId: "spaces/AAA/threads/thread-1",
},
hasRepliedRef,
});
expect(context).toMatchObject({
currentChannelId: "spaces/AAA",
currentMessageId: "spaces/AAA/threads/thread-1",
currentThreadTs: "spaces/AAA/threads/thread-1",
replyToMode: "all",
hasRepliedRef,
});
});
it("does not use message resources as implicit Google Chat reply targets", () => {
const cfg = {
channels: {
googlechat: {
replyToMode: "all",
},
},
} as OpenClawConfig;
const context = googlechatThreadingAdapter.buildToolContext({
cfg,
accountId: "default",
context: {
To: "googlechat:spaces/AAA",
CurrentMessageId: "spaces/AAA/messages/msg-1",
},
});
expect(context).toMatchObject({
currentChannelId: "spaces/AAA",
replyToMode: "all",
});
expect(context.currentMessageId).toBeUndefined();
expect(context.currentThreadTs).toBeUndefined();
});
});
const resolveTarget = googlechatOutboundAdapter.base.resolveTarget;

View File

@@ -152,6 +152,7 @@ export function buildThreadingToolContext(params: {
ChatType: sessionCtx.ChatType,
CurrentMessageId: currentMessageId,
ReplyToId: sessionCtx.ReplyToId,
ReplyToIdFull: sessionCtx.ReplyToIdFull,
ThreadLabel: sessionCtx.ThreadLabel,
MessageThreadId: sessionCtx.MessageThreadId,
TransportThreadId: sessionCtx.TransportThreadId,
@@ -159,10 +160,13 @@ export function buildThreadingToolContext(params: {
},
hasRepliedRef,
}) ?? {};
const hasAdapterCurrentMessageId = Object.hasOwn(context, "currentMessageId");
return {
...context,
currentChannelProvider: provider!, // guaranteed non-null since threading exists
currentMessageId: context.currentMessageId ?? currentMessageId,
// Some providers expose only thread resources as reply targets; explicit
// `undefined` means the adapter rejected the generic message-id fallback.
currentMessageId: hasAdapterCurrentMessageId ? context.currentMessageId : currentMessageId,
};
}

View File

@@ -193,6 +193,44 @@ describe("buildThreadingToolContext", () => {
expect(result.currentChannelId).toBe("C1");
expect(result.currentThreadTs).toBe("123.456");
});
it("lets plugin threading adapters suppress the generic message-id fallback", () => {
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "googlechat",
plugin: {
...createChannelTestPluginBase({ id: "googlechat", label: "Google Chat" }),
threading: {
buildToolContext: ({ context }) => ({
currentChannelId: context.To?.replace(/^googlechat:/, ""),
currentMessageId: undefined,
currentThreadTs: context.ReplyToIdFull ?? context.ReplyToId,
}),
},
} as ChannelPlugin,
source: "test",
},
]),
);
const sessionCtx = {
Provider: "googlechat",
To: "googlechat:spaces/AAA",
MessageSidFull: "spaces/AAA/messages/msg-1",
ReplyToId: "spaces/AAA/threads/short",
ReplyToIdFull: "spaces/AAA/threads/full",
} as TemplateContext;
const result = buildThreadingToolContext({
sessionCtx,
config: { channels: { googlechat: { replyToMode: "all" } } } as OpenClawConfig,
hasRepliedRef: undefined,
});
expect(result.currentChannelId).toBe("spaces/AAA");
expect(result.currentThreadTs).toBe("spaces/AAA/threads/full");
expect(result.currentMessageId).toBeUndefined();
});
});
describe("applyReplyThreading auto-threading", () => {