Skip to content

Commit f1b8827

Browse files
authored
refactor: route bundled plugin session callers through seam (#89129)
Merged via squash. Prepared head SHA: 7975bc0 Co-authored-by: jalehman <[email protected]> Co-authored-by: jalehman <[email protected]> Reviewed-by: @jalehman
1 parent 3563850 commit f1b8827

19 files changed

Lines changed: 355 additions & 115 deletions

extensions/discord/src/monitor/native-command-model-picker-apply.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
55
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
66
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
77
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
8-
import { resolveStorePath, updateSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
8+
import { patchSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
99
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
1010
import type { ButtonInteraction, StringSelectMenuInteraction } from "../internal/discord.js";
1111
import {
@@ -42,34 +42,39 @@ async function persistDiscordModelPickerOverride(params: {
4242
agentId: params.route.agentId,
4343
});
4444
let persisted = false;
45-
await updateSessionStore(storePath, (store) => {
46-
const entry = store[params.route.sessionKey] ?? {
45+
await patchSessionEntry({
46+
storePath,
47+
sessionKey: params.route.sessionKey,
48+
fallbackEntry: {
4749
sessionId: randomUUID(),
4850
updatedAt: Date.now(),
49-
};
50-
store[params.route.sessionKey] = entry;
51-
persisted =
52-
applyModelOverrideToSessionEntry({
53-
entry,
54-
selection: {
55-
provider: params.provider,
56-
model: params.model,
57-
isDefault: params.isDefault,
58-
},
59-
markLiveSwitchPending: true,
60-
}).updated || persisted;
61-
const runtime = params.runtime?.trim();
62-
if (runtime && runtime !== "auto" && runtime !== "default") {
63-
if (entry.agentRuntimeOverride !== runtime) {
64-
entry.agentRuntimeOverride = runtime;
51+
},
52+
replaceEntry: true,
53+
update: (entry) => {
54+
persisted =
55+
applyModelOverrideToSessionEntry({
56+
entry,
57+
selection: {
58+
provider: params.provider,
59+
model: params.model,
60+
isDefault: params.isDefault,
61+
},
62+
markLiveSwitchPending: true,
63+
}).updated || persisted;
64+
const runtime = params.runtime?.trim();
65+
if (runtime && runtime !== "auto" && runtime !== "default") {
66+
if (entry.agentRuntimeOverride !== runtime) {
67+
entry.agentRuntimeOverride = runtime;
68+
delete entry.agentHarnessId;
69+
persisted = true;
70+
}
71+
} else if (runtime && entry.agentRuntimeOverride) {
72+
delete entry.agentRuntimeOverride;
6573
delete entry.agentHarnessId;
6674
persisted = true;
6775
}
68-
} else if (runtime && entry.agentRuntimeOverride) {
69-
delete entry.agentRuntimeOverride;
70-
delete entry.agentHarnessId;
71-
persisted = true;
72-
}
76+
return entry;
77+
},
7378
});
7479
return persisted;
7580
}

extensions/discord/src/monitor/native-command.model-picker.test.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import * as globalsModule from "openclaw/plugin-sdk/runtime-env";
1414
import {
1515
loadSessionStore,
1616
resolveStorePath,
17-
saveSessionStore,
17+
upsertSessionEntry,
1818
} from "openclaw/plugin-sdk/session-store-runtime";
1919
import * as commandTextModule from "openclaw/plugin-sdk/text-utility-runtime";
2020
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -814,8 +814,10 @@ describe("Discord model picker interactions", () => {
814814
});
815815
const modelCommand = createModelCommandDefinition();
816816
const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
817-
await saveSessionStore(storePath, {
818-
"agent:worker:subagent:bound": {
817+
await upsertSessionEntry({
818+
storePath,
819+
sessionKey: "agent:worker:subagent:bound",
820+
entry: {
819821
updatedAt: Date.now(),
820822
sessionId: "bound-session",
821823
},
@@ -864,8 +866,10 @@ describe("Discord model picker interactions", () => {
864866
const pickerData = createDefaultModelPickerData();
865867
const modelCommand = createModelCommandDefinition();
866868
const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
867-
await saveSessionStore(storePath, {
868-
"agent:worker:subagent:bound": {
869+
await upsertSessionEntry({
870+
storePath,
871+
sessionKey: "agent:worker:subagent:bound",
872+
entry: {
869873
updatedAt: Date.now(),
870874
sessionId: "bound-session",
871875
},

extensions/discord/src/monitor/thread-session-close.test.ts

Lines changed: 61 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
33

44
const hoisted = vi.hoisted(() => {
5-
const updateSessionStore = vi.fn();
5+
const listSessionEntries = vi.fn();
6+
const patchSessionEntry = vi.fn();
67
const resolveStorePath = vi.fn(() => "/tmp/openclaw-sessions.json");
7-
return { updateSessionStore, resolveStorePath };
8+
return { listSessionEntries, patchSessionEntry, resolveStorePath };
89
});
910

1011
vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
@@ -13,16 +14,37 @@ vi.mock("openclaw/plugin-sdk/session-store-runtime", async () => {
1314
);
1415
return {
1516
...actual,
16-
updateSessionStore: hoisted.updateSessionStore,
17+
listSessionEntries: hoisted.listSessionEntries,
18+
patchSessionEntry: hoisted.patchSessionEntry,
1719
resolveStorePath: hoisted.resolveStorePath,
1820
};
1921
});
2022

2123
let closeDiscordThreadSessions: typeof import("./thread-session-close.js").closeDiscordThreadSessions;
2224

23-
function setupStore(store: Record<string, { updatedAt: number }>) {
24-
hoisted.updateSessionStore.mockImplementation(
25-
async (_storePath: string, mutator: (s: typeof store) => unknown) => mutator(store),
25+
function setupStore(store: Record<string, { sessionId?: string; updatedAt: number }>) {
26+
hoisted.listSessionEntries.mockImplementation(() =>
27+
Object.entries(store).map(([sessionKey, entry]) => ({ sessionKey, entry })),
28+
);
29+
hoisted.patchSessionEntry.mockImplementation(
30+
async (params: {
31+
sessionKey: string;
32+
update: (entry: {
33+
sessionId?: string;
34+
updatedAt: number;
35+
}) => { sessionId?: string; updatedAt: number } | null;
36+
}) => {
37+
const entry = store[params.sessionKey];
38+
if (!entry) {
39+
return null;
40+
}
41+
const next = params.update({ ...entry });
42+
if (!next) {
43+
return entry;
44+
}
45+
store[params.sessionKey] = next;
46+
return next;
47+
},
2648
);
2749
}
2850

@@ -38,7 +60,8 @@ describe("closeDiscordThreadSessions", () => {
3860
});
3961

4062
beforeEach(() => {
41-
hoisted.updateSessionStore.mockClear();
63+
hoisted.listSessionEntries.mockReset();
64+
hoisted.patchSessionEntry.mockReset();
4265
hoisted.resolveStorePath.mockClear();
4366
hoisted.resolveStorePath.mockReturnValue("/tmp/openclaw-sessions.json");
4467
});
@@ -143,7 +166,8 @@ describe("closeDiscordThreadSessions", () => {
143166
});
144167

145168
expect(count).toBe(0);
146-
expect(hoisted.updateSessionStore).not.toHaveBeenCalled();
169+
expect(hoisted.listSessionEntries).not.toHaveBeenCalled();
170+
expect(hoisted.patchSessionEntry).not.toHaveBeenCalled();
147171
});
148172

149173
it("does not recount sessions that were already reset", async () => {
@@ -164,6 +188,35 @@ describe("closeDiscordThreadSessions", () => {
164188
expect(store[UNMATCHED_KEY].updatedAt).toBe(1_700_000_000_001);
165189
});
166190

191+
it("does not reset a matching session that changed after the list snapshot", async () => {
192+
const store = {
193+
[MATCHED_KEY]: {
194+
sessionId: "fresh-session",
195+
updatedAt: 2_000,
196+
},
197+
};
198+
setupStore(store);
199+
hoisted.listSessionEntries.mockReturnValue([
200+
{
201+
sessionKey: MATCHED_KEY,
202+
entry: {
203+
sessionId: "old-session",
204+
updatedAt: 1_000,
205+
},
206+
},
207+
]);
208+
209+
const count = await closeDiscordThreadSessions({
210+
cfg: {},
211+
accountId: "default",
212+
threadId: THREAD_ID,
213+
});
214+
215+
expect(count).toBe(0);
216+
expect(store[MATCHED_KEY].updatedAt).toBe(2_000);
217+
expect(store[MATCHED_KEY].sessionId).toBe("fresh-session");
218+
});
219+
167220
it("resolves the store path using cfg.session.store and accountId", async () => {
168221
const store = {};
169222
setupStore(store);

extensions/discord/src/monitor/thread-session-close.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
// Discord plugin module implements thread session close behavior.
22
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
3-
import { resolveStorePath, updateSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
3+
import {
4+
listSessionEntries,
5+
patchSessionEntry,
6+
resolveStorePath,
7+
} from "openclaw/plugin-sdk/session-store-runtime";
48
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
59

610
/**
@@ -44,21 +48,32 @@ export async function closeDiscordThreadSessions(params: {
4448

4549
let resetCount = 0;
4650

47-
await updateSessionStore(storePath, (store) => {
48-
for (const [key, entry] of Object.entries(store)) {
49-
if (!entry || !sessionKeyContainsThreadId(key)) {
50-
continue;
51-
}
52-
if (entry.updatedAt === 0) {
53-
continue;
54-
}
55-
// Setting updatedAt to 0 signals that this session is stale.
56-
// evaluateSessionFreshness will create a new session on the next message.
57-
entry.updatedAt = 0;
51+
for (const { sessionKey, entry } of listSessionEntries({ storePath })) {
52+
if (!sessionKeyContainsThreadId(sessionKey) || entry.updatedAt === 0) {
53+
continue;
54+
}
55+
// Setting updatedAt to 0 signals that this session is stale.
56+
// evaluateSessionFreshness will create a new session on the next message.
57+
let resetEntry = false;
58+
await patchSessionEntry({
59+
storePath,
60+
sessionKey,
61+
replaceEntry: true,
62+
update: (current) => {
63+
if (current.updatedAt === 0) {
64+
return null;
65+
}
66+
if (current.updatedAt !== entry.updatedAt || current.sessionId !== entry.sessionId) {
67+
return null;
68+
}
69+
resetEntry = true;
70+
return { ...current, updatedAt: 0 };
71+
},
72+
});
73+
if (resetEntry) {
5874
resetCount += 1;
5975
}
60-
return resetCount;
61-
});
76+
}
6277

6378
return resetCount;
6479
}

extensions/telegram/src/bot-deps.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/re
1515
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
1616
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
1717
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
18-
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
18+
import {
19+
getSessionEntry,
20+
listSessionEntries,
21+
readSessionUpdatedAt,
22+
resolveStorePath,
23+
} from "openclaw/plugin-sdk/session-store-runtime";
1924
import { loadSessionStore } from "openclaw/plugin-sdk/session-store-runtime";
2025
import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/skill-commands-runtime";
2126
import { enqueueSystemEvent } from "openclaw/plugin-sdk/system-event-runtime";
@@ -31,6 +36,8 @@ import { wasSentByBot } from "./sent-message-cache.js";
3136
export type TelegramBotDeps = {
3237
getRuntimeConfig: typeof getRuntimeConfig;
3338
resolveStorePath: typeof resolveStorePath;
39+
getSessionEntry?: typeof getSessionEntry;
40+
listSessionEntries?: typeof listSessionEntries;
3441
loadSessionStore?: typeof loadSessionStore;
3542
readSessionUpdatedAt?: typeof readSessionUpdatedAt;
3643
recordInboundSession?: typeof recordInboundSession;
@@ -64,6 +71,12 @@ export const defaultTelegramBotDeps: TelegramBotDeps = {
6471
get resolveStorePath() {
6572
return resolveStorePath;
6673
},
74+
get getSessionEntry() {
75+
return getSessionEntry;
76+
},
77+
get listSessionEntries() {
78+
return listSessionEntries;
79+
},
6780
get readChannelAllowFromStore() {
6881
return readChannelAllowFromStore;
6982
},

extensions/telegram/src/bot-handlers.runtime.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Telegram plugin module implements bot handlers behavior.
2+
import { randomUUID } from "node:crypto";
23
import type { Message, ReactionTypeEmoji } from "grammy/types";
34
import { parseExecApprovalCommandText } from "openclaw/plugin-sdk/approval-reply-runtime";
45
import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers";
@@ -36,9 +37,9 @@ import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
3637
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
3738
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
3839
import {
39-
loadSessionStore,
40-
resolveSessionStoreEntry,
41-
updateSessionStore,
40+
getSessionEntry,
41+
listSessionEntries,
42+
patchSessionEntry,
4243
} from "openclaw/plugin-sdk/session-store-runtime";
4344
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
4445
import { expandTelegramAllowFromWithAccessGroups } from "./access-groups.js";
@@ -666,7 +667,7 @@ export const registerTelegramHandlers = ({
666667
runtimeCfg?: OpenClawConfig;
667668
}): {
668669
agentId: string;
669-
sessionEntry: ReturnType<typeof resolveSessionStoreEntry>["existing"];
670+
sessionEntry: ReturnType<typeof getSessionEntry>;
670671
sessionKey: string;
671672
model?: string;
672673
} => {
@@ -708,8 +709,12 @@ export const registerTelegramHandlers = ({
708709
const storePath = telegramDeps.resolveStorePath(runtimeCfg.session?.store, {
709710
agentId: route.agentId,
710711
});
711-
const store = (telegramDeps.loadSessionStore ?? loadSessionStore)(storePath);
712-
const entry = resolveSessionStoreEntry({ store, sessionKey }).existing;
712+
const entry = (telegramDeps.getSessionEntry ?? getSessionEntry)({ storePath, sessionKey });
713+
const store = Object.fromEntries(
714+
(telegramDeps.listSessionEntries ?? listSessionEntries)({ storePath }).map(
715+
({ sessionKey: key, entry: value }) => [key, value],
716+
),
717+
);
713718
const storedOverride = resolveStoredModelOverride({
714719
sessionEntry: entry,
715720
sessionStore: store,
@@ -2716,18 +2721,25 @@ export const registerTelegramHandlers = ({
27162721
selection.model === resolvedDefault.model;
27172722

27182723
try {
2719-
await updateSessionStore(storePath, (store) => {
2720-
const sessionKey = sessionState.sessionKey;
2721-
const entry = store[sessionKey] ?? {};
2722-
store[sessionKey] = entry;
2723-
applyModelOverrideToSessionEntry({
2724-
entry,
2725-
selection: {
2726-
provider: selection.provider,
2727-
model: selection.model,
2728-
isDefault: isDefaultSelection,
2729-
},
2730-
});
2724+
await patchSessionEntry({
2725+
storePath,
2726+
sessionKey: sessionState.sessionKey,
2727+
fallbackEntry: {
2728+
sessionId: randomUUID(),
2729+
updatedAt: Date.now(),
2730+
},
2731+
replaceEntry: true,
2732+
update: (entry) => {
2733+
applyModelOverrideToSessionEntry({
2734+
entry,
2735+
selection: {
2736+
provider: selection.provider,
2737+
model: selection.model,
2738+
isDefault: isDefaultSelection,
2739+
},
2740+
});
2741+
return entry;
2742+
},
27312743
});
27322744
} catch (err) {
27332745
throw new TelegramRetryableCallbackError(err);

0 commit comments

Comments
 (0)