fix(runtime): preserve reviewed routing and transcript behavior (#79076)

* fix(runtime): preserve reviewed routing and transcript behavior

* docs(changelog): note runtime review fixes
This commit is contained in:
Vincent Koc
2026-05-09 07:40:43 +08:00
committed by GitHub
parent 791e83419b
commit c6d4f1fab8
6 changed files with 89 additions and 0 deletions

View File

@@ -192,6 +192,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord/Gateway: preserve username target resolution for Discord outbound sends and rotate generated transcript paths when gateway sessions reset.
- Dependencies: pin the transitive `fast-uri` production dependency to `3.1.2` so the production dependency audit no longer resolves the vulnerable `<=3.1.1` range. Thanks @shakkernerd.
- Cron/agents: recognize same-target `edit``write` recovery in `isSameToolMutationAction`, so a successful `write` to a path clears an earlier failed `edit` on the same path. Stops cron from reporting fatal failures when an agent self-heals across `edit` and `write`, while preserving same-tool fingerprint matching, blocking different-target writes, and excluding tools (including `apply_patch`) whose real call args do not produce a stable `path` fingerprint segment. Fixes #79024. Thanks @RenzoMXD.
- Gateway/Tailscale: add opt-in `gateway.tailscale.preserveFunnel` so when `tailscale.mode = "serve"` and an externally configured Tailscale Funnel route already covers the gateway port, OpenClaw skips re-applying `tailscale serve` on startup and skips the `resetOnExit` teardown for that run, keeping operator-managed Funnel exposure alive across gateway restarts. Fixes #57241. Thanks @RenzoMXD.

View File

@@ -20,6 +20,9 @@ export const loadDiscordResolveUsersModule = createLazyRuntimeModule(
export const loadDiscordThreadBindingsManagerModule = createLazyRuntimeModule(
() => import("./monitor/thread-bindings.manager.js"),
);
export const loadDiscordTargetResolverModule = createLazyRuntimeModule(
() => import("./target-resolver.js"),
);
export async function loadDiscordProviderRuntime() {
discordProviderRuntimePromise ??= import("./monitor/provider.runtime.js");

View File

@@ -5,6 +5,7 @@ import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-help
import type { PluginRuntime } from "openclaw/plugin-sdk/core";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedDiscordAccount } from "./accounts.js";
import * as directoryLive from "./directory-live.js";
import type { OpenClawConfig } from "./runtime-api.js";
import * as sendModule from "./send.js";
import { createDiscordSendReceipt } from "./send.receipt.js";
@@ -173,6 +174,33 @@ describe("discordPlugin outbound", () => {
});
});
it("resolves Discord usernames through the messaging target resolver", async () => {
vi.spyOn(directoryLive, "listDiscordDirectoryPeersLive").mockResolvedValueOnce([
{ kind: "user", id: "user:999", name: "Jane" } as const,
]);
const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget;
if (!resolveTarget) {
throw new Error(
"Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined",
);
}
await expect(
resolveTarget({
cfg: createCfg(),
accountId: "default",
input: "jane",
normalized: "channel:jane",
preferredKind: "user",
}),
).resolves.toEqual({
to: "user:999",
kind: "user",
display: "jane",
source: "directory",
});
});
it("honors per-account replyToMode overrides", () => {
const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {

View File

@@ -58,6 +58,7 @@ import {
loadDiscordResolveChannelsModule,
loadDiscordResolveUsersModule,
loadDiscordSendModule,
loadDiscordTargetResolverModule,
loadDiscordThreadBindingsManagerModule,
} from "./channel.loaders.js";
import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js";
@@ -326,6 +327,28 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
targetResolver: {
looksLikeId: looksLikeDiscordTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ cfg, accountId, input, normalized, preferredKind }) => {
const resolved = await (
await loadDiscordTargetResolverModule()
).resolveDiscordTarget(
input,
{ cfg, accountId },
preferredKind === "user"
? { defaultKind: "user" }
: preferredKind === "channel" || preferredKind === "group"
? { defaultKind: "channel" }
: {},
);
if (!resolved) {
return null;
}
return {
to: resolved.normalized,
kind: resolved.kind === "user" ? "user" : "channel",
display: resolved.raw,
source: resolved.normalized === normalized ? "normalized" : "directory",
};
},
},
},
approvalCapability: getDiscordApprovalCapability(),

View File

@@ -470,6 +470,38 @@ describe("gateway agent handler", () => {
);
});
it("drops a stale transcript path when a stale session rotates ids", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
dateOnlyFakeClockActive = true;
vi.setSystemTime(new Date("2026-05-07T12:00:00.000Z"));
const staleEntry = {
sessionId: "old-session-id",
sessionFile: "/tmp/openclaw/agents/main/sessions/old-session-id.jsonl",
updatedAt: 0,
sessionStartedAt: 0,
};
mockMainSessionEntry(staleEntry);
let capturedEntry: Record<string, unknown> | undefined;
mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
const store: Record<string, unknown> = {
"agent:main:main": { ...staleEntry },
};
const result = await updater(store);
capturedEntry = result as Record<string, unknown>;
return result;
});
mocks.agentCommand.mockResolvedValue({
payloads: [{ text: "ok" }],
meta: { durationMs: 100 },
});
await runMainAgent("test", "test-idem-stale-transcript");
expect(capturedEntry?.sessionId).not.toBe("old-session-id");
expect(capturedEntry?.sessionFile).toBeUndefined();
});
it("keeps stored group metadata when a trusted group session receives caller-supplied selectors", async () => {
const sessionKey = "agent:main:slack:group:C123";
const existingEntry = buildExistingMainStoreEntry({

View File

@@ -1064,6 +1064,8 @@ export const agentHandlers: GatewayRequestHandlers = {
groupChannel: resolvedGroupChannel,
space: resolvedGroupSpace,
...(pluginOwnerId ? { pluginOwnerId } : {}),
sessionFile:
entry?.sessionId && entry.sessionId !== sessionId ? undefined : entry?.sessionFile,
cliSessionIds: entry?.cliSessionIds,
cliSessionBindings: entry?.cliSessionBindings,
claudeCliSessionId: entry?.claudeCliSessionId,