Skip to content

GoogleChat: respect replyToMode for thread replies#43112

Closed
neeravmakwana wants to merge 1 commit into
openclaw:mainfrom
neeravmakwana:fix/googlechat-replytomode-threading-v2
Closed

GoogleChat: respect replyToMode for thread replies#43112
neeravmakwana wants to merge 1 commit into
openclaw:mainfrom
neeravmakwana:fix/googlechat-replytomode-threading-v2

Conversation

@neeravmakwana

Copy link
Copy Markdown
Contributor

Summary

  • Problem: Google Chat threaded replies were carrying the inbound thread through a separate implicit threadId path, so replyToMode filtering on replyToId did not actually disable or limit threading.
  • Why it matters: channels.googlechat.replyToMode: \"off\" still threaded replies, and \"first\" could keep threading follow-up payloads after the first reply.
  • What changed: Google Chat now stores the thread name as the provider-specific current reply reference, the shared reply pipeline uses that reference for Google Chat payload threading, and routing no longer forwards Google Chat session threadId as an unconditional explicit thread target.
  • What did NOT change (scope boundary): No config/schema changes for users, no non-Google-Chat behavior changes beyond the shared reply-reference plumbing needed to support provider-specific current reply IDs.

AI-assisted: yes. Testing: fully tested locally.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

Google Chat now respects channels.googlechat.replyToMode in the shared reply pipeline:

  • off keeps replies in the root conversation
  • first only threads the first reply
  • all preserves threaded replies consistently

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No
  • Data access scope changed? No
  • If any Yes, explain risk + mitigation:

Repro + Verification

Environment

  • OS: macOS
  • Runtime/container: Node 24 / pnpm
  • Model/provider: N/A
  • Integration/channel (if any): Google Chat
  • Relevant config (redacted): channels.googlechat.replyToMode=off|first|all

Steps

  1. Receive a Google Chat inbound event with message.thread.name set.
  2. Persist session metadata and build reply payloads through the shared reply pipeline.
  3. Send immediate and routed replies for replyToMode=off, first, and all.

Expected

  • off strips implicit threading.
  • first uses the thread only once.
  • all preserves the Google Chat thread reference consistently.

Actual

  • Before this change, Google Chat could still thread replies through a preserved threadId path even after replyToId had been filtered out.

Evidence

  • Failing test/log before + passing after
  • Trace/log snippets
  • Screenshot/recording
  • Perf numbers (if relevant)

Human Verification (required)

  • Verified scenarios: fresh real git clone of the PR branch, pnpm install, pnpm build, pnpm check, pnpm test
  • Edge cases checked: replyToMode=off, replyToMode=first, immediate monitor replies, routed shared-pipeline replies, typing-placeholder behavior for threaded replies
  • What you did not verify: live Google Chat end-to-end against a real service account

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yes
  • Config/env changes? No
  • Migration needed? No
  • If yes, exact upgrade steps:

Failure Recovery (if this breaks)

  • How to disable/revert this change quickly: revert commit 1bbbf961
  • Files/config to restore: extensions/googlechat/src/monitor.ts, src/auto-reply/reply/route-reply.ts, src/auto-reply/reply/agent-runner-utils.ts
  • Known bad symptoms reviewers should watch for: Google Chat replies threading when replyToMode=off, or later replies continuing to thread when replyToMode=first

Risks and Mitigations

  • Risk: shared reply plumbing now honors provider-specific CurrentMessageId for Google Chat, so regressions would show up as incorrect implicit threading rather than explicit send failures.
  • Mitigation: added coverage at the monitor layer, shared payload builder, and route-reply boundary.

Made with Cursor

Preserve Google Chat thread references through the shared reply pipeline so off/first/all modes behave correctly without forcing implicit thread targets.
@openclaw-barnacle openclaw-barnacle Bot added channel: googlechat Channel integration: googlechat size: L labels Mar 11, 2026
@neeravmakwana
neeravmakwana marked this pull request as ready for review March 11, 2026 12:36
@greptile-apps

greptile-apps Bot commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes the root cause of Google Chat always threading replies regardless of replyToMode configuration. The fix has three coordinated parts: (1) the monitor now stores the inbound thread name as CurrentMessageId (and MessageThreadId/ReplyToId) rather than implicitly threading through a raw thread: parameter on every send, (2) a new resolveCurrentReplyReferenceId helper is introduced so the shared reply pipeline uses this provider-specific reference ID instead of always falling back to MessageSidFull/MessageSid, and (3) route-reply.ts explicitly nulls out threadId for Google Chat so the session-level thread value cannot bypass the replyToMode filter.

Key observations:

  • The fix is clean and well-scoped — only Google Chat behaviour changes; all other providers are unaffected.
  • Test coverage spans three layers: monitor-level delivery, payload-builder pipeline, and the route-reply boundary.
  • UX note: the typing indicator is no longer sent into the inbound thread (the thread: message.thread?.name argument was removed from the typing-indicator sendGoogleChatMessage call). For replyToMode: "all" users, this means the "is typing…" message will always appear in the root conversation even when the eventual reply lands in a thread — a minor visual inconsistency worth monitoring for user feedback.
  • The second test in monitor.threading.test.ts ("uses the preserved inbound thread for immediate replies") uses a config with replyToMode: "off" but explicitly delivers with replyToId set, which is a state the shared pipeline would never produce under that mode — slightly misleading but not incorrect.

Confidence Score: 4/5

  • Safe to merge — the fix is well-scoped, covered by tests at three layers, and backward-compatible; no schema or config changes.
  • The logic change is correct and well-understood. The only deductions are: (1) no live end-to-end test against a real Google Chat service account (acknowledged by the author), (2) the typing indicator now always appears at the root level even for fully-threaded replyToMode: "all" sessions (minor UX regression), and (3) one test fixture has a slightly misleading setup. None of these are blockers.
  • extensions/googlechat/src/monitor.ts — the deliverGoogleChatReply typing-placeholder cleanup paths are the most complex new logic and the most likely source of subtle edge-case regressions under failure conditions.

Last reviewed commit: 1bbbf96

Comment on lines +243 to +280
path: "/googlechat",
mediaMaxMb: 5,
});

try {
const handled = await handleGoogleChatWebhookRequest(
createWebhookRequest(createMessageEvent(threadName)),
createMockServerResponse(),
);

expect(handled).toBe(true);
expect(recordSessionMetaFromInbound).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
CurrentMessageId: threadName,
ReplyToId: threadName,
MessageThreadId: threadName,
}),
}),
);
} finally {
unregister();
}
});

it("uses the preserved inbound thread for immediate replies", async () => {
const threadName = "spaces/AAA/threads/thread-2";
sendGoogleChatMessageMock.mockResolvedValue({
messageName: "spaces/AAA/messages/reply-1",
});
const core = createCore({
onDispatchReply: async ({ dispatcherOptions }) => {
await dispatcherOptions.deliver({ text: "Threaded reply", replyToId: threadName });
},
});
const unregister = registerGoogleChatWebhookTarget({
account: createAccount({ typingIndicator: "none" }),
config: createConfig(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading test config vs. test intent

createConfig() hardcodes replyToMode: "off", yet this test explicitly delivers { text: "Threaded reply", replyToId: threadName } — a combination that could never occur in production because the shared reply pipeline would have already stripped replyToId before calling deliver when the mode is "off".

The test is technically exercising a valid low-level path (that deliverGoogleChatReply passes replyToId through when it is present), but it can mislead a future maintainer into thinking that replyToMode: "off" still allows an explicit replyToId to reach the transport layer. Consider either (a) using a config with replyToMode: "all" for this test, or (b) adding a comment clarifying that this test exercises the delivery function in isolation, independent of the replyToMode filter.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/googlechat/src/monitor.threading.test.ts
Line: 243-280

Comment:
**Misleading test config vs. test intent**

`createConfig()` hardcodes `replyToMode: "off"`, yet this test explicitly delivers `{ text: "Threaded reply", replyToId: threadName }` — a combination that could never occur in production because the shared reply pipeline would have already stripped `replyToId` before calling `deliver` when the mode is `"off"`.

The test is technically exercising a valid low-level path (that `deliverGoogleChatReply` passes `replyToId` through when it is present), but it can mislead a future maintainer into thinking that `replyToMode: "off"` still allows an explicit `replyToId` to reach the transport layer. Consider either (a) using a config with `replyToMode: "all"` for this test, or (b) adding a comment clarifying that this test exercises the delivery function in isolation, independent of the `replyToMode` filter.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +464 to +475
let editableTypingMessageName = typingMessageName;
if (editableTypingMessageName && resolvedThreadId) {
try {
await deleteGoogleChatMessage({
account,
messageName: editableTypingMessageName,
});
} catch (err) {
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
}
editableTypingMessageName = undefined;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typing placeholder leaked on delete failure for threaded replies

editableTypingMessageName is unconditionally set to undefined after the try/catch block regardless of whether the delete succeeded. On delete failure, the typing message remains visible as _<bot> is typing..._ in the root conversation indefinitely — no fallback update clears it. This is consistent with the media-path behaviour, but unlike the media path there is no fallback updateGoogleChatMessage call here either.

This is likely intentional (avoiding a root-level message update when the real reply belongs in a thread), but could be worth leaving a brief code comment to that effect — otherwise it looks like the error handler is incomplete compared to the non-threaded path.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/googlechat/src/monitor.ts
Line: 464-475

Comment:
**Typing placeholder leaked on delete failure for threaded replies**

`editableTypingMessageName` is unconditionally set to `undefined` after the try/catch block regardless of whether the delete succeeded. On delete failure, the typing message remains visible as `_<bot> is typing..._` in the root conversation indefinitely — no fallback update clears it. This is consistent with the media-path behaviour, but unlike the media path there is no fallback `updateGoogleChatMessage` call here either.

This is likely intentional (avoiding a root-level message update when the real reply belongs in a thread), but could be worth leaving a brief code comment to that effect — otherwise it looks like the error handler is incomplete compared to the non-threaded path.

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1bbbf96147

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

(channelId === "slack" && threadId != null && threadId !== "" ? String(threadId) : undefined);
const resolvedThreadId = channelId === "slack" ? null : (threadId ?? null);
const resolvedThreadId =
channelId === "slack" || channelId === "googlechat" ? null : (threadId ?? null);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve explicit Google Chat thread targets in routeReply

Setting resolvedThreadId to null for Google Chat drops caller-supplied threadId even when the payload has no replyToId, so routeReply posts to the root space instead of the intended thread. This affects existing call sites that pass only threadId (for example sendResetSessionNotice in src/auto-reply/reply/get-reply-run.ts:108-122 and ACP routing in src/auto-reply/reply/dispatch-acp-delivery.ts:152-159), which means thread-scoped notices/tool replies can silently lose thread context after this change. A safer behavior is to avoid unconditional dropping and preserve thread targeting when no payload-level reply reference is present.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

channel: googlechat Channel integration: googlechat size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Google Chat: replyToMode: "off" does not suppress thread replies

1 participant