fix(routing): treat group/channel peer.kind as equivalent (land #31135 by @Sid-Qin)

Landed-from: #31135
Contributor: @Sid-Qin
Co-authored-by: Sid <sidqin0410@gmail.com>
This commit is contained in:
Peter Steinberger 2026-03-02 01:46:46 +00:00
parent e076665e5e
commit 70ee256ae0
3 changed files with 82 additions and 1 deletions

View file

@ -102,6 +102,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Routing/Binding peer-kind parity: treat `peer.kind` `group` and `channel` as equivalent for binding scope matching (while keeping `direct` separate) so Slack/public channel bindings do not silently fall through. Landed from contributor PR #31135 by @Sid-Qin. Thanks @Sid-Qin.
- Agents/FS workspace default: honor documented host file-tool default `tools.fs.workspaceOnly=false` when unset so host `write`/`edit` calls are not incorrectly workspace-restricted unless explicitly enabled. Landed from contributor PR #31128 by @SaucePackets. Thanks @SaucePackets.
- Gateway/CLI session recovery: handle expired CLI session IDs gracefully by clearing stale session state and retrying without crashing gateway runs. Landed from contributor PR #31090 by @frankekn. Thanks @frankekn.
- Slack/Subagent completion delivery: stop forcing bound conversation IDs into `threadId` so Slack completion announces do not send invalid `thread_ts` for DMs/top-level channels. Landed from contributor PR #31105 by @stakeswky. Thanks @stakeswky.

View file

@ -547,6 +547,74 @@ describe("backward compatibility: peer.kind dm → direct", () => {
});
});
describe("backward compatibility: peer.kind group ↔ channel", () => {
test("config group binding matches runtime channel scope", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "slack-group-agent",
match: {
channel: "slack",
peer: { kind: "group", id: "C123456" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: null,
peer: { kind: "channel", id: "C123456" },
});
expect(route.agentId).toBe("slack-group-agent");
expect(route.matchedBy).toBe("binding.peer");
});
test("config channel binding matches runtime group scope", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "slack-channel-agent",
match: {
channel: "slack",
peer: { kind: "channel", id: "C123456" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: null,
peer: { kind: "group", id: "C123456" },
});
expect(route.agentId).toBe("slack-channel-agent");
expect(route.matchedBy).toBe("binding.peer");
});
test("group/channel compatibility does not match direct peer kind", () => {
const cfg: OpenClawConfig = {
bindings: [
{
agentId: "group-only-agent",
match: {
channel: "slack",
peer: { kind: "group", id: "C123456" },
},
},
],
};
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: null,
peer: { kind: "direct", id: "C123456" },
});
expect(route.agentId).toBe("main");
expect(route.matchedBy).toBe("default");
});
});
describe("role-based agent routing", () => {
type DiscordBinding = NonNullable<OpenClawConfig["bindings"]>[number];

View file

@ -262,12 +262,24 @@ function hasRolesConstraint(match: NormalizedBindingMatch): boolean {
return Boolean(match.roles);
}
function peerKindMatches(bindingKind: ChatType, scopeKind: ChatType): boolean {
if (bindingKind === scopeKind) {
return true;
}
const both = new Set([bindingKind, scopeKind]);
return both.has("group") && both.has("channel");
}
function matchesBindingScope(match: NormalizedBindingMatch, scope: BindingScope): boolean {
if (match.peer.state === "invalid") {
return false;
}
if (match.peer.state === "valid") {
if (!scope.peer || scope.peer.kind !== match.peer.kind || scope.peer.id !== match.peer.id) {
if (
!scope.peer ||
!peerKindMatches(match.peer.kind, scope.peer.kind) ||
scope.peer.id !== match.peer.id
) {
return false;
}
}