Compare commits

..

3 Commits

Author SHA1 Message Date
Bek
99180ec420 test: remove temporary Slack proof plumbing 2026-06-26 23:49:26 -04:00
Bek
0e7a6a5272 test: add Slack reset-history QA proof 2026-06-26 23:16:43 -04:00
Bek
9f07d8906c fix: seed Slack thread context after reset 2026-06-26 22:29:56 -04:00
25 changed files with 672 additions and 284 deletions

View File

@@ -1,2 +1,2 @@
760812c17f7e48d7ceafeebbbe348dad13916ccb9ecaf41b3abc9a09b1e690c1 plugin-sdk-api-baseline.json
4d9b76016b2f845e101949a3d2ac92437f49783906d1c263d65f3534bb333de5 plugin-sdk-api-baseline.jsonl
d6953afecef50712face2a38f54744af6121bab670695f2ad85ae0048b1105a3 plugin-sdk-api-baseline.json
67485084391dada9372630d8cefcd0562461b2af906f5312763a7f9d77a4a29d plugin-sdk-api-baseline.jsonl

View File

@@ -588,7 +588,7 @@ releases.
| `plugin-sdk/reply-history` | Reply-history helpers | `createChannelHistoryWindow`; deprecated map-helper compatibility exports such as `buildPendingHistoryContextFromMap`, `recordPendingHistoryEntry`, and `clearHistoryEntriesIfEnabled` |
| `plugin-sdk/reply-reference` | Reply reference planning | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Reply chunk helpers | Text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session store helpers | Store path + updated-at helpers |
| `plugin-sdk/session-store-runtime` | Session store helpers | Store path, updated-at, and reset-freshness helpers |
| `plugin-sdk/state-paths` | State path helpers | State and OAuth dir helpers |
| `plugin-sdk/routing` | Routing/session-key helpers | `resolveAgentRoute`, `buildAgentSessionKey`, `resolveDefaultAgentBoundAccountId`, session-key normalization helpers |
| `plugin-sdk/status-helpers` | Channel status helpers | Channel/account status summary builders, runtime-state defaults, issue metadata helpers |

View File

@@ -247,7 +247,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), reset freshness resolution for one session entry, bounded recent user/assistant transcript text reads by session identity, legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
@@ -307,7 +307,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/inline-image-data-url-runtime` | Inline image data URL sanitizer and signature sniffing helpers without the broad media runtime surface |
| `plugin-sdk/response-limit-runtime` | Bounded response-body reader without the broad media runtime surface |
| `plugin-sdk/session-binding-runtime` | Current conversation binding state without configured binding routing or pairing stores |
| `plugin-sdk/session-store-runtime` | Session-store helpers without broad config writes/maintenance imports |
| `plugin-sdk/session-store-runtime` | Session-store and reset-freshness helpers without broad config writes/maintenance imports |
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers without database lifecycle controls |
| `plugin-sdk/context-visibility-runtime` | Context visibility resolution and supplemental context filtering without broad config/security imports |
| `plugin-sdk/string-coerce-runtime` | Narrow primitive record/string coercion and normalization helpers without markdown/logging imports |

View File

@@ -6,7 +6,6 @@ import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { z } from "zod";
import { resolveSlackAccount } from "./accounts.js";
import { validateSlackBlocksArray } from "./blocks-input.js";
import { createSlackApiUrlClientOptions } from "./client-options.js";
import { createSlackWebClient, getSlackWriteClient } from "./client.js";
import { buildSlackEditTextPayload } from "./edit-text.js";
import { resolveSlackMedia } from "./monitor/media.js";
@@ -72,22 +71,6 @@ function resolveToken(explicit?: string, accountId?: string, cfg?: OpenClawConfi
return token;
}
function resolveSlackActionClientOptions(opts: SlackActionClientOpts) {
if (!opts.cfg) {
return createSlackApiUrlClientOptions();
}
const cfg = requireRuntimeConfig(opts.cfg, "Slack actions");
resolveSlackAccount({ cfg, accountId: opts.accountId });
return createSlackApiUrlClientOptions();
}
function slackActionClientOptionArgs(
opts: SlackActionClientOpts,
): [] | [ReturnType<typeof createSlackApiUrlClientOptions>] {
const clientOptions = resolveSlackActionClientOptions(opts);
return clientOptions.slackApiUrl ? [clientOptions] : [];
}
function normalizeEmoji(raw: string) {
const trimmed = raw.trim();
if (!trimmed) {
@@ -148,10 +131,7 @@ async function getClient(opts: SlackActionClientOpts = {}, mode: "read" | "write
return opts.client;
}
const token = resolveToken(opts.token, opts.accountId, opts.cfg);
const clientOptionArgs = slackActionClientOptionArgs(opts);
return mode === "write"
? getSlackWriteClient(token, ...clientOptionArgs)
: createSlackWebClient(token, ...clientOptionArgs);
return mode === "write" ? getSlackWriteClient(token) : createSlackWebClient(token);
}
async function resolveBotUserId(client: WebClient) {

View File

@@ -4,7 +4,6 @@ import {
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveSlackAccount } from "./accounts.js";
import { createSlackApiUrlClientOptions } from "./client-options.js";
import { createSlackWebClient } from "./client.js";
import { normalizeAllowListLower } from "./monitor/allow-list.js";
import type { OpenClawConfig } from "./runtime-api.js";
@@ -77,7 +76,7 @@ export async function resolveSlackConversationInfo(params: {
}
try {
const client = createSlackWebClient(token, createSlackApiUrlClientOptions());
const client = createSlackWebClient(token);
if (isNativeImChannel) {
const opened = await client.conversations.open({
channel: channelId,

View File

@@ -1,6 +1,7 @@
// Slack plugin module implements channel behavior.
import {
buildLegacyDmAccountAllowlistAdapter,
createAccountScopedAllowlistNameResolver,
createFlatAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers";
@@ -51,7 +52,6 @@ import {
type OpenClawConfig,
} from "./channel-api.js";
import { resolveSlackChannelType, resolveSlackConversationInfo } from "./channel-type.js";
import { createSlackApiUrlClientOptions, type SlackApiUrlClientOptions } from "./client-options.js";
import { shouldSuppressLocalSlackExecApprovalPrompt } from "./exec-approvals.js";
import { resolveSlackGroupRequireMention, resolveSlackGroupToolPolicy } from "./group-policy.js";
import {
@@ -405,40 +405,19 @@ function formatSlackScopeDiagnostic(params: {
} as const;
}
function slackApiUrlOptionArgs(): [] | [SlackApiUrlClientOptions] {
const options = createSlackApiUrlClientOptions();
return options.slackApiUrl ? [options] : [];
}
const resolveSlackAllowlistGroupOverrides = createFlatAllowlistOverrideResolver({
resolveRecord: (account: ResolvedSlackAccount) => account.channels,
label: (key) => key,
resolveEntries: (value) => value?.users,
});
const resolveSlackAllowlistNames = async ({
accountId,
cfg,
entries,
}: {
accountId?: string | null;
cfg: OpenClawConfig;
entries: string[];
}) => {
const account = resolveSlackAccount({ cfg, accountId });
const token =
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken);
if (!token) {
return [];
}
return await (
await loadSlackResolveUsersModule()
).resolveSlackUserAllowlist({
token,
entries,
...createSlackApiUrlClientOptions(),
});
};
const resolveSlackAllowlistNames = createAccountScopedAllowlistNameResolver({
resolveAccount: resolveSlackAccount,
resolveToken: (account: ResolvedSlackAccount) =>
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
resolveNames: async ({ token, entries }) =>
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({ token, entries }),
});
const slackChannelOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
@@ -675,7 +654,6 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
(await loadSlackResolveChannelsModule()).resolveSlackChannelAllowlist({
token,
entries: inputsValue,
...createSlackApiUrlClientOptions(),
}),
mapResolved: (entry) =>
toResolvedTarget(entry, entry.archived ? "archived" : undefined),
@@ -683,14 +661,14 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
}
return resolveTargetsWithOptionalToken({
token:
normalizeOptionalString(account.userToken) ?? normalizeOptionalString(account.botToken),
normalizeOptionalString(account.userToken) ??
normalizeOptionalString(account.botToken),
inputs,
missingTokenNote: "missing Slack token",
resolveWithToken: async ({ token, inputs: inputsLocal }) =>
(await loadSlackResolveUsersModule()).resolveSlackUserAllowlist({
token,
entries: inputsLocal,
...createSlackApiUrlClientOptions(),
}),
mapResolved: (entry) => toResolvedTarget(entry, entry.note),
});
@@ -717,9 +695,7 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
if (!token) {
return { ok: false, error: "missing token" };
}
return await (
await loadSlackProbeModule()
).probeSlack(token, timeoutMs, ...slackApiUrlOptionArgs());
return await (await loadSlackProbeModule()).probeSlack(token, timeoutMs);
},
formatCapabilitiesProbe: ({ probe }) => {
const slackProbe = probe as SlackProbe | undefined;
@@ -739,14 +715,13 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount, SlackProbe> = crea
const botToken = account.botToken?.trim();
const userToken = account.userToken?.trim();
const { fetchSlackScopes } = await loadSlackScopesModule();
const apiUrlOptionArgs = slackApiUrlOptionArgs();
const botScopes: SlackScopesResultShape = botToken
? await fetchSlackScopes(botToken, timeoutMs, ...apiUrlOptionArgs)
? await fetchSlackScopes(botToken, timeoutMs)
: { ok: false, error: "Slack bot token missing." };
lines.push(formatSlackScopeDiagnostic({ tokenType: "bot", result: botScopes }));
details.botScopes = botScopes;
if (userToken) {
const userScopes = await fetchSlackScopes(userToken, timeoutMs, ...apiUrlOptionArgs);
const userScopes = await fetchSlackScopes(userToken, timeoutMs);
lines.push(formatSlackScopeDiagnostic({ tokenType: "user", result: userScopes }));
details.userScopes = userScopes;
}

View File

@@ -3,8 +3,6 @@ import type { Agent } from "node:http";
import type { RetryOptions, WebClientOptions } from "@slack/web-api";
import { createNodeProxyAgent } from "openclaw/plugin-sdk/fetch-runtime";
export type SlackApiUrlClientOptions = Pick<WebClientOptions, "slackApiUrl">;
export const SLACK_DEFAULT_RETRY_OPTIONS: RetryOptions = {
retries: 2,
factor: 2,
@@ -32,11 +30,12 @@ export const SLACK_WRITE_RETRY_OPTIONS: RetryOptions = {
* Returns `undefined` when no proxy env var is configured or when Slack hosts
* are excluded by `NO_PROXY`.
*/
function resolveSlackProxyAgent(targetUrl: string): Agent | undefined {
function resolveSlackProxyAgent(): Agent | undefined {
try {
return createNodeProxyAgent({
mode: "env",
targetUrl,
targetUrl: "https://slack.com/",
protocol: "https",
});
} catch {
// Malformed proxy URL; degrade gracefully to direct connection.
@@ -44,38 +43,19 @@ function resolveSlackProxyAgent(targetUrl: string): Agent | undefined {
}
}
function resolveSlackApiUrlFromOptions(
options: Pick<WebClientOptions, "slackApiUrl">,
): string | undefined {
const explicit = options.slackApiUrl?.trim();
const envDefault = process.env.SLACK_API_URL?.trim();
return explicit || envDefault || undefined;
}
export function createSlackApiUrlClientOptions(): SlackApiUrlClientOptions {
const slackApiUrl = process.env.SLACK_API_URL?.trim();
return slackApiUrl ? { slackApiUrl } : {};
}
export function resolveSlackWebClientOptions(options: WebClientOptions = {}): WebClientOptions {
const slackApiUrl = resolveSlackApiUrlFromOptions(options);
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
return {
...options,
agent: options.agent ?? resolveSlackProxyAgent(proxyTargetUrl),
agent: options.agent ?? resolveSlackProxyAgent(),
retryConfig: options.retryConfig ?? SLACK_DEFAULT_RETRY_OPTIONS,
...(slackApiUrl ? { slackApiUrl } : {}),
};
}
export function resolveSlackWriteClientOptions(options: WebClientOptions = {}): WebClientOptions {
const slackApiUrl = resolveSlackApiUrlFromOptions(options);
const proxyTargetUrl = slackApiUrl ?? "https://slack.com/";
return {
...options,
agent: options.agent ?? resolveSlackProxyAgent(proxyTargetUrl),
agent: options.agent ?? resolveSlackProxyAgent(),
retryConfig: options.retryConfig ?? SLACK_WRITE_RETRY_OPTIONS,
maxRequestConcurrency: options.maxRequestConcurrency ?? 1,
...(slackApiUrl ? { slackApiUrl } : {}),
};
}

View File

@@ -27,7 +27,6 @@ let SLACK_DEFAULT_RETRY_OPTIONS: typeof import("./client.js").SLACK_DEFAULT_RETR
let SLACK_WRITE_RETRY_OPTIONS: typeof import("./client.js").SLACK_WRITE_RETRY_OPTIONS;
let WebClient: ReturnType<typeof vi.fn>;
const SLACK_API_URL_KEYS = ["SLACK_API_URL", "OPENCLAW_SLACK_API_URL"] as const;
const PROXY_KEYS = [
"HTTPS_PROXY",
"HTTP_PROXY",
@@ -57,22 +56,6 @@ function restoreProxyEnvForTest() {
}
}
function clearSlackApiUrlEnvForTest() {
for (const key of SLACK_API_URL_KEYS) {
delete process.env[key];
}
}
function restoreSlackApiUrlEnvForTest() {
for (const key of SLACK_API_URL_KEYS) {
if (originalEnv[key] !== undefined) {
process.env[key] = originalEnv[key];
} else {
delete process.env[key];
}
}
}
function requireAgent<T extends { agent?: unknown }>(options: T): NonNullable<T["agent"]> {
if (!options.agent) {
throw new Error("expected proxy agent");
@@ -107,11 +90,6 @@ beforeAll(async () => {
beforeEach(() => {
WebClient.mockClear();
clearSlackWriteClientCacheForTest();
clearSlackApiUrlEnvForTest();
});
afterEach(() => {
restoreSlackApiUrlEnvForTest();
});
describe("slack web client config", () => {
@@ -128,40 +106,6 @@ describe("slack web client config", () => {
expect(options.retryConfig).toBe(customRetry);
});
it("uses explicit Slack API URL as the Slack Web API root", () => {
expect(
resolveSlackWebClientOptions({ slackApiUrl: "http://127.0.0.1:49152/api/" }).slackApiUrl,
).toBe("http://127.0.0.1:49152/api/");
expect(
resolveSlackWriteClientOptions({ slackApiUrl: "http://127.0.0.1:49152/api/" }).slackApiUrl,
).toBe("http://127.0.0.1:49152/api/");
});
it("uses SLACK_API_URL as the default Slack Web API root", () => {
process.env.SLACK_API_URL = " http://127.0.0.1:49152/api/ ";
expect(resolveSlackWebClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
expect(resolveSlackWriteClientOptions().slackApiUrl).toBe("http://127.0.0.1:49152/api/");
});
it("does not read OPENCLAW_SLACK_API_URL as a default Slack Web API root", () => {
process.env.OPENCLAW_SLACK_API_URL = "http://127.0.0.1:49152/api/";
expect(resolveSlackWebClientOptions().slackApiUrl).toBeUndefined();
expect(resolveSlackWriteClientOptions().slackApiUrl).toBeUndefined();
});
it("prefers explicit Slack API URL over SLACK_API_URL", () => {
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
expect(
resolveSlackWebClientOptions({ slackApiUrl: "http://127.0.0.1:49153/api/" }).slackApiUrl,
).toBe("http://127.0.0.1:49153/api/");
expect(
resolveSlackWriteClientOptions({ slackApiUrl: "http://127.0.0.1:49153/api/" }).slackApiUrl,
).toBe("http://127.0.0.1:49153/api/");
});
it("passes merged options into WebClient", () => {
const customAgent = {} as never;
@@ -246,38 +190,6 @@ describe("slack web client config", () => {
expect(WebClient).toHaveBeenCalledTimes(2);
});
it("keeps write clients separated by Slack API URL", () => {
clearProxyEnvForTest();
try {
const first = getSlackWriteClient("xoxb-test", {
slackApiUrl: "http://127.0.0.1:49152/api/",
});
const second = getSlackWriteClient("xoxb-test", {
slackApiUrl: "http://127.0.0.1:49153/api/",
});
expect(second).not.toBe(first);
expect(WebClient).toHaveBeenCalledTimes(2);
} finally {
restoreProxyEnvForTest();
}
});
it("keeps write clients separated by SLACK_API_URL", () => {
clearProxyEnvForTest();
try {
process.env.SLACK_API_URL = "http://127.0.0.1:49152/api/";
const first = getSlackWriteClient("xoxb-test");
process.env.SLACK_API_URL = "http://127.0.0.1:49153/api/";
const second = getSlackWriteClient("xoxb-test");
expect(second).not.toBe(first);
expect(WebClient).toHaveBeenCalledTimes(2);
} finally {
restoreProxyEnvForTest();
}
});
it("builds stable non-secret token cache keys", () => {
const token = "xoxb-sensitive-token";
const first = createSlackTokenCacheKey(token);

View File

@@ -1,22 +1,14 @@
// Slack plugin module implements client behavior.
import { createHash } from "node:crypto";
import { type WebClientOptions, WebClient } from "@slack/web-api";
import {
resolveSlackWebClientOptions,
resolveSlackWriteClientOptions,
type SlackApiUrlClientOptions,
} from "./client-options.js";
import { resolveSlackWebClientOptions, resolveSlackWriteClientOptions } from "./client-options.js";
const SLACK_WRITE_CLIENT_CACHE_MAX = 32;
const slackWriteClientCache = new Map<string, WebClient>();
export type SlackWriteClientCacheOptions = SlackApiUrlClientOptions;
export {
createSlackApiUrlClientOptions,
resolveSlackWebClientOptions,
resolveSlackWriteClientOptions,
type SlackApiUrlClientOptions,
SLACK_DEFAULT_RETRY_OPTIONS,
SLACK_WRITE_RETRY_OPTIONS,
} from "./client-options.js";
@@ -33,27 +25,15 @@ export function createSlackTokenCacheKey(token: string): string {
return `sha256:${createHash("sha256").update(token).digest("base64url")}`;
}
function createSlackWriteClientCacheKey(
token: string,
options: SlackWriteClientCacheOptions,
): string {
export function getSlackWriteClient(token: string): WebClient {
const tokenKey = createSlackTokenCacheKey(token);
return options.slackApiUrl ? `${tokenKey}:api:${options.slackApiUrl}` : tokenKey;
}
export function getSlackWriteClient(
token: string,
options: SlackWriteClientCacheOptions = {},
): WebClient {
const resolvedOptions = resolveSlackWriteClientOptions(options);
const tokenKey = createSlackWriteClientCacheKey(token, resolvedOptions);
const cached = slackWriteClientCache.get(tokenKey);
if (cached) {
slackWriteClientCache.delete(tokenKey);
slackWriteClientCache.set(tokenKey, cached);
return cached;
}
const client = new WebClient(token, resolvedOptions);
const client = createSlackWriteClient(token);
if (slackWriteClientCache.size >= SLACK_WRITE_CLIENT_CACHE_MAX) {
const oldestTokenKey = slackWriteClientCache.keys().next().value;
if (oldestTokenKey) {

View File

@@ -38,22 +38,6 @@ describe("slack config schema", () => {
}
});
it("rejects Slack Web API URL config overrides", () => {
const res = SlackConfigSchema.safeParse({
apiUrl: "http://127.0.0.1:49152/api/",
accounts: { ops: { apiUrl: "http://127.0.0.1:49153/api/" } },
});
expect(res.success).toBe(false);
if (!res.success) {
expect(
res.error.issues.some(
(issue) => issue.code === "unrecognized_keys" && issue.keys.includes("apiUrl"),
),
).toBe(true);
}
});
it("accepts unfurl controls at root and account level", () => {
const res = SlackConfigSchema.safeParse({
unfurlLinks: false,

View File

@@ -9,7 +9,6 @@ import {
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveSlackAccount } from "./accounts.js";
import { createSlackApiUrlClientOptions } from "./client-options.js";
import { createSlackWebClient } from "./client.js";
type SlackUser = {
@@ -51,10 +50,9 @@ type SlackAuthTestResponse = {
team?: string;
};
function createSlackDirectoryClient(params: DirectoryConfigParams) {
function resolveReadToken(params: DirectoryConfigParams): string | undefined {
const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId });
const token = account.userToken ?? account.botToken?.trim();
return token ? createSlackWebClient(token, createSlackApiUrlClientOptions()) : null;
return account.userToken ?? account.botToken?.trim();
}
function normalizeQuery(value?: string | null): string {
@@ -103,10 +101,11 @@ function slackUserToDirectoryEntry(
export async function getSlackDirectorySelfLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry | null> {
const client = createSlackDirectoryClient(params);
if (!client) {
const token = resolveReadToken(params);
if (!token) {
return null;
}
const client = createSlackWebClient(token);
const auth = (await client.auth.test()) as SlackAuthTestResponse;
const userId = normalizeOptionalString(auth.user_id);
if (!userId) {
@@ -126,10 +125,11 @@ export async function getSlackDirectorySelfLive(
export async function listSlackDirectoryPeersLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const client = createSlackDirectoryClient(params);
if (!client) {
const token = resolveReadToken(params);
if (!token) {
return [];
}
const client = createSlackWebClient(token);
const query = normalizeQuery(params.query);
const members: SlackUser[] = [];
let cursor: string | undefined;
@@ -172,10 +172,11 @@ export async function listSlackDirectoryPeersLive(
export async function listSlackDirectoryGroupsLive(
params: DirectoryConfigParams,
): Promise<ChannelDirectoryEntry[]> {
const client = createSlackDirectoryClient(params);
if (!client) {
const token = resolveReadToken(params);
if (!token) {
return [];
}
const client = createSlackWebClient(token);
const query = normalizeQuery(params.query);
const channels: SlackChannel[] = [];
let cursor: string | undefined;

View File

@@ -3,6 +3,8 @@ export { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export {
readSessionUpdatedAt,
resolveChannelResetConfig,
resolveSessionEntryFreshness,
resolveSessionKey,
resolveStorePath,
updateLastRoute,

View File

@@ -10,7 +10,7 @@ import {
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
import { resolveSlackAllowListMatch } from "../allow-list.js";
import { readSessionUpdatedAt } from "../config.runtime.js";
import { resolveChannelResetConfig, resolveSessionEntryFreshness } from "../config.runtime.js";
import type { SlackMonitorContext } from "../context.js";
import type { SlackMediaResult } from "../media-types.js";
import { resolveSlackThreadHistory, type SlackThreadStarter } from "../thread.js";
@@ -35,7 +35,7 @@ function loadSlackMediaModule(): Promise<SlackMediaModule> {
type SlackThreadContextData = {
threadStarterBody: string | undefined;
threadHistoryBody: string | undefined;
threadSessionPreviousTimestamp: number | undefined;
shouldSeedInitialThreadContext: boolean;
threadLabel: string | undefined;
threadStarterMedia: SlackMediaResult[] | null;
};
@@ -125,19 +125,32 @@ export async function resolveSlackThreadContextData(params: {
let threadHistoryBody: string | undefined;
let threadLabel: string | undefined;
let threadStarterMedia: SlackMediaResult[] | null = null;
const threadSessionPreviousTimestamp =
const threadSessionFreshness =
params.isThreadReply && params.threadTs
? readSessionUpdatedAt({
? resolveSessionEntryFreshness({
storePath: params.storePath,
sessionKey: params.sessionKey,
sessionCfg: params.ctx.cfg.session,
resetType: "thread",
resetOverride: resolveChannelResetConfig({
sessionCfg: params.ctx.cfg.session,
channel: "slack",
}),
})
: undefined;
const shouldSeedInitialThreadContext = Boolean(
params.isThreadReply &&
params.threadTs &&
(!threadSessionFreshness || threadSessionFreshness.state !== "fresh"),
);
const shouldLoadInitialThreadHistory =
shouldSeedInitialThreadContext || params.forceInitialHistory === true;
if (!params.isThreadReply || !params.threadTs) {
return {
threadStarterBody,
threadHistoryBody,
threadSessionPreviousTimestamp,
shouldSeedInitialThreadContext,
threadLabel,
threadStarterMedia,
};
@@ -195,10 +208,9 @@ export async function resolveSlackThreadContextData(params: {
threadLabel = `Slack thread ${params.roomLabel}`;
}
const isNewThreadSession = !threadSessionPreviousTimestamp;
const includeBotStarterAsRootContext = shouldIncludeBotThreadStarterContext({
starterIsCurrentBot,
isNewThreadSession,
isNewThreadSession: shouldSeedInitialThreadContext,
hasStarterText: Boolean(starter?.text),
});
@@ -218,10 +230,7 @@ export async function resolveSlackThreadContextData(params: {
const threadInitialHistoryLimit = params.account.config?.thread?.initialHistoryLimit ?? 20;
if (
threadInitialHistoryLimit > 0 &&
(!threadSessionPreviousTimestamp || params.forceInitialHistory)
) {
if (threadInitialHistoryLimit > 0 && shouldLoadInitialThreadHistory) {
const currentBotRootTs = starter?.ts ?? params.threadTs;
const threadHistory = await resolveSlackThreadHistory({
channelId: params.message.channel,
@@ -333,7 +342,7 @@ export async function resolveSlackThreadContextData(params: {
return {
threadStarterBody,
threadHistoryBody,
threadSessionPreviousTimestamp,
shouldSeedInitialThreadContext,
threadLabel,
threadStarterMedia,
};

View File

@@ -1870,12 +1870,15 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
baseSessionKey: route.sessionKey,
threadId: "200.000",
});
const now = Date.now();
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "existing-thread-session",
updatedAt: Date.now(),
updatedAt: now,
sessionStartedAt: now,
lastInteractionAt: now,
},
},
{ skipMaintenance: true },
@@ -1905,6 +1908,208 @@ Second paragraph should still reach the agent after Slack's preview cutoff.`;
expect(replies).toHaveBeenCalledTimes(1);
});
it("loads bounded thread history for existing thread sessions stale under reset policy", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const now = Date.now();
const cfg = {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig;
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: "default",
teamId: "T1",
peer: { kind: "channel", id: "C123" },
});
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: "300.000",
});
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "stale-thread-session",
updatedAt: now,
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
},
},
{ skipMaintenance: true },
);
const replies = vi
.fn()
.mockResolvedValueOnce({
messages: [{ text: "starter", user: "U2", ts: "300.000" }],
})
.mockResolvedValueOnce({
messages: [
{ text: "starter", user: "U2", ts: "300.000" },
{ text: "assistant prior output", bot_id: "B1", ts: "300.500" },
{ text: "prior human context", user: "U1", ts: "300.800" },
{ text: "current post-reset message", user: "U1", ts: "301.000" },
],
response_metadata: { next_cursor: "" },
});
const slackCtx = createThreadSlackCtx({ cfg, replies });
slackCtx.threadInheritParent = true;
slackCtx.resolveUserName = async (id: string) => ({
name: id === "U1" ? "Alice" : "Bob",
});
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 10, inheritParent: true },
}),
createThreadReplyMessage({
text: "current post-reset message",
ts: "301.000",
thread_ts: "300.000",
}),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.SessionKey).toBe(threadKeys.sessionKey);
expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true);
expect(prepared.ctxPayload.ThreadStarterBody).toBe("starter");
expect(prepared.ctxPayload.ThreadHistoryBody).toContain("prior human context");
expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("assistant prior output");
expect(prepared.ctxPayload.ThreadHistoryBody).not.toContain("current post-reset message");
expect(prepared.ctxPayload.ParentSessionKey).toBe(route.sessionKey);
expect(replies).toHaveBeenCalledTimes(2);
expect(replies).toHaveBeenLastCalledWith({
channel: "C123",
ts: "300.000",
limit: 200,
inclusive: true,
});
});
it("keeps provider-owned thread sessions existing when reset policy is implicit", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const now = Date.now();
const cfg = {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig;
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: "default",
teamId: "T1",
peer: { kind: "channel", id: "C123" },
});
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: "350.000",
});
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "provider-owned-thread-session",
updatedAt: now,
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
providerOverride: "claude-cli",
cliSessionBindings: {
"claude-cli": { sessionId: "claude-cli-thread-session" },
},
},
},
{ skipMaintenance: true },
);
const replies = vi.fn().mockResolvedValueOnce({
messages: [{ text: "starter", user: "U2", ts: "350.000" }],
});
const slackCtx = createThreadSlackCtx({ cfg, replies });
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 10 },
}),
createThreadReplyMessage({
text: "reply after implicit reset boundary",
ts: "351.000",
thread_ts: "350.000",
}),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.IsFirstThreadTurn).toBeUndefined();
expect(prepared.ctxPayload.ThreadStarterBody).toBeUndefined();
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
expect(replies).toHaveBeenCalledTimes(1);
});
it("keeps initialHistoryLimit zero as a hard disable for stale thread sessions", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const now = Date.now();
const cfg = {
session: { store: storePath },
channels: { slack: { enabled: true, replyToMode: "all", groupPolicy: "open" } },
} as OpenClawConfig;
const route = resolveAgentRoute({
cfg,
channel: "slack",
accountId: "default",
teamId: "T1",
peer: { kind: "channel", id: "C123" },
});
const threadKeys = resolveThreadSessionKeys({
baseSessionKey: route.sessionKey,
threadId: "400.000",
});
await saveSessionStore(
storePath,
{
[threadKeys.sessionKey]: {
sessionId: "stale-zero-history-thread-session",
updatedAt: now,
sessionStartedAt: now - 2 * 24 * 60 * 60 * 1000,
lastInteractionAt: now - 2 * 24 * 60 * 60 * 1000,
},
},
{ skipMaintenance: true },
);
const replies = vi.fn().mockResolvedValueOnce({
messages: [{ text: "starter", user: "U2", ts: "400.000" }],
});
const slackCtx = createThreadSlackCtx({ cfg, replies });
slackCtx.resolveUserName = async () => ({ name: "Alice" });
slackCtx.resolveChannelName = async () => ({ name: "general", type: "channel" });
const prepared = await prepareMessageWith(
slackCtx,
createSlackAccount({
replyToMode: "all",
thread: { initialHistoryLimit: 0 },
}),
createThreadReplyMessage({
text: "current post-reset message",
ts: "401.000",
thread_ts: "400.000",
}),
);
assertPrepared(prepared);
expect(prepared.ctxPayload.IsFirstThreadTurn).toBe(true);
expect(prepared.ctxPayload.ThreadStarterBody).toBe("starter");
expect(prepared.ctxPayload.ThreadHistoryBody).toBeUndefined();
expect(replies).toHaveBeenCalledTimes(1);
});
it("drops ambiguous thread replies instead of treating them as root messages", async () => {
const { storePath } = storeFixture.makeTmpStorePath();
const cfg = {

View File

@@ -1225,7 +1225,7 @@ export async function prepareSlackMessage(params: {
const {
threadStarterBody,
threadHistoryBody,
threadSessionPreviousTimestamp,
shouldSeedInitialThreadContext,
threadLabel,
threadStarterMedia,
} = await resolveSlackThreadContextData({
@@ -1320,7 +1320,7 @@ export async function prepareSlackMessage(params: {
thread: {
// Only include thread starter body for NEW sessions (existing sessions already have it in their transcript)
starterBody:
!directThreadRoutedToDmSession && !threadSessionPreviousTimestamp
!directThreadRoutedToDmSession && shouldSeedInitialThreadContext
? threadStarterBody
: undefined,
historyBody: supplementalThreadHistoryBody,
@@ -1340,7 +1340,7 @@ export async function prepareSlackMessage(params: {
isThreadReply &&
threadTs &&
!directThreadRoutedToDmSession &&
!threadSessionPreviousTimestamp
shouldSeedInitialThreadContext
? true
: undefined,
...buildSlackMentionContextPayload({

View File

@@ -32,7 +32,7 @@ import {
resolveSlackAccountDmPolicy,
} from "../accounts.js";
import { isSlackAnyNativeApprovalClientEnabled } from "../approval-native-gates.js";
import { createSlackApiUrlClientOptions, resolveSlackWebClientOptions } from "../client-options.js";
import { resolveSlackWebClientOptions } from "../client-options.js";
import { normalizeSlackWebhookPath, registerSlackHttpHandler } from "../http/index.js";
import { SLACK_TEXT_LIMIT } from "../limits.js";
import { resolveSlackChannelAllowlist } from "../resolve-channels.js";
@@ -288,8 +288,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const typingReaction = slackCfg.typingReaction?.trim() ?? "";
const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
const slackApiUrlClientOptions = createSlackApiUrlClientOptions();
const clientOptions = resolveSlackWebClientOptions(slackApiUrlClientOptions);
const clientOptions = resolveSlackWebClientOptions();
const { app, receiver, socketModeLogger } = createSlackBoltApp({
interop: await getSlackBoltInterop(),
slackMode,
@@ -463,7 +462,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const resolved = await resolveSlackChannelAllowlist({
token: resolveToken,
entries,
...slackApiUrlClientOptions,
});
const nextChannels = { ...channelsConfig };
const mapping: string[] = [];
@@ -509,7 +507,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const resolvedUsers = await resolveSlackUserAllowlist({
token: resolveToken,
entries: allowEntries,
...slackApiUrlClientOptions,
});
const { mapping, unresolved, additions } = buildAllowlistResolutionSummary(
resolvedUsers,
@@ -556,7 +553,6 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
const resolvedUsers = await resolveSlackUserAllowlist({
token: resolveToken,
entries: Array.from(userEntries),
...slackApiUrlClientOptions,
});
const { resolvedMap, mapping, unresolved } = buildAllowlistResolutionSummary(
resolvedUsers,

View File

@@ -1,7 +1,7 @@
// Slack plugin module implements probe behavior.
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
import { createSlackWebClient, type SlackApiUrlClientOptions } from "./client.js";
import { createSlackWebClient } from "./client.js";
import { formatSlackError } from "./errors.js";
export type SlackProbe = BaseProbeResult & {
@@ -11,14 +11,8 @@ export type SlackProbe = BaseProbeResult & {
team?: { id?: string; name?: string };
};
export async function probeSlack(
token: string,
timeoutMs = 2500,
options: SlackApiUrlClientOptions = {},
): Promise<SlackProbe> {
const client = options.slackApiUrl
? createSlackWebClient(token, options)
: createSlackWebClient(token);
export async function probeSlack(token: string, timeoutMs = 2500): Promise<SlackProbe> {
const client = createSlackWebClient(token);
const start = Date.now();
try {
const result = await withTimeout(client.auth.test(), timeoutMs);

View File

@@ -1,7 +1,6 @@
// Slack plugin module implements resolve channels behavior.
import type { WebClient } from "@slack/web-api";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SlackApiUrlClientOptions } from "./client-options.js";
import { createSlackWebClient } from "./client.js";
import {
collectSlackCursorItems,
@@ -102,14 +101,8 @@ export async function resolveSlackChannelAllowlist(params: {
token: string;
entries: string[];
client?: WebClient;
slackApiUrl?: SlackApiUrlClientOptions["slackApiUrl"];
}): Promise<SlackChannelResolution[]> {
const client =
params.client ??
createSlackWebClient(
params.token,
params.slackApiUrl ? { slackApiUrl: params.slackApiUrl } : {},
);
const client = params.client ?? createSlackWebClient(params.token);
const channels = await listSlackChannels(client);
return resolveSlackAllowlistEntries<
{ id?: string; name?: string },

View File

@@ -4,7 +4,6 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { SlackApiUrlClientOptions } from "./client-options.js";
import { createSlackWebClient } from "./client.js";
import {
collectSlackCursorItems,
@@ -154,14 +153,8 @@ export async function resolveSlackUserAllowlist(params: {
token: string;
entries: string[];
client?: WebClient;
slackApiUrl?: SlackApiUrlClientOptions["slackApiUrl"];
}): Promise<SlackUserResolution[]> {
const client =
params.client ??
createSlackWebClient(
params.token,
params.slackApiUrl ? { slackApiUrl: params.slackApiUrl } : {},
);
const client = params.client ?? createSlackWebClient(params.token);
const users = await listSlackUsers(client);
return resolveSlackAllowlistEntries<
{ id?: string; name?: string; email?: string },

View File

@@ -6,7 +6,7 @@ import {
normalizeOptionalString,
sortUniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { createSlackWebClient, type SlackApiUrlClientOptions } from "./client.js";
import { createSlackWebClient } from "./client.js";
import { formatSlackError } from "./errors.js";
export type SlackScopesResult = {
@@ -95,9 +95,8 @@ async function callSlack(
export async function fetchSlackScopes(
token: string,
timeoutMs: number,
options: SlackApiUrlClientOptions = {},
): Promise<SlackScopesResult> {
const client = createSlackWebClient(token, { ...options, timeout: timeoutMs });
const client = createSlackWebClient(token, { timeout: timeoutMs });
const attempts: SlackScopesMethod[] = ["auth.test", "auth.scopes", "apps.permissions.info"];
const errors: string[] = [];

View File

@@ -29,7 +29,6 @@ import type { SlackTokenSource } from "./accounts.js";
import { resolveSlackAccount } from "./accounts.js";
import { buildSlackBlocksFallbackText } from "./blocks-fallback.js";
import { validateSlackBlocksArray } from "./blocks-input.js";
import { createSlackApiUrlClientOptions } from "./client-options.js";
import { createSlackTokenCacheKey, getSlackWriteClient } from "./client.js";
import { markdownToSlackMrkdwnChunks } from "./format.js";
import { SLACK_TEXT_LIMIT } from "./limits.js";
@@ -751,7 +750,7 @@ async function sendMessageSlackQueuedInner(params: {
blocks?: (Block | KnownBlock)[];
}): Promise<SlackSendResult> {
const { opts, cfg, account, token, recipient, blocks, trimmedMessage } = params;
const client = opts.client ?? getSlackWriteClient(token, createSlackApiUrlClientOptions());
const client = opts.client ?? getSlackWriteClient(token);
const identity = resolveSlackSendIdentity({
accountId: account.accountId,
explicit: opts.identity,

View File

@@ -117,6 +117,9 @@ export const pluginSdkDocMetadata = {
"runtime-store": {
category: "runtime",
},
"session-store-runtime": {
category: "runtime",
},
"session-transcript-runtime": {
category: "runtime",
},

View File

@@ -163,7 +163,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"channel-pairing-paths": 1,
"channel-policy": 8,
"channel-route": 5,
"session-store-runtime": 1,
"session-store-runtime": 2,
"session-transcript-runtime": 1,
"group-access": 13,
"media-generation-runtime-shared": 3,
@@ -202,8 +202,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10388),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5214),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10392),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5215),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3247,

View File

@@ -2,6 +2,8 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } from "../config/config.js";
import type { OpenClawConfig } from "../config/types.js";
import * as jsonFiles from "../infra/json-files.js";
import {
cleanupSessionLifecycleArtifacts,
@@ -9,6 +11,7 @@ import {
listSessionEntries,
patchSessionEntry,
readSessionUpdatedAt,
resolveSessionEntryFreshness,
saveSessionStore,
updateSessionStore,
updateSessionStoreEntry,
@@ -27,6 +30,7 @@ describe("session-store-runtime compatibility surface", () => {
});
afterEach(() => {
clearRuntimeConfigSnapshot();
fs.rmSync(tempDir, { recursive: true, force: true });
});
@@ -70,6 +74,262 @@ describe("session-store-runtime compatibility surface", () => {
expect(getSessionEntry({ sessionKey, storePath })?.model).toBeUndefined();
});
it("returns missing state with a resolved reset policy for absent entries", () => {
const result = resolveSessionEntryFreshness({
sessionKey: "agent:main:missing:thread:100.000",
storePath,
sessionCfg: {},
resetType: "thread",
now: new Date("2026-01-02T12:00:00Z").getTime(),
});
expect(result).toMatchObject({
state: "missing",
entry: undefined,
freshness: undefined,
resetType: "thread",
resetPolicy: {
mode: "daily",
atHour: 4,
},
});
});
it("resolves stale daily freshness from lifecycle timestamps instead of activity", async () => {
const sessionKey = "agent:main:main:thread:100.000";
const now = new Date("2026-01-02T12:00:00Z").getTime();
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionId: "session-stale-thread",
updatedAt: now,
sessionStartedAt: now - 2 * DAY_MS,
lastInteractionAt: now - 2 * DAY_MS,
},
});
const result = resolveSessionEntryFreshness({
sessionKey,
storePath,
sessionCfg: {},
resetType: "thread",
now,
});
expect(result.state).toBe("stale");
expect(result.entry?.sessionId).toBe("session-stale-thread");
expect(result.resetType).toBe("thread");
expect(result.freshness).toMatchObject({
fresh: false,
staleReason: "daily",
});
});
it("keeps provider-owned sessions fresh when reset policy is implicit", async () => {
const sessionKey = "agent:main:main:thread:provider-owned";
const now = new Date("2026-01-02T12:00:00Z").getTime();
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionId: "session-provider-owned",
updatedAt: now,
sessionStartedAt: now - 2 * DAY_MS,
lastInteractionAt: now - 2 * DAY_MS,
providerOverride: "claude-cli",
cliSessionBindings: {
"claude-cli": { sessionId: "cli-session-provider-owned" },
},
},
});
const result = resolveSessionEntryFreshness({
sessionKey,
storePath,
sessionCfg: {},
resetType: "thread",
now,
});
expect(result.state).toBe("fresh");
expect(result.freshness).toMatchObject({ fresh: true });
});
it("applies configured reset policies to provider-owned sessions", async () => {
const sessionKey = "agent:main:main:thread:provider-owned-configured";
const now = new Date("2026-01-02T12:00:00Z").getTime();
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionId: "session-provider-owned-configured",
updatedAt: now,
sessionStartedAt: now - 2 * DAY_MS,
lastInteractionAt: now - 2 * DAY_MS,
providerOverride: "claude-cli",
cliSessionBindings: {
"claude-cli": { sessionId: "cli-session-provider-owned-configured" },
},
},
});
const result = resolveSessionEntryFreshness({
sessionKey,
storePath,
sessionCfg: { reset: { mode: "daily" } },
resetType: "thread",
now,
});
expect(result.state).toBe("stale");
expect(result.freshness).toMatchObject({
fresh: false,
staleReason: "daily",
});
});
it("resolves fresh daily freshness for active lifecycle timestamps", async () => {
const sessionKey = "agent:main:main";
const now = new Date("2026-01-02T12:00:00Z").getTime();
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionId: "session-fresh",
updatedAt: now,
sessionStartedAt: now - 60_000,
lastInteractionAt: now - 60_000,
},
});
const result = resolveSessionEntryFreshness({
sessionKey,
storePath,
sessionCfg: {},
resetType: "direct",
now,
});
expect(result.state).toBe("fresh");
expect(result.entry?.sessionId).toBe("session-fresh");
expect(result.resetType).toBe("direct");
expect(result.freshness).toMatchObject({ fresh: true });
});
it("honors reset overrides when resolving entry freshness", async () => {
const sessionKey = "agent:main:main:thread:idle";
const now = new Date("2026-01-02T12:00:00Z").getTime();
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionId: "session-idle-stale",
updatedAt: now,
sessionStartedAt: now,
lastInteractionAt: now - 60 * 60 * 1000,
},
});
const result = resolveSessionEntryFreshness({
sessionKey,
storePath,
sessionCfg: { reset: { mode: "daily" } },
resetOverride: { mode: "idle", idleMinutes: 30 },
resetType: "thread",
now,
});
expect(result.state).toBe("stale");
expect(result.resetPolicy).toMatchObject({
mode: "idle",
idleMinutes: 30,
});
expect(result.freshness).toMatchObject({
fresh: false,
staleReason: "idle",
});
});
it("uses runtime session config when store path and session config are omitted", async () => {
const sessionKey = "agent:main:main:thread:runtime-config";
const runtimeStorePath = path.join(tempDir, "runtime-sessions.json");
const now = new Date("2026-01-02T12:00:00Z").getTime();
setRuntimeConfigSnapshot({
session: {
store: runtimeStorePath,
reset: { mode: "idle", idleMinutes: 30 },
},
} as OpenClawConfig);
await upsertSessionEntry({
sessionKey,
storePath: runtimeStorePath,
entry: {
sessionId: "session-runtime-config",
updatedAt: now,
sessionStartedAt: now,
lastInteractionAt: now - 60 * 60 * 1000,
},
});
const result = resolveSessionEntryFreshness({
sessionKey,
resetType: "thread",
now,
});
expect(result.state).toBe("stale");
expect(result.entry?.sessionId).toBe("session-runtime-config");
expect(result.resetPolicy).toMatchObject({
mode: "idle",
idleMinutes: 30,
});
expect(result.freshness).toMatchObject({
fresh: false,
staleReason: "idle",
});
});
it("uses transcript header startedAt when entry lifecycle metadata is missing", async () => {
const sessionKey = "agent:main:main:thread:header";
const now = new Date("2026-01-02T12:00:00Z").getTime();
const headerTimestamp = new Date(now - 2 * DAY_MS).toISOString();
const transcriptPath = path.join(tempDir, "session-header-fallback.jsonl");
fs.writeFileSync(
transcriptPath,
`${JSON.stringify({
type: "session",
id: "session-header-fallback",
timestamp: headerTimestamp,
})}\n`,
"utf-8",
);
await upsertSessionEntry({
sessionKey,
storePath,
entry: {
sessionFile: transcriptPath,
sessionId: "session-header-fallback",
updatedAt: now,
},
});
const result = resolveSessionEntryFreshness({
sessionKey,
storePath,
sessionCfg: {},
resetType: "thread",
now,
});
expect(result.state).toBe("stale");
expect(result.lifecycleTimestamps.sessionStartedAt).toBe(Date.parse(headerTimestamp));
expect(result.freshness).toMatchObject({
fresh: false,
staleReason: "daily",
});
});
it("keeps the public entry mutation signature while delegating to the seam", async () => {
const sessionKey = "agent:main:main";
@@ -338,7 +598,9 @@ describe("session-store-runtime compatibility surface", () => {
sessionId: "regular",
});
expect(
fs.readdirSync(tempDir).filter((file) => file.startsWith("lifecycle-owned-old.jsonl.deleted.")),
fs
.readdirSync(tempDir)
.filter((file) => file.startsWith("lifecycle-owned-old.jsonl.deleted.")),
).toHaveLength(1);
});
});

View File

@@ -1,6 +1,17 @@
// Narrow session-store helpers for channel hot paths.
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { getRuntimeConfig } from "../config/io.js";
import { resolveSessionLifecycleTimestamps } from "../config/sessions/lifecycle.js";
import { resolveStorePath as resolveSessionStorePath } from "../config/sessions/paths.js";
import {
evaluateSessionFreshness as evaluateSessionFreshnessImpl,
resolveSessionResetPolicy as resolveSessionResetPolicyImpl,
type SessionFreshness,
type SessionResetPolicy,
type SessionResetType,
} from "../config/sessions/reset.js";
import {
cleanupSessionLifecycleArtifacts as cleanupAccessorSessionLifecycleArtifacts,
listSessionEntries as listAccessorSessionEntries,
@@ -15,6 +26,8 @@ import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/sto
import { normalizeResolvedMaintenanceConfigInput } from "../config/sessions/store-maintenance.js";
import type { ResolvedSessionMaintenanceConfigInput } from "../config/sessions/store.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { SessionConfig, SessionResetConfig } from "../config/types.base.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
type SessionStoreReadParams = {
agentId?: string;
@@ -51,6 +64,36 @@ type PatchSessionEntryParams = SessionStoreReadParams & {
type ReadSessionUpdatedAtParams = SessionStoreReadParams;
export type ResolveSessionEntryFreshnessParams = SessionStoreReadParams & {
now?: number;
resetOverride?: SessionResetConfig;
resetType: SessionResetType;
sessionCfg?: SessionConfig;
};
export type SessionEntryLifecycleTimestamps = {
sessionStartedAt?: number;
lastInteractionAt?: number;
};
export type ResolvedSessionEntryFreshness =
| {
state: "missing";
entry: undefined;
freshness: undefined;
lifecycleTimestamps: SessionEntryLifecycleTimestamps;
resetPolicy: SessionResetPolicy;
resetType: SessionResetType;
}
| {
state: "fresh" | "stale";
entry: SessionEntry;
freshness: SessionFreshness;
lifecycleTimestamps: SessionEntryLifecycleTimestamps;
resetPolicy: SessionResetPolicy;
resetType: SessionResetType;
};
type UpdateSessionStoreEntryParams = {
storePath: string;
sessionKey: string;
@@ -81,6 +124,24 @@ type SessionLifecycleArtifactsCleanupResult = {
removedEntries: number;
};
function hasProviderOwnedSession(entry: SessionEntry | undefined): boolean {
const provider = normalizeOptionalString(entry?.providerOverride ?? entry?.modelProvider);
if (!entry || !provider) {
return false;
}
const normalizedProvider = normalizeProviderId(provider);
if (normalizeOptionalString(entry.cliSessionBindings?.[normalizedProvider]?.sessionId)) {
return true;
}
if (normalizeOptionalString(entry.cliSessionIds?.[normalizedProvider])) {
return true;
}
return (
normalizedProvider === "claude-cli" &&
Boolean(normalizeOptionalString(entry.claudeCliSessionId))
);
}
function toSessionAccessScope(params: SessionStoreReadParams): SessionAccessScope {
// Maintainer note: keep this adapter narrow so plugin callers retain the
// object-parameter API while internal accessor-only options stay private.
@@ -143,6 +204,67 @@ export function readSessionUpdatedAt(params: ReadSessionUpdatedAtParams): number
return readAccessorSessionUpdatedAt(toSessionAccessScope(params));
}
/** Resolves one session entry's reset freshness using the runtime lifecycle rules. */
export function resolveSessionEntryFreshness(
params: ResolveSessionEntryFreshnessParams,
): ResolvedSessionEntryFreshness {
const agentId = params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey);
const sessionCfg = params.sessionCfg ?? getRuntimeConfig().session;
const storePath =
params.storePath ??
resolveSessionStorePath(sessionCfg?.store, {
agentId,
env: params.env,
});
const entry = loadSessionEntry(
toSessionAccessScope({
...params,
agentId,
storePath,
}),
);
const resetType = params.resetType;
const resetPolicy = resolveSessionResetPolicyImpl({
sessionCfg,
resetType,
resetOverride: params.resetOverride,
});
const lifecycleTimestamps = resolveSessionLifecycleTimestamps({
entry,
agentId,
storePath,
});
const base = {
lifecycleTimestamps,
resetPolicy,
resetType,
};
if (!entry) {
return {
state: "missing",
entry: undefined,
freshness: undefined,
...base,
};
}
const freshness =
resetPolicy.configured !== true && hasProviderOwnedSession(entry)
? ({ fresh: true } satisfies SessionFreshness)
: evaluateSessionFreshnessImpl({
updatedAt: entry.updatedAt,
sessionStartedAt: lifecycleTimestamps.sessionStartedAt,
lastInteractionAt: lifecycleTimestamps.lastInteractionAt,
now: params.now ?? Date.now(),
policy: resetPolicy,
});
return {
state: freshness.fresh ? "fresh" : "stale",
entry,
freshness,
...base,
};
}
/** Updates an existing session entry by store path and session key. */
export async function updateSessionStoreEntry(
params: UpdateSessionStoreEntryParams,